feat: launch privacy-first local lyrics tool

This commit is contained in:
Flechazo committed 2026-08-25 22:44:26 +08:00
commit 70fbfc2889
17 files changed
+893

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
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("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)
}