401 lines
13 KiB
Go
401 lines
13 KiB
Go
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
|
|
}
|