218 lines
6.7 KiB
Go
218 lines
6.7 KiB
Go
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)
|
||
}
|