feat: launch privacy-first local lyrics tool

This commit is contained in:
2026-08-25 22:44:26 +08:00
commit 70fbfc2889
17 changed files with 893 additions and 0 deletions
+6
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/dist/
*.exe
*.test
+24
View File
@@ -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"]
+21
View File
@@ -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.
+47
View File
@@ -0,0 +1,47 @@
# LRC Local
一个本地优先的 QQ 音乐歌词搜索与导出工具,使用 Go 编写。
公开下载页:<https://lrc.flechazo.xin>
## 为什么不是纯网页
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 音乐是其权利人的商标。使用者应遵守相关服务条款与著作权规则,只访问和使用自己有权获取的内容。
+27
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module git.sighs.cc/Flechazo/lrc-local
go 1.24
+95
View File
@@ -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()
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="LRC Local:在你自己的电脑上搜索并导出 LRC 歌词。">
<title>LRC Local — 歌词只经过你的电脑</title>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<main>
<nav><b>LRC <i>Local</i></b><a href="https://git.sighs.cc/Flechazo/lrc-local">查看源码 ↗</a></nav>
<section class="hero">
<div class="copy"><p class="tag">NO CLOUD · NO ACCOUNT · NO HISTORY</p><h1>歌词只经过<br><em>你的电脑。</em></h1><p class="lead">一个很小的本地歌词工具。下载、运行,浏览器会自动打开;搜索词、歌词和生成的文件都不会经过本站服务器。</p></div>
<aside><span>01</span><h2>下载本机程序</h2><p>无需安装。首次运行时,系统可能提示未知发布者。</p><a class="primary" href="/download/lrc-local-windows-amd64.exe">Windows 64 位</a><div class="secondary"><a href="/download/lrc-local-linux-amd64.tar.gz">Linux x64</a><a href="/download/lrc-local-darwin-arm64.tar.gz">macOS Apple 芯片</a><a href="/download/lrc-local-darwin-amd64.tar.gz">macOS Intel</a></div></aside>
</section>
<section class="steps"><article><b>02</b><h3>双击运行</h3><p>程序只监听随机的本机回环端口,不对局域网或公网开放。</p></article><article><b>03</b><h3>搜索与导出</h3><p>支持原文 LRC、翻译 LRC 和去除时间轴的 TXT。</p></article><article><b>04</b><h3>关掉即停止</h3><p>关闭程序窗口,本地服务立即消失;不创建账号,不保存历史。</p></article></section>
<section class="trust"><h2>它到底把什么发到哪里?</h2><p>本站只提供静态页面和程序下载。运行后的程序只向固定的 QQ 音乐搜索与歌词接口发出请求,不接受任意代理地址。浏览器只连接 <code>127.0.0.1</code>,文件由浏览器在本地生成。</p><p class="small">非 QQ 音乐官方工具。QQ 音乐是其权利人的商标。请遵守服务条款和著作权规则,只使用你有权访问的内容。校验文件:<a href="/download/SHA256SUMS">SHA256SUMS</a></p></section>
</main>
</body>
</html>
+1
View File
@@ -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)}}
+170
View File
@@ -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
}
+52
View File
@@ -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)
}
}
+214
View File
@@ -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": "关键词长度应为 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)
}
+105
View File
@@ -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)
}
}
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>LRC Local</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<header>
<div class="brand">LRC <span>Local</span></div>
<div class="local-pill"><i></i> 本机运行中</div>
</header>
<section class="hero">
<p class="eyebrow">LOCAL-FIRST LYRIC TOOL</p>
<h1>把歌词带走,<br><em>别把隐私留下。</em></h1>
<p class="lead">搜索与歌词请求从这台电脑直接发出。关键词、歌词和下载文件不会经过我们的服务器。</p>
<form id="search-form">
<label for="keyword">歌曲、歌手或专辑</label>
<div class="search-row">
<input id="keyword" name="keyword" maxlength="100" autocomplete="off" placeholder="例如:起风了" required autofocus>
<button type="submit">搜索歌词</button>
</div>
</form>
<div id="notice" class="notice" role="status" aria-live="polite"></div>
</section>
<section id="results" class="results" aria-label="搜索结果"></section>
<footer>非 QQ 音乐官方工具。请仅下载和使用你有权访问的歌词内容。</footer>
</main>
<script src="/app.js" defer></script>
</body>
</html>
+70
View File
@@ -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; }
});
+1
View File
@@ -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}}