Compare commits

...

37 Commits

Author SHA1 Message Date
truewhile a7b24e1bf1 Merge pull request #47 from truewhile/main
拉齐代码
2026-10-20 11:33:06 +08:00
truewhile 0b68397265 Merge pull request #46 from truewhile/fix/introdb-api-v4
迁移 TheIntroDB 接口到 v4
2026-10-19 22:26:33 +08:00
truewhile 90bfac3b09 迁移 TheIntroDB 接口到 v4
v1 已删除、v2/v3 已弃用,仅保留只读的 GET /media。本次把 base URL 切到 v4,并适配响应以 tvdb_id 为身份(tmdb_id 字段已移除)。请求仍用 tmdb_id 寻址,v4 仍接受并由服务端解析季集映射。
2026-10-19 22:23:06 +08:00
truewhile f1974a179e 优化 2026-10-19 21:38:55 +08:00
truewhile f2357200be 优化 2026-10-19 21:11:35 +08:00
truewhile ca9a80b862 优化 2026-10-18 22:30:02 +08:00
truewhile 4f19e2210e feat: 字幕轨名称可读化
外挂字幕原来直接拿文件名的语言后缀当名称(chs、chs[BilibiliID_danmu]),
内嵌字幕没有 title 标签时也是裸语言码(chi)。改成统一映射成中文名:
chi/zh -> 中文,chs -> 简体中文,jpn -> 日本語,并把弹幕转存的
[BilibiliID_danmu] 文件标注为「弹幕」,避免和对白字幕混淆。

认不出来的标记保持原样(含 und),Emby 客户端的「字幕 (ASS)」兜底不变。
2026-10-18 17:01:08 +08:00
truewhile 91667c400c bug处理 2026-10-18 15:37:31 +08:00
truewhile 0b8438d798 Merge pull request #45 from truewhile/feature/android-mobile-api
优化
2026-10-18 10:45:05 +08:00
truewhile 2faedae0bf 优化 2026-10-18 10:43:43 +08:00
truewhile fd57d2e59b Merge pull request #43 from truewhile/feature/android-mobile-api
优化
2026-10-18 02:20:26 +08:00
truewhile 8356f5c3c7 优化 2026-10-18 02:19:33 +08:00
truewhile 5d137ab6db Merge pull request #42 from truewhile/feature/android-mobile-api
Feature/android mobile api
2026-10-17 23:25:30 +08:00
truewhile e2133cd76f app兼容接口开发 2026-10-17 23:24:48 +08:00
truewhile 1c45483e1f feat(mobile): add v1 server info and device auth sessions 2026-10-17 12:27:28 +08:00
truewhile 40982f6675 Merge pull request #41 from truewhile/fix/database-playback-stability
修复数据库兼容性与 HLS 播放生命周期问题
2026-10-17 09:31:04 +08:00
jason jia 27dcfc2fe9 fix(playback): reuse completed HLS output and preserve resume positions 2026-10-17 01:30:04 +00:00
jason jia 984b98e61b fix: preserve database compatibility and harden request lifecycles 2026-10-17 01:29:51 +00:00
truewhile 5e496b35f1 Merge pull request #40 from truewhile/fix/search-library-grouping
fix(search): 按媒体库区分同名番剧搜索结果
2026-10-16 23:52:34 +08:00
jason jia 91eb0dadf6 fix(search): keep same series separate across libraries 2026-10-16 15:50:55 +00:00
truewhile 3d7d661199 Merge pull request #39 from truewhile/work
优化媒体搜索页面与返回状态保留
2026-10-16 22:58:45 +08:00
jason jia 8686d8cae6 优化媒体搜索页面与返回状态保留 2026-10-16 14:57:30 +00:00
truewhile 43cfeabea1 bug处理 2026-10-16 14:54:06 +08:00
truewhile d112e8e4db 优化 2026-10-13 23:35:06 +08:00
truewhile 6604691c19 优化 2026-10-13 22:16:14 +08:00
truewhile 23937d8137 优化,bug处理 2026-10-12 23:43:40 +08:00
truewhile 6864a9c7f1 优化 2026-10-12 23:01:46 +08:00
truewhile 079b3ff063 优化兼容性 2026-10-11 23:44:11 +08:00
truewhile 3c30c64a39 优化 2026-10-11 21:44:20 +08:00
truewhile 6186c8b848 优化 2026-10-11 21:29:19 +08:00
truewhile 0db8698fce 优化 2026-10-10 13:49:13 +08:00
truewhile 42703c6760 优化 2026-10-10 12:00:33 +08:00
truewhile 4ed2edab5b 优化,阅读问题处理 2026-10-10 11:26:47 +08:00
truewhile 374ee28f01 添加点击2次全屏的功能 2026-10-10 09:54:01 +08:00
truewhile 3a6b8db0b3 添加全屏功能 2026-10-08 23:59:35 +08:00
truewhile cd6b37c6a0 Merge pull request #35 from truewhile/feature/reading
Feature/reading
2026-10-01 21:59:11 +08:00
truewhile ef8b017c63 Merge pull request #19 from truewhile/main
拉取最新代码
2026-09-02 17:13:21 +08:00
204 changed files with 15333 additions and 943 deletions
+34 -1
View File
@@ -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"
+9
View File
@@ -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`。
+6 -3
View File
@@ -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"]
+1 -1
View File
@@ -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)
}
+129
View File
@@ -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) }
+140
View File
@@ -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)
}
})
}
}
+126 -18
View File
@@ -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://<host>:%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),
)
}
+139
View File
@@ -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)
}
}
+4 -2
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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
+3 -1
View File
@@ -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
+39
View File
@@ -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
+91
View File
@@ -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 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
+389
View File
@@ -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: 可选的稳定结构化细节,未定义时客户端必须忽略。
+24 -1
View File
@@ -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/<bookID>.<txt\|epub>` + 解析目录入 `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. 风险与对策
+5 -1
View File
@@ -34,7 +34,9 @@
- 排序六档,对齐 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 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
@@ -103,6 +105,8 @@
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
**本地书籍(对应 legado 本地 TXT / EPUB)**
+4
View File
@@ -33,6 +33,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
v.SetDefault("database.postgres_trgm_index", true)
v.SetDefault("secrets.jwt_secret", "")
@@ -52,6 +53,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+9
View File
@@ -62,6 +62,15 @@ func (c *Config) normalize() error {
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 90
}
if c.Cache.ReaderContentMaxSizeMB < 0 {
c.Cache.ReaderContentMaxSizeMB = 0
}
if c.Cache.ReaderContentTTLHours < 0 {
c.Cache.ReaderContentTTLHours = 0
}
if c.Cache.ReaderFilesTTLHours < 0 {
c.Cache.ReaderFilesTTLHours = 0
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mebox_media"
+13 -1
View File
@@ -50,7 +50,8 @@ type AppConfig struct {
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
HTTPSEnabled bool `mapstructure:"https_enabled"`
// SSLCert 是 PEM 编码的 SSL 证书内容。
SSLCert string `mapstructure:"ssl_cert"`
@@ -86,6 +87,10 @@ type DatabaseConfig struct {
CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
// PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的
// FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是
// media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。
PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"`
}
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
@@ -133,6 +138,13 @@ type CacheConfig struct {
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
// ReaderFilesTTLHours 书源文件缓存(java.cacheFile / downloadFile)的保留时长(小时)。
// 0 表示使用内置兜底值(30 天)。
ReaderFilesTTLHours int `mapstructure:"reader_files_ttl_hours"`
}
type SearchConfig struct {
+238
View File
@@ -0,0 +1,238 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
t.Helper()
var db *gorm.DB
if dialect == "sqlite" {
cfg := &config.Config{}
cfg.Database.Type = dialect
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.MaxOpenConns = 8
var err error
db, err = Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
} else {
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
}
pgcfg, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal("invalid PostgreSQL test configuration")
}
admin := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = admin.Close() })
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
t.Error(err)
}
})
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
conn := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = conn.Close() })
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
}
db.Logger = logger.Default.LogMode(logger.Silent)
if conn, err := db.DB(); err == nil {
t.Cleanup(func() { _ = conn.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestDatabaseCompatibility(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
t.Run("long_paths_and_restart", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Distinct CJK characters prevent TOAST compression from hiding
// the PostgreSQL btree entry size limit (> 2704 bytes).
var path strings.Builder
path.WriteString("/media/")
for i := range 980 {
path.WriteRune(rune(0x4e00 + i))
}
path.WriteString(".mkv")
media := model.Media{Path: path.String(), Title: "Long path"}
if err := db.Create(&media).Error; err != nil {
t.Fatalf("insert long media path: %v", err)
}
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
t.Fatal("duplicate path accepted")
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("migrate existing database: %v", err)
}
var found model.Media
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
t.Fatalf("lookup long path: %v", err)
}
})
if dialect == "postgres" {
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
db := compatibilityDB(t, dialect)
for _, unique := range []string{"UNIQUE ", ""} {
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := ensureMediaPathIndex(db); err != nil {
t.Fatal(err)
}
}
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil || !found || isUnique || method != "hash" {
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
}
}
})
}
t.Run("search_ranking", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Exercise LIKE fallback in both engines.
if dialect == "sqlite" {
resetMediaSearchIndex(db)
}
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
}
})
t.Run("concurrent_media_upsert", func(t *testing.T) {
db := compatibilityDB(t, dialect)
repo := repository.New(db).Media
const workers = 8
// Synchronize the initial lookups to force the insert race.
var lookups sync.WaitGroup
lookups.Add(workers)
if dialect == "postgres" {
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
lookups.Done()
lookups.Wait()
}
}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
errs := make(chan error, workers)
for range workers {
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
}
for range workers {
if err := <-errs; err != nil {
t.Errorf("concurrent upsert: %v", err)
}
}
var count int64
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("media count=%d, err=%v", count, err)
}
})
t.Run("search_filters", func(t *testing.T) {
db := compatibilityDB(t, dialect)
rows := []model.Media{
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
}
for i := range rows {
rows[i].Title = "Alpha " + rows[i].ID
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
t.Fatal(err)
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
UnwatchedOnly: true, UnwatchedUserID: "viewer",
})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
}
})
t.Run("migration_preserves_zero_values", func(t *testing.T) {
src := compatibilityDB(t, "sqlite")
dst := compatibilityDB(t, dialect)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
t.Fatal(err)
}
if _, _, err := copyModelTables(src, dst, 500); err != nil {
t.Fatal(err)
}
var got model.User
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
})
})
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
}
// 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil {
if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
}
+50
View File
@@ -0,0 +1,50 @@
package database
import (
"fmt"
"reflect"
"gorm.io/gorm"
)
// InsertPreservingValues copies persisted model rows without applying creation
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
// missing values, which would reactivate disabled accounts during migration or
// restore. Column maps preserve the exact values already stored in the source.
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(rows); err != nil {
return err
}
slice := reflect.Indirect(reflect.ValueOf(rows))
if slice.Kind() != reflect.Slice {
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
}
if len(stmt.Schema.DBNames) == 0 {
return fmt.Errorf("persisted rows have no database columns")
}
values := make([]map[string]any, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
row := make(map[string]any, len(stmt.Schema.DBNames))
for _, name := range stmt.Schema.DBNames {
field := stmt.Schema.FieldsByDBName[name]
if field.Creatable {
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
}
}
values = append(values, row)
}
if len(values) == 0 {
return nil
}
// Bound batches by each engine's bind-parameter limit.
parameterLimit := 65535
if isSQLite(db) {
parameterLimit = 32766
}
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
if batchSize <= 0 || batchSize > maxBatch {
batchSize = maxBatch
}
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
}
@@ -0,0 +1,141 @@
package database
import (
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。
type legacyReaderSource struct {
ID string `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
SourceURL string `gorm:"type:varchar(512);uniqueIndex"`
RawJSON string
Enabled bool
EnabledExplore bool
Variables string
Header string
}
func (legacyReaderSource) TableName() string { return "reader_book_sources" }
// 迁移路径:老库(书源全局唯一、无 user_id)升级后
// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本;
// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本;
// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。
func TestReaderSourceMigrationBackfill(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。
if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil {
t.Fatal(err)
}
shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true}
if err := db.Create(shared).Error; err != nil {
t.Fatal(err)
}
softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"}
if err := db.Create(softDeleted).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删
t.Fatal(err)
}
// u1 两本、u2 一本都引用共享源;孤儿源无人引用。
books := []model.ReaderBook{
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"},
{UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"},
}
for i := range books {
if err := db.Create(&books[i]).Error; err != nil {
t.Fatal(err)
}
}
// 迁移。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
t.Fatalf("删旧索引失败: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatalf("回填失败: %v", err)
}
// 1. 旧唯一索引已删除:两个用户同 URL 各自一份。
var rows []model.ReaderBookSource
if err := db.Order("user_id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
byUser := map[string]model.ReaderBookSource{}
for _, r := range rows {
if r.SourceURL == "https://shared.example.com" {
byUser[r.UserID] = r
}
}
if len(byUser) != 2 {
t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser)
}
if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID {
t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID)
}
if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" {
t.Fatal("副本应保留原书源内容")
}
// 引用最多的用户保留原行(u1 有两本书)。
if byUser["u1"].ID != "legacy-1" {
t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID)
}
// 2. 软删的历史行被清理。
var softLeft int64
if err := db.Unscoped().Model(&model.ReaderBookSource{}).
Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil {
t.Fatal(err)
}
if softLeft != 0 {
t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft)
}
// 3. 无人引用的书源保持无归属(不会凭空分配给谁)。
var orphans int64
if err := db.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil {
t.Fatal(err)
}
if orphans != 0 {
t.Fatal("无人引用的书源不应被分配归属")
}
// 4. 回填是幂等的(再次执行不产生重复副本)。
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatal(err)
}
var total int64
if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil {
t.Fatal(err)
}
if total != 2 {
t.Fatalf("重复回填不应新增副本,实际 %d 份", total)
}
}
+288 -9
View File
@@ -1,27 +1,52 @@
package database
import (
"fmt"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
return AutoMigrateWithConfig(db, nil, nil)
}
// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志:
// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤
// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。
func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
// 不去重会导致唯一索引 uniq_user_history 创建失败。
if err := dedupePlaybackHistories(db); err != nil {
return err
}
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil {
return err
}
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureMediaPathIndex(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
@@ -34,6 +59,113 @@ func AutoMigrate(db *gorm.DB) error {
}
return ensureSQLiteQueryOptimizer(db)
}
return ensurePostgresSearchIndexes(db, cfg, log)
}
// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。
//
// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的
// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节,
// 对整列建唯一索引会让这类行插入时报
//
// index row size ... exceeds btree version 4 maximum 2704
//
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
func ensureMediaPathIndex(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable("media") {
return nil
}
if !isPostgres(db) {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
}
// 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
return err
}
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil {
return err
}
if found && (unique || method != "hash") {
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
return err
}
}
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
}
// postgresIndexProperties 返回当前 schema 下索引的唯一性与访问方法。
func postgresIndexProperties(db *gorm.DB, indexName string) (unique bool, method string, found bool, err error) {
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
var row struct {
IsUnique *bool
Method string
}
if err := db.Raw(`
SELECT i.indisunique AS is_unique, a.amname AS method
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_am a ON a.oid = c.relam
WHERE n.nspname = current_schema() AND c.relname = ?
`, indexName).Scan(&row).Error; err != nil {
return false, "", false, err
}
if row.IsUnique == nil {
return false, "", false, nil
}
return *row.IsUnique, row.Method, true, nil
}
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
//
// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下
// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE
// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引,
// 把搜索从整表扫描降到索引扫描。
//
// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会
// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。
//
// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条
// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。
func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
if !isPostgres(db) {
return nil
}
if cfg != nil && !cfg.Database.PostgresTrgmIndex {
if log != nil {
log.Info("postgres trigram search index disabled; media search falls back to sequential scan")
}
return nil
}
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
if log != nil {
log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err))
}
return nil
}
// 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因
// IF NOT EXISTS 直接跳过。
if log != nil {
log.Info("ensuring postgres trigram search indexes")
}
for _, stmt := range []string{
`CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`,
} {
if err := db.Exec(stmt).Error; err != nil {
if log != nil {
log.Warn("create trigram index failed; media search may fall back to sequential scan",
zap.String("statement", stmt), zap.Error(err))
}
return nil
}
}
return nil
}
@@ -42,6 +174,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error
}
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
//
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
return err
}
}
}
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
}
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
//
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
// 这样谁都不会因为别人删源而失去书源。
//
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
// 列表里,但数据仍在,需要时可以人工认领。
func backfillReaderSourceOwnership(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
var pending []model.ReaderBookSource
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
return err
}
for i := range pending {
src := pending[i]
if err := assignOrCloneReaderSource(db, &src); err != nil {
return err
}
}
return nil
}
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
if src == nil || src.SourceURL == "" {
return nil
}
// 引用该书源的书籍分布(去重到用户)。
type ownerCount struct {
UserID string
N int64
}
var owners []ownerCount
if err := db.Model(&model.ReaderBook{}).
Select("user_id, COUNT(*) AS n").
Where("origin = ?", src.SourceURL).
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
return err
}
if len(owners) == 0 {
return nil // 无人引用:保持无归属
}
// 第一个用户(引用最多)直接接手这一行。
owner := owners[0].UserID
if owner == "" {
return nil
}
if err := db.Model(&model.ReaderBookSource{}).
Where("id = ?", src.ID).
Updates(map[string]any{"user_id": owner}).Error; err != nil {
return err
}
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
for _, o := range owners[1:] {
if o.UserID == "" || o.UserID == owner {
continue
}
var exists int64
if err := db.Model(&model.ReaderBookSource{}).
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
continue
}
clone := *src
clone.ID = ""
clone.UserID = o.UserID
if err := db.Create(&clone).Error; err != nil {
return err
}
}
return nil
}
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique
// index can be created on existing databases. Keeps the most recent row per
@@ -67,16 +298,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
}
statements := []string{
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
// 只在类型确实不匹配时才 ALTER。
//
// PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
// 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
// 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
// 启动会卡住数分钟,且期间这张表连读都被阻塞。
changes := []struct {
table string
column string
target string
}{
{"media", "container", "varchar(128)"},
{"media", "genres", "text"},
{"media", "series_id", "varchar(128)"},
{"media", "duplicate_of", "varchar(128)"},
{"playback_histories", "media_id", "varchar(128)"},
{"favorites", "media_id", "varchar(128)"},
{"playlist_items", "media_id", "varchar(128)"},
}
for _, stmt := range statements {
for _, change := range changes {
current, found, err := postgresColumnType(db, change.table, change.column)
if err != nil {
return err
}
if !found || current == change.target {
continue
}
stmt := "ALTER TABLE " + quoteIdent(change.table) +
" ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target
if err := db.Exec(stmt).Error; err != nil {
return err
}
@@ -84,6 +334,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
return nil
}
// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如
// "varchar(128)" / "text"。列不存在时 found 为 false。
func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) {
var row struct {
DataType string
MaxLength *int64
}
if err := db.Raw(`
SELECT data_type AS data_type, character_maximum_length AS max_length
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
`, table, column).Scan(&row).Error; err != nil {
return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err)
}
dataType := strings.ToLower(strings.TrimSpace(row.DataType))
if dataType == "" {
return "", false, nil
}
// information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。
switch dataType {
case "character varying", "varchar":
dataType = "varchar"
}
if row.MaxLength != nil && *row.MaxLength > 0 {
dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength)
}
return dataType, true, nil
}
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
@@ -0,0 +1,54 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。
//
// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是
// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让
// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式
// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。
func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
// 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。
// t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
var definition string
if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil {
t.Fatal(err)
}
if definition == "" {
t.Fatal("AutoMigrate did not create idx_media_path")
}
if !strings.Contains(strings.ToUpper(definition), "UNIQUE") {
t.Fatalf("idx_media_path is not unique: %q", definition)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil {
t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced")
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
if filtered.Len() > 0 {
filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
if err := InsertPreservingValues(target.Clauses(clause.OnConflict{DoNothing: true}), filteredPtr.Interface(), batchSize); err != nil {
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copiedForTable += int64(filtered.Len())
+15 -15
View File
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
if ctx == nil {
ctx = context.Background()
}
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。
if done := ctx.Done(); done != nil {
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-done:
g.cond.Broadcast()
case <-stop:
}
}()
// 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
wake := func() {
g.mu.Lock()
defer g.mu.Unlock()
g.cond.Broadcast()
}
stopCancel := context.AfterFunc(ctx, wake)
defer stopCancel()
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if g.owner == nil {
holder := &sqliteGateHolder{
id: sqliteGateHolderSeq.Add(1),
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.owner = holder
return holder, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
if remaining <= 0 {
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
g.owner = nil
g.cond.Broadcast()
continue
}
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
timer := time.AfterFunc(remaining, wake)
g.cond.Wait()
timer.Stop()
}
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"context"
"errors"
"testing"
"time"
)
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
gate := newSQLiteWriteGate()
holder, err := gate.Lock(ctx)
defer gate.Unlock(holder)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
}
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
// Make the lease expire shortly, without waiting a full minute.
gate.mu.Lock()
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
gate.mu.Unlock()
done := make(chan *sqliteGateHolder, 1)
go func() {
next, _ := gate.Lock(t.Context())
done <- next
}()
select {
case next := <-done:
gate.Unlock(holder) // A stale holder must not unlock its successor.
gate.mu.Lock()
owner := gate.owner
gate.mu.Unlock()
gate.Unlock(next)
if next == nil || owner != next {
t.Fatal("expired holder released the new owner's lock")
}
case <-time.After(time.Second):
gate.Unlock(holder)
gate.Unlock(<-done)
t.Fatal("waiter was not woken when the holder expired")
}
}
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
next, err := gate.Lock(ctx)
gate.Unlock(next)
done <- err
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
case <-time.After(time.Second):
gate.Unlock(holder)
<-done
t.Fatal("waiter was not woken for cancellation")
}
}
+26
View File
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
}
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
//
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
@@ -0,0 +1,108 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
// 只影响目标账号,且配置保留(不是删除)。
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
for _, m := range []*model.EmbyMount{m1, m2, other} {
if err := repos.EmbyMount.Create(ctx, m); err != nil {
t.Fatalf("create mount: %v", err)
}
}
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
call := func(body map[string]any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := call(map[string]any{"enabled": false})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Affected != 2 {
t.Fatalf("expected ok with affected=2, got %+v", resp)
}
rows, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
if row.Enabled != want {
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
}
}
// 重新启用:挂载记录仍在,只是开关回 true。
w = call(map[string]any{"enabled": true})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
rows, err = repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
}
for _, row := range rows {
if !row.Enabled {
t.Fatalf("mount %s should be enabled again", row.ID)
}
}
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
}
}
+2
View File
@@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
registerPublicAuthRoutes(api, svc, log)
registerMobileRoutes(api, svc, log)
registerAuthenticatedRoutes(api, cfg, svc)
registerAdminRoutes(api, cfg, svc)
+14
View File
@@ -810,6 +810,20 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// 远程条目可能是「整部剧/季/目录」:这类条目自身没有媒体源,远程 Emby 对它
// 们的 /Videos/{id}/stream 会直接回 500(Unable to cast object of type
// 'Series' to type 'IHasMediaSources')。网页端、外部播放器、第三方客户端都
// 可能把这种 ID 送进来,这里统一改投第一集:302 让客户端自己跟过去,播放器
// 原有的 token / proxy 查询参数一并保留。
if playable := svc.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID); playable != "" && playable != remoteID {
target := "/api/stream/" + service.EncodeEmbyRemoteID(mountID, playable)
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
target += "?" + rawQuery
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
+111
View File
@@ -0,0 +1,111 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"},
}},
})
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "本地番剧", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
{Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
expectedIDs := map[string]string{lib.ID: "local-1"}
for _, suffix := range []string{"a", "b"} {
mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series")
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
}
request := func(params string) ([]model.Media, int) {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items, response.Total
}
items, _ := request("limit=8")
if len(items) != len(expectedIDs) {
t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items)
}
seen := make(map[string]bool)
for page := 1; page <= len(expectedIDs); page++ {
items, total := request(fmt.Sprintf("page=%d&page_size=1", page))
if total != len(expectedIDs) || len(items) != 1 {
t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items)
}
item := items[0]
if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" {
t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item)
}
seen[item.DisplayLibraryID] = true
}
}
+3 -1
View File
@@ -26,7 +26,9 @@ import (
)
func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// The scanner and polling loop use separate connections; :memory: gives
// each connection its own empty database.
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "scan.db")+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
+395
View File
@@ -0,0 +1,395 @@
// Package handler — 移动端 /api/v1 认证端点。
package handler
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
// 使用的偏好和内部状态带进新契约。
type mobileUserPayload struct {
ID string `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname,omitempty"`
Role string `json:"role"`
Tier string `json:"tier"`
IsActive bool `json:"is_active"`
ForcePasswordReset bool `json:"force_password_reset"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func toMobileUserPayload(u *model.User) *mobileUserPayload {
if u == nil {
return nil
}
return &mobileUserPayload{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Role: u.Role,
Tier: u.Tier,
IsActive: u.IsActive,
ForcePasswordReset: u.ForcePasswordReset,
ExpiresAt: u.ExpiredAt,
}
}
type mobileSessionPayload struct {
AuthSessionID string `json:"auth_session_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt time.Time `json:"last_used_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
if s == nil {
return nil
}
return &mobileSessionPayload{
AuthSessionID: s.ID,
DeviceID: s.DeviceID,
DeviceName: s.DeviceName,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
CreatedAt: s.CreatedAt,
LastUsedAt: s.LastUsedAt,
ExpiresAt: s.ExpiresAt,
}
}
type mobileLoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
ClientName string `json:"client_name"`
ClientVersion string `json:"client_version"`
}
type mobileRefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
// 并统一输出 v1 错误契约。
func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
raw := middleware.ExtractToken(c)
if raw == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"access token required", false, 0)
return
}
claims, err := svc.Token.ValidateAccessToken(raw)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
"access token expired", false, 0)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"invalid access token", false, 0)
return
}
if strings.TrimSpace(claims.Purpose) != "" {
writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
"token scope denied for this endpoint", false, 0)
return
}
sessionID := strings.TrimSpace(claims.Sid)
if sessionID == "" {
// v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"auth session required; sign in again", false, 0)
return
}
session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
if err != nil {
if service.IsTransientDatabaseLock(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"auth session check temporarily unavailable", true, 2)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
"auth session revoked or expired", false, 0)
return
}
c.Set(middleware.CtxUserID, claims.UserID)
c.Set(middleware.CtxUserRole, claims.Role)
c.Set(middleware.CtxUserTier, claims.Tier)
c.Set(middleware.CtxAuthSessionID, sessionID)
c.Set(mobileCtxAuthSession, session)
// 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
// 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
if time.Since(session.LastUsedAt) > time.Minute {
svc.AuthSessions.Touch(c.Request.Context(), sessionID)
}
c.Next()
}
}
// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
// 被会话校验拦成 401。
func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
raw := middleware.ExtractToken(c)
if raw == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"access token required", false, 0)
return
}
claims, err := svc.Token.ValidateAccessToken(raw)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
"access token expired", false, 0)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"invalid access token", false, 0)
return
}
sessionID := strings.TrimSpace(claims.Sid)
if sessionID == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"auth session required; sign in again", false, 0)
return
}
c.Set(middleware.CtxUserID, claims.UserID)
c.Set(middleware.CtxAuthSessionID, sessionID)
c.Next()
}
}
func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
var req mobileLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"username, password and device_id are required", false, 0)
return
}
req.Username = strings.TrimSpace(req.Username)
req.DeviceID = strings.TrimSpace(req.DeviceID)
if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"invalid username or device_id", false, 0)
return
}
ctx := c.Request.Context()
user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
if err != nil {
writeMobileAuthError(c, log, err)
return
}
deviceName := strings.TrimSpace(req.DeviceName)
clientName := strings.TrimSpace(req.ClientName)
if clientName == "" {
clientName = "MeBox Android"
}
session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
UserID: user.ID,
DeviceID: req.DeviceID,
DeviceName: deviceName,
ClientName: clientName,
ClientVersion: req.ClientVersion,
})
if err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"cannot create auth session right now", true, 2)
return
}
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"invalid device information", false, 0)
return
}
tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
// 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
_ = svc.AuthSessions.Revoke(ctx, session.ID)
writeMobileAuthError(c, log, err)
return
}
if svc.Device != nil {
svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
}
if svc.Audit != nil {
svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
}
c.JSON(http.StatusOK, gin.H{
"auth_session_id": session.ID,
"user": toMobileUserPayload(user),
"tokens": tokens,
})
}
}
func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
var req mobileRefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"refresh_token required", false, 0)
return
}
ctx := c.Request.Context()
rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
if err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"token store temporarily unavailable", true, 2)
return
}
writeMobileAuthError(c, log, err)
return
}
// 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
// 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
// 由 TokenService 判定。
if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"refresh token is not bound to a device session; sign in again", false, 0)
return
}
if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"auth session check temporarily unavailable", true, 2)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
"auth session revoked or expired", false, 0)
return
}
tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
if err != nil {
writeMobileAuthError(c, log, err)
return
}
user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
if err != nil {
writeMobileAuthError(c, log, err)
return
}
if user == nil {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"user not found", false, 0)
return
}
c.JSON(http.StatusOK, gin.H{
"auth_session_id": rt.AuthSessionID,
"user": toMobileUserPayload(user),
"tokens": tokens,
})
}
}
func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString(middleware.CtxUserID)
user, err := svc.Repo.User.FindByID(ctx, userID)
if err != nil {
writeMobileAuthError(c, svc.Log, err)
return
}
if user == nil {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"user not found", false, 0)
return
}
session, _ := c.Get(mobileCtxAuthSession)
sessionModel, _ := session.(*model.AuthSession)
c.JSON(http.StatusOK, gin.H{
"auth_session_id": c.GetString(middleware.CtxAuthSessionID),
"user": toMobileUserPayload(user),
"session": toMobileSessionPayload(sessionModel),
})
}
}
func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sessionID := c.GetString(middleware.CtxAuthSessionID)
if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"cannot revoke session right now", true, 2)
return
}
writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
"logout failed", false, 0)
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
}
// writeMobileAuthError 把 service 层错误映射为稳定错误码。
// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
switch {
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"invalid username, password or refresh token", false, 0)
case errors.Is(err, service.ErrTokenExpired):
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
"refresh token expired", false, 0)
case errors.Is(err, service.ErrTokenRevoked),
errors.Is(err, service.ErrAuthSessionRevoked),
errors.Is(err, service.ErrAuthSessionExpired),
errors.Is(err, service.ErrAuthSessionRequired):
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
"auth session revoked or expired", false, 0)
case errors.Is(err, service.ErrUserInactive):
writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
"account is disabled", false, 0)
case errors.Is(err, service.ErrUserExpired):
writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
"account has expired", false, 0)
case isTransientServiceError(err):
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"service temporarily unavailable, please retry", true, 2)
default:
if log != nil {
log.Warn("mobile auth request failed", zap.Error(err))
}
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"service temporarily unavailable, please retry", true, 2)
}
}
func isTransientServiceError(err error) bool {
if err == nil {
return false
}
if service.IsTransientDatabaseLock(err) {
return true
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
return false
}
+434
View File
@@ -0,0 +1,434 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
const mobileTestSecret = "mobile-test-secret"
func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
// :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
// shared cache 名字在并行测试间串库。
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(
&model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
&model.UserDevice{}, &model.UserPermission{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
router := gin.New()
Register(router, cfg, zap.NewNop(), svc)
return router, svc
}
func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
if err := svc.Repo.User.Create(t.Context(), u); err != nil {
t.Fatal(err)
}
return u
}
func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
t.Helper()
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
t.Fatalf("decode body %q: %v", w.Body.String(), err)
}
}
func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
router, _ := newMobileTestEnv(t)
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
var info service.ServerInfo
decodeMobileJSON(t, w, &info)
if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
t.Fatalf("unexpected server info: %+v", info)
}
if !info.Capabilities.DeviceSessions {
t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
}
if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
}
if info.MinSupportedAppVersionCode < 1 {
t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
}
// 匿名访问不应要求 Authorization,也不应记录 cookie。
if len(w.Result().Cookies()) != 0 {
t.Fatalf("server-info should not set cookies")
}
}
func TestMobileAuthLifecycle(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "alice", "secret-123")
// 1. 错误密码:401 + 稳定错误码 + request_id。
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "alice", "password": "wrong", "device_id": "device-1",
}, map[string]string{"X-Request-ID": "mobile-test-0001"})
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
}
var authErr mobileErrorBody
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
t.Fatalf("unexpected auth error: %+v", authErr)
}
if authErr.Retryable {
t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
}
// 2. 缺少 device_id:400 INVALID_ARGUMENT。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "alice", "password": "secret-123",
}, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
}
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeInvalidArgument {
t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
}
// 3. 正常登录。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "alice", "password": "secret-123", "device_id": "device-1",
"device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var loginResp struct {
AuthSessionID string `json:"auth_session_id"`
User mobileUserPayload `json:"user"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &loginResp)
if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
t.Fatalf("incomplete login response: %+v", loginResp)
}
if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
t.Fatalf("unexpected user payload: %+v", loginResp.User)
}
claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
if err != nil {
t.Fatalf("validate access token: %v", err)
}
if claims.Sid != loginResp.AuthSessionID {
t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
}
rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
}
// 4. 会话读取。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
}
var sessionResp struct {
AuthSessionID string `json:"auth_session_id"`
User mobileUserPayload `json:"user"`
Session *mobileSessionPayload `json:"session"`
}
decodeMobileJSON(t, w, &sessionResp)
if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
t.Fatalf("unexpected session response: %+v", sessionResp)
}
if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
}
// 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
"refresh_token": loginResp.Tokens.RefreshToken,
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
}
var refreshResp struct {
AuthSessionID string `json:"auth_session_id"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &refreshResp)
if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
t.Fatalf("unexpected refresh response: %+v", refreshResp)
}
if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
t.Fatal("refresh token should rotate")
}
oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
if err != nil || oldRT == nil || !oldRT.Revoked {
t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
}
// 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
}
// 7. 退出:成功且可重复。
for i := 0; i < 2; i++ {
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
}
}
// 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
}
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeSessionRevoked {
t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
}
// 9. 被踢后的未过期 JWT 不能绕过旧 API。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
}
// 10. 刷新令牌随会话一起撤销。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
"refresh_token": refreshResp.Tokens.RefreshToken,
}, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
}
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeSessionRevoked {
t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
}
}
func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "legacy", "secret-123")
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
if err != nil {
t.Fatal(err)
}
if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
}
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
}
var body mobileErrorBody
decodeMobileJSON(t, w, &body)
if body.Code != mobileCodeInvalidCredentials {
t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
}
// 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
}
}
func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "bob", "secret-123")
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
if err != nil {
t.Fatal(err)
}
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
"refresh_token": pair.RefreshToken,
}, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
}
var body mobileErrorBody
decodeMobileJSON(t, w, &body)
if body.Code != mobileCodeInvalidCredentials {
t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
}
}
func TestMobileDeviceKickRevokesSession(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "carol", "secret-123")
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "carol", "password": "secret-123", "device_id": "device-kick",
"device_name": "Kick Me", "client_name": "MeBox Android",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var loginResp struct {
AuthSessionID string `json:"auth_session_id"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &loginResp)
if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
t.Fatalf("kick device: %v", err)
}
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
if err != nil || session == nil || !session.Revoked {
t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
}
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
}
}
func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
router, svc := newMobileTestEnv(t)
seedMobileUser(t, svc, "erin", "secret-123")
login := func(deviceID string) string {
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "erin", "password": "secret-123", "device_id": deviceID,
"device_name": deviceID, "client_name": "MeBox Android",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
}
var resp struct {
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &resp)
return resp.Tokens.AccessToken
}
tokenA := login("device-a")
tokenB := login("device-b")
if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
t.Fatalf("kick device a: %v", err)
}
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
}
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
}
}
func svcUserID(t *testing.T, svc *service.Container, username string) string {
t.Helper()
u, err := svc.Repo.User.FindByUsername(t.Context(), username)
if err != nil || u == nil {
t.Fatalf("find user %s: %v", username, err)
}
return u.ID
}
func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
router, svc := newMobileTestEnv(t)
seedMobileUser(t, svc, "dave", "secret-123")
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "dave", "password": "secret-123", "device_id": "device-default",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var loginResp struct {
AuthSessionID string `json:"auth_session_id"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &loginResp)
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
if err != nil || session == nil {
t.Fatalf("load session: %v", err)
}
if session.ClientName != "MeBox Android" {
t.Fatalf("client_name default = %q", session.ClientName)
}
if session.ExpiresAt.Before(time.Now()) {
t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
}
if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
}
}
+129
View File
@@ -0,0 +1,129 @@
package handler
import (
"net/http"
"strconv"
"strings"
"unicode"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/truewhile/MeBox/internal/middleware"
)
// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
const (
mobileCodeInvalidArgument = "INVALID_ARGUMENT"
mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
mobileCodeTokenExpired = "TOKEN_EXPIRED"
mobileCodeSessionRevoked = "SESSION_REVOKED"
mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
mobileCodePermissionDenied = "PERMISSION_DENIED"
mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
mobileCodeRateLimited = "RATE_LIMITED"
mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
mobileCodeResourceExpired = "RESOURCE_EXPIRED"
mobileCodeProgressConflict = "PROGRESS_CONFLICT"
mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
)
const (
mobileCtxRequestID = "mobile_request_id"
mobileCtxAuthSession = "mobile_auth_session"
mobileRequestIDHeader = "X-Request-ID"
)
// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
// code 分流,不需要再判断后端历史包装层。
type mobileErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Retryable bool `json:"retryable"`
RetryAfterSec int `json:"retry_after_sec,omitempty"`
Details any `json:"details,omitempty"`
}
// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
// 重新生成;该值仅用于诊断,绝不参与鉴权。
func mobileRequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
if !validRequestID(id) {
id = uuid.NewString()
}
c.Set(mobileCtxRequestID, id)
c.Header(mobileRequestIDHeader, id)
c.Next()
}
}
func validRequestID(id string) bool {
if len(id) < 8 || len(id) > 128 {
return false
}
for _, r := range id {
if r > unicode.MaxASCII {
return false
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '-', '_', '.', ':':
continue
default:
return false
}
}
return true
}
func currentMobileRequestID(c *gin.Context) string {
if c == nil {
return ""
}
if v, ok := c.Get(mobileCtxRequestID); ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return ""
}
// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
requestID := currentMobileRequestID(c)
if requestID == "" {
requestID = uuid.NewString()
c.Header(mobileRequestIDHeader, requestID)
}
if retryAfterSec > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfterSec))
}
c.AbortWithStatusJSON(status, mobileErrorBody{
Code: code,
Message: message,
RequestID: requestID,
Retryable: retryable,
RetryAfterSec: retryAfterSec,
})
}
// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
if !limiter.Allow(c.ClientIP()) {
writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
"too many requests, please try again later", true, 60)
return
}
c.Next()
}
}
+81
View File
@@ -0,0 +1,81 @@
package handler
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。
//
// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体
// 或书源业务逻辑。
func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil {
// 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。
return
}
wireAuthSessionValidator(svc)
v1 := api.Group("/v1")
v1.Use(mobileRequestIDMiddleware())
v1.GET("/server-info", mobileServerInfoHandler(svc, log))
authLimiter := middleware.NewRateLimiter(30, time.Minute)
v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log))
v1.POST("/auth/refresh", mobileRefreshHandler(svc, log))
authed := v1.Group("/auth")
{
authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc))
authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc))
}
}
// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。
// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。
func wireAuthSessionValidator(svc *service.Container) {
middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
if svc == nil || svc.AuthSessions == nil {
return nil
}
_, err := svc.AuthSessions.Validate(ctx, sessionID, userID)
if err == nil {
return nil
}
if errors.Is(err, service.ErrAuthSessionRevoked) ||
errors.Is(err, service.ErrAuthSessionExpired) ||
errors.Is(err, service.ErrAuthSessionRequired) {
return middleware.ErrSessionRevoked
}
return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err)
})
}
func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc))
if err != nil {
if log != nil {
log.Warn("mobile server info failed", zap.Error(err))
}
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"server identity temporarily unavailable", true, 2)
return
}
// 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, info)
}
}
+44 -2
View File
@@ -51,11 +51,53 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
// 起播路径原本还要各发一次 /playback/{id}/resume 和
// /media/{id}/subtitles。把这两份数据并进播放地址响应后,移动端起播
// 只剩一个请求 —— 跨境链路上每多一条连接就多一次 DNS/TLS 建连,
// 实测单次建连可到数秒。老客户端不读这两个字段,行为不变。
uid, _ := c.Get(middleware.CtxUserID)
resumePayload := gin.H{"position_ms": int64(0), "duration_ms": int64(0), "completed": false}
if svc.Playback != nil {
if row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), m.ID); err == nil && row != nil {
resumePayload = gin.H{
"position_ms": row.PositionMs,
"duration_ms": row.DurationMs,
"completed": row.Completed,
}
}
}
// 直连播放只需要外挂字幕;内嵌字幕由播放器从容器里解。
subtitles := []service.SubtitleTrack{}
if svc.Subtitle != nil && !(svc.EmbyRemote != nil && service.IsEmbyRemoteID(m.ID)) {
if discovered, err := svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), m.ID); err == nil && discovered != nil {
subtitles = discovered
}
}
// 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉
// /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的
// DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。
// 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走
// stream_url 跳转,行为与改动前一致。
directURL, hasDirectURL := "", false
if svc.Stream != nil {
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
// 重新换链,别把同一条死链再发一次。
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
service.InvalidateMediaLinkCache(m)
}
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
}
payload := gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL,
})
"resume": resumePayload,
"subtitles": subtitles,
}
if hasDirectURL {
payload["direct_url"] = directURL
}
c.JSON(http.StatusOK, payload)
}
}
+65
View File
@@ -22,6 +22,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/cloud"
)
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
@@ -435,6 +436,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
var payload struct {
StreamURL string `json:"stream_url"`
HlsURL string `json:"hls_url"`
// 移动端起播只打这一个接口:resume / subtitles 必须始终存在,
// 哪怕是默认值,否则客户端会退回额外两个请求。
Resume *struct{ PositionMs int64 `json:"position_ms"` } `json:"resume"`
Subtitles *[]struct{} `json:"subtitles"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
@@ -445,6 +450,66 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
}
if payload.Resume == nil {
t.Fatalf("playback info must always carry resume (got body=%s)", w.Body.String())
}
if payload.Subtitles == nil {
t.Fatalf("playback info must always carry subtitles (got body=%s)", w.Body.String())
}
}
// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能
// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。
func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotUA string
svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) {
gotUA = userAgent
return &service.StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "media-direct"},
LibraryID: "lib-1",
Title: "Direct",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
DurationSec: 3600,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
DirectURL string `json:"direct_url"`
StreamURL string `json:"stream_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.DirectURL != direct {
t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String())
}
if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") {
t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL)
}
// 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。
if gotUA != "MeBox-Android/0.1.0" {
t.Fatalf("resolver UA = %q, want the client UA", gotUA)
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
+145 -17
View File
@@ -14,6 +14,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
readerrule "github.com/truewhile/MeBox/internal/service/reader/rule"
)
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
@@ -77,6 +78,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
g.GET("/books/:id/content", readerBookContentHandler(svc))
// 批量取正文(离线缓存协议,对应 legado CacheBook):返回每章内容与命中统计
g.POST("/books/:id/content-batch", readerBookContentBatchHandler(svc))
// 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
@@ -87,7 +90,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sources, err := svc.Reader.ListSources(c.Request.Context())
userID := c.GetString(middleware.CtxUserID)
sources, err := svc.Reader.ListSources(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -105,7 +109,8 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
userID := c.GetString(middleware.CtxUserID)
imported, err := svc.Reader.ImportSources(c.Request.Context(), userID, body.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -123,7 +128,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return
}
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -133,7 +139,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteSource(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -150,7 +157,8 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
userID := c.GetString(middleware.CtxUserID)
logs, err := svc.Reader.Debug(c.Request.Context(), userID, c.Param("id"), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -315,7 +323,8 @@ func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -332,7 +341,8 @@ func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceVariable(c.Request.Context(), userID, c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -349,7 +359,8 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), userID, c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -370,7 +381,8 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
userID := c.GetString(middleware.CtxUserID)
books, skipped, err := svc.Reader.Search(c.Request.Context(), userID, body.Key, body.SourceIDs, body.Page)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -389,6 +401,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
}
}
@@ -435,6 +451,10 @@ func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 封面解密代理只在下发时改写:库里仍存原始地址,换源/换签名密钥后不会失效。
for i := range books {
books[i].CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), books[i].ID, books[i].Origin, books[i].CoverURL)
}
c.JSON(http.StatusOK, gin.H{"books": books})
}
}
@@ -819,12 +839,37 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
// 封面解密代理(d=cover):签名主体可能是书源 URL(搜索结果尚未入库)。
// 封面不读取书籍正文,因此不需要书籍记录;统一整段读取后解密下发。
if c.Query("d") == "cover" {
resp, fetchErr := svc.Reader.FetchCover(c.Request.Context(), bookID, rawURL)
if fetchErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面拉取失败: " + fetchErr.Error()})
return
}
defer resp.Body.Close()
data, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(data) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面过大或读取失败"})
return
}
decoded := svc.Reader.DecodeCoverBytes(c.Request.Context(), bookID, rawURL, data)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
@@ -832,13 +877,51 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
if isPlaylist {
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
// 这类响应必须整段读取后处理,不再支持 Range(解密后长度会变)。
if decodeJS := svc.Reader.ImageDecodeRule(c.Request.Context(), book); decodeJS != "" && !isMediaPlaylistRequest(resp) {
sniffed, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(sniffed) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
return
}
contentType := ct
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -852,6 +935,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
return strings.Contains(ct, "mpegurl")
}
// readerBodyIsPlaylist 判断上游响应体是否是 HLS 播放列表。
// 以 #EXTM3U 标记为准:Content-Type 只是辅助(上游常标成 octet-stream)。
func readerBodyIsPlaylist(head []byte, contentType string) bool {
return reader.BodyIsPlaylist(head, contentType)
}
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
@@ -940,3 +1039,32 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, content)
}
}
// readerBookContentBatchHandler 批量取正文(对应 legado CacheBook / getContentBatch)。
//
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
return
}
if len(body.ChapterIndexes) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多请求 64 章"})
return
}
userID := c.GetString(middleware.CtxUserID)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}
+1
View File
@@ -50,6 +50,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
@@ -0,0 +1,200 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// 首页轮播/剧集卡片交给播放器的是「整部剧」的伪装 ID,它自身没有媒体源:远程 Emby
// 对它的取流请求会回 500(Unable to cast 'Series' to 'IHasMediaSources')。这里锁定
// 播放接口的行为——改投第一集,且客户端原有的查询参数(token / profile / proxy)
// 必须原样带过去,否则播放器跟过去之后会掉鉴权。
func TestStreamRedirectsRemoteContainerToPlayableEpisode(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "某部新番", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
map[string]any{
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
"ParentIndexNumber": 1, "IndexNumber": 1,
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
},
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
&model.StrmAccount{}, &model.EmbyMount{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "stream-redirect-secret"
log := zap.NewNop()
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote}
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/stream/:id", streamHandler(svc))
req := httptest.NewRequest(
http.MethodGet,
"/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "series-100")+"?token=tok-1&profile=p-1",
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
}
want := "/api/stream/" + service.EncodeEmbyRemoteID("mount-1", "ep-1") + "?token=tok-1&profile=p-1"
if got := w.Header().Get("Location"); got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
}
// 本身可播的远程条目(单集/电影)不能被改写,必须照原样进入原来的代理/直连分支。
func TestStreamKeepsPlayableRemoteItem(t *testing.T) {
gin.SetMode(gin.TestMode)
var streamCalls int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
"ParentIndexNumber": 1, "IndexNumber": 1,
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
})
case "/emby/Videos/ep-1/stream":
streamCalls++
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("bytes"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
&model.StrmAccount{}, &model.EmbyMount{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "stream-keep-secret"
log := zap.NewNop()
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
emby := service.NewEmbyService(cfg, log, repos).SetEmbyRemote(remote)
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote, Emby: emby}
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatal(err)
}
// proxy_play=true:直接走反向代理分支,便于断言确实取到了流。
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
ProxyPlay: true,
Enabled: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/stream/:id", streamHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "ep-1"), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "bytes" {
t.Fatalf("body = %q, want the proxied stream", got)
}
if streamCalls != 1 {
t.Fatalf("upstream stream calls = %d, want 1", streamCalls)
}
}
+22 -3
View File
@@ -17,6 +17,13 @@ import (
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
// maxDecompressedBody 解压后的响应体上限。
//
// 调用方只限制了压缩前的字节数(例如 reader 的 8MiB),而高压缩比响应可以
// 放大几个数量级:一个几 MB 的 gzip 就能在解压时把内存吃光。这里统一兜底,
// 超过上限时按解压失败处理(原样返回压缩字节,由调用方报错)。
const maxDecompressedBody = 32 << 20
// DecompressBody 兜底解压响应体(gzip / deflate)。
//
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
@@ -37,17 +44,20 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
return data
}
defer r.Close()
if out, err := io.ReadAll(r); err == nil {
// 不吞掉多个 member:默认 Multistream(true) 会让串接的 member 继续解,
// 是压缩炸弹的常见放大手段。
r.Multistream(false)
if out, ok := readLimited(r); ok {
return out
}
case "deflate":
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil {
if out, ok := readLimited(flate.NewReader(bytes.NewReader(data))); ok {
return out
}
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
defer zr.Close()
if out, err := io.ReadAll(zr); err == nil {
if out, ok := readLimited(zr); ok {
return out
}
}
@@ -55,6 +65,15 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
return data
}
// readLimited 读取解压流,超过上限返回 ok=false。
func readLimited(r io.Reader) ([]byte, bool) {
out, err := io.ReadAll(io.LimitReader(r, maxDecompressedBody+1))
if err != nil || len(out) > maxDecompressedBody {
return nil, false
}
return out, true
}
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
//
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
+20
View File
@@ -17,6 +17,8 @@ type Claims struct {
Tier string `json:"tier,omitempty"`
Purpose string `json:"purpose,omitempty"`
MediaID string `json:"media_id,omitempty"`
// Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。
Sid string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
@@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
return
}
// 新协议令牌携带 sid:旧 API 也必须检查会话是否已被撤销,否则
// 设备被踢后仍能用未过期的 JWT 访问 /api/*。
if sid := strings.TrimSpace(claims.Sid); sid != "" {
if err := ValidateAuthSession(c.Request.Context(), sid, claims.UserID); err != nil {
if errors.Is(err, ErrSessionUnavailable) {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": 50303, "message": "auth session unavailable"})
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40104, "message": "auth session revoked"})
return
}
c.Set(CtxAuthSessionID, sid)
}
syncAccessTokenCookie(c, raw, claims)
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
@@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string {
}
return ""
}
// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。
func ExtractToken(c *gin.Context) string {
return extractToken(c)
}
+48
View File
@@ -0,0 +1,48 @@
package middleware
import (
"context"
"errors"
"strings"
"sync"
)
// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware
// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。
var (
// ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。
ErrSessionRevoked = errors.New("auth session revoked")
// ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。
ErrSessionUnavailable = errors.New("auth session unavailable")
)
// SessionValidator 校验认证会话是否仍属于该用户。
type SessionValidator func(ctx context.Context, sessionID, userID string) error
var (
sessionValidatorMu sync.RWMutex
sessionValidator SessionValidator
)
// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。
func SetSessionValidator(fn SessionValidator) {
sessionValidatorMu.Lock()
defer sessionValidatorMu.Unlock()
sessionValidator = fn
}
// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行,
// 以保持旧测试和嵌入式调用的兼容行为。
func ValidateAuthSession(ctx context.Context, sessionID, userID string) error {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil
}
sessionValidatorMu.RLock()
fn := sessionValidator
sessionValidatorMu.RUnlock()
if fn == nil {
return nil
}
return fn(ctx, sessionID, userID)
}
+2
View File
@@ -9,6 +9,8 @@ const (
CtxUserTier = "ctx_user_tier"
CtxTokenPurpose = "ctx_token_purpose"
CtxTokenMediaID = "ctx_token_media_id"
// CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。
CtxAuthSessionID = "ctx_auth_session_id"
// AccessTokenCookieName carries the web access token for browser-managed
// resource requests such as <img>, which cannot attach Authorization.
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthSession 是可撤销的设备认证会话。
//
// 会话与刷新令牌的关系是 1:N:一次登录创建一个会话,之后每次轮换刷新
// 令牌都继续归属同一会话。访问令牌通过 JWT 的 sid 字段引用会话,因此
// 服务端可以在 JWT 尚未过期时立即撤销某台设备的访问能力。
//
// 旧客户端没有会话关联(sid 为空),继续走既有兼容路径;本模型不强制
// 把历史刷新令牌绑定到任何设备。
type AuthSession struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
DeviceID string `gorm:"index:idx_auth_session_device,priority:1;size:128;not null" json:"device_id"`
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
ClientName string `gorm:"size:64" json:"client_name,omitempty"`
ClientVersion string `gorm:"size:64" json:"client_version,omitempty"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastUsedAt time.Time `json:"last_used_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate 生成 UUID 主键。
func (s *AuthSession) BeforeCreate(_ *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.NewString()
}
return nil
}
// IsExpired 判断会话是否超过最长有效期。
func (s *AuthSession) IsExpired() bool {
return s != nil && !s.ExpiresAt.IsZero() && time.Now().After(s.ExpiresAt)
}
// IsValid 判断会话当前是否可继续使用。
func (s *AuthSession) IsValid() bool {
return s != nil && !s.Revoked && !s.IsExpired()
}
+11 -7
View File
@@ -26,13 +26,17 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
// (SQLite 没有这个限制)。见 database.ensureMediaPathIndex。
Path string `gorm:"size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
+2
View File
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
&APIConfig{},
&UserPermission{},
&RefreshToken{},
&AuthSession{},
&PlayProfile{},
&RegistrationCode{},
&SignIn{},
@@ -65,6 +66,7 @@ func AllModels() []interface{} {
&ReaderSourceState{},
&ReaderBook{},
&ReaderChapter{},
&ReaderContentCache{},
&ReaderReplaceRule{},
&ReaderProfile{},
&ReaderBookGroups{},
+47 -1
View File
@@ -8,12 +8,19 @@ import (
)
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
//
// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量),
// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠
// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。
type ReaderBookSource struct {
Base
// UserID 归属用户。历史数据迁移时会回填;值为空表示「旧版全局书源」,
// 只在没有任何用户引用时才会出现。
UserID string `gorm:"type:varchar(36);uniqueIndex:uniq_reader_source_user_url;index" json:"user_id"`
Name string `gorm:"type:varchar(255);index" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
SourceURL string `gorm:"type:varchar(512);uniqueIndex:uniq_reader_source_user_url;index" json:"source_url"`
RawJSON string `gorm:"type:text" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"`
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
@@ -98,6 +105,45 @@ type ReaderChapter struct {
Tag string `gorm:"type:varchar(255)" json:"tag"`
}
// ReaderContentCache 正文持久缓存的索引行(内容本体在磁盘上,见 reader_content_cache.go)。
//
// 与 legado BookHelp 的章节正文缓存对应:缓存的是「书源侧产物」(书源 replaceRegex
// 之后、用户替换规则与代理改写之前),因此可以跨用户共享;用户维度的处理在读出后
// 逐请求应用,规则改动即时生效。
//
// 章节身份不落库为外键,而是 BookKey(书源 + 书本地址)与 ChapterKey(绝对化章节
// 地址或标题)的哈希:目录刷新(ReplaceChapters 物理重建、行 ID 会变)与书源更新
// 之后仍然能按同一身份命中或迁移。
type ReaderContentCache struct {
Base
// OriginHash 书源地址哈希(磁盘目录的第一层,清理时定位文件用)。
OriginHash string `gorm:"type:varchar(64)" json:"origin_hash"`
// BookKey 书源身份哈希(sha256(origin + "\0" + bookURL) 前 16 字节 hex)。
BookKey string `gorm:"type:varchar(64);index:idx_reader_content_book" json:"book_key"`
// ChapterKey 章节身份哈希(绝对化 URL 优先,退化为 title)。
ChapterKey string `gorm:"type:varchar(64);index:idx_reader_content_chapter" json:"chapter_key"`
// ChapterIdentity 章节身份原文(便于诊断与 remap 时的标题兜底匹配)。
ChapterIdentity string `gorm:"type:varchar(512)" json:"chapter_identity"`
// ChapterIndex 保存时的章节序号(remap 时更新)。
ChapterIndex int `json:"chapter_index"`
// ContentType text / audio / image。
ContentType string `gorm:"type:varchar(16)" json:"content_type"`
// SourceHash 书源内容指纹(RawJSON 哈希):书源更新后自然失效。
SourceHash string `gorm:"type:varchar(64)" json:"source_hash"`
// FormatVersion 缓存载荷格式版本:解析管线语义变化时递增,旧条目自然失效。
FormatVersion int `json:"format_version"`
// SizeBytes 载荷字节数(容量统计用)。
SizeBytes int64 `json:"size_bytes"`
// AssetCount 音频轨/图片张数(清单类内容的完整性统计)。
AssetCount int `json:"asset_count"`
// ExpiresAt 过期时间(unix 秒);0 表示不过期。
ExpiresAt int64 `json:"expires_at"`
// LastAccessAt 最近命中时间(unix 秒),LRU 淘汰依据。
LastAccessAt int64 `json:"last_access_at"`
// Hits 命中次数(诊断用)。
Hits int `json:"hits"`
}
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
type ReaderReplaceRule struct {
Base
+8 -6
View File
@@ -11,12 +11,14 @@ import (
// RefreshToken 用于双令牌认证机制中的刷新令牌。
// 存储时使用 SHA256 哈希,原始令牌不存储。
type RefreshToken struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
// AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。
AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
}
// BeforeCreate 生成 UUID。
@@ -0,0 +1,101 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// AuthSessionRepository persists model.AuthSession records.
type AuthSessionRepository struct{ db *gorm.DB }
// Create inserts a new authentication session.
func (r *AuthSessionRepository) Create(ctx context.Context, s *model.AuthSession) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(s).Error
})
}
// FindByID returns the session or (nil, nil) when absent.
func (r *AuthSessionRepository) FindByID(ctx context.Context, id string) (*model.AuthSession, error) {
if id == "" {
return nil, nil
}
var s model.AuthSession
err := withSQLiteBusyRetry(ctx, func() error {
s = model.AuthSession{}
return r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// TouchLastUsed 更新会话最近使用时间。
func (r *AuthSessionRepository) TouchLastUsed(ctx context.Context, id string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("id = ? AND revoked = ?", id, false).
Update("last_used_at", at).Error
})
}
// Revoke 撤销单个会话(幂等)。
func (r *AuthSessionRepository) Revoke(ctx context.Context, id string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("id = ? AND revoked = ?", id, false).
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
})
}
// RevokeByUser 撤销某用户的全部会话(幂等)。
func (r *AuthSessionRepository) RevokeByUser(ctx context.Context, userID string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("user_id = ? AND revoked = ?", userID, false).
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
})
}
// RevokeByDevice 撤销某用户某设备关联的全部会话(幂等)。
func (r *AuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("user_id = ? AND device_id = ? AND revoked = ?", userID, deviceID, false).
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
})
}
// ListActiveByUser 返回某用户当前有效(未撤销、未过期)的会话。
func (r *AuthSessionRepository) ListActiveByUser(ctx context.Context, userID string) ([]model.AuthSession, error) {
var rows []model.AuthSession
err := withSQLiteBusyRetry(ctx, func() error {
rows = nil
return r.db.WithContext(ctx).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Order("last_used_at desc, created_at desc").
Find(&rows).Error
})
return rows, err
}
// ListByDevice 返回某用户某设备的全部会话,用于踢出设备时逐个撤销。
func (r *AuthSessionRepository) ListByDevice(ctx context.Context, userID, deviceID string) ([]model.AuthSession, error) {
var rows []model.AuthSession
err := withSQLiteBusyRetry(ctx, func() error {
rows = nil
return r.db.WithContext(ctx).
Where("user_id = ? AND device_id = ?", userID, deviceID).
Find(&rows).Error
})
return rows, err
}
+21
View File
@@ -0,0 +1,21 @@
package repository
import "gorm.io/gorm"
// LikeOperator 返回当前方言下「与 SQLite LIKE 行为一致」的模式匹配运算符。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写(默认编译选项),PostgreSQL 的 LIKE 区分
// 大小写、只有 ILIKE 才不区分。同一个搜索词在两种方言下必须给出同样的结果集,
// 否则换库之后「inception」就再也搜不到「Inception」。
//
// 供构造原始 SQL 片段的调用方(含 service 层)使用。
func LikeOperator(db *gorm.DB) string {
return likeOperator(db)
}
func likeOperator(db *gorm.DB) string {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" {
return "ILIKE"
}
return "LIKE"
}
+54
View File
@@ -0,0 +1,54 @@
package repository
import (
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// TestSegmentPrewarmQueryBindsBooleanFound 守住 PostgreSQL 下片段预取查询的合法性。
//
// media_segment_fetches.found 是布尔列,而 PostgreSQL 没有 boolean = integer
// 运算符:写成 f.found = 0 / = 1 会让计划任务里的片段预取固定报
//
// operator does not exist: boolean = integer (SQLSTATE 42883)
//
// SQLite 一直能跑,所以这个错只在 PostgreSQL 上暴露(表现为日志里周期性出现
// 该错误、片头片尾数据永远补不上)。这里断言语句里只能出现占位符绑定。
func TestSegmentPrewarmQueryBindsBooleanFound(t *testing.T) {
query := segmentPrewarmCandidatesQuery
lower := strings.ToLower(query)
for _, forbidden := range []string{"found = 0", "found = 1", "found=0", "found=1", "found = true", "found = false"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("预取查询把布尔列与字面量比较(%q),PostgreSQL 会直接报错:\n%s", forbidden, query)
}
}
if got := strings.Count(query, "f.found = ?"); got != 2 {
t.Fatalf("expected 2 placeholder-bound found comparisons, got %d:\n%s", got, query)
}
}
// TestLikeOperatorMatchesSQLiteCaseInsensitivity 守住搜索大小写行为的方言差异。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写,PostgreSQL 的 LIKE 区分大小写:同一个
// 搜索词在两种方言下必须给出同样的结果集,PostgreSQL 必须走 ILIKE。
func TestLikeOperatorMatchesSQLiteCaseInsensitivity(t *testing.T) {
pg, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
if got := LikeOperator(pg); got != "ILIKE" {
t.Fatalf("postgres LikeOperator = %q, want ILIKE", got)
}
if got := LikeOperator(nil); got != "LIKE" {
t.Fatalf("nil LikeOperator = %q, want LIKE", got)
}
}
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
})
}
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
var affected int64
if strings.TrimSpace(accountID) == "" {
return 0, nil
}
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
Where("account_id = ?", accountID).
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
+17 -3
View File
@@ -33,6 +33,20 @@ func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.Playbac
return r.upsert(ctx, h, true)
}
// historyColumn 生成「当前表」限定的列引用。
//
// PostgreSQL 的 ON CONFLICT DO UPDATE ... WHERE 里,未限定的列名会同时可解析到
// 目标表和本次插入的行,于是报
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 结果是 POST /api/history 一律 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查,所以这个错只在 PostgreSQL 上暴露。clause.CurrentTable 由 GORM 在构建语句
// 时替换成真实表名,两种方言都能正确渲染。
func historyColumn(name string) clause.Column {
return clause.Column{Table: clause.CurrentTable, Name: name}
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h)
if versioned {
@@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Column: historyColumn("sequence"),
Value: h.Sequence,
},
),
@@ -0,0 +1,65 @@
package repository
import (
"context"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestHistoryProgressUpsertQualifiesConflictWhereColumns 守住 PostgreSQL 下进度上报
// 的 upsert 语句合法性。
//
// ON CONFLICT DO UPDATE 的 WHERE 里如果不给列名加表限定,PostgreSQL 会因为这些列
// 同时出现在本次插入的行里而拒绝该语句:
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 后果是 POST /api/history 全部 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查(旧写法在 SQLite 下一直正常),所以只能在 PostgreSQL 方言下断言。
func TestHistoryProgressUpsertQualifiesConflictWhereColumns(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
ctx := context.Background()
if err := repos.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 2_000,
SessionID: "session-1",
SessionStartedAtMs: 111,
Sequence: 2,
}); err != nil {
t.Fatalf("build upsert: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from UpsertProgress")
}
if !strings.Contains(sql, "ON CONFLICT") {
t.Fatalf("expected an ON CONFLICT upsert, got:\n%s", sql)
}
// 只看 ON CONFLICT 之后的部分:SET 的赋值目标和 CASE 里的 duration_ms 引用
// 属于另一套语法规则,不在这里断言。
conflict := sql[strings.Index(sql, "ON CONFLICT"):]
whereIdx := strings.Index(conflict, " WHERE ")
if whereIdx < 0 {
t.Fatalf("ON CONFLICT clause has no DO UPDATE ... WHERE guard:\n%s", sql)
}
where := conflict[whereIdx:]
for _, want := range []string{
`"playback_histories"."session_started_at_ms"`,
`"playback_histories"."sequence"`,
} {
if !strings.Contains(where, want) {
t.Fatalf("DO UPDATE WHERE is missing the table-qualified %s; it is ambiguous on PostgreSQL:\n%s", want, sql)
}
}
if strings.Contains(where, `("session_started_at_ms"`) || strings.Contains(where, ` AND "sequence"`) {
t.Fatalf("DO UPDATE WHERE still references unqualified columns:\n%s", sql)
}
}
+11 -11
View File
@@ -63,38 +63,38 @@ type MediaQueryFilter struct {
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("nsfw = ?", false)
q = q.Where("media.nsfw = ?", false)
}
if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
}
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
q = q.Where("library_id = ?", libraryID)
q = q.Where("media.library_id = ?", libraryID)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
q = q.Where("media.series_id = ?", seriesID)
}
if len(filter.Genres) > 0 {
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
}
if filter.YearMin > 0 {
q = q.Where("year >= ?", filter.YearMin)
q = q.Where("media.year >= ?", filter.YearMin)
}
if filter.YearMax > 0 {
q = q.Where("year <= ?", filter.YearMax)
q = q.Where("media.year <= ?", filter.YearMax)
}
if filter.RatingMin > 0 {
q = q.Where("rating >= ?", filter.RatingMin)
q = q.Where("media.rating >= ?", filter.RatingMin)
}
if filter.UnwatchedOnly {
userID := strings.TrimSpace(filter.UnwatchedUserID)
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
if userID != "" {
q = q.Where(
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
"media.id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
userID, true,
)
}
@@ -113,7 +113,7 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
func genreMatchClause(genres []string) string {
clauses := make([]string, 0, len(genres))
for range genres {
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(media.genres), ' ', ''), ',', ',') || ',' LIKE ?")
}
return "(" + strings.Join(clauses, " OR ") + ")"
}
@@ -122,7 +122,7 @@ func genreMatchClause(genres []string) string {
//
// 必须与 genreMatchClause 的列端处理完全对称:
// - TRIM → TrimSpace
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ' ', ”) → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
func genreMatchArgs(genres []string) []any {
args := make([]any, 0, len(genres))
+65 -13
View File
@@ -7,6 +7,7 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -95,6 +96,9 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
return
}
for _, id := range ids {
if id == "" {
continue
@@ -115,7 +119,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return m.ID, nil
}
updates := mediaUpsertUpdates(existing, *m)
// 孤儿行回收:原库已被删除时(DeleteLibrary 硬删库行;扫描与删除并发时扫描也会
// 把行写回已删库),这条行会钉死在不存在的 library_id 上。media.path 是全局唯一
// 索引,重扫既插不进同路径新行,又因 LibraryID 非空而拒绝迁移归属,于是新库/重建库
// 永远查不到这些条目(表现为"扫描明明有文件,库却是空的")。这里显式识别并允许迁移。
reclaimLibrary := r.mediaRowLibraryIsOrphaned(ctx, db, existing.LibraryID, m.LibraryID, m.Path)
updates := mediaUpsertUpdates(existing, *m, reclaimLibrary)
if adopted {
updates["path"] = m.Path
if existing.DeletedAt.Valid {
@@ -139,6 +148,42 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return existing.ID, nil
}
// mediaRowLibraryIsOrphaned reports whether a media row is still pinned to a
// library that no longer exists.
//
// 删除媒体库会硬删库行(见 MediaService.DeleteLibrary),但扫描与删除并发时,
// 已在途的扫描仍可能把这批文件写回已删库的 library_id;历史数据里也可能留下
// 指向早已消失的库的行。因为 media.path 是全局唯一索引,这些行一旦存在:
// 1. 重扫无法插入同路径的新行(唯一约束冲突);
// 2. addMediaPlacementUpdates 又因为 existing.LibraryID 非空而拒绝迁移归属。
//
// 结果就是新库 / 重建的同名库永远查不到这些条目,扫描报告有文件而库显示为空。
// 这里把"原库已不存在"这种情况单独判定出来,交给扫描把归属迁移到当前库。
func (r *MediaRepository) mediaRowLibraryIsOrphaned(ctx context.Context, db *gorm.DB, existingLibraryID, incomingLibraryID, incomingPath string) bool {
existingLibraryID = strings.TrimSpace(existingLibraryID)
incomingLibraryID = strings.TrimSpace(incomingLibraryID)
// 归属未变或原行为空(后者走既有的回填分支)时无需查库。
if existingLibraryID == "" || existingLibraryID == incomingLibraryID {
return false
}
// 云盘媒体的归属迁移由 addMediaPlacementUpdates 无条件处理,无需再查库。
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(incomingPath)), "cloud://") {
return false
}
if db == nil || !db.Migrator().HasTable(&model.Library{}) {
return false
}
var count int64
// 刻意不加 Unscoped:软删除的库同样不再出现在库里,挂在它下面的媒体行
// 也属于"查不到"的孤儿,一并允许回收。查询失败时保守处理:宁可保留旧
// 归属,也不要把仍有效的库误判成孤儿。
if err := db.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", existingLibraryID).Count(&count).Error; err != nil {
return false
}
return count == 0
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
@@ -152,14 +197,19 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, false, nil
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return model.Media{}, false, false, createErr
} else {
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, false, nil
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
if result.Error != nil {
return model.Media{}, false, false, result.Error
}
if result.RowsAffected > 0 {
return *m, true, false, nil
}
if err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; err != nil {
return model.Media{}, false, false, err
}
return existing, false, false, nil
}
if err != nil {
return model.Media{}, false, false, err
@@ -194,14 +244,14 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
return existing, err
}
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{}
addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
addMatchedMediaMetadataUpdates(updates, existing, incoming)
addMediaArtworkUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming, reclaimLibrary)
addMediaSTRMUpdate(updates, existing, incoming)
return updates
}
@@ -308,13 +358,15 @@ func addMediaArtworkUpdates(updates map[string]any, existing, incoming model.Med
}
}
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media) {
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media, reclaimLibrary bool) {
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。
// 本地媒体物理位置固定:仅在原 library_id 为空、或原库已不存在(孤儿行,
// reclaimLibrary)时才回填/迁移,既避免把仍属于其他库的本地媒体抢走,
// 又能让扫描回收被删除库遗留的行。
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID {
if isCloudMediaPath || existing.LibraryID == "" {
if isCloudMediaPath || existing.LibraryID == "" || reclaimLibrary {
updates["library_id"] = incoming.LibraryID
}
}
+6 -24
View File
@@ -6,6 +6,7 @@ import (
"unicode"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -86,7 +87,7 @@ func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, o
Joins("JOIN media_search_fts ON media_search_fts.rowid = media.rowid").
Where("media.deleted_at IS NULL").
Where("media_search_fts MATCH ?", ftsQuery)
q = applyQualifiedMediaQueryFilter(q, filter)
q = applyMediaQueryFilter(q, filter)
if err := q.Count(&total).Error; err != nil {
return nil, 0, false
}
@@ -106,11 +107,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
q := r.db.WithContext(ctx).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter)
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
// 需用 ILIKE 保持两端搜索行为一致。
likeOp := "LIKE"
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
likeOp = "ILIKE"
}
// 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。
likeOp := likeOperator(r.db)
terms := mediaSearchTerms(query)
for _, term := range terms {
like := "%" + escapeLike(term) + "%"
@@ -125,10 +123,10 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
if query != "" {
prefix := escapeLike(query) + "%"
exact := query
q = q.Order(gorm.Expr(
q = q.Order(clause.OrderBy{Expression: gorm.Expr(
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
exact, exact, prefix, prefix,
))
)})
} else {
q = q.Order("created_at desc")
}
@@ -136,22 +134,6 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
return items, total, err
}
func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("media.nsfw = ?", false)
}
if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
}
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("media.series_id = ?", seriesID)
}
return q
}
func mediaFTSQuery(query string) string {
terms := mediaSearchTerms(query)
if len(terms) == 0 {
+30 -18
View File
@@ -86,21 +86,15 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).Raw(`
// segmentPrewarmCandidatesQuery 是片段预取候选查询。
//
// 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下
// GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。
//
// 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1:
// PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883
// (SQLite 宽容,所以只在 PostgreSQL 上暴露)。
const segmentPrewarmCandidatesQuery = `
SELECT m.*
FROM media m
LEFT JOIN media_segment_fetches f
@@ -119,11 +113,29 @@ WHERE m.deleted_at IS NULL
)
AND (
f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
)
ORDER BY ph.last_played DESC
LIMIT ?
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
`
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).
Raw(segmentPrewarmCandidatesQuery, source, false, missBefore, true, hitBefore, limit).
Scan(&rows).Error
return rows, err
}
+53
View File
@@ -0,0 +1,53 @@
package repository
import (
"context"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSeriesIndexColumns 是解析剧集归组 key 所需的全部列。
//
// 剧集归组(首页「最近添加」、媒体库卡片)必须看到整库每一行才能算出正确的
// key 和集数,因此不能只用 SQL 顶端的几行。但整行扫描会把 overview、genres、
// strm_url 这些大文本列一并读进来再丢掉:7k 行规模下驱动层的逐列 bind 占了
// 绝大部分耗时(实测 ~60%)。这里只取归组真正用到的列,卡片代表行在选出
// 前 N 个剧集后按 id 再补一次完整行。
const MediaSeriesIndexColumns = "" +
"id, created_at, updated_at, library_id, library_root_id, series_id, " +
"title, original_name, path, poster_url, backdrop_url, " +
"year, release_date, season_num, episode_num, episode_fraction, " +
"tm_db_id, bangumi_id, douban_id, thetvdb_id, " +
"width, height, size_bytes, video_codec, audio_codec, container, nsfw"
// ListMediaSeriesIndex 按 created_at 倒序返回可见媒体的「归组投影」。
//
// 与 SearchFiltered 的区别:
// - 不做 COUNT(*)(调用方不需要总数,7k 行时这一条 SQL 白跑一遍全表);
// - 只 SELECT 归组所需列,避免大文本列的解码与内存拷贝。
func (r *MediaRepository) ListMediaSeriesIndex(ctx context.Context, limit int, filter MediaQueryFilter) ([]model.Media, error) {
var items []model.Media
q := r.db.WithContext(ctx).Model(&model.Media{}).
Select(MediaSeriesIndexColumns).
Order("created_at desc")
q = applyMediaQueryFilter(q, filter)
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// ListMediaByIDs 按 ID 批量取完整行(用于补齐剧集卡片的代表行)。
func (r *MediaRepository) ListMediaByIDs(ctx context.Context, ids []string) ([]model.Media, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.Media
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
+215 -16
View File
@@ -6,6 +6,7 @@ import (
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -15,10 +16,22 @@ type ReaderRepository struct {
db *gorm.DB
}
// ListSources 书源列表(按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
// readerOrderBy 组装方言安全的 ORDER BY。
//
// 用 clause.Column 让 GORM 按当前方言给列名加引号(SQLite 反引号 / PostgreSQL 双引号),
// 而不是把引号硬编码进排序字符串。reader_books.order、reader_chapters.index、
// reader_replace_rules.order 都撞上了 SQL 关键字(order 在 PostgreSQL 里是保留字):
// 之前写成 .Order("`order` ASC"),SQLite 下正常,PostgreSQL 下反引号被词法分析成
// 运算符,直接报 `syntax error at or near "order"`,书架 / 目录 / 替换规则整页 500。
func readerOrderBy(columns ...clause.OrderByColumn) clause.OrderBy {
return clause.OrderBy{Columns: columns}
}
// ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("custom_order ASC, updated_at DESC").Find(&out).Error
return out, err
}
@@ -31,8 +44,31 @@ func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.Rea
return &out, nil
}
// GetSourceByURL 按书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
// GetSourceForUser 按 ID 取书源并校验归属用户。
func (r *ReaderRepository) GetSourceForUser(ctx context.Context, userID, id string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
if err := r.db.WithContext(ctx).First(&out, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetSourceByURL 按用户 + 书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, userID, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND source_url = ?", userID, sourceURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetSourceAnyByURL 按书源 URL 取任意一份副本(不限定用户)。
//
// 媒体代理、目录刷新等路径只拿得到书籍的 origin(书源 URL)而没有用户上下文;
// 请求目标由 URL 决定,任一副本都够用。多个用户各自改过规则/header 时,
// 取到哪一份只影响「用谁的默认 header」,不影响书籍本身的定位。
func (r *ReaderRepository) GetSourceAnyByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
@@ -41,6 +77,13 @@ func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string)
return &out, nil
}
// ListSourceRowsByURL 取某书源 URL 的全部副本(判断是否仍有其他用户引用)。
func (r *ReaderRepository) ListSourceRowsByURL(ctx context.Context, sourceURL string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Where("source_url = ?", sourceURL).Find(&out).Error
return out, err
}
// CreateSource 新增书源。
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Create(src).Error
@@ -52,20 +95,34 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo
}
// DeleteSource 删除书源(连带清理其会话状态)。
//
// 用物理删除而不是软删:唯一键是 (user_id, source_url),而索引会覆盖软删行——
// 软删后再导入同一书源会撞唯一约束(表现为「导入失败」),删掉再导入是本模块的
// 正常操作。书源本身是可重新导入的数据,不需要软删保留。
//
// 会话状态表仍按 source_url 一源一条(跨用户共享),因此只有在该 URL 已无任何
// 其他用户的书源副本时才连带清理,否则会把别人的登录态一起删掉。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
var others int64
if err := tx.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND id <> ?", src.SourceURL, id).Count(&others).Error; err != nil {
return err
}
if others == 0 {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
}
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
return tx.Unscoped().Delete(&model.ReaderBookSource{}, "id = ?", id).Error
})
}
@@ -108,7 +165,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
// ListBooks 用户书架(按 order 排序)。
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` ASC, updated_at DESC").Find(&out).Error
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}},
clause.OrderByColumn{Column: clause.Column{Name: "updated_at"}, Desc: true},
)).Find(&out).Error
return out, err
}
@@ -131,6 +192,15 @@ func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bo
return &out, nil
}
// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。
// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的
// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。
func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error
return out, err
}
// CreateBook / UpdateBook / DeleteBook。
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Create(b).Error
@@ -163,10 +233,127 @@ func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, c
})
}
// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。
// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。
func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil {
return err
}
return tx.Save(book).Error
})
}
// ListChapters 按序取章节。
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
var out []model.ReaderChapter
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Order("`index` ASC").Find(&out).Error
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "index"}},
)).Find(&out).Error
return out, err
}
// ─── 正文持久缓存索引 ──────────────────────────────────────────────────────
// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。
func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
var out model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType).
First(&out).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。
// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。
func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey)
if contentType != "" {
q = q.Where("content_type = ?", contentType)
}
var out model.ReaderContentCache
if err := q.Order("last_access_at DESC").First(&out).Error; err != nil {
return nil, err
}
return &out, nil
}
// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。
func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error {
existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType)
if err == nil && existing != nil {
existing.ChapterIdentity = row.ChapterIdentity
existing.ChapterIndex = row.ChapterIndex
existing.SourceHash = row.SourceHash
existing.FormatVersion = row.FormatVersion
existing.SizeBytes = row.SizeBytes
existing.AssetCount = row.AssetCount
existing.ExpiresAt = row.ExpiresAt
existing.LastAccessAt = row.LastAccessAt
return r.db.WithContext(ctx).Save(existing).Error
}
return r.db.WithContext(ctx).Create(row).Error
}
// TouchContentCache 记录一次命中(更新命中时间与次数)。
func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error {
return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Where("id = ?", id).
Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error
}
// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。
func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error
return out, err
}
// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。
func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
rows, err := r.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return nil, err
}
if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil {
return nil, err
}
return rows, nil
}
// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。
func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error
}
// ListContentCacheExpired 按 TTL 取过期条目。
func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("expires_at > 0 AND expires_at < ?", now).
Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。
func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) {
var row struct {
Count int64
Bytes int64
}
err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error
return row.Count, row.Bytes, err
}
// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。
func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
@@ -197,9 +384,17 @@ func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []st
}
// GetChapter 取指定章节。
//
// index 在 PostgreSQL 里是保留字,必须让 GORM 按方言加引号(SQLite 反引号 /
// PostgreSQL 双引号)。此前写成硬编码的 "book_id = ? AND `index` = ?",
// PostgreSQL 会把反引号词法分析成运算符并报 syntax error at or near "`"。
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
var out model.ReaderChapter
if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil {
err := r.db.WithContext(ctx).
Where(clause.Eq{Column: clause.Column{Name: "book_id"}, Value: bookID}).
Where(clause.Eq{Column: clause.Column{Name: "index"}, Value: index}).
First(&out).Error
if err != nil {
return nil, err
}
return &out, nil
@@ -265,7 +460,11 @@ func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.Reader
// ListReplaceRules 用户替换规则(按 order 排序)。
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
var out []model.ReaderReplaceRule
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` DESC, created_at ASC").Find(&out).Error
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}, Desc: true},
clause.OrderByColumn{Column: clause.Column{Name: "created_at"}},
)).Find(&out).Error
return out, err
}
@@ -0,0 +1,116 @@
package repository
import (
"context"
"strings"
"sync"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// sqlCaptureLogger 只记录 GORM 生成的 SQL,不打印任何日志。
type sqlCaptureLogger struct {
mu sync.Mutex
lines []string
}
func (l *sqlCaptureLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface { return l }
func (l *sqlCaptureLogger) Info(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Warn(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Error(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()
l.mu.Lock()
l.lines = append(l.lines, sql)
l.mu.Unlock()
}
func (l *sqlCaptureLogger) all() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.lines, "\n")
}
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
// SkipDefaultTransaction 则挡掉 GORM 默认事务包装——那一步会真的去开事务连接,
// 在 DryRun 下同样会拨号(ToSQL 内部也是这么绕开的)。于是可以直接断言生成语句
// 在 PostgreSQL 下是否合法。
func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) {
t.Helper()
rec := &sqlCaptureLogger{}
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
Logger: rec,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
return New(db), rec
}
// TestReaderListsUseDialectQuotedReservedColumns 守住阅读子系统列表查询在
// PostgreSQL 下的合法性。reader_books.order / reader_chapters.index /
// reader_replace_rules.order 都是关键字级列名(order 在 PostgreSQL 里是保留字)。
//
// 排序条件曾经写成硬编码反引号的原始字符串,例如 .Order("`order` ASC"):
// SQLite 正常,PostgreSQL 会把反引号词法分析成运算符,直接报
// `syntax error at or near "order"` (SQLSTATE 42601),书架 / 目录 / 替换规则整页 500。
// 现在改用 clause.Column,由 GORM 按方言加引号(SQLite 反引号 / PostgreSQL 双引号)。
func TestReaderListsUseDialectQuotedReservedColumns(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
run func(repos *Container) error
wants []string
}{
{
name: "books",
run: func(repos *Container) error { _, err := repos.Reader.ListBooks(ctx, "user-1"); return err },
wants: []string{`"order"`, `"updated_at" DESC`},
},
{
name: "chapters",
run: func(repos *Container) error { _, err := repos.Reader.ListChapters(ctx, "book-1"); return err },
wants: []string{`"index"`},
},
{
name: "replace rules",
run: func(repos *Container) error { _, err := repos.Reader.ListReplaceRules(ctx, "user-1"); return err },
wants: []string{`"order" DESC`, `"created_at"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
if err := tc.run(repos); err != nil {
t.Fatalf("build query: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from the repository call")
}
if strings.Contains(sql, "`") {
t.Fatalf("SQL uses backtick quoting, which PostgreSQL rejects:\n%s", sql)
}
if !strings.Contains(sql, "ORDER BY") {
t.Fatalf("SQL has no ORDER BY clause:\n%s", sql)
}
for _, want := range tc.wants {
if !strings.Contains(sql, want) {
t.Fatalf("SQL missing %q:\n%s", want, sql)
}
}
})
}
}
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
})
}
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
// user by revoking the oldest non-expired, non-revoked tokens.
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
// for a user among tokens that are not bound to a device auth session
// (legacy web clients). Device sessions are independent: trimming one
// device's token because another device logged in would silently sign the
// first device out even though its session is still valid.
func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error {
if limit < 1 {
limit = 1
}
return withSQLiteBusyRetry(ctx, func() error {
var tokens []model.RefreshToken
if err := r.db.WithContext(ctx).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')",
userID, false, time.Now()).
Order("created_at desc, id desc").
Find(&tokens).Error; err != nil {
return err
@@ -79,7 +83,7 @@ func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
})
}
// Revoke revokes a specific refresh token.
// Revoke 撤销某个刷新令牌。
func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
@@ -87,6 +91,18 @@ func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error
})
}
// RevokeByAuthSessionID 撤销某个认证会话名下的全部刷新令牌。
// 设备被踢或主动退出后,旧刷新令牌不能再换出新令牌。
func (r *RefreshTokenRepository) RevokeByAuthSessionID(ctx context.Context, sessionID string) error {
if sessionID == "" {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
Where("auth_session_id = ?", sessionID).Update("revoked", true).Error
})
}
// HashToken returns the SHA256 hash of a token.
func HashToken(token string) string {
h := sha256.Sum256([]byte(token))
+2
View File
@@ -23,6 +23,7 @@ type Container struct {
Log *AccessLogRepository
Permission *PermissionRepository
RefreshToken *RefreshTokenRepository
AuthSession *AuthSessionRepository
ApiConfig *ApiConfigRepository
PlayProfile *PlayProfileRepository
RegCode *RegistrationCodeRepository
@@ -56,6 +57,7 @@ func New(db *gorm.DB) *Container {
Log: &AccessLogRepository{db: db},
Permission: &PermissionRepository{db: db},
RefreshToken: &RefreshTokenRepository{db: db},
AuthSession: &AuthSessionRepository{db: db},
ApiConfig: &ApiConfigRepository{db: db},
PlayProfile: &PlayProfileRepository{db: db},
RegCode: &RegistrationCodeRepository{db: db},
+24 -7
View File
@@ -34,6 +34,7 @@ type AuthService struct {
repo *repository.Container
tokenSvc *TokenService
permissionSvc *PermissionService
sessionSvc *AuthSessionService
tempPassMu sync.RWMutex
tempPasswords map[string]temporaryPasswordEntry
@@ -51,6 +52,11 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
}
}
// SetAuthSessionService 注入设备会话服务;旧登出路径会同时撤销全部会话。
func (s *AuthService) SetAuthSessionService(sessions *AuthSessionService) {
s.sessionSvc = sessions
}
// Common service-level errors.
var (
ErrInvalidCredentials = errors.New("invalid username or password")
@@ -162,6 +168,23 @@ type LoginResponse struct {
// Login validates credentials and returns the user + a fresh JWT token pair.
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
u, err := s.Authenticate(ctx, username, password)
if err != nil {
return nil, err
}
// 签发令牌对
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
if err != nil {
return nil, err
}
s.touchLoginBestEffort(u.ID)
return &LoginResponse{User: u, Tokens: tokens}, nil
}
// Authenticate 只校验账号与密码,不签发任何令牌。
// 移动端 v1 登录需要先确认账号有效,再创建设备会话并签发带 sid 的令牌;
// 分离这一步可以避免先签发一对无会话的令牌再作废。
func (s *AuthService) Authenticate(ctx context.Context, username, password string) (*model.User, error) {
u, err := s.repo.User.FindByUsername(ctx, username)
if err != nil {
return nil, err
@@ -180,13 +203,7 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// 签发令牌对
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
if err != nil {
return nil, err
}
s.touchLoginBestEffort(u.ID)
return &LoginResponse{User: u, Tokens: tokens}, nil
return u, nil
}
func (s *AuthService) touchLoginBestEffort(userID string) {
+187
View File
@@ -0,0 +1,187 @@
// Package service — 设备认证会话。
package service
import (
"context"
"errors"
"strings"
"time"
"unicode"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 认证会话错误。middleware 与 handler 依据这些错误区分「重新登录」和
// 「临时基础设施故障」。
var (
ErrAuthSessionRequired = errors.New("auth session required")
ErrAuthSessionRevoked = errors.New("auth session revoked")
ErrAuthSessionExpired = errors.New("auth session expired")
)
// AuthSessionService 管理设备会话的创建、校验和撤销。
type AuthSessionService struct {
log *zap.Logger
repo *repository.Container
now func() time.Time
}
// NewAuthSessionService 创建会话服务。
func NewAuthSessionService(log *zap.Logger, repo *repository.Container) *AuthSessionService {
return &AuthSessionService{log: log, repo: repo, now: time.Now}
}
// CreateAuthSessionInput 是创建会话所需的最小设备信息。
type CreateAuthSessionInput struct {
UserID string
DeviceID string
DeviceName string
ClientName string
ClientVersion string
}
// Create 创建一次登录对应的认证会话。
//
// 设备 ID 由 App 安装随机生成,不接收 IMEI/MAC 等硬件标识;这里只做长度
// 与字符白名单校验,避免任意字符串进入索引和日志。
func (s *AuthSessionService) Create(ctx context.Context, in CreateAuthSessionInput) (*model.AuthSession, error) {
userID := strings.TrimSpace(in.UserID)
deviceID := strings.TrimSpace(in.DeviceID)
if userID == "" || deviceID == "" {
return nil, errors.New("user id and device id required")
}
if len(deviceID) > 128 || !validDeviceID(deviceID) {
return nil, errors.New("invalid device id")
}
now := s.currentTime()
session := &model.AuthSession{
UserID: userID,
DeviceID: deviceID,
DeviceName: truncateRunes(strings.TrimSpace(in.DeviceName), 128),
ClientName: truncateRunes(strings.TrimSpace(in.ClientName), 64),
ClientVersion: truncateRunes(strings.TrimSpace(in.ClientVersion), 64),
LastUsedAt: now,
ExpiresAt: now.Add(RefreshTokenDuration),
}
if err := s.repo.AuthSession.Create(ctx, session); err != nil {
return nil, err
}
return session, nil
}
// Validate 校验会话仍可用于给定用户。
//
// 未找到的会话按「已撤销」处理:App 收到 SESSION_REVOKED 后应重新登录,
// 而不是无限刷新。数据库错误保持原样返回,由上层映射成可重试的 503。
func (s *AuthSessionService) Validate(ctx context.Context, sessionID, userID string) (*model.AuthSession, error) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil, ErrAuthSessionRequired
}
session, err := s.repo.AuthSession.FindByID(ctx, sessionID)
if err != nil {
return nil, err
}
if session == nil || session.UserID != userID {
return nil, ErrAuthSessionRevoked
}
if session.Revoked {
return nil, ErrAuthSessionRevoked
}
if session.IsExpired() {
return nil, ErrAuthSessionExpired
}
return session, nil
}
// Touch 更新会话最近使用时间,失败只记日志,不影响请求。
func (s *AuthSessionService) Touch(ctx context.Context, sessionID string) {
if s == nil || sessionID == "" {
return
}
if err := s.repo.AuthSession.TouchLastUsed(ctx, sessionID, s.currentTime()); err != nil && s.log != nil {
s.log.Debug("touch auth session failed", zap.String("session_id", sessionID), zap.Error(err))
}
}
// Revoke 撤销单个会话及其名下刷新令牌(幂等)。
func (s *AuthSessionService) Revoke(ctx context.Context, sessionID string) error {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil
}
if err := s.repo.AuthSession.Revoke(ctx, sessionID, s.currentTime()); err != nil {
return err
}
return s.repo.RefreshToken.RevokeByAuthSessionID(ctx, sessionID)
}
// RevokeByUser 撤销某用户的全部会话(幂等)。
func (s *AuthSessionService) RevokeByUser(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
if userID == "" {
return nil
}
sessions, err := s.repo.AuthSession.ListActiveByUser(ctx, userID)
if err != nil {
return err
}
for i := range sessions {
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
return err
}
}
return nil
}
// RevokeDevice 撤销某用户某设备的全部会话(幂等)。
func (s *AuthSessionService) RevokeDevice(ctx context.Context, userID, deviceID string) error {
userID = strings.TrimSpace(userID)
deviceID = strings.TrimSpace(deviceID)
if userID == "" || deviceID == "" {
return nil
}
sessions, err := s.repo.AuthSession.ListByDevice(ctx, userID, deviceID)
if err != nil {
return err
}
for i := range sessions {
if sessions[i].Revoked {
continue
}
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
return err
}
}
return nil
}
func (s *AuthSessionService) currentTime() time.Time {
if s == nil || s.now == nil {
return time.Now()
}
return s.now()
}
func validDeviceID(id string) bool {
for _, r := range id {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':') {
return false
}
}
return true
}
func truncateRunes(s string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}
+261
View File
@@ -0,0 +1,261 @@
package service
import (
"context"
"errors"
"fmt"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newAuthSessionTestEnv(t *testing.T) (*AuthSessionService, *TokenService, *repository.Container) {
t.Helper()
db := newServiceTestDB(t, &model.AuthSession{}, &model.RefreshToken{}, &model.User{}, &model.Setting{})
repos := repository.New(db)
sessions := NewAuthSessionService(zap.NewNop(), repos)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
tokens := NewTokenService(cfg, zap.NewNop(), repos)
tokens.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
_, err := sessions.Validate(ctx, sessionID, userID)
return err
})
return sessions, tokens, repos
}
func seedSessionUser(t *testing.T, repos *repository.Container) *model.User {
t.Helper()
u := &model.User{Username: "mobile", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
if err := repos.User.Create(t.Context(), u); err != nil {
t.Fatal(err)
}
return u
}
func TestAuthSessionLifecycle(t *testing.T) {
sessions, tokens, repos := newAuthSessionTestEnv(t)
ctx := context.Background()
user := seedSessionUser(t, repos)
session, err := sessions.Create(ctx, CreateAuthSessionInput{
UserID: user.ID,
DeviceID: "device-abc",
DeviceName: "Pixel",
ClientName: "MeBox Android",
})
if err != nil {
t.Fatalf("create session: %v", err)
}
if session.ID == "" || session.ExpiresAt.IsZero() {
t.Fatal("session missing id or expiry")
}
if _, err := sessions.Validate(ctx, session.ID, user.ID); err != nil {
t.Fatalf("validate active session: %v", err)
}
if _, err := sessions.Validate(ctx, session.ID, "other-user"); !errors.Is(err, ErrAuthSessionRevoked) {
t.Fatalf("cross-user validate err = %v, want ErrAuthSessionRevoked", err)
}
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
t.Fatalf("issue pair: %v", err)
}
claims, err := tokens.ValidateAccessToken(pair.AccessToken)
if err != nil {
t.Fatalf("validate access token: %v", err)
}
if claims.Sid != session.ID {
t.Fatalf("access token sid = %q, want %q", claims.Sid, session.ID)
}
rt, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
if err != nil || rt == nil {
t.Fatalf("refresh token row = %v, err = %v", rt, err)
}
if rt.AuthSessionID != session.ID {
t.Fatalf("refresh token session = %q, want %q", rt.AuthSessionID, session.ID)
}
if err := sessions.Revoke(ctx, session.ID); err != nil {
t.Fatalf("revoke session: %v", err)
}
if _, err := sessions.Validate(ctx, session.ID, user.ID); !errors.Is(err, ErrAuthSessionRevoked) {
t.Fatalf("validate revoked err = %v, want ErrAuthSessionRevoked", err)
}
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
if err != nil || rt == nil {
t.Fatalf("reload refresh token: %v", err)
}
if !rt.Revoked {
t.Fatal("refresh token should be revoked with the session")
}
// 幂等:重复撤销不报错。
if err := sessions.Revoke(ctx, session.ID); err != nil {
t.Fatalf("second revoke: %v", err)
}
}
func TestRefreshRejectsRevokedSession(t *testing.T) {
sessions, tokens, repos := newAuthSessionTestEnv(t)
ctx := context.Background()
user := seedSessionUser(t, repos)
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-1"})
if err != nil {
t.Fatal(err)
}
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
t.Fatal(err)
}
if err := sessions.Revoke(ctx, session.ID); err != nil {
t.Fatal(err)
}
if _, err := tokens.Refresh(ctx, pair.RefreshToken); !errors.Is(err, ErrAuthSessionRevoked) {
t.Fatalf("refresh revoked session err = %v, want ErrAuthSessionRevoked", err)
}
}
func TestRefreshKeepsSessionAssociation(t *testing.T) {
sessions, tokens, repos := newAuthSessionTestEnv(t)
ctx := context.Background()
user := seedSessionUser(t, repos)
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-2"})
if err != nil {
t.Fatal(err)
}
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
t.Fatal(err)
}
rotated, err := tokens.Refresh(ctx, pair.RefreshToken)
if err != nil {
t.Fatalf("refresh: %v", err)
}
claims, err := tokens.ValidateAccessToken(rotated.AccessToken)
if err != nil {
t.Fatal(err)
}
if claims.Sid != session.ID {
t.Fatalf("rotated access sid = %q, want %q", claims.Sid, session.ID)
}
// 刷新路径使用 best-effort 落库;轮换结果可能稍后才写入。
var rt *model.RefreshToken
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(rotated.RefreshToken))
if err == nil && rt != nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil || rt == nil {
t.Fatalf("rotated refresh row: %v err=%v", rt, err)
}
if rt.AuthSessionID != session.ID {
t.Fatalf("rotated refresh session = %q, want %q", rt.AuthSessionID, session.ID)
}
}
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
sessions, tokens, repos := newAuthSessionTestEnv(t)
ctx := context.Background()
user := seedSessionUser(t, repos)
// 默认上限是 3,这里故意登录 4 台设备。
const devices = 4
pairs := make([]*TokenPair, 0, devices)
for i := 0; i < devices; i++ {
session, err := sessions.Create(ctx, CreateAuthSessionInput{
UserID: user.ID,
DeviceID: fmt.Sprintf("device-%02d", i),
DeviceName: fmt.Sprintf("Phone %d", i+1),
})
if err != nil {
t.Fatalf("create session %d: %v", i+1, err)
}
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
t.Fatalf("issue pair %d: %v", i+1, err)
}
pairs = append(pairs, pair)
}
for i, pair := range pairs {
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
if err != nil {
t.Fatalf("load device %d token: %v", i+1, err)
}
if row == nil {
t.Fatalf("device %d refresh token missing", i+1)
}
if row.Revoked {
t.Fatalf("device %d refresh token was revoked by another device", i+1)
}
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
t.Fatalf("device %d refresh failed: %v", i+1, err)
}
}
}
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
db := newServiceTestDB(t, &model.Setting{})
repos := repository.New(db)
ctx := context.Background()
first := NewServerIdentityService(repos, "MeBox-v1.2.3")
id1, err := first.ServerID(ctx)
if err != nil {
t.Fatal(err)
}
if id1 == "" {
t.Fatal("empty server id")
}
// 独立服务实例(模拟重启)读到同一个持久身份。
second := NewServerIdentityService(repos, "MeBox-v1.2.3")
id2, err := second.ServerID(ctx)
if err != nil {
t.Fatal(err)
}
if id2 != id1 {
t.Fatalf("server id changed across instances: %q != %q", id2, id1)
}
info, err := second.Info(ctx, ServerCapabilities{DeviceSessions: true})
if err != nil {
t.Fatal(err)
}
if info.ServerID != id1 || info.ServerVersion != "MeBox-v1.2.3" || info.APIVersion != "v1" {
t.Fatalf("unexpected info: %+v", info)
}
if !info.Capabilities.DeviceSessions || info.Capabilities.PlaybackSessions {
t.Fatalf("unexpected capabilities: %+v", info.Capabilities)
}
}
func TestAuthSessionExpiry(t *testing.T) {
sessions, _, repos := newAuthSessionTestEnv(t)
user := seedSessionUser(t, repos)
sessions.now = func() time.Time { return time.Now().Add(-40 * 24 * time.Hour) }
session, err := sessions.Create(context.Background(), CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-old"})
if err != nil {
t.Fatal(err)
}
sessions.now = time.Now
if _, err := sessions.Validate(context.Background(), session.ID, user.ID); !errors.Is(err, ErrAuthSessionExpired) {
t.Fatalf("expired validate err = %v, want ErrAuthSessionExpired", err)
}
}
+8 -2
View File
@@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*
return s.tokenSvc.Refresh(ctx, refreshToken)
}
// Logout 撤销用户的所有刷新令牌。
// Logout 撤销用户的所有刷新令牌与认证会话。
func (s *AuthService) Logout(ctx context.Context, userID string) error {
return s.tokenSvc.RevokeAll(ctx, userID)
if err := s.tokenSvc.RevokeAll(ctx, userID); err != nil {
return err
}
if s.sessionSvc != nil {
return s.sessionSvc.RevokeByUser(ctx, userID)
}
return nil
}
+64 -18
View File
@@ -1,9 +1,13 @@
// Package service — database hot backup and restore.
//
// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to
// produce a consistent copy of the database without blocking writers.
// Backup files are stored under {data_dir}/backups/ with a timestamp
// suffix. The admin UI can list / download / delete / restore them.
// Backup files are stored under {data_dir}/backups/ with a timestamp suffix.
// The admin UI can list / download / delete / restore them.
//
// 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service
import (
@@ -65,18 +69,34 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil
}
// Create produces a new hot backup via "VACUUM INTO".
// isPostgres reports whether the service is backed by PostgreSQL.
//
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
// 因此备份/恢复必须按方言分支,不能共用一条路径。
func (b *BackupService) isPostgres() bool {
return b.db != nil && b.db.Dialector != nil && b.db.Dialector.Name() == "postgres"
}
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
dst := filepath.Join(dir, fmt.Sprintf("mebox_%s.db", ts))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return nil, fmt.Errorf("VACUUM INTO failed: %w", err)
var (
dst string
err error
)
if b.isPostgres() {
dst, err = b.createPostgresBackup(ctx, ts)
} else {
dst, err = b.createSQLiteBackup(ctx, ts)
}
if err != nil {
return nil, err
}
stat, err := os.Stat(dst)
if err != nil {
@@ -91,6 +111,15 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
}, nil
}
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
}
return dst, nil
}
// List returns every backup file in the backup directory, newest first.
func (b *BackupService) List() ([]BackupInfo, error) {
dir := b.backupDir()
@@ -103,7 +132,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
if e.IsDir() || !isBackupFilename(e.Name()) {
continue
}
info, err := e.Info()
@@ -132,9 +161,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path)
}
// Restore copies a backup over the live database using VACUUM INTO in
// reverse. WARNING: this is destructive — the live DB will be replaced.
// Callers should shut down the server after this call.
// Restore replaces the live database with the given backup.
// WARNING: this is destructive. SQLite restores by swapping the database file
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename)
if err != nil {
@@ -143,6 +173,19 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("backup not found: %s", filename)
}
if b.isPostgres() {
if !strings.HasSuffix(filename, postgresBackupSuffix) {
return errors.New("该备份是 SQLite 文件备份,当前运行在 PostgreSQL 上,无法恢复")
}
return b.restorePostgresBackup(ctx, src)
}
if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
}
return b.restoreSQLiteBackup(filename, src)
}
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
@@ -171,7 +214,13 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
return nil
}
// isValidBackupFilename rejects path traversal attempts and non-.db files.
// isBackupFilename reports whether name looks like a backup produced by either
// dialect (SQLite ".db" or PostgreSQL ".json.gz").
func isBackupFilename(name string) bool {
return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, postgresBackupSuffix)
}
// isValidBackupFilename rejects path traversal attempts and non-backup files.
func isValidBackupFilename(name string) bool {
if name == "" {
return false
@@ -179,8 +228,5 @@ func isValidBackupFilename(name string) bool {
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
return false
}
if !strings.HasSuffix(name, ".db") {
return false
}
return true
return isBackupFilename(name)
}
+280
View File
@@ -0,0 +1,280 @@
package service
import (
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"time"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
// PostgreSQL 逻辑备份 / 恢复。
//
// SQLite 的备份是「VACUUM INTO 出一个一致的库文件,恢复时替换文件」,而
// PostgreSQL 既没有 VACUUM INTO,数据也由服务端进程管理,没有可替换的单文件,
// 所以这里实现逻辑备份:把每张表按主键顺序分块序列化成 JSON Lines(gzip 压缩)
// 写入 {data_dir}/backups/。
//
// 分块序列化而不是整表 marshal:media 表在真实媒体库上可达几十万行、每行还带
// overview / path 等长文本,整表进内存会把小内存的 NAS 打爆。
//
// 恢复在单个事务内「先清空全部模型表、再逐块插入」:任何一步失败(包括备份文件
// 读到一半损坏)都整体回滚,不会留下半清空的库。因此恢复不需要重启服务。
const (
postgresBackupSuffix = ".json.gz"
postgresBackupFormat = "mebox-postgres-logical"
postgresBackupVersion = 1
backupDumpChunkSize = 1000
backupRestoreBatchSize = 500
)
type postgresBackupHeader struct {
Format string `json:"format"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
// postgresBackupRow 是「列名 → JSON 值」的一行。
//
// 刻意逐列反射序列化,而不是直接 json.Marshal 模型结构体:模型里有大量带
// json:"-" 的列(Base.DeletedAt、User.AllowedLibraryIDs、APIConfig.APIKey、
// StrmAccount.Config、PlayProfile.PINHash、ReaderBookSource.RawJSON…),走结构体
// 自带的序列化会把这些列整列丢掉——软删除行会在恢复后「复活」,加密后的第三方
// 密钥与 PIN 会全部丢失。
type postgresBackupRow map[string]json.RawMessage
type postgresBackupChunk struct {
Table string `json:"table"`
Rows []postgresBackupRow `json:"rows"`
}
// parseModelSchema 返回模型对应的表名与 GORM schema(逐列反射所需)。
func parseModelSchema(db *gorm.DB, m any) (table string, sch *schema.Schema, err error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", nil, err
}
return stmt.Schema.Table, stmt.Schema, nil
}
// postgresBackupModelSchemas 返回「表名 → 模型 schema / 结构体类型」,用于把备份
// 里的行还原成 GORM 能插入的具体类型。
func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[string]reflect.Type, error) {
schemas := make(map[string]*schema.Schema, len(model.AllModels()))
types := make(map[string]reflect.Type, len(model.AllModels()))
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(db, m)
if err != nil {
return nil, nil, err
}
schemas[table] = sch
types[table] = reflect.TypeOf(m).Elem()
}
return schemas, types, nil
}
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
complete := false
defer func() {
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
}
}()
zw := gzip.NewWriter(file)
enc := json.NewEncoder(zw)
if err := enc.Encode(postgresBackupHeader{
Format: postgresBackupFormat,
Version: postgresBackupVersion,
CreatedAt: time.Now(),
}); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err)
}
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(b.db, m)
if err != nil {
return "", err
}
if !b.db.Migrator().HasTable(table) {
continue
}
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err
}
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
}
if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err)
}
complete = true
return dst, nil
}
// dumpPostgresTable 按主键分块导出一张表。FindInBatches 会自己按主键升序游标
// 翻页(WHERE pk > last),不会像 OFFSET 分页那样在大表上做 O(n²) 扫描。
func (b *BackupService) dumpPostgresTable(ctx context.Context, m any, sch *schema.Schema, table string, enc *json.Encoder) error {
// 软删除行也要备份:恢复后应还原成删除前的样子,而不是让它们「复活」。
slicePtr := reflect.New(reflect.SliceOf(reflect.TypeOf(m).Elem()))
err := b.db.WithContext(ctx).Unscoped().
FindInBatches(slicePtr.Interface(), backupDumpChunkSize, func(_ *gorm.DB, _ int) error {
rows, err := encodePostgresBackupRows(ctx, sch, slicePtr.Elem())
if err != nil {
return fmt.Errorf("编码表 %s 失败: %w", table, err)
}
return enc.Encode(postgresBackupChunk{Table: table, Rows: rows})
}).Error
if err != nil {
return fmt.Errorf("导出表 %s 失败: %w", table, err)
}
return nil
}
// backupPersistableFields 返回真正对应数据库列的字段。
//
// GORM 的 Schema.Fields 里还带着 gorm:"-" 的字段(例如 User.AllowedLibraryList、
// LibraryTagList),它们的 DBName 是空字符串。不过滤的话多个「列」会共用空键:
// 编码时互相覆盖,解码时把某个字段的值写到另一个字段上,报出
// 「cannot unmarshal number into Go value of type []string」这类与真实数据无关的错。
func backupPersistableFields(sch *schema.Schema) []*schema.Field {
out := make([]*schema.Field, 0, len(sch.Fields))
for _, field := range sch.Fields {
if field.DBName == "" {
continue
}
out = append(out, field)
}
return out
}
// encodePostgresBackupRows 逐列反射序列化一个批次。
func encodePostgresBackupRows(ctx context.Context, sch *schema.Schema, slice reflect.Value) ([]postgresBackupRow, error) {
fields := backupPersistableFields(sch)
rows := make([]postgresBackupRow, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
element := slice.Index(i)
row := make(postgresBackupRow, len(fields))
for _, field := range fields {
value := field.ReflectValueOf(ctx, element)
if !value.IsValid() {
continue
}
raw, err := json.Marshal(value.Interface())
if err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
row[field.DBName] = raw
}
rows = append(rows, row)
}
return rows, nil
}
// decodePostgresBackupRows 把「列名 → JSON 值」还原成可直接插入的具体切片。
func decodePostgresBackupRows(ctx context.Context, sch *schema.Schema, modelType reflect.Type, rows []postgresBackupRow) (any, error) {
fields := backupPersistableFields(sch)
slice := reflect.MakeSlice(reflect.SliceOf(modelType), len(rows), len(rows))
for i, row := range rows {
element := slice.Index(i)
for _, field := range fields {
raw, ok := row[field.DBName]
if !ok {
continue
}
holder := reflect.New(field.FieldType)
if err := json.Unmarshal(raw, holder.Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
if err := field.Set(ctx, element, holder.Elem().Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
}
}
return slice.Interface(), nil
}
// restorePostgresBackup 用逻辑备份重建全库。
func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
zr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("备份文件不是有效的 gzip: %w", err)
}
defer zr.Close()
dec := json.NewDecoder(zr)
var header postgresBackupHeader
if err := dec.Decode(&header); err != nil {
return fmt.Errorf("备份头损坏: %w", err)
}
if header.Format != postgresBackupFormat || header.Version != postgresBackupVersion {
return fmt.Errorf("不支持的备份格式 %q (v%d)", header.Format, header.Version)
}
schemas, modelTypes, err := postgresBackupModelSchemas(b.db)
if err != nil {
return err
}
// 恢复必须在单个事务里完成:清空成功后中途失败若不回滚,库就空了。
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 逆序清空,保证 library_roots → libraries 这类外键先删子表。
models := model.AllModels()
for i := len(models) - 1; i >= 0; i-- {
m := models[i]
if !tx.Migrator().HasTable(m) {
continue
}
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil {
return fmt.Errorf("清空表失败: %w", err)
}
}
for {
var chunk postgresBackupChunk
decodeErr := dec.Decode(&chunk)
if errors.Is(decodeErr, io.EOF) {
return nil
}
if decodeErr != nil {
return fmt.Errorf("备份文件损坏: %w", decodeErr)
}
sch, ok := schemas[chunk.Table]
if !ok {
return fmt.Errorf("备份包含未知表 %s,可能来自不兼容的版本", chunk.Table)
}
if len(chunk.Rows) == 0 {
continue
}
rows, err := decodePostgresBackupRows(ctx, sch, modelTypes[chunk.Table], chunk.Rows)
if err != nil {
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
}
if err := database.InsertPreservingValues(tx.Unscoped(), rows, backupRestoreBatchSize); err != nil {
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
}
}
})
}
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"compress/gzip"
"context"
"os"
"path/filepath"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newBackupTestService(t *testing.T) (*BackupService, *config.Config) {
t.Helper()
cfg := &config.Config{}
cfg.App.DataDir = t.TempDir()
db := newServiceTestDB(t)
svc := &BackupService{cfg: cfg, log: zap.NewNop(), db: db}
// Create 会先建目录;直接调各方言的 create* 助手时需要它已存在。
if err := os.MkdirAll(svc.backupDir(), 0o750); err != nil {
t.Fatal(err)
}
return svc, cfg
}
// TestPostgresLogicalBackupRoundTrip 验证「备份 → 改库 → 恢复」后整库回到备份时的
// 样子,包括软删除行与 json:"-" 的列。
//
// 逻辑备份/恢复本身是方言无关的 GORM 代码(PostgreSQL 专有的只有文件后缀与
// isPostgres() 分派),所以可以直接在 SQLite 上跑。
func TestPostgresLogicalBackupRoundTrip(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
db := svc.db
lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.LibraryRoot{Base: model.Base{ID: "root-1"}, LibraryID: lib.ID, Path: "/media/movies"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-keep"}, LibraryID: lib.ID, Title: "Keep", Path: "/media/movies/keep.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-gone"}, LibraryID: lib.ID, Title: "Gone", Path: "/media/movies/gone.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&model.Media{}, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Setting{Key: "k", Value: "v"}).Error; err != nil {
t.Fatal(err)
}
// 下面这些列都带 json:"-":走结构体自带的 JSON 序列化会被整列丢掉,
// 恢复之后用户权限、置顶库、加密后的密钥会一起消失。
user := model.User{
Base: model.Base{ID: "u-1"},
Username: "alice",
PasswordHash: "hash",
Role: "user",
AllowedLibraryIDs: `["lib-1"]`,
PinnedLibraryIDs: `["lib-1"]`,
HideAdult: true,
IsActive: true,
DanmakuEnabled: true,
PlayerVr360GuideSeen: true,
}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.APIConfig{Base: model.Base{ID: "cfg-1"}, Provider: "tmdb", APIKey: "ciphertext", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(ctx, "20260101_000000")
if err != nil {
t.Fatalf("createPostgresBackup: %v", err)
}
if dump == "" {
t.Fatal("createPostgresBackup returned an empty path")
}
// 破坏当前数据:加一条备份里没有的、硬删一条备份里有的。
if err := db.Create(&model.Media{Base: model.Base{ID: "m-extra"}, LibraryID: lib.ID, Title: "Extra", Path: "/media/movies/extra.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Unscoped().Delete(&model.Media{}, "id = ?", "m-keep").Error; err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, dump); err != nil {
t.Fatalf("restorePostgresBackup: %v", err)
}
var ids []string
if err := db.Unscoped().Model(&model.Media{}).Order("id asc").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
if want := []string{"m-gone", "m-keep"}; !reflect.DeepEqual(ids, want) {
t.Fatalf("media ids after restore = %v, want %v", ids, want)
}
// 软删除状态必须原样还原,而不是被「复活」。
var gone model.Media
if err := db.Unscoped().First(&gone, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if !gone.DeletedAt.Valid {
t.Fatal("soft-deleted media was revived by restore")
}
var live int64
if err := db.Model(&model.Media{}).Where("id = ?", "m-keep").Count(&live).Error; err != nil {
t.Fatal(err)
}
if live != 1 {
t.Fatalf("live media count = %d, want 1", live)
}
var value string
if err := db.Model(&model.Setting{}).Where("key = ?", "k").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "v" {
t.Fatalf("setting value = %q, want v", value)
}
var libCount int64
if err := db.Model(&model.Library{}).Count(&libCount).Error; err != nil {
t.Fatal(err)
}
if libCount != 1 {
t.Fatalf("library count = %d, want 1", libCount)
}
var rootCount int64
if err := db.Model(&model.LibraryRoot{}).Count(&rootCount).Error; err != nil {
t.Fatal(err)
}
if rootCount != 1 {
t.Fatalf("library root count = %d, want 1", rootCount)
}
// json:"-" 的列必须同样被备份:它们是权限与凭据,丢了比丢一条媒体严重得多。
var restoredUser model.User
if err := db.First(&restoredUser, "id = ?", "u-1").Error; err != nil {
t.Fatal(err)
}
if restoredUser.PasswordHash != "hash" {
t.Fatalf("password hash = %q, want hash", restoredUser.PasswordHash)
}
if restoredUser.AllowedLibraryIDs != `["lib-1"]` {
t.Fatalf(`allowed library ids = %q, want ["lib-1"]`, restoredUser.AllowedLibraryIDs)
}
if restoredUser.PinnedLibraryIDs != `["lib-1"]` {
t.Fatalf(`pinned library ids = %q, want ["lib-1"]`, restoredUser.PinnedLibraryIDs)
}
if !restoredUser.HideAdult || !restoredUser.IsActive || !restoredUser.DanmakuEnabled || !restoredUser.PlayerVr360GuideSeen {
t.Fatalf("boolean columns lost: %+v", restoredUser)
}
var restoredConfig model.APIConfig
if err := db.First(&restoredConfig, "id = ?", "cfg-1").Error; err != nil {
t.Fatal(err)
}
if restoredConfig.APIKey != "ciphertext" {
t.Fatalf("api key = %q, want ciphertext", restoredConfig.APIKey)
}
}
// TestRestorePostgresBackupKeepsDataOnCorruptFile 验证损坏的备份不会在回滚后留下
// 半清空的库:恢复是先清空再插入,必须整体在一个事务里,否则中途失败就把库清掉了。
func TestRestorePostgresBackupKeepsDataOnCorruptFile(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
if err := svc.db.Create(&model.Setting{Key: "keep", Value: "me"}).Error; err != nil {
t.Fatal(err)
}
corrupt := filepath.Join(t.TempDir(), "broken.json.gz")
file, err := os.Create(corrupt)
if err != nil {
t.Fatal(err)
}
zw := gzip.NewWriter(file)
// 头部完整、正文半截:解码在清空表之后才会失败。
if _, err := zw.Write([]byte(
`{"format":"mebox-postgres-logical","version":1,"created_at":"2026-01-01T00:00:00Z"}` + "\n" +
`{"table":"media","rows":[`)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, corrupt); err == nil {
t.Fatal("corrupt backup should fail to restore")
}
var value string
if err := svc.db.Model(&model.Setting{}).Where("key = ?", "keep").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "me" {
t.Fatalf("a failed restore wiped the database: settings keep = %q", value)
}
}
// TestRestoreRejectsBackupFromOtherDialect 验证备份文件与当前方言不匹配时给出明确
// 错误,而不是把 SQLite 文件写到 PostgreSQL 部署的库路径上(那是原实现的隐患)。
func TestRestoreRejectsBackupFromOtherDialect(t *testing.T) {
svc, _ := newBackupTestService(t)
pgStyle := filepath.Join(svc.backupDir(), "mebox_20260101_000000.json.gz")
if err := os.WriteFile(pgStyle, []byte("not-gzip"), 0o600); err != nil {
t.Fatal(err)
}
// SQLite 路径下不允许恢复 PostgreSQL 逻辑备份。
if err := svc.Restore(context.Background(), filepath.Base(pgStyle)); err == nil {
t.Fatal("SQLite restore accepted a PostgreSQL logical backup")
}
}
@@ -0,0 +1,34 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestPostgresLogicalBackupPreservesZeroValues(t *testing.T) {
svc, _ := newBackupTestService(t)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := svc.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := svc.db.Model(&user).Updates(map[string]any{
"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false,
}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(t.Context(), "zero-values")
if err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(t.Context(), dump); err != nil {
t.Fatal(err)
}
var got model.User
if err := svc.db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("restore replaced zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
}
+89 -10
View File
@@ -1,26 +1,34 @@
// 下载直链进程级缓存。
//
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目,
// 下一轮重试会重新换取链接。
// 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
// 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
// pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
// 调用方主动清除对应条目,下一轮重试会重新换取链接。
package cloud115
import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右,
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。
const downloadURLCacheTTL = 45 * time.Minute
// downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
// 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
// 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
const downloadURLCacheMaxTTL = 45 * time.Minute
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
const linkExpirySafety = 2 * time.Minute
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
const maxCachedURLs = 10000
type urlCacheEntry struct {
url string
createdAt time.Time
expiresAt time.Time
}
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
return pickCode + "@" + ua
}
// LinkExpiry 解析 115 直链自带的过期时间戳(`t=` 参数,单位秒)。
// 解析不出时返回零值,调用方按兜底 TTL 处理。
func LinkExpiry(rawURL string) time.Time {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u == nil {
return time.Time{}
}
raw := strings.TrimSpace(u.Query().Get("t"))
if raw == "" {
return time.Time{}
}
sec, err := strconv.ParseInt(raw, 10, 64)
if err != nil || sec <= 0 {
return time.Time{}
}
return time.Unix(sec, 0)
}
// cacheExpiry 计算写入缓存时应使用的过期时刻:跟随直链自身的 `t=` 并提前
// linkExpirySafety,解析不出时退回 downloadURLCacheMaxTTL。返回零值表示这条
// 直链已经过期,不应写入缓存。
func cacheExpiry(rawURL string, now time.Time) time.Time {
maxExpiry := now.Add(downloadURLCacheMaxTTL)
linkExpiry := LinkExpiry(rawURL)
if linkExpiry.IsZero() {
return maxExpiry
}
expiry := linkExpiry.Add(-linkExpirySafety)
if !expiry.After(now) {
return time.Time{}
}
if expiry.After(maxExpiry) {
return maxExpiry
}
return expiry
}
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
func GetDownloadURLCache(pickCode string, uas ...string) string {
if pickCode == "" {
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
return entry.url
}
// SetDownloadURLCache 写入直链缓存。
// SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
// 直链直接丢弃,避免把死链缓存下来。
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
if pickCode == "" || url == "" {
return
}
now := time.Now()
expiresAt := cacheExpiry(url, now)
if expiresAt.IsZero() {
return
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
if len(urlCache) >= maxCachedURLs {
now := time.Now()
for k, e := range urlCache {
if now.After(e.expiresAt) {
delete(urlCache, k)
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
}
}
key := urlCacheKey(pickCode, ua)
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
urlCache[key] = urlCacheEntry{url: url, createdAt: now, expiresAt: expiresAt}
}
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
@@ -95,3 +145,32 @@ func ClearDownloadURLCache(pickCode string) {
}
}
}
// ClearDownloadURLCacheIfOlderThan 删除指定 pickcode 下「已经存在超过 minAge」
// 的缓存条目,返回是否删除了至少一条。
//
// 客户端上报「直链被 CDN 拒绝」时用它强制换链:既让下一次解析拿到新地址,
// 又不会让同一秒内的重试风暴把 115 换链接口打出突发。
func ClearDownloadURLCacheIfOlderThan(pickCode string, minAge time.Duration) bool {
if pickCode == "" {
return false
}
if minAge < 0 {
minAge = 0
}
now := time.Now()
removed := false
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
for k, e := range urlCache {
if k != pickCode && !strings.HasPrefix(k, pickCode+"@") {
continue
}
if now.Sub(e.createdAt) < minAge {
continue
}
delete(urlCache, k)
removed = true
}
return removed
}
@@ -1,6 +1,7 @@
package cloud115
import (
"fmt"
"testing"
"time"
)
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
t.Fatalf("cleared entry should be a miss, got %q", got)
}
}
// 115 直链自带 `t=` 过期时间戳(实测为换取时刻 + 1800 秒)。缓存必须跟随它,
// 否则会出现「服务端认为还有效、CDN 已经 403」的窗口:直链被缓存到 45 分钟,
// 但第 30 分钟起客户端就必然拿到 403。
func TestDownloadURLCacheExpiryFollowsLinkTimestamp(t *testing.T) {
now := time.Now()
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d&u=1&k=abc", now.Add(30*time.Minute).Unix())
got := cacheExpiry(link, now)
want := now.Add(30*time.Minute - linkExpirySafety)
if diff := got.Sub(want); diff > time.Second || diff < -time.Second {
t.Fatalf("cacheExpiry = %v, want %v", got, want)
}
// 解析不出 t= 时退回兜底 TTL,保持旧行为。
fallback := cacheExpiry("https://cdn/1/a.mkv", now)
if diff := fallback.Sub(now.Add(downloadURLCacheMaxTTL)); diff > time.Second || diff < -time.Second {
t.Fatalf("fallback cacheExpiry = %v, want %v", fallback, now.Add(downloadURLCacheMaxTTL))
}
// 直链本身已经过期(或只剩不足提前量的寿命)时不进缓存。
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(-time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("expired link must not be cached, got expiry %v", got)
}
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("link expiring inside the safety margin must not be cached, got %v", got)
}
}
// 客户端上报「直链被 CDN 拒绝」时按 pickcode 清缓存,但要挡住刚写入的条目,
// 避免重试风暴把 115 换链接口打出突发。
func TestClearDownloadURLCacheIfOlderThan(t *testing.T) {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
t.Cleanup(func() {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
})
future := time.Now().Add(30 * time.Minute)
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d", future.Unix())
SetDownloadURLCache("pick-fresh", link, "ua-a")
if ClearDownloadURLCacheIfOlderThan("pick-fresh", time.Minute) {
t.Fatal("fresh entry must survive the min-age guard")
}
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("fresh entry lost, got %q", got)
}
SetDownloadURLCache("pick-old", link, "ua-a")
urlCacheMu.Lock()
entry := urlCache[urlCacheKey("pick-old", "ua-a")]
entry.createdAt = time.Now().Add(-10 * time.Minute)
urlCache[urlCacheKey("pick-old", "ua-a")] = entry
urlCacheMu.Unlock()
if !ClearDownloadURLCacheIfOlderThan("pick-old", time.Minute) {
t.Fatal("stale entry should be cleared")
}
if got := GetDownloadURLCache("pick-old", "ua-a"); got != "" {
t.Fatalf("stale entry should be gone, got %q", got)
}
// 无关 pickcode 不受影响。
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("unrelated pickcode lost, got %q", got)
}
}
@@ -0,0 +1,157 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newDanmakuRemoteResolverTest 起一个假的远程 Emby(账号 + 挂载齐备),返回接好
// 弹幕解析器的 builder。
func newDanmakuRemoteResolverTest(t *testing.T, handler http.Handler) *serviceContainerBuilder {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatalf("create account: %v", err)
}
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}); err != nil {
t.Fatalf("create mount: %v", err)
}
return &serviceContainerBuilder{
cfg: cfg,
log: zap.NewNop(),
repos: repos,
c: &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, EmbyRemote: remote},
}
}
func danmakuResolverTestEpisode(id string) map[string]any {
return map[string]any{
"Id": id,
"Name": "第 1 集",
"Type": "Episode",
"Path": "/media/anime/PSYREN/" + id + ".mkv",
"MediaSources": []any{
map[string]any{"Id": id + "-src", "Container": "mkv", "Size": int64(1 << 20)},
},
}
}
// 目录类条目(整部剧/季)自身没有媒体源,拿它的 ID 去请求远程 /Videos/{id}/stream 只会
// 回 500,哈希层于是每次都白白跳过。这条用例保证弹幕解析器跟随 /api/stream 的规则先
// 落到第一集:哈希算的是真正要播的那条流。
func TestResolveDanmakuRemoteMediaUsesPlayableChildForContainer(t *testing.T) {
var requested []string
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "PSYREN", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
t.Errorf("ParentId = %q, want series-100", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
danmakuResolverTestEpisode("ep-1"),
},
})
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-1"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "series-100"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-1/stream") {
t.Fatalf("stream url = %q, want the playable child ep-1", streamURL)
}
if strings.Contains(streamURL, "series-100") {
t.Fatalf("stream url = %q, must not point at the container id (remote Emby answers 500)", streamURL)
}
if !strings.Contains(strings.Join(requested, " "), "/Items/ep-1") {
t.Fatalf("metadata must come from the playable child, requests = %v", requested)
}
}
// 本身可播的条目(单集/电影)不能被改写:直接取它自己的流地址。
func TestResolveDanmakuRemoteMediaKeepsPlayableItem(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
t.Errorf("unexpected upstream path %q", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-2"))
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "ep-2"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-2/stream") {
t.Fatalf("stream url = %q, want /Videos/ep-2/stream", streamURL)
}
}
// 不是远程伪装 ID 时直接报错,不去猜一个可以拉取的地址。
func TestResolveDanmakuRemoteMediaRejectsPlainID(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("plain local ids must not reach the remote Emby")
w.WriteHeader(http.StatusNotFound)
}))
if _, _, err := b.resolveDanmakuRemoteMedia(context.Background(), "local-media-1"); err == nil {
t.Fatal("expected an error for a non remote-emby id")
}
}
+16
View File
@@ -7,6 +7,8 @@ import (
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
@@ -28,6 +30,14 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
if err != nil {
return err
}
// 立即撤销该设备的认证会话:只标记 UserDevice 不足以让已签发的 JWT
// 失效,新协议令牌必须同时被会话校验拒绝。
if s.authSessions != nil {
if err := s.authSessions.RevokeDevice(ctx, userID, d.DeviceID); err != nil && s.log != nil {
s.log.Warn("revoke auth sessions for kicked device failed",
zap.String("user_id", userID), zap.String("device_id", d.DeviceID), zap.Error(err))
}
}
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
return nil
}
@@ -37,6 +47,12 @@ func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
return err
}
if s.authSessions != nil {
if err := s.authSessions.RevokeByUser(ctx, userID); err != nil && s.log != nil {
s.log.Warn("revoke auth sessions for kicked user failed",
zap.String("user_id", userID), zap.Error(err))
}
}
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
return nil
}
+7
View File
@@ -31,6 +31,8 @@ type DeviceService struct {
log *zap.Logger
repo *repository.Container
sessions *SessionTrackerService
// authSessions 用于踢出设备时立即撤销其认证会话;nil 表示旧装配路径。
authSessions *AuthSessionService
// notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications.
@@ -60,6 +62,11 @@ func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
s.sessions = tracker
}
// SetAuthSessionService 注入认证会话服务,使踢出设备同时撤销其新协议令牌。
func (s *DeviceService) SetAuthSessionService(sessions *AuthSessionService) {
s.authSessions = sessions
}
// fingerprint derives a stable terminal hash from the device name. Client/app
// names are deliberately ignored so one phone/TV/PC using multiple apps is
// still counted as one terminal device; Client remains a login channel label.
@@ -0,0 +1,82 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestLikelyEpisodicPathSQLNormalisesSeparator 守住剧集路径判定的转义差异。
//
// 此前 embyLikelyEpisodicPathSQL 会额外生成一份反斜杠模式
// (strings.ReplaceAll(pattern, "/", `\`))。PostgreSQL 的 LIKE 默认就把反斜杠
// 当转义符,模式结尾的 `\%` 被解析成「字面百分号」而不是「反斜杠 + 通配符」,
// 那些模式在 PostgreSQL 下永不命中;SQLite 没有默认转义符所以一直正常。
// 现在统一在 SQL 里把分隔符归一化成 '/',模式本身不再含反斜杠。
func TestLikelyEpisodicPathSQLNormalisesSeparator(t *testing.T) {
clause, args := embyLikelyEpisodicPathSQL()
if !strings.Contains(clause, "REPLACE(media.path") {
t.Fatalf("clause no longer normalises the path separator:\n%s", clause)
}
if got := strings.Count(clause, "LIKE ?"); got != len(args) {
t.Fatalf("clause has %d placeholders but %d args:\n%s", got, len(args), clause)
}
for _, arg := range args {
pattern, ok := arg.(string)
if !ok {
t.Fatalf("unexpected pattern type %T", arg)
}
if strings.Contains(pattern, `\`) {
t.Fatalf("pattern %q still contains a backslash; PostgreSQL's LIKE would treat it as an escape character", pattern)
}
if !strings.Contains(pattern, "/") {
t.Fatalf("pattern %q lost its separator", pattern)
}
}
}
// TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths 验证反斜杠路径(Windows
// 宿主机入库的媒体)仍然被判成剧集并从电影库查询里排除。
func TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths(t *testing.T) {
db := newServiceTestDB(t)
episodic := model.Media{
Base: model.Base{ID: "ep-1"},
Path: `D:\Media\国产剧\我的剧\Season 1\我的剧 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
movie := model.Media{
Base: model.Base{ID: "mv-1"},
Path: `D:\Media\电影\某电影\某电影.mkv`,
}
if err := db.Create(&episodic).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&movie).Error; err != nil {
t.Fatal(err)
}
q := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0"),
)
var rows []model.Media
if err := q.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("windows-style episodic path was not excluded, got %d rows", len(rows))
}
// 反证:电影类型的行必须保留。
var kept []model.Media
keepQ := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("path LIKE ?", `D:\Media\电影\%`),
)
if err := keepQ.Find(&kept).Error; err != nil {
t.Fatal(err)
}
if len(kept) != 1 || kept[0].ID != "mv-1" {
t.Fatalf("plain movie row was dropped from the movie query: %+v", kept)
}
}
+11 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
@@ -21,7 +22,11 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID)
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词
// 大小写不匹配就搜不到结果。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
@@ -264,7 +269,11 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID))
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词
// 大小写不匹配就搜不到结果。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
+17 -8
View File
@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
@@ -51,7 +52,11 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
q = q.Where("library_id IN ?", libIDs)
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// 方言相关的 ILIKE/LIKE:PostgreSQL 的 LIKE 区分大小写,
// 直接用 LIKE 会让客户端搜索的大小写与入库标题不一致时搜不到。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
@@ -266,6 +271,14 @@ func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB {
return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...)
}
// embyEpisodicPathExpr 把路径分隔符归一化成 '/' 后再小写化,供剧集路径判定使用。
//
// 反斜杠归一化必须放在 SQL 里做,而不是再生成一份反斜杠模式:PostgreSQL 的
// LIKE 默认就把反斜杠当转义符,模式结尾的 `\%` 会被解析成「字面百分号」而不是
// 「反斜杠 + 通配符」,那些模式在 PostgreSQL 下永不命中(SQLite 没有默认转义符,
// 所以只有换库之后才会暴露)。归一化列值后两种方言结果一致。
const embyEpisodicPathExpr = `LOWER(REPLACE(media.path, '\', '/'))`
func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
@@ -274,15 +287,11 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns)*2)
args := make([]any, 0, len(patterns)*2)
clauses := make([]string, 0, len(patterns))
args := make([]any, 0, len(patterns))
for _, pattern := range patterns {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
clauses = append(clauses, embyEpisodicPathExpr+" LIKE ?")
args = append(args, pattern)
if strings.Contains(pattern, "/") {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, strings.ReplaceAll(pattern, "/", `\`))
}
}
return strings.Join(clauses, " OR "), args
}
+83 -2
View File
@@ -40,6 +40,23 @@ import (
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
const embyRemoteHTTPTimeout = 15 * time.Second
// embyRemoteViewsBudget 是网页端聚合远程媒体库时的总时间预算。
//
// 上游 Emby 整体不可用时(典型表现:Cloudflare 522,连接卡住近 20 秒),
// 首页 /api/libraries 会被同步阻塞,而前端 axios 30 秒就放弃,于是一个挂掉的
// 远端会把整个首页拖成加载失败。这里给聚合调用一个硬预算:超时即降级为
// 「只返回本地库」,把首屏确定性地控制在几秒内。
var embyRemoteViewsBudget = 5 * time.Second
// embyRemoteViewsFailureTTL 是远程 Views 拉取失败后的负缓存时长。
//
// 失败结果也缓存一小段时间,避免每个请求都重新等满预算(首页每次刷新、
// 每个并发客户端都会各打一次)。时间很短,远端恢复后最多 30 秒即可自动重试。
var embyRemoteViewsFailureTTL = 30 * time.Second
// errRemoteViewsRecentlyFailed 表示远程 Emby 刚失败过,当前处于负缓存窗口。
var errRemoteViewsRecentlyFailed = errors.New("远程 Emby 暂时不可用(近期请求失败,稍后自动重试)")
// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截
// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。
const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
@@ -422,6 +439,22 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
return err
}
// SetMountsEnabled 批量启用/停用某账号下的全部挂载。
//
// 账号整体不可用时(远端宕机、线路全挂),管理员可以一键把所有挂载从媒体库
// 中摘掉:网页端 /api/libraries 不再为该账号发起任何远程请求,首页恢复秒开;
// 远端恢复后再一键启用即可,挂载配置和播放设置都不丢。
func (r *EmbyRemoteService) SetMountsEnabled(ctx context.Context, accountID string, enabled bool) (int64, error) {
affected, err := r.repo.EmbyMount.SetEnabledByAccountID(ctx, accountID, enabled)
if err != nil {
return 0, err
}
// 启停会改变可见库集合,顺手清掉远程元数据缓存(含 Views 失败负缓存),
// 让下一次请求立刻按新状态重新探测,而不是继续吃 30 秒的负缓存。
r.invalidateRemoteMediaCache(ctx)
return affected, nil
}
// ReorderMounts 批量重排挂载媒体库顺序。
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
if len(ids) == 0 {
@@ -822,7 +855,13 @@ func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.Strm
return err
}
var out json.RawMessage
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
if err := r.doGet(ctx, acct, cfg, "/System/Info", nil, &out); err != nil {
return err
}
// 连通性恢复:清掉 Views 失败负缓存与旧元数据,让下一次媒体库列表请求
// 立即重新探测,而不是继续在负缓存窗口里显示「没有远程库」。
r.invalidateRemoteMediaCache(ctx)
return nil
}
// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。
@@ -837,7 +876,34 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
// ─── 元数据 / 目录聚合 ─────────────────────────────────────────────────────────
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
//
// 失败时写入短时负缓存:远程整站不可用时,避免每次首页刷新都重新等满超时。
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
if acct == nil {
return nil, errors.New("缺少远程 Emby 账号")
}
failKey := r.remoteCacheKey("views-fail", acct.ID)
var failed bool
if r.cache != nil && r.cache.GetJSON(ctx, failKey, &failed) && failed {
return nil, errRemoteViewsRecentlyFailed
}
views, err := r.remoteViewsUncached(ctx, acct)
if err != nil {
// 客户端主动断开(切换页面 / 关标签)不算上游故障,不能让它把
// 负缓存窗口写进去,否则正常用户会被别人的一次取消拖慢 30 秒。
if r.cache != nil && !errors.Is(err, context.Canceled) {
r.cache.SetJSON(ctx, failKey, true, embyRemoteViewsFailureTTL)
}
return nil, err
}
if r.cache != nil {
r.cache.SetJSON(ctx, failKey, false, embyRemoteViewsFailureTTL)
}
return views, nil
}
// remoteViewsUncached 是 RemoteViews 的实际拉取逻辑(含正常结果缓存)。
func (r *EmbyRemoteService) remoteViewsUncached(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
@@ -1587,7 +1653,22 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
detail := strings.TrimSpace(string(data))
// 远程 Emby 对「整部剧/目录」这类自身没有媒体源的条目会直接 500,正文是
// 一句 .NET 异常(如 Unable to cast object of type 'Series' to type
// 'IHasMediaSources')。原样丢给用户没人看得懂,也没有排查线索——细节进日志,
// 给用户一句能照着做的提示。
if r.log != nil {
r.log.Warn("remote emby video stream rejected",
zap.String("remote_id", remoteID),
zap.Int("status", resp.StatusCode),
zap.String("detail", detail),
)
}
if resp.StatusCode >= 500 {
return redactSensitiveError(fmt.Errorf("远程 Emby 无法播放该条目(HTTP %d):请在详情页选择具体集数后重试", resp.StatusCode))
}
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, detail))
}
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
if value := resp.Header.Get(header); value != "" {
@@ -0,0 +1,160 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newPlayableIDTestService 起一个假的远程 Emby,并建好账号。
func newPlayableIDTestService(t *testing.T, handler http.Handler) (*EmbyRemoteService, *model.StrmAccount) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
return remote, acct
}
func playableIDTestEpisode(id string, index, sources int) map[string]any {
item := map[string]any{
"Id": id,
"Name": "第" + strconv.Itoa(index) + "集",
"Type": "Episode",
"SeriesId": "series-100",
"ParentIndexNumber": 1,
"IndexNumber": index,
}
if sources > 0 {
item["MediaSources"] = []any{map[string]any{"Id": id + "-source"}}
}
return item
}
// 整部剧的 ID 自身没有媒体源,远程 Emby 对它的取流请求会回 500
// (Unable to cast 'Series' to 'IHasMediaSources')。这条用例保证我们会换成第一集,
// 而不是把这种 ID 直接交给远程。
func TestPlayableRemoteIDResolvesContainerToFirstEpisode(t *testing.T) {
var itemsCalls int64
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "剧一", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items/ep-2":
_ = json.NewEncoder(w).Encode(playableIDTestEpisode("ep-2", 2, 1))
case "/emby/Users/uid-1/Items":
atomic.AddInt64(&itemsCalls, 1)
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
t.Errorf("ParentId = %q, want series-100", got)
}
// 故意乱序,并让第一条没有媒体源:只有真按季/集排序、且检查过媒体源,
// 才会选中 ep-1。
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
playableIDTestEpisode("ep-3", 3, 1),
playableIDTestEpisode("ep-1", 1, 1),
playableIDTestEpisode("ep-2", 2, 1),
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
ctx := context.Background()
if got := remote.PlayableRemoteID(ctx, acct, "series-100"); got != "ep-1" {
t.Fatalf("series id resolved to %q, want ep-1", got)
}
// 播放器对同一个地址会反复发 Range 请求,解析结果必须命中缓存,不再打远程。
if got := remote.PlayableRemoteID(ctx, acct, "series-100"); got != "ep-1" {
t.Fatalf("cached resolve = %q, want ep-1", got)
}
if n := atomic.LoadInt64(&itemsCalls); n != 1 {
t.Fatalf("episode query issued %d times, want 1 (cached)", n)
}
}
// 本身带媒体源的条目(电影/单集)必须原样返回,不能被改写。
func TestPlayableRemoteIDKeepsPlayableItem(t *testing.T) {
var queries int64
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&queries, 1)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
t.Errorf("unexpected upstream path %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(playableIDTestEpisode("ep-2", 2, 1))
}))
if got := remote.PlayableRemoteID(context.Background(), acct, "ep-2"); got != "ep-2" {
t.Fatalf("playable item = %q, want ep-2", got)
}
if n := atomic.LoadInt64(&queries); n != 1 {
t.Fatalf("upstream queries = %d, want 1", n)
}
}
// 远程不可用/查不到条目时保持原 ID:让原有流程去报错,别把播放地址改成空。
func TestPlayableRemoteIDKeepsOriginalOnFailure(t *testing.T) {
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
if got := remote.PlayableRemoteID(context.Background(), acct, "series-100"); got != "series-100" {
t.Fatalf("failure path = %q, want the original id", got)
}
}
// 容器下没有任何带媒体源的子条目时同样返回原 ID(下一层会给友好报错)。
func TestPlayableRemoteIDKeepsContainerWithoutPlayableChild(t *testing.T) {
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/emby/Users/uid-1/Items" {
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Type": "Series", "IsFolder": true})
}))
if got := remote.PlayableRemoteID(context.Background(), acct, "series-100"); got != "series-100" {
t.Fatalf("container without playable child = %q, want the original id", got)
}
}
@@ -0,0 +1,84 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
)
// 远程条目自身没有媒体源时(例如把「整部剧」的 ID 当播放目标),远程 Emby 会回
// 500 + 一句 .NET 异常。异常原文不能直接丢给用户,但要能从日志里查得到。
func TestProxyVideoStreamOnLineHidesUpstreamException(t *testing.T) {
const upstreamBody = "Unable to cast object of type 'MediaBrowser.Controller.Entities.TV.Series' to type 'MediaBrowser.Controller.Entities.IHasMediaSources'."
var gotPath, gotStatic, gotRange, gotToken string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
gotPath = req.URL.Path
gotStatic = req.URL.Query().Get("Static")
gotRange = req.Header.Get("Range")
gotToken = req.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(upstreamBody))
}))
defer upstream.Close()
svc := &EmbyRemoteService{stream: upstream.Client(), log: zap.NewNop()}
cfg := &EmbyRemoteConfig{BaseURL: upstream.URL, Token: "tok-1"}
req := httptest.NewRequest(http.MethodGet, "/api/stream/embyremote~acct~222679", nil)
req.Header.Set("Range", "bytes=0-1023")
rec := httptest.NewRecorder()
err := svc.proxyVideoStreamOnLine(context.Background(), rec, req, cfg, "222679")
if err == nil {
t.Fatal("expected an error for upstream 500")
}
if gotPath != "/emby/Videos/222679/stream" {
t.Fatalf("upstream path = %q", gotPath)
}
if gotStatic != "true" {
t.Fatalf("Static = %q, want true", gotStatic)
}
if gotRange != "bytes=0-1023" {
t.Fatalf("Range = %q, want forwarded range", gotRange)
}
if gotToken != "tok-1" {
t.Fatalf("X-Emby-Token = %q", gotToken)
}
if rec.Body.Len() != 0 {
t.Fatalf("nothing should be written to the client on upstream failure, got %q", rec.Body.String())
}
msg := err.Error()
if strings.Contains(msg, "Unable to cast") || strings.Contains(msg, "MediaBrowser") {
t.Fatalf("raw upstream exception must not leak to clients: %q", msg)
}
if !strings.Contains(msg, "无法播放该条目") {
t.Fatalf("error should tell the user what to do, got %q", msg)
}
}
// 4xx(例如远程条目被删除/无权限)保持原有措辞,带着上游说明便于排查。
func TestProxyVideoStreamOnLineKeepsClientErrorDetail(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Item not found"))
}))
defer upstream.Close()
svc := &EmbyRemoteService{stream: upstream.Client(), log: zap.NewNop()}
cfg := &EmbyRemoteConfig{BaseURL: upstream.URL, Token: "tok-1"}
req := httptest.NewRequest(http.MethodGet, "/api/stream/embyremote~acct~404", nil)
err := svc.proxyVideoStreamOnLine(context.Background(), httptest.NewRecorder(), req, cfg, "404")
if err == nil {
t.Fatal("expected an error for upstream 404")
}
if !strings.Contains(err.Error(), "404") || !strings.Contains(err.Error(), "Item not found") {
t.Fatalf("client error detail should be kept, got %q", err.Error())
}
}
@@ -0,0 +1,155 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// TestRemoteLibrariesFastFailAndNegativeCache 覆盖首页可用的底线保证:
// 上游 Emby 整体挂起时,聚合远程库必须在一个很短的预算内失败并降级为
// 「没有远程库」,并且在负缓存窗口内不再重复打上游。
func TestRemoteLibrariesFastFailAndNegativeCache(t *testing.T) {
oldBudget, oldTTL := embyRemoteViewsBudget, embyRemoteViewsFailureTTL
embyRemoteViewsBudget = 200 * time.Millisecond
embyRemoteViewsFailureTTL = time.Minute
t.Cleanup(func() {
embyRemoteViewsBudget = oldBudget
embyRemoteViewsFailureTTL = oldTTL
})
release := make(chan struct{})
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
// 模拟 Cloudflare 522 前近 20 秒的挂起:请求一直不返回。
select {
case <-release:
case <-time.After(10 * time.Second):
}
}))
defer upstream.Close()
defer close(release)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
SetRuntimeCache(NewRuntimeCacheService(nil, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-hang"},
Name: "挂起的远端",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-hang"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "卡住的库",
CollectionType: "movies",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
start := time.Now()
views, err := remote.RemoteLibraries(t.Context())
elapsed := time.Since(start)
if err != nil {
t.Fatalf("RemoteLibraries should degrade instead of failing: %v", err)
}
if len(views) != 0 {
t.Fatalf("expected no remote libraries while upstream hangs, got %d", len(views))
}
if elapsed > 2*time.Second {
t.Fatalf("RemoteLibraries took %v, want within the short budget", elapsed)
}
if atomic.LoadInt32(&hits) == 0 {
t.Fatal("expected upstream to be probed once")
}
// 负缓存窗口内:不再打上游,且立即返回空结果。
before := atomic.LoadInt32(&hits)
start = time.Now()
views, err = remote.RemoteLibraries(t.Context())
elapsed = time.Since(start)
if err != nil {
t.Fatalf("second RemoteLibraries should not fail: %v", err)
}
if len(views) != 0 {
t.Fatalf("expected no remote libraries from negative cache, got %d", len(views))
}
if elapsed > time.Second {
t.Fatalf("negative cache should answer immediately, took %v", elapsed)
}
if got := atomic.LoadInt32(&hits); got != before {
t.Fatalf("negative cache should skip upstream, hits went %d -> %d", before, got)
}
}
// TestRemoteViewsNotNegativeCachedOnClientCancel 保证客户端主动断开不会污染
// 负缓存:否则一个用户切页就会让所有人在 30 秒内看不到远程库。
func TestRemoteViewsNotNegativeCachedOnClientCancel(t *testing.T) {
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-time.After(10 * time.Second):
}
}))
defer upstream.Close()
defer close(release)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cache := NewRuntimeCacheService(nil, zap.NewNop())
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
SetRuntimeCache(cache)
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-cancel"},
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := remote.RemoteViews(ctx, acct); err == nil {
t.Fatal("expected canceled context to fail")
}
var failed bool
if cache.GetJSON(t.Context(), remote.remoteCacheKey("views-fail", acct.ID), &failed) && failed {
t.Fatal("client cancel must not write the negative cache")
}
}
+123
View File
@@ -35,6 +35,12 @@ type RemoteLibraryView struct {
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
// (只有显式挂载的库才出现在本项目媒体库中)。
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
// 远程库只是「追加」到本地库列表上,绝不允许它拖住整页首屏:给整段
// 聚合(多账号 × 多线路故障转移)一个总预算,超时后各账号按失败处理,
// 调用方随即降级为只返回本地库。
ctx, cancel := context.WithTimeout(ctx, embyRemoteViewsBudget)
defer cancel()
mounts, err := r.ListMounts(ctx)
if err != nil || len(mounts) == 0 {
return nil, err
@@ -820,6 +826,123 @@ func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.
return remoteItemString(out, "SeriesId")
}
// PlayableRemoteID 把「自身没有媒体源」的远程条目换成可直接播放的条目 ID。
//
// 远程 Emby 只为带媒体源的条目提供 /Videos/{id}/stream:拿整部剧/季/目录的 ID 去
// 请求,它会回 500 加一句 .NET 异常(Unable to cast object of type 'Series' to
// type 'IHasMediaSources')。首页轮播、剧集库卡片、外部播放器与第三方客户端都可能
// 把这种 ID 交过来,统一在这里回落到第一集,保证拿到的 ID 真能取到流。
//
// 条目本身可播、或解析失败时返回原 ID(调用方拿到后用原逻辑处理即可,无需区分错误)。
func (r *EmbyRemoteService) PlayableRemoteID(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
remoteID = strings.TrimSpace(remoteID)
if r == nil || acct == nil || remoteID == "" {
return remoteID
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return remoteID
}
cacheKey := r.remoteCacheKey("playable-remote-id", acct.ID, remoteID)
if r.cache != nil {
var cached string
// 播放器会对同一个地址发很多次 Range 请求,判定结果必须走缓存,否则每次拖动
// 进度条都要多打两次远程 Emby。缓存比常规媒体缓存长:条目可播性是静态属性。
if r.cache.GetJSON(ctx, cacheKey, &cached) {
if cached == "" {
return remoteID
}
return cached
}
}
resolved := r.resolvePlayableRemoteID(ctx, acct, cfg, remoteID)
// 解析失败不写缓存:远程抖动时下次照样重试,而不是把「播不了」记住十分钟。
if r.cache != nil && resolved != "" {
r.cache.SetJSON(ctx, cacheKey, resolved, playableRemoteIDCacheTTL)
}
if resolved == "" {
return remoteID
}
return resolved
}
// playableRemoteIDCacheTTL 是「条目可播性」判定的缓存时长。
const playableRemoteIDCacheTTL = 10 * time.Minute
// resolvePlayableRemoteID 返回 remoteID 对应的可播条目:本身带媒体源时原样返回,
// 容器条目(整部剧/季/目录)返回排在最前的、确实带媒体源的子条目。
func (r *EmbyRemoteService) resolvePlayableRemoteID(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID string) string {
var item map[string]any
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &item); err != nil {
return ""
}
if remoteItemHasMediaSources(item) || !remoteItemIsContainer(item) {
return remoteID
}
q := url.Values{}
q.Set("ParentId", remoteID)
q.Set("Recursive", "true")
q.Set("IncludeItemTypes", "Episode,Movie,Video")
q.Set("Fields", "MediaSources")
q.Set("SortBy", "ParentIndexNumber,IndexNumber,SortName")
q.Set("Limit", "50")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
return ""
}
// 远程 SortBy 只覆盖它认识的字段(不同 Emby/Jellyfin 版本支持度不一),
// 这里再按季/集排一次,确保拿到的是第一集而不是服务端返回顺序里的第一条。
children := body.Items
sort.SliceStable(children, func(i, j int) bool {
if a, b := remoteItemInt(children[i], "ParentIndexNumber"), remoteItemInt(children[j], "ParentIndexNumber"); a != b {
return a < b
}
if a, b := remoteItemInt(children[i], "IndexNumber"), remoteItemInt(children[j], "IndexNumber"); a != b {
return a < b
}
return remoteItemString(children[i], "SortName") < remoteItemString(children[j], "SortName")
})
for _, child := range children {
if id := remoteItemString(child, "Id"); id != "" && remoteItemHasMediaSources(child) {
return id
}
}
return ""
}
// remoteItemHasMediaSources 该条目是否自带媒体源(即能直接取流)。
func remoteItemHasMediaSources(item map[string]any) bool {
if item == nil {
return false
}
switch sources := item["MediaSources"].(type) {
case []any:
return len(sources) > 0
case []map[string]any:
return len(sources) > 0
}
return false
}
// remoteItemIsContainer 该条目是否是「装别人」的容器:整部剧/季/目录/合集。
func remoteItemIsContainer(item map[string]any) bool {
switch remoteItemString(item, "Type") {
case "Series", "Season", "Folder", "BoxSet", "CollectionFolder", "AggregateFolder", "Playlist":
return true
}
// 类型缺失时退回 IsFolder(Emby 的目录类条目都会带)。
if _, ok := item["Type"]; !ok {
if isFolder, okBool := item["IsFolder"].(bool); okBool {
return isFolder
}
}
return false
}
// ─── 远程 item JSON 取值辅助 ────────────────────────────────────────────────
func anyString(v any) string {
+15 -3
View File
@@ -35,16 +35,28 @@ func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, med
func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error {
if favorite {
var existing model.Favorite
err := repo.DB.WithContext(ctx).
// 收藏行的删除是软删除;重新收藏时必须连已软删除的行一起查出来,
// 否则会再次插入同一 (user_id, media_id),撞上唯一索引后稳定返回
// 500,用户表现为“取消收藏后无法再收藏”。
err := repo.DB.WithContext(ctx).Unscoped().
Where("user_id = ? AND media_id = ?", userID, mediaID).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return repo.DB.WithContext(ctx).Create(&model.Favorite{
UserID: userID,
MediaID: mediaID,
}).Error
case err != nil:
return err
case existing.DeletedAt.Valid:
return repo.DB.WithContext(ctx).Unscoped().
Model(&model.Favorite{}).
Where("id = ?", existing.ID).
Update("deleted_at", nil).Error
default:
return nil
}
return err
}
return repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
+13
View File
@@ -44,6 +44,19 @@ func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) {
if favorite {
t.Fatal("expected remote favourite to be removed locally")
}
// 软删除后重新收藏必须恢复原行:唯一索引 (user_id, media_id) 不允许
// 再插入一条,旧的实现会在这里返回约束错误。
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil {
t.Fatalf("SyncUserFavorite re-favorite: %v", err)
}
favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
if err != nil {
t.Fatalf("IsUserFavorite after re-favorite: %v", err)
}
if !favorite {
t.Fatal("expected re-favourite to restore the soft-deleted row")
}
}
func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) {
+2
View File
@@ -49,6 +49,8 @@ func TestSafeZipTargetRejectsTraversal(t *testing.T) {
}
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Chdir(t.TempDir())
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
st := svc.Status(context.Background())
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
+4 -45
View File
@@ -14,14 +14,12 @@ package service
import (
"context"
"errors"
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"go.uber.org/zap"
@@ -103,53 +101,14 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// from image.tmdb.org via their HTTP proxy without extra config. On
// Windows we also honor the current user's system proxy settings.
transport := NewExternalTransport()
if proxyConfiguredForImageFetch() {
// 走本地代理(如 127.0.0.1:7890)时,拨号目标是代理本身,
// 连接层 SSRF 校验会误杀本地回环代理;此时沿用 URL 级校验。
log.Info("image proxy: outbound proxy detected, connection-level SSRF guard disabled")
} else {
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
// 校验(含重定向后的每条连接)堵住该旁路。
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
dialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if proxy.isAllowedRemoteHost(host) {
return nil
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("image proxy: refusing non-IP dial target")
}
if isPrivateIP(ip) {
return errors.New("image proxy: requests to private/internal hosts are not allowed")
}
return nil
},
}
transport.DialContext = dialer.DialContext
}
proxy.guardTransport(transport)
directTransport := NewInternalTransport()
proxy.guardTransport(directTransport)
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: NewInternalTransport()}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: directTransport}
return proxy
}
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
func proxyConfiguredForImageFetch() bool {
req, err := http.NewRequest(http.MethodGet, "https://image.tmdb.org/", nil)
if err != nil {
return false
}
proxy, err := ProxyFromEnvironmentOrSystem(req)
return err == nil && proxy != nil
}
// SetLibraryRootsProvider injects a callback that returns the current set of
// media library root directories. Sidecar posters live under these roots
// (which are arbitrary, user-defined, and not necessarily under the
@@ -0,0 +1,193 @@
package service
import (
"context"
"net"
"net/url"
"sort"
"strings"
"github.com/truewhile/MeBox/internal/repository"
)
// 显式配置的服务地址也要进入图片代理的私网放行名单。
//
// 背景(现场 2026-10-16):MetaTube 刮削后端被配置成 docker 服务名
// `http://mebox-metatube-1:8081`,刮削结果里的 poster_url 就是这个地址。
// 图片代理的连接层 SSRF 校验只放行远程 Emby 挂载主机,于是每一次封面回源都
// 被拒(日志 warn: requests to private/internal hosts are not allowed),
// 前端长期只拿到占位图。
//
// 这里的取舍与 isPrivateHost 的注释一致:私网校验防的是「用户可控 URL 打到
// 内网」,而管理员显式配置的服务地址属于受信输入,和远程 Emby 挂载同级。
func configuredServiceImageHosts(values ...string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, raw := range values {
host := imageHostFromBaseURL(raw)
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
sort.Strings(out)
return out
}
// internalServiceImageHosts 只保留指向内网/局域网的服务地址。
//
// 公网域名(image.tmdb.org、api.themoviedb.org 等)必须留在名单之外:放行后
// 图片代理会优先走直连客户端,而 GFW 环境恰恰需要先用 HTTP(S)_PROXY。把它们
// 加进来会让每张海报都白付一次直连失败。
func internalServiceImageHosts(values ...string) []string {
hosts := configuredServiceImageHosts(values...)
out := make([]string, 0, len(hosts))
for _, host := range hosts {
if isInternalServiceHost(host) {
out = append(out, host)
}
}
return out
}
// isInternalServiceHost 判断主机名是否只在内网可达:私网/回环/链路本地 IP、
// 单标签服务名(docker compose 服务名如 mebox-metatube-1),或常见的内部域名后缀。
func isInternalServiceHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
if ip := net.ParseIP(host); ip != nil {
return isPrivateIP(ip)
}
// 单标签名(不含点)不是公网可解析的 FQDN,只可能是容器名/主机名。
if !strings.Contains(host, ".") {
return true
}
for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa", ".localhost"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
// imageHostFromBaseURL 从配置里的 base URL 取出小写主机名(不含端口)。
// 容忍缺少 scheme 的写法(`mebox-metatube-1:8081`),并拒绝非 http/https。
func imageHostFromBaseURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return ""
}
return strings.ToLower(strings.TrimSpace(u.Hostname()))
}
// mergeImageProxyAllowedHosts 合并多组放行主机(远程 Emby 挂载 + 显式配置的
// 服务地址),去重时忽略大小写与端口。
func mergeImageProxyAllowedHosts(groups ...[]string) []string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, group := range groups {
for _, raw := range group {
host := strings.ToLower(strings.TrimSpace(raw))
if host == "" {
continue
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = strings.ToLower(strings.TrimSpace(h))
}
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
}
sort.Strings(out)
return out
}
// imageMintingSettingKeys 是「其取值会被拼进图片 URL」的设置项。
//
// 只收录这类设置:放行名单每多一条就是多一处 SSRF 例外,因此 app.server_url、
// strm.base_url 这些只出现在播放地址里的设置不在此列(它们由 streamURL 使用,
// 与 /api/img 无关)。
var imageMintingSettingKeys = []string{
"adult.scraper.metatube_server",
"adult.scraper.builtin_javdb_url",
"adult.scraper.builtin_javbus_url",
}
// imageMintingAPIProviders 是「其 base_url 会被拼进图片 URL」的 api_configs 行。
//
// 同样刻意收窄:api_configs 里每个 provider 都有一行出厂默认值,其中 metatube
// 默认指向 http://127.0.0.1:7700。把整张表都放行会让每次安装都无条件放开回环
// 地址,而只有真正用 MetaTube 刮削的部署才需要这个例外。
var imageMintingAPIProviders = []string{"metatube"}
// configuredServiceImageHostsFromRepo 从持久化配置里收集「会被写进图片 URL」的
// 内网服务地址。只在构造阶段调用一次,所以直接读库、忽略错误——读不到只是少
// 一条放行,不影响启动。
func configuredServiceImageHostsFromRepo(ctx context.Context, repos *repository.Container) []string {
if repos == nil {
return nil
}
values := make([]string, 0, 4)
if repos.Setting != nil {
for _, key := range imageMintingSettingKeys {
if val, err := repos.Setting.Get(ctx, key); err == nil && strings.TrimSpace(val) != "" {
values = append(values, val)
}
}
}
if repos.ApiConfig != nil {
// 用 List 而不是 FindByProvider:后者走 First(),缺行时 gorm 会打一条
// "record not found" 日志,而「没配置 MetaTube」是完全正常的状态。
if rows, err := repos.ApiConfig.List(ctx); err == nil {
for _, row := range rows {
if !imageMintingAPIProvider(row.Provider) {
continue
}
if strings.TrimSpace(row.BaseURL) != "" {
values = append(values, row.BaseURL)
}
// Extra 是逗号/分号分隔的备用源列表(见 api_config.go 的 adult 默认值)。
values = append(values, adultConfiguredBases(row.Extra)...)
}
}
}
return internalServiceImageHosts(values...)
}
func imageMintingAPIProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
for _, allowed := range imageMintingAPIProviders {
if provider == allowed {
return true
}
}
return false
}
@@ -0,0 +1,249 @@
package service
import (
"context"
"sort"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 现场(2026-10-16):13 条媒体记录的 poster_url 指向
// http://mebox-metatube-1:8081/v1/images/primary/... ,而图片代理只放行
// 远程 Emby 挂载主机。MetaTube 是用户显式配置的刮削后端(settings 里的
// adult.scraper.metatube_server),它跑在 docker 私网里,于是每一次封面
// 回源都被连接层 SSRF 校验拒掉,日志刷 warn,前端长期只拿到占位图。
//
// 结论:显式配置的服务地址必须和远程 Emby 一样被加入放行名单。
func TestConfiguredServiceImageHostsExtractsHostnames(t *testing.T) {
got := configuredServiceImageHosts(
"http://mebox-metatube-1:8081",
"https://api.themoviedb.org/3",
"http://192.168.1.50:8096/emby",
)
want := []string{"192.168.1.50", "api.themoviedb.org", "mebox-metatube-1"}
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
func TestConfiguredServiceImageHostsNormalizesAndDedupes(t *testing.T) {
got := configuredServiceImageHosts(
" HTTP://MeBox-MetaTube-1:8081 ",
"http://mebox-metatube-1:8081/other/path",
"mebox-metatube-1:8081",
)
if len(got) != 1 || got[0] != "mebox-metatube-1" {
t.Fatalf("hosts = %v, want exactly [mebox-metatube-1]", got)
}
}
func TestConfiguredServiceImageHostsSkipsUnusableValues(t *testing.T) {
got := configuredServiceImageHosts(
"",
" ",
"not a url at all",
"http://",
"://missing-scheme",
"/relative/path",
"ftp://files.example.com",
)
if len(got) != 0 {
t.Fatalf("hosts = %v, want none", got)
}
}
func TestConfiguredServiceImageHostsPreservesIPv6(t *testing.T) {
got := configuredServiceImageHosts("http://[fd00::5]:8081")
if len(got) != 1 || got[0] != "fd00::5" {
t.Fatalf("hosts = %v, want [fd00::5]", got)
}
}
func TestImageProxyAllowlistCombinesMountsAndConfiguredServices(t *testing.T) {
// 回归:合并后的名单必须同时覆盖远程 Emby 挂载与显式配置的服务,
// 否则两组图片里必有一组被 SSRF 校验挡住。
proxy := newUpstreamImageProxy(t)
proxy.SetAllowedRemoteHostsProvider(func() []string {
return mergeImageProxyAllowedHosts(
[]string{"192.168.1.50"},
configuredServiceImageHosts("http://mebox-metatube-1:8081"),
)
})
for _, host := range []string{"192.168.1.50", "mebox-metatube-1"} {
if !proxy.isAllowedRemoteHost(host) {
t.Fatalf("host %q must bypass the private-IP guard", host)
}
}
if proxy.isAllowedRemoteHost("evil.example.com") {
t.Fatal("unrelated hosts must stay blocked")
}
}
func TestMergeImageProxyAllowedHostsDedupesCaseInsensitively(t *testing.T) {
got := mergeImageProxyAllowedHosts(
[]string{"192.168.1.50:8096", "MeBox-MetaTube-1"},
[]string{"mebox-metatube-1", "", " ", "192.168.1.50"},
)
sort.Strings(got)
want := []string{"192.168.1.50", "mebox-metatube-1"}
if len(got) != len(want) {
t.Fatalf("merged = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("merged = %v, want %v", got, want)
}
}
}
// 公网上游必须留在名单之外:放行后图片代理会优先走直连,而 GFW 环境恰恰要靠
// HTTP(S)_PROXY 才能取到 image.tmdb.org 这类海报。只有内网地址才需要豁免。
func TestInternalServiceImageHostsKeepsOnlyInternalAddresses(t *testing.T) {
got := internalServiceImageHosts(
"http://mebox-metatube-1:8081",
"http://127.0.0.1:7700",
"http://192.168.1.50:8096",
"https://api.themoviedb.org/3",
"https://image.tmdb.org",
"https://api.bgm.tv",
"https://javdb.com",
"http://nas.local:7700",
"http://media.internal",
)
want := []string{
"127.0.0.1",
"192.168.1.50",
"mebox-metatube-1",
"media.internal",
"nas.local",
}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
func TestIsInternalServiceHost(t *testing.T) {
internal := []string{
"mebox-metatube-1", "127.0.0.1", "10.1.2.3", "172.18.0.5",
"192.168.1.50", "fd00::5", "nas.local", "media.internal",
"box.lan", "db.home.arpa",
}
for _, host := range internal {
if !isInternalServiceHost(host) {
t.Errorf("isInternalServiceHost(%q) = false, want true", host)
}
}
external := []string{
"", "api.themoviedb.org", "image.tmdb.org", "www.javbus.com",
"pics.dmm.co.jp", "8.8.8.8", "example.com",
}
for _, host := range external {
if isInternalServiceHost(host) {
t.Errorf("isInternalServiceHost(%q) = true, want false", host)
}
}
}
// 端到端装配:从真实的 settings / api_configs 里读出内网服务地址。
// 现场数据是 settings.adult.scraper.metatube_server = http://mebox-metatube-1:8081,
// 而 api_configs.metatube.base_url 还是出厂默认的 http://127.0.0.1:7700。
func TestConfiguredServiceImageHostsFromRepoReadsSettingsAndAPIConfigs(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", "http://mebox-metatube-1:8081"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, "adult.scraper.builtin_javdb_url", "https://javdb.com"); err != nil {
t.Fatal(err)
}
for _, row := range []model.APIConfig{
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Enabled: true},
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
} {
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
got := configuredServiceImageHostsFromRepo(ctx, repos)
// javdb.com / api.themoviedb.org 是公网地址,必须留在名单之外(否则图片
// 代理会改走直连,GFW 环境下反而取不到图);openai 的 base_url 虽然指向
// 内网,但它从不产出图片 URL,同样不应成为 SSRF 例外。
want := []string{"127.0.0.1", "mebox-metatube-1"}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
// 放行名单是 SSRF 例外,必须收窄:只有「会产出图片 URL」的配置才入选。
func TestConfiguredServiceImageHostsFromRepoIgnoresUnrelatedInternalServices(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
// 只配置了内网的 OpenAI 兼容网关与内网下载器,没有任何图片来源。
if err := repos.Setting.Set(ctx, "app.server_url", "http://10.0.0.2:1314"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, "strm.base_url", "http://10.0.0.2:1314"); err != nil {
t.Fatal(err)
}
for _, row := range []model.APIConfig{
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
} {
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
if got := configuredServiceImageHostsFromRepo(ctx, repos); len(got) != 0 {
t.Fatalf("hosts = %v, want none: unrelated internal services must not become SSRF exceptions", got)
}
}
func TestConfiguredServiceImageHostsFromRepoToleratesMissingRepos(t *testing.T) {
if got := configuredServiceImageHostsFromRepo(context.Background(), nil); len(got) != 0 {
t.Fatalf("hosts = %v, want none", got)
}
}
@@ -0,0 +1,112 @@
package service
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// hostAndPortOf 从测试服务器的 URL 里取出主机名与端口。
func hostAndPortOf(t *testing.T, raw string) (string, string) {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse upstream url: %v", err)
}
return u.Hostname(), u.Port()
}
// 现场(2026-10-16,生产容器内实测):放行名单里明明有 mebox-metatube-1,
// 回源仍然失败于
//
// image proxy: requests to private/internal hosts are not allowed
//
// 原因:连接层校验(dialer 的 Control 回调)拿到的 address 是**解析后的 IP**,
// 而名单里存的是主机名(ConfiguredRemoteHosts 与
// configuredServiceImageHostsFromRepo 都返回 u.Hostname()),两者永远不相等:
// 那段 isAllowedRemoteHost 分支对主机名条目是死代码。
//
// 远程 Emby 挂载用 IP 配置时看不出问题,用主机名(NAS 名、docker 服务名)配置
// 时同样中招。这个用例锁住「按主机名放行」这条路径。
func TestImageProxyDialsAllowlistedHostname(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := NewImageProxy(
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
zap.NewNop(),
)
// 名单里放「主机名」而不是 IP —— 这正是生产里 mebox-metatube-1 的形态。
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{"localhost"} })
// 直接用该代理自己的 transport 拨号,才能命中连接层校验。
_, port := hostAndPortOf(t, upstream.URL)
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
if err != nil {
t.Fatalf("allowlisted hostname must be dialable, got: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
// 反向约束:未放行的主机名解析到私网地址时仍必须被拒。
// 这是连接层校验存在的理由(DNS rebinding),不能为了修上面那条而拆掉。
func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := NewImageProxy(
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
zap.NewNop(),
)
// 名单为空:localhost 解析到回环地址,必须被拦。
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
_, port := hostAndPortOf(t, upstream.URL)
for _, client := range []*http.Client{proxy.client, proxy.directClient} {
resp, err := client.Get("http://localhost:" + port + "/poster.jpg")
if err == nil {
resp.Body.Close()
t.Fatal("a non-allowlisted hostname resolving to a private IP must be refused")
}
}
}
func TestImageProxyAllowsSelectedLocalProxy(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxyURL, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
p := NewImageProxy(&config.Config{}, zap.NewNop())
transport := NewExternalTransport()
transport.Proxy = http.ProxyURL(proxyURL)
p.guardTransport(transport)
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport}
resp, err := client.Get("http://images.example.com/poster.jpg")
if err != nil {
t.Fatalf("configured local proxy must remain usable: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
}
@@ -0,0 +1,152 @@
// 在真实 docker 网络里验证图片代理的私网放行名单修复。
//
// 现场(修复前):MetaTube 刮削结果里的 poster_url 指向
// http://mebox-metatube-1:8081/... ,而放行名单只含远程 Emby 挂载主机,
// 于是每一次封面回源都被连接层 SSRF 校验拒掉,前端长期只拿到占位图。
//
// 本文件用与 service_builder.initImageProxy 相同的装配方式复现那条路径。
// 真实回源那一步只在能解析到 MetaTube 服务名的环境里跑(生产容器网络);
// 开发机上会 Skip,因为那属于环境缺失,不是回归。
//
// 在生产机上运行的姿势(无需 Go 工具链):
//
// GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/verify.test ./internal/service
// scp /tmp/verify.test root@HOST:/tmp/ && ssh root@HOST \
// 'docker cp /tmp/verify.test mebox:/tmp/ && docker exec mebox /tmp/verify.test \
// -test.run TestImageProxyFetchesFromMetaTubeService -test.v'
package service
import (
"context"
"errors"
"net"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 与生产环境一致的两个取值(取自 10.0.0.2 上真实的 settings / api_configs)。
const (
metaTubeServer = "http://mebox-metatube-1:8081"
metaTubeLegacy = "http://127.0.0.1:7700"
sampleImageURL = "http://mebox-metatube-1:8081/v1/images/primary/JAV321/dsam-15"
)
// newMetaTubeVerificationRepos 建一个带现场取值的临时库(不碰生产数据)。
func newMetaTubeVerificationRepos(t *testing.T) (*repository.Container, *ImageProxy) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", metaTubeServer); err != nil {
t.Fatalf("seed setting: %v", err)
}
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
Provider: "metatube", BaseURL: metaTubeLegacy, Enabled: true,
}); err != nil {
t.Fatalf("seed metatube api config: %v", err)
}
// 一个公网上游,用来确认它没有混进放行名单。
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true,
}); err != nil {
t.Fatalf("seed tmdb api config: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
cfg.Secrets.JWTSecret = "verify-secret"
proxy := NewImageProxy(cfg, zap.NewNop())
// 与 service_builder.initImageProxy 相同的装配方式(无远程 Emby 挂载:
// 本次只验证「显式配置的服务地址」这条路径)。
proxy.SetAllowedRemoteHostsProvider(func() []string {
return mergeImageProxyAllowedHosts(
nil,
configuredServiceImageHostsFromRepo(ctx, repos),
)
})
return repos, proxy
}
// TestImageProxyAllowlistIncludesConfiguredMetaTube 确认内网服务地址被放行、
// 公网上游没有被放行。
func TestImageProxyAllowlistIncludesConfiguredMetaTube(t *testing.T) {
repos, proxy := newMetaTubeVerificationRepos(t)
t.Logf("allowlist from config: %v",
configuredServiceImageHostsFromRepo(context.Background(), repos))
for _, host := range []string{"mebox-metatube-1", "127.0.0.1"} {
if !proxy.isAllowedRemoteHost(host) {
t.Fatalf("host %q must be allowlisted (it is an explicitly configured service)", host)
}
}
if proxy.isAllowedRemoteHost("api.themoviedb.org") {
t.Fatal("public upstreams must stay off the allowlist so image fetches keep using the proxy")
}
}
// TestImageProxyReachesMetaTubeService 证明放行名单真的解除了私网拦截。
//
// 现场(修复前)回源必然失败于
//
// dial tcp 172.18.0.5:8081: image proxy: requests to private/internal hosts are not allowed
//
// 也就是连接根本没发生。修复后连接成功,失败只可能来自上游自己的 HTTP 状态
// (MetaTube 冷缓存时对未刮削过的番号返回 500)。
//
// 因此这个用例断言的不是「拿到 200」,而是**「不再是被私网校验拒绝」**:
// 后者才是本次修复的对象,前者取决于 MetaTube 缓存里有没有那条记录。
func TestImageProxyReachesMetaTubeService(t *testing.T) {
_, proxy := newMetaTubeVerificationRepos(t)
data, ctype, err := proxy.Fetch(context.Background(), sampleImageURL)
if err != nil {
if isMetaTubeUnreachable(err) {
t.Skipf("mebox-metatube-1 is not reachable from this environment: %v", err)
}
if isPrivateHostRejection(err) {
t.Fatalf("the allowlist did not take effect: %v", err)
}
// 连接已经建立,只是上游自己返回了错误状态(MetaTube 冷缓存)。
t.Logf("reached metaTube; upstream returned its own error: %v", err)
return
}
t.Logf("fetched %d bytes (content-type=%s)", len(data), ctype)
if len(data) == 0 {
t.Fatal("fetched an empty image body")
}
}
// isPrivateHostRejection 报告错误是否来自连接层的私网校验。
// 这正是本次修复必须消除的那一类失败。
func isPrivateHostRejection(err error) bool {
return err != nil && strings.Contains(err.Error(), "requests to private/internal hosts are not allowed")
}
// isMetaTubeUnreachable 区分「环境里没有这个服务」与「放行名单没生效」。
func isMetaTubeUnreachable(err error) bool {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
msg := strings.ToLower(err.Error())
for _, needle := range []string{"no such host", "server misbehaving", "i/o timeout", "connection refused"} {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
+3 -1
View File
@@ -32,7 +32,9 @@ func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClie
}
direct := p.directClient
if direct == nil {
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
transport := NewInternalTransport()
p.guardTransport(transport)
direct = &http.Client{Timeout: timeout, Transport: transport}
}
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
+67
View File
@@ -0,0 +1,67 @@
package service
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strings"
"sync"
"syscall"
"time"
)
// guardTransport checks resolved destination addresses even when a deployment
// has an outbound proxy. Only actual proxy endpoints and explicitly configured
// image services may use private addresses; NO_PROXY and direct fallback
// requests still need the connection-level check against DNS rebinding.
func (p *ImageProxy) guardTransport(transport *http.Transport) {
var proxyEndpoints sync.Map
if selectProxy := transport.Proxy; selectProxy != nil {
transport.Proxy = func(req *http.Request) (*url.URL, error) {
proxyURL, err := selectProxy(req)
if err == nil && proxyURL != nil {
port := proxyURL.Port()
if port == "" {
switch strings.ToLower(proxyURL.Scheme) {
case "https":
port = "443"
case "socks5", "socks5h":
port = "1080"
default:
port = "80"
}
}
proxyEndpoints.Store(net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port), true)
}
return proxyURL, err
}
}
guardDialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || isPrivateIP(ip) {
return errors.New("image proxy: requests to private/internal hosts are not allowed")
}
return nil
},
}
trustedDialer := &net.Dialer{Timeout: 15 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
_, isProxyEndpoint := proxyEndpoints.Load(strings.ToLower(address))
if isProxyEndpoint || p.isAllowedRemoteHost(host) {
return trustedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}

Some files were not shown because too many files have changed in this diff Show More