package main import ( "crypto/subtle" "encoding/json" "errors" "io" "io/fs" "net/http" "net/url" "regexp" "strings" "sync" "time" ) const sessionCookie = "lrc_local_session" var songMIDPattern = regexp.MustCompile(`^[A-Za-z0-9]{8,64}$`) type localApp struct { baseURL string host string token string web fs.FS qq qqAPI sem chan struct{} mu sync.Mutex window time.Time count int } func newApp(baseURL, token string, web fs.FS, qq qqAPI) *localApp { parsed, _ := url.Parse(baseURL) return &localApp{ baseURL: baseURL, host: parsed.Host, token: token, web: web, qq: qq, sem: make(chan struct{}, 4), window: time.Now(), } } func (a *localApp) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /start", a.start) mux.HandleFunc("GET /app", a.withSession(a.appPage)) mux.HandleFunc("GET /app.js", a.withSession(a.asset("app.js", "text/javascript; charset=utf-8"))) mux.HandleFunc("GET /styles.css", a.withSession(a.asset("styles.css", "text/css; charset=utf-8"))) mux.HandleFunc("GET /api/info", a.withSession(func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"mode": "local", "version": version}) })) mux.HandleFunc("POST /api/search", a.withSession(a.withAPI(a.search))) mux.HandleFunc("POST /api/lyrics", a.withSession(a.withAPI(a.lyrics))) mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) return a.securityHeaders(a.validateHost(mux)) } func (a *localApp) validateHost(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Host != a.host || r.RemoteAddr == "" { http.Error(w, "invalid local request", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } func (a *localApp) 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("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") next.ServeHTTP(w, r) }) } func (a *localApp) start(w http.ResponseWriter, r *http.Request) { if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("t")), []byte(a.token)) != 1 { http.Error(w, "invalid launch token", http.StatusForbidden) return } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: a.token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 12 * 60 * 60, }) http.Redirect(w, r, "/app", http.StatusSeeOther) } func (a *localApp) withSession(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(sessionCookie) if err != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(a.token)) != 1 { http.Error(w, "please launch LRC Local again", http.StatusUnauthorized) return } next(w, r) } } func (a *localApp) withAPI(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Origin") != a.baseURL || !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { writeJSON(w, http.StatusForbidden, map[string]string{"error": "invalid local origin"}) return } if !a.allowRequest() { 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 (a *localApp) allowRequest() bool { a.mu.Lock() defer a.mu.Unlock() now := time.Now() if now.Sub(a.window) >= time.Minute { a.window, a.count = now, 0 } if a.count >= 60 { return false } a.count++ return true } func (a *localApp) appPage(w http.ResponseWriter, r *http.Request) { a.serveFile(w, r, "app.html", "text/html; charset=utf-8") } func (a *localApp) asset(name, contentType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a.serveFile(w, r, name, contentType) } } func (a *localApp) 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.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (a *localApp) search(w http.ResponseWriter, r *http.Request) { var input struct { Keyword string `json:"keyword"` Limit int `json:"limit"` } if err := decodeOneJSON(r, &input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求格式无效"}) return } input.Keyword = strings.TrimSpace(input.Keyword) if len([]rune(input.Keyword)) < 1 || len([]rune(input.Keyword)) > 100 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "关键词长度应为 1–100 个字符"}) return } if input.Limit < 1 || input.Limit > 20 { input.Limit = 10 } songs, err := a.qq.Search(input.Keyword, input.Limit) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"songs": songs}) } func (a *localApp) lyrics(w http.ResponseWriter, r *http.Request) { var input struct { MID string `json:"mid"` } if err := decodeOneJSON(r, &input); err != nil || !songMIDPattern.MatchString(input.MID) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "歌曲标识无效"}) return } result, err := a.qq.Lyrics(input.MID) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, result) } func decodeOneJSON(r *http.Request, destination any) error { decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { return err } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return errors.New("multiple JSON values") } return nil } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }