feat: launch privacy-first local lyrics tool
This commit is contained in:
commit
70fbfc2889
17 files changed
+893
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user