Files
hd4k-downloader/internal/core/bridge.go
T
2026-10-11 12:56:38 +08:00

347 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}