mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-13 03:44:23 +08:00
Compare commits
37 Commits
mebox-v0.1.190
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| a7b24e1bf1 | |||
| 0b68397265 | |||
| 90bfac3b09 | |||
| f1974a179e | |||
| f2357200be | |||
| ca9a80b862 | |||
| 4f19e2210e | |||
| 91667c400c | |||
| 0b8438d798 | |||
| 2faedae0bf | |||
| fd57d2e59b | |||
| 8356f5c3c7 | |||
| 5d137ab6db | |||
| e2133cd76f | |||
| 1c45483e1f | |||
| 40982f6675 | |||
| 27dcfc2fe9 | |||
| 984b98e61b | |||
| 5e496b35f1 | |||
| 91eb0dadf6 | |||
| 3d7d661199 | |||
| 8686d8cae6 | |||
| 43cfeabea1 | |||
| d112e8e4db | |||
| 6604691c19 | |||
| 23937d8137 | |||
| 6864a9c7f1 | |||
| 079b3ff063 | |||
| 3c30c64a39 | |||
| 6186c8b848 | |||
| 0db8698fce | |||
| 42703c6760 | |||
| 4ed2edab5b | |||
| 374ee28f01 | |||
| 3a6b8db0b3 | |||
| cd6b37c6a0 | |||
| ef8b017c63 |
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
|
||||
@@ -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
@@ -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. 风险与对策
|
||||
|
||||
@@ -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)**
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 才会自动解压,因此任何来源(预设头、书源
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user