package main import ( "errors" "io/fs" "mime" "net" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" ) const ( publicClientLimit = 30 publicGlobalLimit = 300 ) var publicDownloadNames = map[string]struct{}{ "SHA256SUMS": {}, "lrc-local-windows-amd64.exe": {}, "lrc-local-linux-amd64.tar.gz": {}, "lrc-local-darwin-arm64.tar.gz": {}, "lrc-local-darwin-amd64.tar.gz": {}, } type rateWindow struct { started time.Time seen time.Time count int } type publicApp struct { origin string host string downloadDir string web fs.FS core *localApp sem chan struct{} mu sync.Mutex global rateWindow clients map[string]rateWindow } func newPublicApp(origin, downloadDir string, web fs.FS, qq qqAPI) (*publicApp, error) { parsed, err := url.Parse(origin) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" { return nil, errors.New("public origin must be an HTTPS origin without a path") } now := time.Now() return &publicApp{ origin: origin, host: parsed.Host, downloadDir: downloadDir, web: web, core: newApp(origin, "", web, qq), sem: make(chan struct{}, 12), global: rateWindow{started: now, seen: now}, clients: make(map[string]rateWindow), }, nil } func (a *publicApp) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", a.index) mux.HandleFunc("GET /app.js", a.asset("app.js", "text/javascript; charset=utf-8")) mux.HandleFunc("GET /styles.css", a.asset("styles.css", "text/css; charset=utf-8")) mux.HandleFunc("GET /api/info", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"mode": "public", "version": version}) }) mux.HandleFunc("POST /api/search", a.withAPI(a.core.search)) mux.HandleFunc("POST /api/lyrics", a.withAPI(a.core.lyrics)) mux.HandleFunc("GET /download/{name}", a.download) mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) return a.securityHeaders(mux) } func (a *publicApp) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("X-Frame-Options", "DENY") next.ServeHTTP(w, r) }) } func (a *publicApp) index(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } a.serveFile(w, r, "app.html", "text/html; charset=utf-8") } func (a *publicApp) asset(name, contentType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a.serveFile(w, r, name, contentType) } } func (a *publicApp) serveFile(w http.ResponseWriter, r *http.Request, name, contentType string) { data, err := fs.ReadFile(a.web, name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", contentType) _, _ = w.Write(data) } func (a *publicApp) download(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if _, allowed := publicDownloadNames[name]; !allowed || a.downloadDir == "" { http.NotFound(w, r) return } path := filepath.Join(a.downloadDir, name) file, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer file.Close() info, err := file.Stat() if err != nil || !info.Mode().IsRegular() { http.NotFound(w, r) return } w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) http.ServeContent(w, r, name, info.ModTime(), file) } func (a *publicApp) withAPI(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) if r.Header.Get("Origin") != a.origin || mediaType != "application/json" { writeJSON(w, http.StatusForbidden, map[string]string{"error": "请求来源无效"}) return } if !a.allowRequest(clientAddress(r)) { w.Header().Set("Retry-After", "60") writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "请求过于频繁,请稍后再试"}) return } select { case a.sem <- struct{}{}: defer func() { <-a.sem }() case <-time.After(time.Second): writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "当前请求较多,请稍后重试"}) return } r.Body = http.MaxBytesReader(w, r.Body, 16*1024) next(w, r) } } func clientAddress(r *http.Request) string { if forwarded := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); forwarded != nil { return forwarded.String() } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { return host } return "unknown" } func (a *publicApp) allowRequest(client string) bool { a.mu.Lock() defer a.mu.Unlock() now := time.Now() if now.Sub(a.global.started) >= time.Minute { a.global = rateWindow{started: now, seen: now} } if a.global.count >= publicGlobalLimit { return false } entry := a.clients[client] if entry.started.IsZero() || now.Sub(entry.started) >= time.Minute { entry = rateWindow{started: now, seen: now} } if entry.count >= publicClientLimit { return false } entry.count++ entry.seen = now a.clients[client] = entry a.global.count++ a.global.seen = now if len(a.clients) > 2048 { for address, candidate := range a.clients { if now.Sub(candidate.seen) > 10*time.Minute { delete(a.clients, address) } } } return true }