commit 70fbfc288906554cf0bd681e3db4eeb940e4e4bd Author: Flechazo <2558755403@qq.com> Date: Tue Aug 25 22:44:26 2026 +0800 feat: launch privacy-first local lyrics tool diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c1d6f78 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf +*.go text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf +*.yml text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..514147f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +*.exe +*.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ec9e5a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.26.2-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS builder +ARG VERSION=dev +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +COPY web ./web +RUN 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 . \ + && 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 \ + && tar -czf lrc-local-linux-amd64.tar.gz lrc-local-linux-amd64 \ + && tar -czf lrc-local-darwin-arm64.tar.gz lrc-local-darwin-arm64 \ + && tar -czf lrc-local-darwin-amd64.tar.gz lrc-local-darwin-amd64 \ + && 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 +COPY --from=builder /out/download /srv/download +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["httpd", "-f", "-p", "8080", "-h", "/srv"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2acf3ad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Flechazo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bce549e --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# LRC Local + +一个本地优先的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。 + +公开下载页: + +## 为什么不是纯网页 + +QQ 音乐接口拒绝第三方网页的跨域请求,并校验来源。浏览器页面无法安全地修改 `Origin` 和 `Referer`。LRC Local 因此以本机单文件程序运行:程序只监听随机的 `127.0.0.1` 端口,浏览器 UI 和外部请求均由用户自己的电脑处理;公开服务器只分发静态页面和二进制文件。 + +## 功能 + +- 搜索歌曲并显示歌手、专辑和时长。 +- 导出原文 `.lrc`、翻译 `.trans.lrc` 或无时间轴 `.txt`。 +- 不创建账号,不保存搜索历史,不向本站服务器上传任何内容。 + +## 安全边界 + +- 只监听操作系统分配的随机 IPv4 回环端口。 +- 启动使用 256 位随机令牌,之后使用 `HttpOnly`、`SameSite=Strict` 会话 Cookie。 +- 校验精确的 `Host` 和 `Origin`,不设置 CORS,拒绝 DNS rebinding 和跨站 API 请求。 +- 只连接源码中固定的两个 HTTPS 接口;禁止重定向,不提供通用代理。 +- 限制请求体、上游响应体、并发、频率和超时。 +- 页面启用严格 CSP、禁止嵌入、禁止外部脚本和外部资源。 +- 所有公开构建均提供 SHA-256 校验值;Linux 和 macOS 使用归档保留可执行权限。 + +## 本地构建与测试 + +```sh +go test ./... +go vet ./... +go build -trimpath . +``` + +运行: + +```sh +./lrc-local +``` + +## 部署 + +生产变更必须先提交并推送到自建 Gitea,再由生产机检出明确提交。`docker compose up -d --build` 会从同一提交交叉编译四个平台的程序,并以无特权、只读容器提供静态下载页。Caddy 只反向代理 `127.0.0.1:8083`。 + +## 声明 + +本项目不是 QQ 音乐官方产品,QQ 音乐是其权利人的商标。使用者应遵守相关服务条款与著作权规则,只访问和使用自己有权获取的内容。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e3d968e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +name: lrc-local + +services: + site: + build: + context: . + args: + VERSION: v2026.08.25-r1 + restart: unless-stopped + ports: + - "127.0.0.1:8083:8080" + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 32 + mem_limit: 64m + cpus: 0.25 + tmpfs: + - /tmp:size=8m,mode=1777 + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a6fee7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.sighs.cc/Flechazo/lrc-local + +go 1.24 diff --git a/main.go b/main.go new file mode 100644 index 0000000..eae4848 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "crypto/rand" + "embed" + "encoding/base64" + "flag" + "fmt" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "time" +) + +//go:embed web/* +var embeddedWeb embed.FS + +var version = "dev" + +func main() { + noOpen := flag.Bool("no-open", false, "do not open the browser automatically") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion { + fmt.Println(version) + return + } + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + log.Fatal(err) + } + token, err := randomToken() + if err != nil { + log.Fatal(err) + } + webFS, err := fs.Sub(embeddedWeb, "web") + if err != nil { + log.Fatal(err) + } + + baseURL := "http://" + listener.Addr().String() + app := newApp(baseURL, token, webFS, newQQClient()) + server := &http.Server{ + Handler: app.routes(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 16 * 1024, + } + + launchURL := baseURL + "/start?t=" + token + fmt.Printf("LRC Local %s\n仅监听本机:%s\n关闭此窗口即可停止。\n", version, baseURL) + if !*noOpen { + go func() { + time.Sleep(150 * time.Millisecond) + if err := openBrowser(launchURL); err != nil { + log.Printf("无法自动打开浏览器,请手动访问:%s", launchURL) + } + }() + } else { + fmt.Printf("请访问:%s\n", launchURL) + } + 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 { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func openBrowser(url string) error { + var command *exec.Cmd + switch runtime.GOOS { + case "windows": + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + command = exec.Command("open", url) + default: + command = exec.Command("xdg-open", url) + } + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Start() +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..d8746c4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,21 @@ + + + + + + + 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 new file mode 100644 index 0000000..7cc288b --- /dev/null +++ b/public/site.css @@ -0,0 +1 @@ +: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/qq.go b/qq.go new file mode 100644 index 0000000..a3e53dc --- /dev/null +++ b/qq.go @@ -0,0 +1,170 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + searchEndpoint = "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp" + lyricsEndpoint = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" + maxUpstreamBytes = 4 << 20 +) + +type song struct { + Name string `json:"name"` + MID string `json:"mid"` + Singer string `json:"singer"` + Album string `json:"album"` + Interval int `json:"interval"` +} + +type lyricResult struct { + Lyric string `json:"lyric"` + Trans string `json:"trans"` + Code int `json:"code"` +} + +type qqAPI interface { + Search(string, int) ([]song, error) + Lyrics(string) (lyricResult, error) +} + +type qqClient struct { + httpClient *http.Client + searchEndpoint string + lyricsEndpoint string +} + +func newQQClient() *qqClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 8 + transport.MaxIdleConnsPerHost = 4 + transport.ResponseHeaderTimeout = 10 * time.Second + return &qqClient{ + httpClient: &http.Client{ + Timeout: 15 * time.Second, + Transport: transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return errors.New("unexpected redirect from fixed QQ Music endpoint") + }, + }, + searchEndpoint: searchEndpoint, + lyricsEndpoint: lyricsEndpoint, + } +} + +func (c *qqClient) Search(keyword string, limit int) ([]song, error) { + query := url.Values{ + "format": {"json"}, + "p": {"1"}, + "n": {fmt.Sprint(limit)}, + "w": {keyword}, + "outCharset": {"utf-8"}, + } + var response struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Song struct { + List []struct { + SongName string `json:"songname"` + SongMID string `json:"songmid"` + AlbumName string `json:"albumname"` + Interval int `json:"interval"` + Singer []struct { + Name string `json:"name"` + } `json:"singer"` + } `json:"list"` + } `json:"song"` + } `json:"data"` + } + if err := c.getJSON(c.searchEndpoint, query, &response); err != nil { + return nil, err + } + if response.Code != 0 { + return nil, fmt.Errorf("QQ Music search failed: %s", response.Message) + } + result := make([]song, 0, len(response.Data.Song.List)) + for _, item := range response.Data.Song.List { + if item.SongMID == "" { + continue + } + names := make([]string, 0, len(item.Singer)) + for _, singer := range item.Singer { + if singer.Name != "" { + names = append(names, singer.Name) + } + } + result = append(result, song{ + Name: item.SongName, MID: item.SongMID, + Singer: strings.Join(names, " / "), Album: item.AlbumName, Interval: item.Interval, + }) + } + return result, nil +} + +func (c *qqClient) Lyrics(mid string) (lyricResult, error) { + query := url.Values{ + "format": {"json"}, "songmid": {mid}, "outCharset": {"utf-8"}, "nobase64": {"1"}, + } + var result lyricResult + if err := c.getJSON(c.lyricsEndpoint, query, &result); err != nil { + return lyricResult{}, err + } + if result.Code != 0 || result.Lyric == "" { + return lyricResult{}, errors.New("lyrics are not available for this track") + } + return result, nil +} + +func (c *qqClient) getJSON(endpoint string, query url.Values, destination any) error { + request, err := http.NewRequest(http.MethodGet, endpoint+"?"+query.Encode(), nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36") + request.Header.Set("Referer", "https://y.qq.com/") + request.Header.Set("Origin", "https://y.qq.com") + request.Header.Set("Accept", "application/json,text/plain,*/*") + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("QQ Music request failed: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("QQ Music returned HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxUpstreamBytes+1)) + if err != nil { + return err + } + if len(body) > maxUpstreamBytes { + return errors.New("QQ Music response exceeded the safety limit") + } + body = unwrapJSONP(body) + if err := json.Unmarshal(body, destination); err != nil { + return errors.New("QQ Music returned an invalid response") + } + return nil +} + +func unwrapJSONP(body []byte) []byte { + body = bytes.TrimSpace(body) + if len(body) == 0 || body[0] == '{' || body[0] == '[' { + return body + } + left := bytes.IndexByte(body, '(') + right := bytes.LastIndexByte(body, ')') + if left >= 0 && right > left { + return bytes.TrimSpace(body[left+1 : right]) + } + return body +} diff --git a/qq_test.go b/qq_test.go new file mode 100644 index 0000000..28a3cfb --- /dev/null +++ b/qq_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUnwrapJSONP(t *testing.T) { + got := unwrapJSONP([]byte(" callback_1( {\"code\":0} ); ")) + if string(got) != `{"code":0}` { + t.Fatalf("unexpected JSONP body: %q", got) + } +} + +func TestQQClientSearchUsesFixedShape(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") != "https://y.qq.com" || r.Header.Get("Referer") != "https://y.qq.com/" { + t.Fatal("required upstream headers were not set") + } + if r.URL.Query().Get("w") != "起风了" || r.URL.Query().Get("n") != "2" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"code":0,"data":{"song":{"list":[{"songname":"歌","songmid":"0004jPDk2eB2dt","albumname":"专辑","interval":215,"singer":[{"name":"歌手"}]}]}}}`)) + })) + defer server.Close() + + client := newQQClient() + client.searchEndpoint = server.URL + songs, err := client.Search("起风了", 2) + if err != nil { + t.Fatal(err) + } + if len(songs) != 1 || songs[0].MID != "0004jPDk2eB2dt" || songs[0].Singer != "歌手" { + t.Fatalf("unexpected songs: %#v", songs) + } +} + +func TestQQClientRejectsOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(bytes.Repeat([]byte("x"), maxUpstreamBytes+1)) + })) + defer server.Close() + client := newQQClient() + client.lyricsEndpoint = server.URL + _, err := client.Lyrics("0004jPDk2eB2dt") + if err == nil || !strings.Contains(err.Error(), "safety limit") { + t.Fatalf("expected response limit error, got %v", err) + } +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..b448c3c --- /dev/null +++ b/server.go @@ -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) +} diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..3513ba0 --- /dev/null +++ b/server_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +type fakeQQ struct{} + +func (fakeQQ) Search(keyword string, limit int) ([]song, error) { + return []song{{Name: keyword, MID: "0004jPDk2eB2dt", Singer: "Tester", Interval: limit}}, nil +} +func (fakeQQ) Lyrics(string) (lyricResult, error) { + return lyricResult{Code: 0, Lyric: "[00:01.00]line"}, nil +} + +func testApp() (*localApp, http.Handler, fs.FS) { + web := fstest.MapFS{ + "app.html": &fstest.MapFile{Data: []byte("app")}, + "app.js": &fstest.MapFile{Data: []byte("js")}, + "styles.css": &fstest.MapFile{Data: []byte("css")}, + } + app := newApp("http://127.0.0.1:18765", "test-secret", web, fakeQQ{}) + return app, app.routes(), web +} + +func request(method, target, body string) *http.Request { + r := httptest.NewRequest(method, target, strings.NewReader(body)) + r.Host = "127.0.0.1:18765" + return r +} + +func validSessionCookie() *http.Cookie { + return &http.Cookie{Name: sessionCookie, Value: "test-secret"} +} + +func TestLaunchTokenCreatesStrictSession(t *testing.T) { + _, handler, _ := testApp() + w := httptest.NewRecorder() + handler.ServeHTTP(w, request(http.MethodGet, "http://127.0.0.1:18765/start?t=test-secret", "")) + if w.Code != http.StatusSeeOther { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode { + t.Fatalf("unsafe cookie: %#v", cookies) + } +} + +func TestRejectsWrongHost(t *testing.T) { + _, handler, _ := testApp() + r := request(http.MethodGet, "http://127.0.0.1:18765/health", "") + r.Host = "attacker.example" + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestRejectsCrossOriginAPI(t *testing.T) { + _, handler, _ := testApp() + r := request(http.MethodPost, "http://127.0.0.1:18765/api/search", `{"keyword":"test","limit":10}`) + r.AddCookie(validSessionCookie()) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "https://attacker.example") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestValidSearchAndSecurityHeaders(t *testing.T) { + _, handler, _ := testApp() + r := request(http.MethodPost, "http://127.0.0.1:18765/api/search", `{"keyword":"测试","limit":10}`) + r.AddCookie(validSessionCookie()) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "http://127.0.0.1:18765") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "测试") { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Header().Get("Content-Security-Policy"), "default-src 'none'") { + t.Fatal("strict CSP is missing") + } +} + +func TestRejectsInvalidMID(t *testing.T) { + _, handler, _ := testApp() + r := request(http.MethodPost, "http://127.0.0.1:18765/api/lyrics", `{"mid":"https://attacker.example/"}`) + r.AddCookie(validSessionCookie()) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "http://127.0.0.1:18765") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} diff --git a/web/app.html b/web/app.html new file mode 100644 index 0000000..5ee1f19 --- /dev/null +++ b/web/app.html @@ -0,0 +1,33 @@ + + + + + + LRC Local + + + +
+
+
LRC Local
+
本机运行中
+
+
+

LOCAL-FIRST LYRIC TOOL

+

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

+

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

+
+ +
+ + +
+
+
+
+
+
非 QQ 音乐官方工具。请仅下载和使用你有权访问的歌词内容。
+
+ + + diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..46a2a80 --- /dev/null +++ b/web/app.js @@ -0,0 +1,70 @@ +const form = document.querySelector('#search-form'); +const keyword = document.querySelector('#keyword'); +const notice = document.querySelector('#notice'); +const results = document.querySelector('#results'); + +function text(value) { return document.createTextNode(value ?? ''); } +function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; } +function duration(seconds) { const n = Number(seconds || 0); return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : ''; } +function plainText(lrc) { return lrc.split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); } + +async function api(path, payload) { + const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`); + return data; +} + +function download(name, body, type = 'text/plain;charset=utf-8') { + const url = URL.createObjectURL(new Blob([body], {type})); + const anchor = document.createElement('a'); + anchor.href = url; anchor.download = name; anchor.hidden = true; + document.body.append(anchor); anchor.click(); anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1500); +} + +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}); + const base = safeName(`${song.name} - ${song.singer}`); + if (kind === 'lrc') download(`${base}.lrc`, data.lyric); + if (kind === 'txt') download(`${base}.txt`, plainText(data.lyric)); + if (kind === 'trans') { + if (!data.trans) throw new Error('这首歌没有翻译歌词'); + download(`${base}.trans.lrc`, data.trans); + } + notice.textContent = '文件已在浏览器中生成,内容未上传。'; + } catch (error) { + notice.className = 'notice error'; notice.textContent = error.message; + } finally { button.disabled = false; } +} + +function renderSongs(songs) { + results.replaceChildren(); + songs.forEach((song, index) => { + const article = document.createElement('article'); article.className = 'song'; + const info = document.createElement('div'); + const title = document.createElement('h2'); title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`)); + const meta = document.createElement('div'); meta.className = 'meta'; + meta.append(text([song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)].filter(Boolean).join(' · '))); + info.append(title, meta); + const actions = document.createElement('div'); actions.className = 'actions'; + [['lrc','下载 LRC','primary'],['txt','纯文本'],['trans','翻译 LRC']].forEach(([kind,label,klass]) => { + const button = document.createElement('button'); button.type = 'button'; button.className = klass || ''; button.append(text(label)); + button.addEventListener('click', () => fetchAndSave(song, kind, button)); actions.append(button); + }); + article.append(info, actions); results.append(article); + }); +} + +form.addEventListener('submit', async event => { + event.preventDefault(); const submit = form.querySelector('button'); submit.disabled = true; + notice.className = 'notice'; notice.textContent = '正在从这台电脑搜索…'; results.replaceChildren(); + 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; } +}); diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..b4b36c7 --- /dev/null +++ b/web/styles.css @@ -0,0 +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}}