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 }