411 lines
13 KiB
Go
411 lines
13 KiB
Go
package app
|
||
|
||
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
|
||
}
|
||
|
||
func normalizeEagleStringSlice(values []string) []string {
|
||
normalized := make([]string, len(values))
|
||
copy(normalized, values)
|
||
return normalized
|
||
}
|
||
|
||
func normalizeEagleItemCollections(item *EagleItem) {
|
||
item.FolderIDs = normalizeEagleStringSlice(item.FolderIDs)
|
||
item.Tags = normalizeEagleStringSlice(item.Tags)
|
||
}
|
||
|
||
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)), ".")
|
||
normalizeEagleItemCollections(item)
|
||
}
|
||
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
|
||
}
|