Files
lrc-local/server.go
T

218 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": "关键词长度应为 1100 个字符"})
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)
}