From 54167c1b593ada3b2d8ca24edb1ae2efaf7782c9 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Tue, 25 Aug 2026 23:08:28 +0800 Subject: [PATCH] feat: add protected public web lyrics workflow --- .dockerignore | 1 + Dockerfile | 8 +- README.md | 13 +-- docker-compose.yml | 10 +-- main.go | 54 +++++++++++- public/index.html | 21 ----- public/site.css | 1 - public_server.go | 187 ++++++++++++++++++++++++++++++++++++++++++ public_server_test.go | 108 ++++++++++++++++++++++++ server.go | 3 + web/app.html | 47 +++++++---- web/app.js | 27 +++++- web/styles.css | 2 +- 13 files changed, 425 insertions(+), 57 deletions(-) delete mode 100644 public/index.html delete mode 100644 public/site.css create mode 100644 public_server.go create mode 100644 public_server_test.go diff --git a/.dockerignore b/.dockerignore index 5f399e6..fbba30f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ .git dist *.exe +public diff --git a/Dockerfile b/Dockerfile index 752f1b5..c8d0160 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN go test ./... \ && mkdir -p /out/download \ && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-windows-amd64.exe . \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-linux-amd64 . \ + && cp /out/download/lrc-local-linux-amd64 /out/lrc-local-server \ && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-darwin-arm64 . \ && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/download/lrc-local-darwin-amd64 . \ && cd /out/download \ @@ -18,9 +19,10 @@ RUN go test ./... \ && rm lrc-local-linux-amd64 lrc-local-darwin-arm64 lrc-local-darwin-amd64 \ && sha256sum lrc-local-* > SHA256SUMS -FROM busybox:1.37.0-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23 -COPY public /srv +FROM scratch +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /out/lrc-local-server /lrc-local COPY --from=builder /out/download /srv/download USER 65534:65534 EXPOSE 8080 -ENTRYPOINT ["httpd", "-f", "-p", "8080", "-h", "/srv"] +ENTRYPOINT ["/lrc-local", "--public", "--listen", "0.0.0.0:8080", "--origin", "https://lrc.flechazo.xin", "--downloads", "/srv/download"] diff --git a/README.md b/README.md index bce549e..55fbae3 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # LRC Local -一个本地优先的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。 +一个可直接在网页使用、也提供可选本机版的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。 公开下载页: -## 为什么不是纯网页 +## 网页版与本机版 -QQ 音乐接口拒绝第三方网页的跨域请求,并校验来源。浏览器页面无法安全地修改 `Origin` 和 `Referer`。LRC Local 因此以本机单文件程序运行:程序只监听随机的 `127.0.0.1` 端口,浏览器 UI 和外部请求均由用户自己的电脑处理;公开服务器只分发静态页面和二进制文件。 +QQ 音乐接口拒绝第三方网页的跨域请求,并校验来源。浏览器页面无法直接调用,因此网页版由本站 Go 后端转发到源码中固定的搜索和歌词接口。请求正文、搜索词和歌词不写入数据库或访问日志。 + +公开页面同时保留本机单文件程序。如果不希望查询经过本站,可以下载本机版:程序只监听随机的 `127.0.0.1` 端口,浏览器 UI 和外部请求均由用户自己的电脑处理。 ## 功能 - 搜索歌曲并显示歌手、专辑和时长。 - 导出原文 `.lrc`、翻译 `.trans.lrc` 或无时间轴 `.txt`。 -- 不创建账号,不保存搜索历史,不向本站服务器上传任何内容。 +- 不创建账号,不保存搜索历史;网页版仅在请求期间处理查询与歌词,本机版不向本站发送这些内容。 ## 安全边界 @@ -23,6 +25,7 @@ QQ 音乐接口拒绝第三方网页的跨域请求,并校验来源。浏览 - 限制请求体、上游响应体、并发、频率和超时。 - 页面启用严格 CSP、禁止嵌入、禁止外部脚本和外部资源。 - 所有公开构建均提供 SHA-256 校验值;Linux 和 macOS 使用归档保留可执行权限。 +- 公开模式按客户端和全局限频,限制并发、请求体与上游响应大小;Caddy 覆盖真实客户端地址头。 ## 本地构建与测试 @@ -40,7 +43,7 @@ go build -trimpath . ## 部署 -生产变更必须先提交并推送到自建 Gitea,再由生产机检出明确提交。`docker compose up -d --build` 会从同一提交交叉编译四个平台的程序,并以无特权、只读容器提供静态下载页。Caddy 只反向代理 `127.0.0.1:8083`。 +生产变更必须先提交并推送到自建 Gitea,再由生产机检出明确提交。`docker compose up -d --build` 会先执行测试,再从同一提交构建公开 Go Web 服务并交叉编译四个平台的本机程序。容器无特权、根文件系统只读;Caddy 只反向代理 `127.0.0.1:8083`。 ## 声明 diff --git a/docker-compose.yml b/docker-compose.yml index 2cf3804..47067ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: . args: - VERSION: v2026.08.25-r2 + VERSION: v2026.08.25-r3 restart: unless-stopped ports: - "127.0.0.1:8083:8080" @@ -14,13 +14,13 @@ services: - no-new-privileges:true cap_drop: - ALL - pids_limit: 32 - mem_limit: 64m - cpus: 0.25 + pids_limit: 64 + mem_limit: 128m + cpus: 0.5 tmpfs: - /tmp:size=8m,mode=1777 healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"] + test: ["CMD", "/lrc-local", "--healthcheck", "http://127.0.0.1:8080/health"] interval: 30s timeout: 3s retries: 3 diff --git a/main.go b/main.go index eae4848..c5e72db 100644 --- a/main.go +++ b/main.go @@ -23,12 +23,37 @@ var version = "dev" func main() { noOpen := flag.Bool("no-open", false, "do not open the browser automatically") + publicMode := flag.Bool("public", false, "run the public web service") + listenAddress := flag.String("listen", "0.0.0.0:8080", "public web service listen address") + publicOrigin := flag.String("origin", "https://lrc.flechazo.xin", "allowed public browser origin") + downloadDirectory := flag.String("downloads", "", "directory containing optional local-app downloads") + healthcheckURL := flag.String("healthcheck", "", "check a running service and exit") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() if *showVersion { fmt.Println(version) return } + if *healthcheckURL != "" { + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Get(*healthcheckURL) + if err != nil { + log.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + log.Fatalf("healthcheck returned HTTP %d", response.StatusCode) + } + return + } + if *publicMode { + runPublic(*listenAddress, *publicOrigin, *downloadDirectory) + return + } + runLocal(*noOpen) +} + +func runLocal(noOpen bool) { listener, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { @@ -56,7 +81,7 @@ func main() { launchURL := baseURL + "/start?t=" + token fmt.Printf("LRC Local %s\n仅监听本机:%s\n关闭此窗口即可停止。\n", version, baseURL) - if !*noOpen { + if !noOpen { go func() { time.Sleep(150 * time.Millisecond) if err := openBrowser(launchURL); err != nil { @@ -71,6 +96,33 @@ func main() { } } +func runPublic(listenAddress, origin, downloadDirectory string) { + listener, err := net.Listen("tcp4", listenAddress) + if err != nil { + log.Fatal(err) + } + webFS, err := fs.Sub(embeddedWeb, "web") + if err != nil { + log.Fatal(err) + } + app, err := newPublicApp(origin, downloadDirectory, webFS, newQQClient()) + if err != nil { + log.Fatal(err) + } + server := &http.Server{ + Handler: app.routes(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 16 * 1024, + } + log.Printf("LRC Local %s public service listening on %s", version, listener.Addr()) + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} + func randomToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { diff --git a/public/index.html b/public/index.html deleted file mode 100644 index d8746c4..0000000 --- a/public/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - LRC Local — 歌词只经过你的电脑 - - - -
- -
-

NO CLOUD · NO ACCOUNT · NO HISTORY

歌词只经过
你的电脑。

一个很小的本地歌词工具。下载、运行,浏览器会自动打开;搜索词、歌词和生成的文件都不会经过本站服务器。

- -
-
02

双击运行

程序只监听随机的本机回环端口,不对局域网或公网开放。

03

搜索与导出

支持原文 LRC、翻译 LRC 和去除时间轴的 TXT。

04

关掉即停止

关闭程序窗口,本地服务立即消失;不创建账号,不保存历史。

-

它到底把什么发到哪里?

本站只提供静态页面和程序下载。运行后的程序只向固定的 QQ 音乐搜索与歌词接口发出请求,不接受任意代理地址。浏览器只连接 127.0.0.1,文件由浏览器在本地生成。

非 QQ 音乐官方工具。QQ 音乐是其权利人的商标。请遵守服务条款和著作权规则,只使用你有权访问的内容。校验文件:SHA256SUMS

-
- - diff --git a/public/site.css b/public/site.css deleted file mode 100644 index 7cc288b..0000000 --- a/public/site.css +++ /dev/null @@ -1 +0,0 @@ -:root{font-family:Inter,"Segoe UI","PingFang SC",sans-serif;background:#08100d;color:#edf6f2;color-scheme:dark;--mint:#75f0b2;--muted:#94a49e;--line:#27352f}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 87% 5%,#185036 0,transparent 34rem),#08100d}main{width:min(1160px,calc(100% - 34px));margin:auto}nav{height:86px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}nav b{letter-spacing:.08em}nav i{font-style:normal;color:var(--mint)}a{color:inherit}.hero{min-height:650px;display:grid;grid-template-columns:minmax(0,1.45fr) minmax(310px,.55fr);gap:70px;align-items:center}.tag{color:var(--mint);font:700 .74rem ui-monospace,monospace;letter-spacing:.18em}.copy h1{font-size:clamp(3.8rem,8.2vw,7.4rem);line-height:.88;letter-spacing:-.07em;margin:28px 0}.copy h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px #a9c9bc}.lead{max-width:690px;color:var(--muted);font-size:1.1rem;line-height:1.8}aside{border:1px solid #3a554a;background:#10211a;padding:28px;border-radius:14px;box-shadow:0 24px 70px #0008}aside>span,.steps b{color:var(--mint);font:700 .78rem ui-monospace,monospace}aside h2{font-size:1.7rem;margin:18px 0 8px}aside p,.steps p,.trust p{color:var(--muted);line-height:1.7}.primary{display:block;text-align:center;background:var(--mint);color:#07110c;text-decoration:none;font-weight:850;border-radius:8px;padding:15px;margin:25px 0 12px}.secondary{display:grid;gap:7px}.secondary a{text-align:center;border:1px solid #365046;border-radius:7px;padding:10px;text-decoration:none;font-size:.84rem}.steps{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.steps article{padding:36px 30px;border-right:1px solid var(--line)}.steps article:last-child{border:0}.steps h3{font-size:1.25rem}.trust{padding:80px 0;max-width:850px}.trust h2{font-size:2.4rem;letter-spacing:-.035em}.small{font-size:.82rem}code{background:#15231e;padding:2px 5px;border-radius:4px;color:#c6e3d7}@media(max-width:800px){.hero{grid-template-columns:1fr;padding:65px 0}.copy h1{font-size:4rem}.steps{grid-template-columns:1fr}.steps article{border-right:0;border-bottom:1px solid var(--line)}} diff --git a/public_server.go b/public_server.go new file mode 100644 index 0000000..9791986 --- /dev/null +++ b/public_server.go @@ -0,0 +1,187 @@ +package main + +import ( + "errors" + "io/fs" + "mime" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + publicClientLimit = 30 + publicGlobalLimit = 300 +) + +var publicDownloadNames = map[string]struct{}{ + "SHA256SUMS": {}, + "lrc-local-windows-amd64.exe": {}, + "lrc-local-linux-amd64.tar.gz": {}, + "lrc-local-darwin-arm64.tar.gz": {}, + "lrc-local-darwin-amd64.tar.gz": {}, +} + +type rateWindow struct { + started time.Time + seen time.Time + count int +} + +type publicApp struct { + origin string + host string + downloadDir string + web fs.FS + core *localApp + sem chan struct{} + mu sync.Mutex + global rateWindow + clients map[string]rateWindow +} + +func newPublicApp(origin, downloadDir string, web fs.FS, qq qqAPI) (*publicApp, error) { + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" { + return nil, errors.New("public origin must be an HTTPS origin without a path") + } + now := time.Now() + return &publicApp{ + origin: origin, host: parsed.Host, downloadDir: downloadDir, web: web, + core: newApp(origin, "", web, qq), sem: make(chan struct{}, 12), + global: rateWindow{started: now, seen: now}, clients: make(map[string]rateWindow), + }, nil +} + +func (a *publicApp) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /", a.index) + mux.HandleFunc("GET /app.js", a.asset("app.js", "text/javascript; charset=utf-8")) + mux.HandleFunc("GET /styles.css", a.asset("styles.css", "text/css; charset=utf-8")) + mux.HandleFunc("GET /api/info", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"mode": "public", "version": version}) + }) + mux.HandleFunc("POST /api/search", a.withAPI(a.core.search)) + mux.HandleFunc("POST /api/lyrics", a.withAPI(a.core.lyrics)) + mux.HandleFunc("GET /download/{name}", a.download) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + return a.core.securityHeaders(mux) +} + +func (a *publicApp) index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + a.serveFile(w, r, "app.html", "text/html; charset=utf-8") +} + +func (a *publicApp) asset(name, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { a.serveFile(w, r, name, contentType) } +} + +func (a *publicApp) 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.Write(data) +} + +func (a *publicApp) download(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if _, allowed := publicDownloadNames[name]; !allowed || a.downloadDir == "" { + http.NotFound(w, r) + return + } + path := filepath.Join(a.downloadDir, name) + file, err := os.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + http.ServeContent(w, r, name, info.ModTime(), file) +} + +func (a *publicApp) withAPI(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if r.Header.Get("Origin") != a.origin || mediaType != "application/json" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "请求来源无效"}) + return + } + if !a.allowRequest(clientAddress(r)) { + w.Header().Set("Retry-After", "60") + 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 clientAddress(r *http.Request) string { + if forwarded := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); forwarded != nil { + return forwarded.String() + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return "unknown" +} + +func (a *publicApp) allowRequest(client string) bool { + a.mu.Lock() + defer a.mu.Unlock() + now := time.Now() + if now.Sub(a.global.started) >= time.Minute { + a.global = rateWindow{started: now, seen: now} + } + if a.global.count >= publicGlobalLimit { + return false + } + entry := a.clients[client] + if entry.started.IsZero() || now.Sub(entry.started) >= time.Minute { + entry = rateWindow{started: now, seen: now} + } + if entry.count >= publicClientLimit { + return false + } + entry.count++ + entry.seen = now + a.clients[client] = entry + a.global.count++ + a.global.seen = now + if len(a.clients) > 2048 { + for address, candidate := range a.clients { + if now.Sub(candidate.seen) > 10*time.Minute { + delete(a.clients, address) + } + } + } + return true +} diff --git a/public_server_test.go b/public_server_test.go new file mode 100644 index 0000000..dda416d --- /dev/null +++ b/public_server_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func newTestPublicApp(t *testing.T, downloads string) (*publicApp, http.Handler) { + t.Helper() + web := fstest.MapFS{ + "app.html": &fstest.MapFile{Data: []byte("public app")}, + "app.js": &fstest.MapFile{Data: []byte("js")}, + "styles.css": &fstest.MapFile{Data: []byte("css")}, + } + app, err := newPublicApp("https://lrc.flechazo.xin", downloads, web, fakeQQ{}) + if err != nil { + t.Fatal(err) + } + return app, app.routes() +} + +func publicRequest(method, path, body string) *http.Request { + request := httptest.NewRequest(method, "https://lrc.flechazo.xin"+path, strings.NewReader(body)) + request.Header.Set("Origin", "https://lrc.flechazo.xin") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Real-IP", "203.0.113.7") + return request +} + +func TestPublicPageNeedsNoSession(t *testing.T) { + _, handler := newTestPublicApp(t, "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, publicRequest(http.MethodGet, "/", "")) + if w.Code != http.StatusOK || w.Body.String() != "public app" { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestPublicAPIRejectsWrongOrigin(t *testing.T) { + _, handler := newTestPublicApp(t, "") + request := publicRequest(http.MethodPost, "/api/search", `{"keyword":"test","limit":2}`) + request.Header.Set("Origin", "https://attacker.example") + w := httptest.NewRecorder() + handler.ServeHTTP(w, request) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestPublicSearchWorksWithoutCookie(t *testing.T) { + _, handler := newTestPublicApp(t, "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, publicRequest(http.MethodPost, "/api/search", `{"keyword":"测试","limit":2}`)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "测试") { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestPublicPerClientRateLimit(t *testing.T) { + _, handler := newTestPublicApp(t, "") + for attempt := 1; attempt <= publicClientLimit+1; attempt++ { + w := httptest.NewRecorder() + handler.ServeHTTP(w, publicRequest(http.MethodPost, "/api/search", `{"keyword":"test","limit":1}`)) + if attempt <= publicClientLimit && w.Code != http.StatusOK { + t.Fatalf("attempt %d unexpectedly returned %d", attempt, w.Code) + } + if attempt == publicClientLimit+1 && w.Code != http.StatusTooManyRequests { + t.Fatalf("expected final attempt to return 429, got %d", w.Code) + } + } +} + +func TestPublicDownloadWhitelistAndRange(t *testing.T) { + directory := t.TempDir() + name := "lrc-local-windows-amd64.exe" + if err := os.WriteFile(filepath.Join(directory, name), []byte("0123456789"), 0o600); err != nil { + t.Fatal(err) + } + _, handler := newTestPublicApp(t, directory) + request := publicRequest(http.MethodGet, "/download/"+name, "") + request.Header.Set("Range", "bytes=2-5") + w := httptest.NewRecorder() + handler.ServeHTTP(w, request) + if w.Code != http.StatusPartialContent || w.Body.String() != "2345" { + t.Fatalf("status=%d body=%q", w.Code, w.Body.String()) + } + if !strings.Contains(w.Header().Get("Content-Disposition"), name) { + t.Fatal("download disposition is missing") + } + + w = httptest.NewRecorder() + handler.ServeHTTP(w, publicRequest(http.MethodGet, "/download/not-allowed", "")) + if w.Code != http.StatusNotFound { + t.Fatalf("unexpected non-whitelist status %d", w.Code) + } +} + +func TestPublicOriginConfigurationMustUseHTTPS(t *testing.T) { + _, err := newPublicApp("http://lrc.flechazo.xin", "", fstest.MapFS{}, fakeQQ{}) + if err == nil { + t.Fatal("expected insecure public origin to be rejected") + } +} diff --git a/server.go b/server.go index b448c3c..589b904 100644 --- a/server.go +++ b/server.go @@ -44,6 +44,9 @@ func (a *localApp) routes() http.Handler { 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) { diff --git a/web/app.html b/web/app.html index 5ee1f19..419bb83 100644 --- a/web/app.html +++ b/web/app.html @@ -3,30 +3,43 @@ + LRC Local -
+
-
LRC Local
-
本机运行中
+

LRC Local

+ 源码
-
-

LOCAL-FIRST LYRIC TOOL

-

把歌词带走,
别把隐私留下。

-

搜索与歌词请求从这台电脑直接发出。关键词、歌词和下载文件不会经过我们的服务器。

-
- -
- - -
-
-
-
+ +

搜索歌曲并下载 LRC、翻译 LRC 或纯文本歌词。

+

正在确认运行模式…

+ +
+ +
+ + +
+
+
-
非 QQ 音乐官方工具。请仅下载和使用你有权访问的歌词内容。
+ + + +
网页版请求由本站转发到固定的 QQ 音乐接口,不保存搜索词、歌词或历史。非 QQ 音乐官方工具,请仅使用你有权访问的内容。
diff --git a/web/app.js b/web/app.js index 46a2a80..cad8590 100644 --- a/web/app.js +++ b/web/app.js @@ -2,6 +2,9 @@ const form = document.querySelector('#search-form'); const keyword = document.querySelector('#keyword'); const notice = document.querySelector('#notice'); const results = document.querySelector('#results'); +const modeNote = document.querySelector('#mode-note'); +const localDownloads = document.querySelector('#local-downloads'); +const lyricCache = new Map(); function text(value) { return document.createTextNode(value ?? ''); } function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; } @@ -15,6 +18,21 @@ async function api(path, payload) { return data; } +async function loadInfo() { + try { + const response = await fetch('/api/info'); + const info = await response.json(); + if (info.mode === 'local') { + modeNote.textContent = '本机模式:查询从这台电脑直接发出,不经过 lrc.flechazo.xin。'; + } else { + modeNote.textContent = '网页模式:查询由本站后端转发,但不会写入数据库或日志正文。下载文件在浏览器中生成。'; + localDownloads.hidden = false; + } + } catch { + modeNote.textContent = '无法确认运行模式,请刷新页面重试。'; + } +} + function download(name, body, type = 'text/plain;charset=utf-8') { const url = URL.createObjectURL(new Blob([body], {type})); const anchor = document.createElement('a'); @@ -27,7 +45,8 @@ async function fetchAndSave(song, kind, button) { button.disabled = true; notice.className = 'notice'; notice.textContent = `正在读取《${song.name}》…`; try { - const data = await api('/api/lyrics', {mid: song.mid}); + if (!lyricCache.has(song.mid)) lyricCache.set(song.mid, api('/api/lyrics', {mid: song.mid})); + const data = await lyricCache.get(song.mid).catch(error => { lyricCache.delete(song.mid); throw error; }); const base = safeName(`${song.name} - ${song.singer}`); if (kind === 'lrc') download(`${base}.lrc`, data.lyric); if (kind === 'txt') download(`${base}.txt`, plainText(data.lyric)); @@ -35,7 +54,7 @@ async function fetchAndSave(song, kind, button) { if (!data.trans) throw new Error('这首歌没有翻译歌词'); download(`${base}.trans.lrc`, data.trans); } - notice.textContent = '文件已在浏览器中生成,内容未上传。'; + notice.textContent = '文件已在浏览器中生成。'; } catch (error) { notice.className = 'notice error'; notice.textContent = error.message; } finally { button.disabled = false; } @@ -61,10 +80,12 @@ function renderSongs(songs) { form.addEventListener('submit', async event => { event.preventDefault(); const submit = form.querySelector('button'); submit.disabled = true; - notice.className = 'notice'; notice.textContent = '正在从这台电脑搜索…'; results.replaceChildren(); + notice.className = 'notice'; notice.textContent = '正在搜索…'; results.replaceChildren(); lyricCache.clear(); try { const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15}); renderSongs(data.songs || []); notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。` : '没有找到相关歌曲。'; } catch (error) { notice.className = 'notice error'; notice.textContent = error.message; } finally { submit.disabled = false; } }); + +loadInfo(); diff --git a/web/styles.css b/web/styles.css index b4b36c7..4e22638 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1 +1 @@ -:root{font-family:Inter,"Segoe UI","PingFang SC",sans-serif;color:#eaf0ee;background:#09100e;color-scheme:dark;--mint:#75f0b2;--muted:#93a39e;--line:#26332f}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 85% 5%,#17442f 0,transparent 31rem),linear-gradient(145deg,#09100e,#101815 60%,#080d0b)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.12;background-image:linear-gradient(#fff 1px,transparent 1px),linear-gradient(90deg,#fff 1px,transparent 1px);background-size:48px 48px}.shell{width:min(1020px,calc(100% - 32px));margin:auto;padding:30px 0 48px;position:relative}header{display:flex;justify-content:space-between;align-items:center}.brand{font-size:1.1rem;font-weight:850;letter-spacing:.08em}.brand span{color:var(--mint)}.local-pill{border:1px solid #315044;background:#10251d;padding:8px 12px;border-radius:99px;color:#b9d3c9;font-size:.82rem}.local-pill i{display:inline-block;width:7px;height:7px;background:var(--mint);border-radius:50%;box-shadow:0 0 12px var(--mint);margin-right:6px}.hero{padding:90px 0 42px;max-width:780px}.eyebrow{color:var(--mint);font-family:ui-monospace,monospace;font-size:.78rem;letter-spacing:.18em}.hero h1{font-size:clamp(3.1rem,9vw,6.8rem);line-height:.91;letter-spacing:-.065em;margin:24px 0;font-weight:880}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px #9ec4b5}.lead{color:var(--muted);font-size:1.08rem;line-height:1.75;max-width:650px}form{margin-top:38px}label{display:block;font-size:.82rem;color:#b5c4bf;margin-bottom:9px}.search-row{display:flex;gap:10px}input{flex:1;min-width:0;background:#eff7f3;color:#0b1511;border:0;border-radius:8px;padding:17px 18px;font:inherit;font-size:1.04rem;outline:2px solid transparent}input:focus{outline-color:var(--mint)}button{border:0;border-radius:8px;padding:0 24px;background:var(--mint);color:#07100c;font:inherit;font-weight:800;cursor:pointer}button:hover{filter:brightness(1.06)}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;color:#b7cac2;margin-top:14px}.notice.error{color:#ff9b9b}.results{display:grid;gap:10px}.song{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px;align-items:center;padding:20px;border:1px solid var(--line);background:#101916d9;border-radius:12px}.song h2{font-size:1.05rem;margin:0 0 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:var(--muted);font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;gap:7px}.actions button{background:#1c2a25;color:#d9e7e2;border:1px solid #31453e;padding:9px 12px;font-size:.82rem}.actions button.primary{background:var(--mint);border-color:var(--mint);color:#07100c}footer{margin-top:70px;padding-top:20px;border-top:1px solid var(--line);color:#75847f;font-size:.78rem}@media(max-width:700px){.shell{width:min(100% - 22px,1020px)}.hero{padding-top:65px}.hero h1{font-size:3.6rem}.search-row{display:grid}.search-row button{padding:15px}.song{grid-template-columns:1fr}.actions{flex-wrap:wrap}.actions button{flex:1}.local-pill{font-size:.72rem}} +:root{font-family:system-ui,-apple-system,"Segoe UI","PingFang SC",sans-serif;color:#1f2937;background:#f7f8fa;color-scheme:light;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{width:min(820px,calc(100% - 28px));margin:0 auto;padding:34px 0 60px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #dfe3e8;padding-bottom:16px;margin-bottom:28px}h1{font-size:1.5rem;margin:0}h2{font-size:1.05rem;margin:0 0 8px}a{color:#155eef}p{margin:10px 0}.note{background:#eef4ff;border:1px solid #c9d9ff;border-radius:8px;padding:10px 12px;color:#344054;font-size:.9rem}form{margin:26px 0 0}label{display:block;font-weight:650;margin-bottom:7px}.search-row{display:flex;gap:8px}input{flex:1;min-width:0;border:1px solid #b8c0cc;border-radius:7px;background:#fff;color:#111827;padding:12px;font:inherit}input:focus{outline:2px solid #8bb6ff;border-color:#3977db}button{border:0;border-radius:7px;background:#2563eb;color:#fff;padding:0 20px;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:wait}.notice{min-height:28px;margin:10px 0;color:#475467}.notice.error{color:#b42318}.results{display:grid;gap:8px}.song{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;padding:14px;background:#fff;border:1px solid #dfe3e8;border-radius:8px}.song h2{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#667085;font-size:.86rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.actions{display:flex;gap:6px}.actions button{background:#fff;color:#344054;border:1px solid #b8c0cc;padding:7px 10px;font-size:.82rem}.actions button.primary{background:#2563eb;color:#fff;border-color:#2563eb}.downloads{margin-top:38px;padding-top:24px;border-top:1px solid #dfe3e8}.downloads p,footer{color:#667085;font-size:.88rem}.download-links{display:flex;flex-wrap:wrap;gap:8px 15px}code{background:#eceff3;padding:1px 4px;border-radius:4px}footer{margin-top:35px;padding-top:20px;border-top:1px solid #dfe3e8}@media(max-width:650px){main{padding-top:22px}.search-row{display:grid}.search-row button{padding:11px}.song{grid-template-columns:1fr}.actions{flex-wrap:wrap}.actions button{flex:1}}