diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index fd02333..cea7814 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -187,7 +187,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: Download web/dist uses: actions/download-artifact@v4 diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml index 44c7208..937b20f 100644 --- a/.github/workflows/beta-build.yml +++ b/.github/workflows/beta-build.yml @@ -4,10 +4,12 @@ # 产出: # 1. 前端 + 后端编译验证(go vet / go test / go build) # 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64) -# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) +# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64), +# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改) # -# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、 -# 不打 release tag,只构建带 -beta 标识的产物供测试。 +# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略 +# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、 +# 不打 release tag、不发布 GitHub Release、不部署服务器。 name: Beta Build @@ -22,9 +24,6 @@ permissions: contents: read packages: write -env: - BETA_VERSION_PREFIX: beta - jobs: # ───────────────────────────────────────────────────────────────────────────── # 1) 编译验证 + 多平台二进制产物 @@ -32,17 +31,24 @@ jobs: test-and-build: name: Test & build artifacts runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + binary_version: ${{ steps.version.outputs.binary_version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。 + # beta 只读不自增,避免与 main 的发布流程争抢版本号。 - name: Resolve beta version id: version run: | - BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") + git fetch origin version || true + BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0") SHA_SHORT=${GITHUB_SHA:0:7} - echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" # The binary embeds the SPA (web/dist) via go:embed, so dist must exist # before the Go toolchain touches the web package. @@ -59,7 +65,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: go vet @@ -71,13 +77,13 @@ jobs: - name: go build (host) run: go build ./... - # 多平台可执行文件(嵌入刚构建的 web/dist) + # 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致 - name: Build linux/amd64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server - name: Build linux/arm64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server - name: Build windows/amd64 - run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -87,7 +93,8 @@ jobs: if-no-files-found: error # ───────────────────────────────────────────────────────────────────────────── - # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta) + # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA}) + # 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。 # ───────────────────────────────────────────────────────────────────────────── docker-beta: name: Build & push beta Docker image @@ -98,13 +105,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Resolve beta version - id: version - run: | - BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") - SHA_SHORT=${GITHUB_SHA:0:7} - echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 @@ -115,6 +115,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/mebox + tags: | + type=raw,value=beta + type=raw,value=${{ needs.test-and-build.outputs.version }} + - name: Build & push uses: docker/build-push-action@v6 with: @@ -123,11 +132,9 @@ jobs: push: true provenance: false sbom: false - tags: ghcr.io/${{ github.repository_owner }}/mebox:beta - labels: | - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.source=${{ github.repository }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} build-args: | - VERSION=${{ steps.version.outputs.full_version }} + VERSION=${{ needs.test-and-build.outputs.binary_version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e69d2e..d9da205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true # The binary embeds the SPA (web/dist) via go:embed, so the dist must exist @@ -40,6 +40,39 @@ jobs: - name: go test run: go test ./... + database-compatibility: + name: SQLite / PostgreSQL ${{ matrix.postgres }} + runs-on: ubuntu-latest + strategy: + matrix: + postgres: [16, 17] + services: + postgres: + image: postgres:${{ matrix.postgres }}-alpine + env: + POSTGRES_USER: mebox + POSTGRES_PASSWORD: mebox-test-only + POSTGRES_DB: mebox_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U mebox -d mebox_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + MEBOX_TEST_POSTGRES_DSN: postgres://mebox:mebox-test-only@127.0.0.1:5432/mebox_test?sslmode=disable + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Database regression tests + run: go test ./internal/database ./internal/repository -count=1 + frontend: name: Frontend (Node) runs-on: ubuntu-latest @@ -78,7 +111,7 @@ jobs: ci-success: name: CI Success runs-on: ubuntu-latest - needs: [backend, frontend, smoke] + needs: [backend, frontend, database-compatibility, smoke] if: success() steps: - run: echo "CI passed, ready for release" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22fccd6..ed4fad8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count go test ./cmd/server -count=1 ``` +数据库兼容测试默认覆盖 SQLite。连接专用测试 PostgreSQL 后可运行真实的双数据库回归: + +```bash +MEBOX_TEST_POSTGRES_DSN='postgres://mebox:test-password@127.0.0.1:5432/mebox_test?sslmode=disable' \ + go test ./internal/database ./internal/repository -count=1 +``` + +测试账号需要创建、删除 schema 的权限;每个用例使用独立的临时 schema。CI 会分别在 PostgreSQL 16、17 上执行这些检查。 + ### 代码要求 - Go 代码使用 `gofmt`。 diff --git a/Dockerfile b/Dockerfile index 79edaff..56f9f74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Multi-architecture build for MeBox. # # Stage 1 (frontend) : Node 20.19+ -> static SPA bundle -# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0) +# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0) # Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user # # Build: @@ -25,7 +25,7 @@ COPY web/ . RUN npm run build # ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) ------------------- -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend ARG TARGETOS ARG TARGETARCH ARG GOPROXY=https://proxy.golang.org,direct @@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \ EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1 +# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的 +# .http_scheme 选择协议并在失败时回退,无需用户改配置。 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["/usr/local/bin/mebox-healthcheck"] # Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without # rewriting /etc/passwd or /etc/group on every container start. COPY docker-entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck +RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck CMD ["/entrypoint.sh"] diff --git a/README.md b/README.md index b48e5d1..6bb7cbc 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

- Go + Go React Docker License diff --git a/README_EN.md b/README_EN.md index 18ab276..8a828ec 100644 --- a/README_EN.md +++ b/README_EN.md @@ -21,7 +21,7 @@

- Go + Go React Docker License diff --git a/cmd/reader-smoke/main.go b/cmd/reader-smoke/main.go index c6e6822..cba4c3a 100644 --- a/cmd/reader-smoke/main.go +++ b/cmd/reader-smoke/main.go @@ -14,6 +14,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -76,7 +77,15 @@ func main() { fatal("未从输入中识别到书源") } - svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{}) + // 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘, + // 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。 + // 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。 + cfg := &config.Config{} + cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache") + if err := os.MkdirAll(cacheDir, 0o750); err == nil { + cfg.Cache.CacheDir = cacheDir + } + svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{}) ctx := context.Background() results := make([]*reader.SmokeChainResult, len(sources)) diff --git a/cmd/server/application.go b/cmd/server/application.go index 2056c29..1c87825 100644 --- a/cmd/server/application.go +++ b/cmd/server/application.go @@ -88,7 +88,7 @@ func (a *application) start() error { a.logger.Error("database not ready", zap.Error(err)) return fmt.Errorf("database not ready: %w", err) } - if err := database.AutoMigrate(db); err != nil { + if err := database.AutoMigrateWithConfig(db, a.cfg, a.logger); err != nil { a.logger.Error("auto-migrate failed", zap.Error(err)) return fmt.Errorf("auto-migrate failed: %w", err) } diff --git a/cmd/server/protocol_mux.go b/cmd/server/protocol_mux.go new file mode 100644 index 0000000..a9181fc --- /dev/null +++ b/cmd/server/protocol_mux.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "net" + "sync" + "time" +) + +// 开启 HTTPS 后,同一个端口会同时面对两类连接: +// +// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务; +// - 明确访问 http:// 的客户端——以前只会撞出一句 +// "Client sent an HTTP request to an HTTPS server.",用户看不懂。 +// +// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节: +// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。 +// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被 +// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。 +const tlsRecordHandshake = 0x16 + +// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住 +// goroutine。分流器按实例持有该值,便于测试用更短的超时。 +const defaultPeekTimeout = 10 * time.Second + +// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。 +type splitListener struct { + ln net.Listener + tlsCh chan net.Conn + plainCh chan net.Conn + quit chan struct{} + once sync.Once + peek time.Duration +} + +func newSplitListener(ln net.Listener) *splitListener { + return newSplitListenerWithTimeout(ln, defaultPeekTimeout) +} + +func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener { + s := &splitListener{ + ln: ln, + tlsCh: make(chan net.Conn), + plainCh: make(chan net.Conn), + quit: make(chan struct{}), + peek: peek, + } + go s.acceptLoop() + return s +} + +// tlsListener 返回只收到 TLS 连接的 listener。 +func (s *splitListener) tlsListener() net.Listener { + return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit} +} + +// plainListener 返回只收到明文 HTTP 连接的 listener。 +func (s *splitListener) plainListener() net.Listener { + return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit} +} + +func (s *splitListener) acceptLoop() { + for { + conn, err := s.ln.Accept() + if err != nil { + // 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed, + // 由持有它们的 http.Server 自行收尾。 + s.stop() + return + } + go s.dispatch(conn) + } +} + +func (s *splitListener) dispatch(conn net.Conn) { + br := bufio.NewReader(conn) + _ = conn.SetReadDeadline(time.Now().Add(s.peek)) + first, err := br.Peek(1) + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + _ = conn.Close() + return + } + + peeked := &peekedConn{Conn: conn, r: br} + ch := s.plainCh + if first[0] == tlsRecordHandshake { + ch = s.tlsCh + } + select { + case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭 + case <-s.quit: + _ = conn.Close() + } +} + +func (s *splitListener) stop() { + s.once.Do(func() { close(s.quit) }) +} + +// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。 +type channelListener struct { + addr net.Addr + conns <-chan net.Conn + quit <-chan struct{} +} + +func (l *channelListener) Accept() (net.Conn, error) { + select { + case conn := <-l.conns: + return conn, nil + case <-l.quit: + return nil, net.ErrClosed + } +} + +// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭, +// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。 +func (l *channelListener) Close() error { return nil } + +func (l *channelListener) Addr() net.Addr { return l.addr } + +// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。 +type peekedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) } diff --git a/cmd/server/protocol_mux_test.go b/cmd/server/protocol_mux_test.go new file mode 100644 index 0000000..d078cc9 --- /dev/null +++ b/cmd/server/protocol_mux_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "io" + "net" + "net/http" + "testing" + "time" +) + +func TestSplitListenerRoutesByFirstByte(t *testing.T) { + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + split := newSplitListener(raw) + addr := raw.Addr().String() + + // TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。 + handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c} + tlsClient, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tlsClient.Close() }() + if _, err := tlsClient.Write(handshake); err != nil { + t.Fatal(err) + } + tlsConn := <-split.tlsCh + defer func() { _ = tlsConn.Close() }() + got := make([]byte, len(handshake)) + if _, err := io.ReadFull(tlsConn, got); err != nil { + t.Fatalf("read peeked tls bytes: %v", err) + } + if string(got) != string(handshake) { + t.Fatalf("tls bytes = %v, want %v", got, handshake) + } + + // 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。 + request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n" + plainClient, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer func() { _ = plainClient.Close() }() + if _, err := plainClient.Write([]byte(request)); err != nil { + t.Fatal(err) + } + plainConn := <-split.plainCh + defer func() { _ = plainConn.Close() }() + head := make([]byte, len("GET /")) + if _, err := io.ReadFull(plainConn, head); err != nil { + t.Fatalf("read peeked plain bytes: %v", err) + } + if string(head) != request[:len(head)] { + t.Fatalf("plain bytes = %q, want prefix of %q", head, request) + } + + // 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。 + _ = raw.Close() + for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} { + deadline := time.Now().Add(3 * time.Second) + for { + _, err := ln.Accept() + if err == net.ErrClosed { + break + } + if time.Now().After(deadline) { + t.Fatalf("Accept after close = %v, want net.ErrClosed", err) + } + time.Sleep(10 * time.Millisecond) + } + } +} + +// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。 +func TestSplitListenerDropsIdleConnection(t *testing.T) { + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = raw.Close() }() + split := newSplitListenerWithTimeout(raw, 200*time.Millisecond) + + conn, err := net.Dial("tcp", raw.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + select { + case c := <-split.plainCh: + _ = c.Close() + t.Fatal("idle connection should not be dispatched") + case c := <-split.tlsCh: + _ = c.Close() + t.Fatal("idle connection should not be dispatched") + case <-time.After(time.Second): + } +} + +func TestHTTPSRedirectTarget(t *testing.T) { + cases := []struct { + name string + host string + rawURL string + want string + wantErr bool + }{ + {name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"}, + {name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"}, + {name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"}, + {name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"}, + {name: "rejects empty host", host: "", rawURL: "/", wantErr: true}, + {name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true}, + {name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil) + if err != nil { + t.Fatal(err) + } + req.Host = tc.host + + if host := redirectHostHeader(req); host == "" { + if !tc.wantErr { + t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host) + } + return + } + if tc.wantErr { + t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host) + } + if got := httpsRedirectURL(tc.host, req); got != tc.want { + t.Fatalf("redirect target = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/server/server_manager.go b/cmd/server/server_manager.go index abe2526..ccd4eff 100644 --- a/cmd/server/server_manager.go +++ b/cmd/server/server_manager.go @@ -7,6 +7,8 @@ import ( "fmt" "net" "net/http" + "os" + "path/filepath" "strings" "sync" "time" @@ -17,6 +19,14 @@ import ( "github.com/truewhile/MeBox/internal/service" ) +// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。 +// +// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启 +// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出 +// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。 +// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。 +const httpSchemeFileName = ".http_scheme" + // tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。 type tlsPair struct { cert tls.Certificate @@ -35,6 +45,9 @@ type tlsPair struct { // - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。 // // 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。 +// +// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走 +// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。 type serverManager struct { cfg *config.Config log *zap.Logger @@ -43,6 +56,7 @@ type serverManager struct { mu sync.Mutex srv *http.Server + redirectSrv *http.Server ln net.Listener pair *tlsPair stopCh chan struct{} @@ -92,11 +106,11 @@ func (m *serverManager) Reload() error { return nil } - oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair + oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair if oldLn != nil { _ = oldLn.Close() // 释放端口后再绑定新监听 } - m.srv, m.ln, m.pair = nil, nil, nil + m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil firstErr := m.bind(pair) if firstErr != nil { @@ -107,6 +121,7 @@ func (m *serverManager) Reload() error { } // 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。 m.drain(oldSrv) + m.drain(oldRedirect) m.logServerReady() m.maybeStartAutoReloadLocked() return firstErr @@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error { close(m.stopCh) } m.mu.Lock() - defer m.mu.Unlock() - if m.srv == nil { - return nil + targets := []*http.Server{m.srv, m.redirectSrv} + m.mu.Unlock() + + var firstErr error + for _, srv := range targets { + if srv == nil { + continue + } + if err := srv.Shutdown(ctx); err != nil && firstErr == nil { + firstErr = err + } } - return m.srv.Shutdown(ctx) + return firstErr } // desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。 @@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool { return pair.version == m.pair.version } -// bind 创建并按需启用 TLS 的监听,异步开始服务。 +// bind 创建监听,异步开始服务。 +// +// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接 +// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动 +// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS +// server."。 func (m *serverManager) bind(pair *tlsPair) error { ln, err := net.Listen("tcp", m.addr) if err != nil { return fmt.Errorf("listen %s: %w", m.addr, err) } - srv := &http.Server{ - Handler: m.handler, + + if pair == nil { + srv := m.newHTTPServer(m.handler) + m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil + m.writeSchemeState(nil) + go m.serve(srv, ln) + return nil + } + + split := newSplitListener(ln) + srv := m.newHTTPServer(m.handler) + tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{ + Certificates: []tls.Certificate{pair.cert}, + MinVersion: tls.VersionTLS12, + }) + redirect := m.newHTTPServer(m.httpsRedirectHandler()) + + m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair + m.writeSchemeState(pair) + go m.serve(srv, tlsLn) + go m.serve(redirect, split.plainListener()) + return nil +} + +func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server { + return &http.Server{ + Handler: handler, ReadHeaderTimeout: 15 * time.Second, } - if pair != nil { - ln = tls.NewListener(ln, &tls.Config{ - Certificates: []tls.Certificate{pair.cert}, - MinVersion: tls.VersionTLS12, - }) +} + +// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、 +// 同路径的 https://。 +// +// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期 +// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。 +func (m *serverManager) httpsRedirectHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := redirectHostHeader(r) + if host == "" { + // 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "HTTPS is enabled; open https://:%d instead.\n", m.cfg.App.Port) + return + } + status := http.StatusFound + if r.Method != http.MethodGet && r.Method != http.MethodHead { + status = http.StatusTemporaryRedirect // 保住方法与请求体 + } + w.Header().Set("Location", httpsRedirectURL(host, r)) + w.WriteHeader(status) + }) +} + +// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。 +func httpsRedirectURL(host string, r *http.Request) string { + target := "https://" + host + r.URL.EscapedPath() + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + return target +} + +// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射 +// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符 +// 带进 Location 造成任意跳转。 +func redirectHostHeader(r *http.Request) string { + host := strings.TrimSpace(r.Host) + if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") { + return "" + } + return host +} + +// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。 +// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。 +func (m *serverManager) writeSchemeState(pair *tlsPair) { + dataDir := strings.TrimSpace(m.cfg.App.DataDir) + if dataDir == "" { + return + } + scheme := "http" + if pair != nil { + scheme = "https" + } + path := filepath.Join(dataDir, httpSchemeFileName) + if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename. + m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err)) } - m.srv, m.ln, m.pair = srv, ln, pair - go m.serve(srv, ln) - return nil } func (m *serverManager) serve(s *http.Server, ln net.Listener) { @@ -250,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) { if s == nil { return } + // Stop reusing old connections before Reload returns. Asynchronous Shutdown + // alone leaves a window where a client receives the previous HTTP/TLS mode. + s.SetKeepAlivesEnabled(false) go func(s *http.Server) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -271,7 +379,7 @@ func (m *serverManager) logServerReady() { zap.String("listen", m.addr), ) if m.pair != nil { - m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port", + m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS", zap.String("addr", m.addr), ) } diff --git a/cmd/server/server_manager_test.go b/cmd/server/server_manager_test.go index 24c0412..09392d2 100644 --- a/cmd/server/server_manager_test.go +++ b/cmd/server/server_manager_test.go @@ -1,13 +1,17 @@ package main import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "io" "math/big" + "net" "net/http" "os" "path/filepath" @@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) { NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), DNSNames: []string{"localhost"}, + // 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。 + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv) if err != nil { @@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) { t.Fatal("version should change after files replaced") } } + +// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致, +// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。 +func TestWriteSchemeState(t *testing.T) { + m := newTestServerManager(t) + m.cfg.App.DataDir = t.TempDir() + path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName) + + m.writeSchemeState(nil) + if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" { + t.Fatalf("plain HTTP state: data=%q err=%v", data, err) + } + + m.writeSchemeState(&tlsPair{}) + if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" { + t.Fatalf("HTTPS state: data=%q err=%v", data, err) + } + + // DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。 + m.cfg.App.DataDir = "" + _ = os.Remove(path) + m.writeSchemeState(nil) + if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) { + t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err) + } +} + +// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是 +// 给用户看 "Client sent an HTTP request to an HTTPS server."。 +func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) { + certPEM, keyPEM := makeTestPairPEM(t) + cfg := &config.Config{} + cfg.App.Port = 8080 + cfg.App.DataDir = t.TempDir() + cfg.App.HTTPSEnabled = true + cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "secure:"+r.URL.Path) + }) + m := newServerManager(cfg, zap.NewNop(), handler) + m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口 + if err := m.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = m.Shutdown(context.Background()) }) + + addr := m.ln.Addr().String() + // 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。 + m.addr = addr + schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName) + if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" { + t.Fatalf("scheme state = %q err=%v, want https", data, err) + } + + plain := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + resp, err := plain.Get("http://" + addr + "/library?tab=1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusFound { + t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want { + t.Fatalf("Location = %q, want %q", got, want) + } + + postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = postResp.Body.Close() }() + if postResp.StatusCode != http.StatusTemporaryRedirect { + t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect) + } + if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want { + t.Fatalf("POST Location = %q, want %q", got, want) + } + + // 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。 + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(certPEM)) { + t.Fatal("append test certificate to pool failed") + } + secure := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + } + secureResp, err := secure.Get("https://" + addr + "/library") + if err != nil { + t.Fatal(err) + } + defer func() { _ = secureResp.Body.Close() }() + body, err := io.ReadAll(secureResp.Body) + if err != nil { + t.Fatal(err) + } + if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" { + t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library") + } + + // 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。 + cfg.App.HTTPSEnabled = false + if err := m.Reload(); err != nil { + t.Fatal(err) + } + if m.redirectSrv != nil { + t.Fatal("redirect server should be released when HTTPS is off") + } + if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" { + t.Fatalf("scheme state = %q err=%v, want http", data, err) + } + plainResp, err := plain.Get("http://" + addr + "/library") + if err != nil { + t.Fatal(err) + } + defer func() { _ = plainResp.Body.Close() }() + plainBody, err := io.ReadAll(plainResp.Body) + if err != nil { + t.Fatal(err) + } + if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" { + t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody) + } +} diff --git a/docker-compose.metatube.yml b/docker-compose.metatube.yml index 62e37c8..b8b014e 100644 --- a/docker-compose.metatube.yml +++ b/docker-compose.metatube.yml @@ -62,7 +62,7 @@ services: # NAS 示例:source: /vol1/1000/Media # Windows Docker Desktop 示例:source: D:/Media # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 - - ./media: /media + - ./media:/media environment: TZ: Asia/Shanghai @@ -105,7 +105,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.search.yml b/docker-compose.search.yml index 5614ac2..85d9e75 100644 --- a/docker-compose.search.yml +++ b/docker-compose.search.yml @@ -115,7 +115,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 1be9d65..c6d8729 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -73,7 +73,9 @@ services: - "host.docker.internal:host-gateway" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.standard.yml b/docker-compose.standard.yml index e6c13a0..a656261 100644 --- a/docker-compose.standard.yml +++ b/docker-compose.standard.yml @@ -44,7 +44,7 @@ services: # NAS 示例:source: /vol1/1000/Media # Windows Docker Desktop 示例:source: D:/Media # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 - - ./media: /media + - ./media:/media environment: TZ: Asia/Shanghai @@ -87,7 +87,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 97bef74..272e779 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-healthcheck.sh b/docker-healthcheck.sh new file mode 100644 index 0000000..65d430c --- /dev/null +++ b/docker-healthcheck.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# MeBox 容器健康检查。 +# +# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再 +# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里 +# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远 +# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生 +# "first record does not look like a TLS handshake"。 +# +# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见 +# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议, +# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。 +set -u + +port="${MEBOX_APP_PORT:-8080}" +data_dir="${MEBOX_APP_DATA_DIR:-/data}" + +scheme_file="$data_dir/.http_scheme" +scheme="" +if [ -r "$scheme_file" ]; then + scheme="$(tr -d '[:space:]' <"$scheme_file")" +fi +if [ "$scheme" = "https" ]; then + first="https" + second="http" +else + first="http" + second="https" +fi + +for proto in "$first" "$second"; do + # --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。 + if busybox wget -q --spider -T 3 --no-check-certificate \ + "$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then + exit 0 + fi +done + +exit 1 diff --git a/docs/mobile-api/deployment.md b/docs/mobile-api/deployment.md new file mode 100644 index 0000000..171ed71 --- /dev/null +++ b/docs/mobile-api/deployment.md @@ -0,0 +1,91 @@ +# MeBox Mobile API 接入与部署说明 + +更新日期:2026-10-17 +适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。 + +## 1. 路径与版本 + +- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。 +- 健康检查:`GET /api/health`。 +- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。 +- 契约字段与错误码见同目录 `openapi.yaml`。 + +`server-info.capabilities` 必须反映真实运行能力。当前分支只有 +`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为 +`false`,App 不得据此进入对应流程。 + +## 2. 连接与传输安全 + +- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。 +- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源 + 使用明文,不会把 MeBox `Authorization` 发往其它主机。 +- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝, + 不会静默截断为根路径。 +- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于 + 日志对账。 + +## 3. 反向代理检查项 + +以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置: + +| 项目 | 要求 | +| --- | --- | +| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 | +| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) | +| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 | +| WebSocket | 升级头透传,读超时大于心跳间隔 | +| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 | + +目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。 + +## 4. 认证与会话 + +- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。 +- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即 + 返回 401,不能借旧入口绕过。 +- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌, + App 需要重新登录。 +- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。 +- 设备管理中的踢出操作会同步撤销该设备认证会话。 + +### 刷新轮换边界 + +- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。 +- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应 + 且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受 + 该边界,未实现持久化幂等轮换。 +- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。 + +## 5. 错误契约 + +统一响应: + +```json +{ + "code": "SESSION_REVOKED", + "message": "auth session revoked or expired", + "request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3", + "retryable": false +} +``` + +- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。 +- 403 表示权限或账号状态,不触发刷新。 +- 429 带 `Retry-After`,客户端必须等待。 +- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。 + +## 6. 数据迁移与回滚 + +- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate + 创建,属于增量变更,不清库。 +- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行 + `go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。 +- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再 + 依赖 `sid` 即时撤销能力。 + +## 7. 已知限制 + +- 单实例部署;未实现多副本共享会话。 +- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。 +- 反代子路径、自签名证书信任、多服务器配置均未支持。 +- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。 diff --git a/docs/mobile-api/openapi.yaml b/docs/mobile-api/openapi.yaml new file mode 100644 index 0000000..0889838 --- /dev/null +++ b/docs/mobile-api/openapi.yaml @@ -0,0 +1,389 @@ +openapi: 3.0.3 +info: + title: MeBox Mobile API + version: 1.0.0 + description: | + MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载, + 本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。 + + 约定: + - JSON 字段使用 snake_case; + - 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒; + - 集合为空时返回 `[]`,未知字段客户端必须忽略; + - 错误响应统一为 `code`、`message`、`request_id`、`retryable`; + - 能力开关只有在接口实现且当前可运行时才为 true。 +servers: + - url: /api +security: + - bearerAuth: [] +tags: + - name: server + - name: auth +paths: + /v1/server-info: + get: + tags: [server] + summary: 匿名读取实例身份与移动端能力 + security: [] + responses: + '200': + description: 实例可连接 + headers: + Cache-Control: + schema: + type: string + description: 固定为 no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ServerInfo' + '503': + description: 暂时无法读取实例身份 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + /v1/auth/login: + post: + tags: [auth] + summary: 账号密码登录并创建认证会话 + security: [] + parameters: + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + $ref: '#/components/responses/InvalidArgument' + '401': + $ref: '#/components/responses/InvalidCredentials' + '403': + description: 账号被禁用或已到期 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v1/auth/refresh: + post: + tags: [auth] + summary: 使用刷新令牌轮换令牌对 + security: [] + description: | + 刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的 + 并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。 + 会话被撤销后刷新返回 SESSION_REVOKED。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: 刷新成功,新令牌继续归属同一 auth_session_id + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + $ref: '#/components/responses/InvalidArgument' + '401': + description: 刷新令牌无效、过期或会话已撤销 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v1/auth/session: + get: + tags: [auth] + summary: 读取当前用户与认证会话 + responses: + '200': + description: 会话有效 + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '401': + $ref: '#/components/responses/SessionInvalid' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v1/auth/logout: + post: + tags: [auth] + summary: 撤销当前认证会话 + description: 幂等;重复退出同样返回 200。 + responses: + '200': + description: 已退出(本机与远端) + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + '401': + $ref: '#/components/responses/SessionInvalid' + '503': + $ref: '#/components/responses/ServiceUnavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + RequestId: + name: X-Request-ID + in: header + required: false + description: | + 可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在 + 响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。 + schema: + type: string + responses: + InvalidArgument: + description: 请求字段缺失或格式错误 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + InvalidCredentials: + description: 用户名或密码错误 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + SessionInvalid: + description: 访问令牌无效、过期或认证会话已撤销 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + RateLimited: + description: 请求过于频繁 + headers: + Retry-After: + schema: + type: integer + description: 建议等待秒数 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + ServiceUnavailable: + description: 暂时性服务/存储错误,可安全重试 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + schemas: + ServerInfo: + type: object + required: + - name + - server_id + - server_version + - api_version + - min_supported_app_version_code + - capabilities + properties: + name: + type: string + example: MeBox + server_id: + type: string + format: uuid + description: 实例持久身份;备份克隆成独立实例时必须显式重置。 + server_version: + type: string + example: MeBox-v0.1.16 + api_version: + type: string + example: v1 + min_supported_app_version_code: + type: integer + minimum: 1 + capabilities: + $ref: '#/components/schemas/ServerCapabilities' + ServerCapabilities: + type: object + required: + - device_sessions + - playback_sessions + - reader_progress_v1 + - reader_content_batch + - local_transcode + properties: + device_sessions: + type: boolean + description: M1 起为 true + playback_sessions: + type: boolean + description: 播放会话接口未实现前必须为 false + reader_progress_v1: + type: boolean + description: v1 阅读锚点/版本冲突未实现前必须为 false + reader_content_batch: + type: boolean + description: v1 批量正文与资源引用未实现前必须为 false + local_transcode: + type: boolean + description: 移动端可用的本地转码会话未实现前必须为 false + MobileUser: + type: object + required: [id, username, role, tier, is_active, force_password_reset] + properties: + id: + type: string + username: + type: string + nickname: + type: string + role: + type: string + enum: [admin, user] + tier: + type: string + example: free + is_active: + type: boolean + force_password_reset: + type: boolean + expires_at: + type: string + format: date-time + nullable: true + TokenPair: + type: object + required: [access_token, refresh_token, expires_in, token_type] + properties: + access_token: + type: string + refresh_token: + type: string + expires_in: + type: integer + description: 访问令牌有效秒数 + token_type: + type: string + example: Bearer + LoginRequest: + type: object + required: [username, password, device_id] + properties: + username: + type: string + password: + type: string + device_id: + type: string + maxLength: 128 + description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。 + device_name: + type: string + maxLength: 128 + client_name: + type: string + maxLength: 64 + example: MeBox Android + client_version: + type: string + maxLength: 64 + RefreshRequest: + type: object + required: [refresh_token] + properties: + refresh_token: + type: string + AuthResponse: + type: object + required: [auth_session_id, user, tokens] + properties: + auth_session_id: + type: string + format: uuid + user: + $ref: '#/components/schemas/MobileUser' + tokens: + $ref: '#/components/schemas/TokenPair' + SessionDevice: + type: object + properties: + device_id: + type: string + device_name: + type: string + client_name: + type: string + client_version: + type: string + last_used_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + SessionResponse: + type: object + required: [auth_session_id, user] + properties: + auth_session_id: + type: string + format: uuid + user: + $ref: '#/components/schemas/MobileUser' + session: + $ref: '#/components/schemas/SessionDevice' + MobileError: + type: object + required: [code, message, request_id, retryable] + properties: + code: + type: string + enum: + - INVALID_ARGUMENT + - INVALID_CREDENTIALS + - TOKEN_EXPIRED + - SESSION_REVOKED + - ACCOUNT_DISABLED + - ACCOUNT_EXPIRED + - PERMISSION_DENIED + - PROFILE_PIN_REQUIRED + - RATE_LIMITED + - UPSTREAM_UNAVAILABLE + - TRANSCODE_BUSY + - PLAYBACK_SESSION_NOT_FOUND + - RESOURCE_EXPIRED + - PROGRESS_CONFLICT + - SERVICE_UNAVAILABLE + message: + type: string + request_id: + type: string + retryable: + type: boolean + retry_after_sec: + type: integer + minimum: 0 + details: + description: 可选的稳定结构化细节,未定义时客户端必须忽略。 diff --git a/docs/reader-plan.md b/docs/reader-plan.md index b65621e..f026466 100644 --- a/docs/reader-plan.md +++ b/docs/reader-plan.md @@ -38,7 +38,7 @@ web/src/ components/reader/ ``` -**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。 +**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。 **数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。 @@ -126,8 +126,31 @@ web/src/ | P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 | | P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 | | P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/.` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 | +| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 | +| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 | +| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 | +| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 | | P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 | +### 实施记录:书源会话状态仍是跨用户共享(下一步) + +书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量) +仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时: + +- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。 + +彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写 +的一批调用点,留作单独一轮处理。 + +### 实施记录:关于「宽松 @put 触发 panic」的复核 + +前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。 +逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。 +真实缺陷是另外两个并已修复: + +- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失; +- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。 + P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。 ## 8. 风险与对策 diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index 8d515ae..69297fb 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -26,6 +26,31 @@ - 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。 - `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。 +**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)** +- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`: + - 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。 + - 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。 + - 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。 +- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。 +- 显示项开关:未读章数徽标、更新时间。 +- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。 +- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。 +- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。 +- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。 + +**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)** +- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。 +- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。 +- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。 +- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。 + +**阅读器偏好按账号同步(跨设备)** +- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。 +- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。 +- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。 +- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。 +- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。 + ## 2. 阅读界面(重点) 文件:`ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。 @@ -71,7 +96,17 @@ - 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。 - 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。 - 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。 -- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。 +- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。 +- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。 +- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。 +- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。 +- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。 +- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。 +- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。 +- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 ``,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`

`/`
`)与空行当作无内容忽略,绝不吞掉可读文字。 +- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。 +- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `