feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界
This commit is contained in:
@@ -96,6 +96,7 @@ func main() {
|
||||
handler.RegisterSessionRoutes(api, svc)
|
||||
handler.RegisterSkillRoutes(api, svc)
|
||||
handler.RegisterUserDataRoutes(api, svc)
|
||||
handler.RegisterPluginRoutes(api, svc)
|
||||
projectAPI := api.Group("")
|
||||
projectAPI.Use(handler.RequireFeature(svc, service.FeatureShortDrama))
|
||||
handler.RegisterProjectRoutes(projectAPI, svc)
|
||||
|
||||
109
backend/internal/handler/plugin.go
Normal file
109
backend/internal/handler/plugin.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/plugins/eagle/library", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
library, err := svc.EagleLibrary(c.Query("baseUrl"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
library.LibraryPath = ""
|
||||
ok(c, gin.H{"library": library})
|
||||
})
|
||||
r.GET("/plugins/eagle/items", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "60"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := svc.EagleItems(c.Query("baseUrl"), service.EagleItemQuery{FolderID: c.Query("folderId"), Keyword: c.Query("keyword"), Limit: limit, Offset: offset})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"items": items})
|
||||
})
|
||||
r.GET("/plugins/eagle/items/:itemId/file", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
file, err := svc.OpenEagleItemFile(c.Query("baseUrl"), c.Param("itemId"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
defer file.Body.Close()
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+file.Name+"\"")
|
||||
c.DataFromReader(http.StatusOK, file.Size, file.MimeType, file.Body, nil)
|
||||
})
|
||||
r.GET("/plugins/eagle/items/:itemId/thumbnail", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
file, err := svc.OpenEagleItemThumbnail(c.Query("baseUrl"), c.Param("itemId"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
defer file.Body.Close()
|
||||
c.Header("Cache-Control", "private, max-age=60")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.DataFromReader(http.StatusOK, file.Size, file.MimeType, file.Body, nil)
|
||||
})
|
||||
r.POST("/plugins/eagle/items", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 160<<20)
|
||||
var request service.EagleAddItemRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := svc.AddEagleItem(c.Query("baseUrl"), request)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"item": item})
|
||||
})
|
||||
r.POST("/plugins/eagle/folders", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var request struct {
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parentId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := svc.CreateEagleFolder(c.Query("baseUrl"), request.Name, request.ParentID); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"created": true})
|
||||
})
|
||||
}
|
||||
400
backend/internal/service/eagle.go
Normal file
400
backend/internal/service/eagle.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const eagleDefaultBaseURL = "http://127.0.0.1:41595"
|
||||
|
||||
type EagleFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Children []EagleFolder `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type EagleLibrary struct {
|
||||
ApplicationVersion string `json:"applicationVersion"`
|
||||
LibraryName string `json:"libraryName"`
|
||||
LibraryPath string `json:"-"`
|
||||
Folders []EagleFolder `json:"folders"`
|
||||
}
|
||||
|
||||
type EagleItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Extension string `json:"extension"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderIDs []string `json:"folderIds"`
|
||||
URL string `json:"url"`
|
||||
Annotation string `json:"annotation"`
|
||||
ModificationTime int64 `json:"modificationTime"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
func (item *EagleItem) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Extension string `json:"ext"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderIDs []string `json:"folders"`
|
||||
URL string `json:"url"`
|
||||
Annotation string `json:"annotation"`
|
||||
ModificationTime int64 `json:"modificationTime"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Deleted bool `json:"isDeleted"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*item = EagleItem{
|
||||
ID: raw.ID, Name: raw.Name, Size: raw.Size, Extension: raw.Extension,
|
||||
Tags: raw.Tags, FolderIDs: raw.FolderIDs, URL: raw.URL, Annotation: raw.Annotation,
|
||||
ModificationTime: raw.ModificationTime, Width: raw.Width, Height: raw.Height, Deleted: raw.Deleted,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type EagleItemQuery struct {
|
||||
FolderID string
|
||||
Keyword string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type EagleAddItemRequest struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
FolderID string `json:"folderId,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
ModificationTime int64 `json:"modificationTime,omitempty"`
|
||||
}
|
||||
|
||||
type EagleCreatedItem struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type EagleFile struct {
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
MimeType string
|
||||
Body io.ReadCloser
|
||||
}
|
||||
|
||||
func (s *Service) EagleLibrary(rawBaseURL string) (*EagleLibrary, error) {
|
||||
baseURL, err := validateEagleBaseURL(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Folders []EagleFolder `json:"folders"`
|
||||
ApplicationVersion string `json:"applicationVersion"`
|
||||
Library struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
} `json:"library"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := eagleJSONRequest(http.MethodGet, baseURL, "/api/library/info", nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != "success" {
|
||||
return nil, errors.New("Eagle 未返回成功状态,请确认 Eagle 已启动并打开素材库")
|
||||
}
|
||||
folders := flattenEagleFolders(response.Data.Folders, "")
|
||||
return &EagleLibrary{
|
||||
ApplicationVersion: response.Data.ApplicationVersion,
|
||||
LibraryName: response.Data.Library.Name,
|
||||
LibraryPath: response.Data.Library.Path,
|
||||
Folders: folders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) EagleItems(rawBaseURL string, query EagleItemQuery) ([]EagleItem, error) {
|
||||
baseURL, err := validateEagleBaseURL(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 60
|
||||
}
|
||||
offset := query.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("offset", fmt.Sprintf("%d", offset))
|
||||
if query.FolderID != "" {
|
||||
params.Set("folders", query.FolderID)
|
||||
}
|
||||
if strings.TrimSpace(query.Keyword) != "" {
|
||||
params.Set("keyword", strings.TrimSpace(query.Keyword))
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data []EagleItem `json:"data"`
|
||||
}
|
||||
if err := eagleJSONRequest(http.MethodGet, baseURL, "/api/item/list?"+params.Encode(), nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != "success" {
|
||||
return nil, errors.New("Eagle 未返回素材列表")
|
||||
}
|
||||
for index := range response.Data {
|
||||
item := &response.Data[index]
|
||||
item.Extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item.Extension)), ".")
|
||||
item.FolderIDs = append([]string(nil), item.FolderIDs...)
|
||||
item.Tags = append([]string(nil), item.Tags...)
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
func (s *Service) OpenEagleItemFile(rawBaseURL string, itemID string) (*EagleFile, error) {
|
||||
if strings.TrimSpace(itemID) == "" || strings.ContainsAny(itemID, "/\\?&") {
|
||||
return nil, BadAuthRequest("Eagle 素材 ID 无效")
|
||||
}
|
||||
baseURL, err := validateEagleBaseURL(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
library, err := s.EagleLibrary(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
pathQuery := "/api/item/thumbnail?id=" + url.QueryEscape(itemID)
|
||||
if err := eagleJSONRequest(http.MethodGet, baseURL, pathQuery, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != "success" || strings.TrimSpace(response.Data) == "" {
|
||||
return nil, errors.New("Eagle 未返回素材路径")
|
||||
}
|
||||
thumbnailPath, err := url.PathUnescape(response.Data)
|
||||
if err != nil {
|
||||
return nil, errors.New("Eagle 素材路径编码无效")
|
||||
}
|
||||
originalPath, err := eagleOriginalPath(thumbnailPath, itemID, library.LibraryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Open(originalPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("无法读取 Eagle 原始文件,请确认素材库仍处于可用状态")
|
||||
}
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(originalPath))
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
return &EagleFile{Path: originalPath, Name: filepath.Base(originalPath), Size: stat.Size(), MimeType: mimeType, Body: file}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddEagleItem(rawBaseURL string, request EagleAddItemRequest) (*EagleCreatedItem, error) {
|
||||
if !isEagleMediaDataURL(request.URL) {
|
||||
return nil, BadAuthRequest("写入 Eagle 只接受图片、视频或音频数据")
|
||||
}
|
||||
if strings.TrimSpace(request.Name) == "" {
|
||||
return nil, BadAuthRequest("写入 Eagle 时必须提供素材名称")
|
||||
}
|
||||
baseURL, err := validateEagleBaseURL(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data EagleCreatedItem `json:"data"`
|
||||
}
|
||||
if err := eagleJSONRequest(http.MethodPost, baseURL, "/api/item/addFromURL", request, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != "success" {
|
||||
return nil, errors.New("Eagle 拒绝写入素材")
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
func isEagleMediaDataURL(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if len(trimmed) < len("data:,") || !strings.EqualFold(trimmed[:5], "data:") {
|
||||
return false
|
||||
}
|
||||
comma := strings.IndexByte(trimmed, ',')
|
||||
if comma <= len("data:") {
|
||||
return false
|
||||
}
|
||||
mediaType := strings.TrimSpace(trimmed[len("data:"):comma])
|
||||
if semicolon := strings.IndexByte(mediaType, ';'); semicolon >= 0 {
|
||||
mediaType = mediaType[:semicolon]
|
||||
}
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
return strings.HasPrefix(mediaType, "image/") || strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/")
|
||||
}
|
||||
func (s *Service) CreateEagleFolder(rawBaseURL string, name string, parentID string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return BadAuthRequest("Eagle 文件夹名称不能为空")
|
||||
}
|
||||
baseURL, err := validateEagleBaseURL(rawBaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := struct {
|
||||
FolderName string `json:"folderName"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
}{FolderName: strings.TrimSpace(name), Parent: strings.TrimSpace(parentID)}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := eagleJSONRequest(http.MethodPost, baseURL, "/api/folder/create", payload, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Status != "success" {
|
||||
return errors.New("Eagle 文件夹创建失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEagleBaseURL(raw string) (*url.URL, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
value = eagleDefaultBaseURL
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, BadAuthRequest("Eagle 地址必须是 http://127.0.0.1:41595")
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
|
||||
return nil, BadAuthRequest("为避免内网代理风险,Eagle 插件只允许连接本机地址")
|
||||
}
|
||||
if parsed.Port() != "41595" {
|
||||
return nil, BadAuthRequest("当前 Eagle 插件只支持默认端口 41595")
|
||||
}
|
||||
parsed.Path = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func eagleJSONRequest(method string, baseURL *url.URL, endpoint string, payload any, target any) error {
|
||||
requestURL := *baseURL
|
||||
endpointURL, err := url.Parse(endpoint)
|
||||
if err != nil || endpointURL.IsAbs() || endpointURL.Host != "" || endpointURL.Path == "" {
|
||||
return errors.New("Eagle API 路径无效")
|
||||
}
|
||||
requestURL.Path = strings.TrimRight(requestURL.Path, "/") + endpointURL.Path
|
||||
requestURL.RawQuery = endpointURL.RawQuery
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = strings.NewReader(string(encoded))
|
||||
}
|
||||
request, err := http.NewRequestWithContext(context.Background(), method, requestURL.String(), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payload != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Eagle 只允许连接回环地址;本机 API 必须绕过 Clash,否则代理可能把本机请求改写成 404。
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
client := &http.Client{Timeout: 2 * time.Minute, Transport: transport}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return errors.New("无法连接 Eagle,请确认 Eagle 已启动并打开素材库")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return errors.New("Eagle 未找到当前接口或素材,请确认 Eagle 已打开素材库并使用默认 API 地址")
|
||||
}
|
||||
return fmt.Errorf("Eagle API 返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
||||
return errors.New("Eagle API 返回了无法解析的数据")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func eagleOriginalPath(thumbnailPath string, itemID string, libraryPath string) (string, error) {
|
||||
thumbnailPath = filepath.Clean(filepath.FromSlash(thumbnailPath))
|
||||
libraryPath = filepath.Clean(filepath.FromSlash(libraryPath))
|
||||
if libraryPath == "." || !filepath.IsAbs(libraryPath) {
|
||||
return "", errors.New("Eagle 素材库路径无效,无法安全读取原文件")
|
||||
}
|
||||
itemDir := filepath.Join(libraryPath, "images", itemID+".info")
|
||||
rel, err := filepath.Rel(itemDir, thumbnailPath)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
return "", errors.New("Eagle 素材路径不在当前素材库内")
|
||||
}
|
||||
base := filepath.Base(thumbnailPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
stem = strings.TrimSuffix(stem, "_thumbnail")
|
||||
if ext := filepath.Ext(base); ext != "" {
|
||||
candidate := filepath.Join(itemDir, stem+ext)
|
||||
if stat, statErr := os.Stat(candidate); statErr == nil && !stat.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(itemDir)
|
||||
if err != nil {
|
||||
return "", errors.New("Eagle 原文件目录不存在")
|
||||
}
|
||||
candidates := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.EqualFold(entry.Name(), "metadata.json") || strings.Contains(strings.ToLower(entry.Name()), "_thumbnail") {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, filepath.Join(itemDir, entry.Name()))
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", errors.New("Eagle 原始文件不存在")
|
||||
}
|
||||
sort.Strings(candidates)
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
func flattenEagleFolders(folders []EagleFolder, parentID string) []EagleFolder {
|
||||
result := make([]EagleFolder, 0, len(folders))
|
||||
for _, folder := range folders {
|
||||
folder.ParentID = parentID
|
||||
children := flattenEagleFolders(folder.Children, folder.ID)
|
||||
folder.Children = nil
|
||||
result = append(result, folder)
|
||||
result = append(result, children...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
59
backend/internal/service/eagle_thumbnail.go
Normal file
59
backend/internal/service/eagle_thumbnail.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) OpenEagleItemThumbnail(rawBaseURL string, itemID string) (*EagleFile, error) {
|
||||
if strings.TrimSpace(itemID) == "" || strings.ContainsAny(itemID, "/\\?&") {
|
||||
return nil, BadAuthRequest("Eagle 素材 ID 无效")
|
||||
}
|
||||
baseURL, err := validateEagleBaseURL(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
library, err := s.EagleLibrary(rawBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := eagleJSONRequest(http.MethodGet, baseURL, "/api/item/thumbnail?id="+url.QueryEscape(itemID), nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != "success" || strings.TrimSpace(response.Data) == "" {
|
||||
return nil, errors.New("Eagle 未返回缩略图路径")
|
||||
}
|
||||
thumbnailPath, err := url.PathUnescape(response.Data)
|
||||
if err != nil {
|
||||
return nil, errors.New("Eagle 缩略图路径编码无效")
|
||||
}
|
||||
thumbnailPath = filepath.Clean(filepath.FromSlash(thumbnailPath))
|
||||
itemDir := filepath.Join(filepath.Clean(filepath.FromSlash(library.LibraryPath)), "images", itemID+".info")
|
||||
rel, err := filepath.Rel(itemDir, thumbnailPath)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
return nil, errors.New("Eagle 缩略图路径不在当前素材库内")
|
||||
}
|
||||
file, err := os.Open(thumbnailPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("无法读取 Eagle 缩略图")
|
||||
}
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(thumbnailPath))
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
return &EagleFile{Path: thumbnailPath, Name: filepath.Base(thumbnailPath), Size: stat.Size(), MimeType: mimeType, Body: file}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user