347 lines
8.8 KiB
Go
347 lines
8.8 KiB
Go
package core
|
||
|
||
// 本地桥口:让油猴脚本把画廊地址推给本工具
|
||
//
|
||
// POST /add { url, title } + X-HD4K-Token 头
|
||
// POST /add-batch { items: [...] } + X-HD4K-Token 头
|
||
// GET /ping -> { ok: true }
|
||
// GET /info -> 端口/版本/站点域名
|
||
//
|
||
// 只监听 127.0.0.1,且同时校验 Origin 与固定 token,防止本机其他网页乱塞任务。
|
||
//
|
||
// ── 与参考项目(EH)的差异 ─────────────────────────────────────
|
||
//
|
||
// ① 推送的是**完整画廊 URL**,不是 gid+token。4KHD 没有 gid/token 这对东西,
|
||
// URL 本身就是唯一标识,服务端自己解析出 host/path/slug。
|
||
// ② Origin 白名单**从站点配置动态生成**(settings.Sites),不硬编码。
|
||
// 这类采集站随时换域名,写死一份白名单等于换域名后油猴立刻失效。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"hd4k-downloader/internal/hd4k"
|
||
)
|
||
|
||
const maxBody = 512 * 1024 // 批量最多 500 个,标题也要带,得给够
|
||
|
||
// Bridge 是本地 HTTP 桥口
|
||
type Bridge struct {
|
||
store *Store
|
||
log func(level, msg string)
|
||
onAdded func(t Task, added bool, batch bool)
|
||
|
||
mu sync.Mutex
|
||
server *http.Server
|
||
port int
|
||
}
|
||
|
||
// NewBridge 建桥口
|
||
func NewBridge(store *Store, log func(string, string), onAdded func(Task, bool, bool)) *Bridge {
|
||
return &Bridge{store: store, log: log, onAdded: onAdded}
|
||
}
|
||
|
||
// allowedOrigins 允许的 Origin 白名单(从站点配置生成)。
|
||
//
|
||
// 站点配置是活的(用户可以随时加域名),所以每次请求现算,不缓存 ——
|
||
// 缓存了用户改完设置还得重启才生效。
|
||
func (b *Bridge) allowedOrigins() map[string]bool {
|
||
m := map[string]bool{}
|
||
for _, s := range b.store.Settings().Sites {
|
||
h := hd4k.NormalizeHost(s.Host)
|
||
if h == "" {
|
||
continue
|
||
}
|
||
m["https://"+h] = true
|
||
m["http://"+h] = true
|
||
// 站点偶尔会用 www 前缀的镜像域名
|
||
m["https://www."+h] = true
|
||
}
|
||
return m
|
||
}
|
||
|
||
// Start 启动(只绑回环)
|
||
func (b *Bridge) Start(port int) {
|
||
if port <= 0 {
|
||
port = 37778
|
||
}
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.port = port
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/ping", b.handlePing)
|
||
mux.HandleFunc("/info", b.handleInfo)
|
||
mux.HandleFunc("/add", b.handleAdd)
|
||
mux.HandleFunc("/add-batch", b.handleAddBatch)
|
||
|
||
srv := &http.Server{
|
||
Handler: b.cors(mux),
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
}
|
||
|
||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "address already in use") {
|
||
b.log("warn", fmt.Sprintf("桥口端口 %d 被占用,油猴推送不可用。可在设置里改端口,或关掉占用它的程序。", port))
|
||
} else {
|
||
b.log("error", fmt.Sprintf("桥口启动失败:%v", err))
|
||
}
|
||
return
|
||
}
|
||
|
||
b.server = srv
|
||
go func() {
|
||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||
b.log("error", fmt.Sprintf("桥口异常:%v", err))
|
||
}
|
||
}()
|
||
b.log("info", fmt.Sprintf("本地桥口已就绪 http://127.0.0.1:%d(油猴推送可用)", port))
|
||
}
|
||
|
||
// Stop 停止桥口
|
||
func (b *Bridge) Stop() {
|
||
b.mu.Lock()
|
||
srv := b.server
|
||
b.server = nil
|
||
b.mu.Unlock()
|
||
if srv != nil {
|
||
_ = srv.Close()
|
||
}
|
||
}
|
||
|
||
// Port 当前端口
|
||
func (b *Bridge) Port() int {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.port
|
||
}
|
||
|
||
// cors 处理 CORS 预检与响应头
|
||
func (b *Bridge) cors(next http.Handler) http.Handler {
|
||
allowed := b.allowedOrigins()
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
origin := r.Header.Get("Origin")
|
||
if origin != "" && allowed[origin] {
|
||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||
w.Header().Set("Vary", "Origin")
|
||
}
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-HD4K-Token")
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||
body, _ := json.Marshal(v)
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(code)
|
||
w.Write(body)
|
||
}
|
||
|
||
// readBody 读 JSON body(限大小)
|
||
func readBody(r *http.Request) (map[string]interface{}, int) {
|
||
data, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||
if err != nil {
|
||
return nil, http.StatusBadRequest
|
||
}
|
||
if len(data) > maxBody {
|
||
return nil, http.StatusRequestEntityTooLarge
|
||
}
|
||
var m map[string]interface{}
|
||
if err := json.Unmarshal(data, &m); err != nil {
|
||
return nil, http.StatusBadRequest
|
||
}
|
||
return m, 0
|
||
}
|
||
|
||
func checkToken(w http.ResponseWriter, r *http.Request) bool {
|
||
if r.Header.Get("X-HD4K-Token") != BridgeToken {
|
||
writeJSON(w, http.StatusForbidden, map[string]interface{}{"ok": false, "error": "bad token"})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (b *Bridge) handlePing(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"ok": true,
|
||
"name": AppName,
|
||
})
|
||
}
|
||
|
||
// handleInfo 除了健康检查,还把站点域名列表回给油猴脚本 ——
|
||
// 脚本启动时读它,就能在用户改了设置之后自动对上 @match 域名,
|
||
// 不用每次都去改脚本头部的 @match 行。
|
||
func (b *Bridge) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||
sites := b.store.Settings().Sites
|
||
hosts := make([]string, 0, len(sites))
|
||
for _, s := range sites {
|
||
if h := hd4k.NormalizeHost(s.Host); h != "" {
|
||
hosts = append(hosts, h)
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"ok": true,
|
||
"port": b.Port(),
|
||
"version": AppVersion,
|
||
"name": AppName,
|
||
"hosts": hosts,
|
||
"token": BridgeToken,
|
||
})
|
||
}
|
||
|
||
type batchItem struct {
|
||
URL string `json:"url"`
|
||
Title string `json:"title"`
|
||
}
|
||
|
||
// parseRef 解析一条推送 → GalleryRef(nil 表示无效)
|
||
func (b *Bridge) parseRef(rawURL, title string) *hd4k.GalleryRef {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return nil
|
||
}
|
||
sites := b.store.Settings().Sites
|
||
ref, err := hd4k.ParseGalleryURL(rawURL, sites)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
ref.Raw = rawURL
|
||
ref.Title = strings.TrimSpace(title)
|
||
return &ref
|
||
}
|
||
|
||
func (b *Bridge) handleAddBatch(w http.ResponseWriter, r *http.Request) {
|
||
if !checkToken(w, r) {
|
||
b.log("warn", "桥口收到 token 不正确的批量请求,已拒绝")
|
||
return
|
||
}
|
||
body, code := readBody(r)
|
||
if code != 0 {
|
||
writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"})
|
||
return
|
||
}
|
||
|
||
raw, _ := body["items"].([]interface{})
|
||
if len(raw) == 0 {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "items 为空"})
|
||
return
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
var fresh []hd4k.GalleryRef
|
||
existed := 0
|
||
invalid := 0
|
||
|
||
for _, it := range raw {
|
||
if len(fresh) >= 500 {
|
||
break
|
||
}
|
||
m, ok := it.(map[string]interface{})
|
||
if !ok {
|
||
invalid++
|
||
continue
|
||
}
|
||
item := batchItem{URL: str(m["url"]), Title: str(m["title"])}
|
||
ref := b.parseRef(item.URL, item.Title)
|
||
if ref == nil {
|
||
invalid++
|
||
continue
|
||
}
|
||
if seen[ref.ID] {
|
||
continue
|
||
}
|
||
seen[ref.ID] = true
|
||
|
||
if b.store.FindTask(ref.ID) != nil {
|
||
existed++
|
||
continue
|
||
}
|
||
fresh = append(fresh, *ref)
|
||
}
|
||
|
||
added := b.store.AddTasks(fresh)
|
||
for _, ref := range fresh {
|
||
if ref.Title != "" {
|
||
b.store.SetTitle(ref.ID, ref.Title)
|
||
}
|
||
}
|
||
b.store.ScheduleFlush(0)
|
||
|
||
for _, ref := range fresh {
|
||
if t := b.store.FindTask(ref.ID); t != nil && b.onAdded != nil {
|
||
b.onAdded(*t, true, true)
|
||
}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"ok": true,
|
||
"added": added,
|
||
"existed": existed,
|
||
"received": len(raw),
|
||
"invalid": invalid,
|
||
})
|
||
}
|
||
|
||
func (b *Bridge) handleAdd(w http.ResponseWriter, r *http.Request) {
|
||
if !checkToken(w, r) {
|
||
b.log("warn", "桥口收到 token 不正确的请求,已拒绝")
|
||
return
|
||
}
|
||
body, code := readBody(r)
|
||
if code != 0 {
|
||
writeJSON(w, code, map[string]interface{}{"ok": false, "error": "bad request"})
|
||
return
|
||
}
|
||
|
||
ref := b.parseRef(str(body["url"]), str(body["title"]))
|
||
if ref == nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||
"ok": false, "error": "缺少 url 或不是画廊链接",
|
||
})
|
||
return
|
||
}
|
||
|
||
exists := b.store.FindTask(ref.ID) != nil
|
||
added := b.store.AddTasks([]hd4k.GalleryRef{*ref})
|
||
if ref.Title != "" {
|
||
b.store.SetTitle(ref.ID, ref.Title)
|
||
}
|
||
b.store.ScheduleFlush(0)
|
||
|
||
if t := b.store.FindTask(ref.ID); t != nil && b.onAdded != nil {
|
||
b.onAdded(*t, added > 0, false)
|
||
}
|
||
|
||
queued := false
|
||
if t := b.store.FindTask(ref.ID); t != nil {
|
||
queued = t.Status == "queued"
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"ok": true,
|
||
"added": added > 0,
|
||
"existed": exists,
|
||
"url": ref.URL,
|
||
"id": ref.ID,
|
||
"queued": queued,
|
||
})
|
||
}
|
||
|
||
// str 安全取字符串
|
||
func str(v interface{}) string {
|
||
s, _ := v.(string)
|
||
return s
|
||
}
|