mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 12:34:22 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74487359bf | |||
| 55854a0adb | |||
| 121a0a3cc5 | |||
| 3057664a48 | |||
| 7793d50906 | |||
| 5ad71b9421 | |||
| 5615806c2c | |||
| 1e8fe2396e | |||
| e34e81cce0 | |||
| 3cf898756b | |||
| 0b68397265 | |||
| 90bfac3b09 | |||
| f1974a179e | |||
| f2357200be | |||
| ca9a80b862 | |||
| 4f19e2210e | |||
| 91667c400c | |||
| 0b8438d798 | |||
| 2faedae0bf | |||
| fd57d2e59b | |||
| 8356f5c3c7 | |||
| 5d137ab6db | |||
| e2133cd76f | |||
| 1c45483e1f | |||
| 40982f6675 | |||
| 27dcfc2fe9 | |||
| 984b98e61b | |||
| 5e496b35f1 | |||
| 91eb0dadf6 | |||
| 3d7d661199 | |||
| 8686d8cae6 | |||
| 43cfeabea1 | |||
| d112e8e4db |
@@ -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`。
|
||||
|
||||
@@ -79,6 +79,9 @@ func (a *application) start() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
|
||||
return fmt.Errorf("apply pending database restore: %w", err)
|
||||
}
|
||||
db, err := database.Open(a.cfg, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Error("database open failed", zap.Error(err))
|
||||
|
||||
@@ -355,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()
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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: 可选的稳定结构化细节,未定义时客户端必须忽略。
|
||||
@@ -0,0 +1,46 @@
|
||||
# Playback Correctness and Isolation Implementation Plan
|
||||
|
||||
**Goal:** Fix the five playback defects from the 2026-10-21 review and make playback capability queries side-effect-free, without replacing the player or adding dependencies.
|
||||
|
||||
**Scope:** The user approved implementing the reviewed fixes. Work in the current clean checkout; do not commit, push, deploy, or alter unrelated files.
|
||||
|
||||
## Design and invariants
|
||||
|
||||
- Keep existing media IDs, stream routes, direct playback and external-player URLs compatible.
|
||||
- Add optional `session_id` to local HLS requests. Authenticated handlers supply the actual user identity; a client parameter never supplies the owner. New web playback sessions use random IDs. Legacy clients are isolated by user and, where available, Emby play-session/device identity.
|
||||
- Keep transcoder media lookup/logging separate from its internal task key. A task key incorporates user, media and playback session. Each actual ffmpeg run writes to a fresh generation directory, so delayed segment requests never read a replacement run's output.
|
||||
- Stop only the authenticated caller's playback session. Check media visibility and scoped playback tokens on stop, just as on playlist/segment requests. Segment and playlist queries must match the running generation.
|
||||
- Cloud child requests resolve their real session media first, compare query media, enforce token scope and visibility against the real media, and reject mismatches before upstream fetch.
|
||||
- Treat player business positions as absolute source seconds. Only local HLS converts to/from a relative ffmpeg timeline.
|
||||
- Invalidate outstanding cloud-transcode requests on media/unmount, explicit source/quality changes, and newer requests. Guard both fulfilled and rejected responses.
|
||||
- Select local fallbacks only from available local qualities. Keep the existing 115-first preference when a cloud quality is available.
|
||||
- `PlaybackInfo` only reads capabilities/status. Explicit `StartTranscode` requests enqueue cloud work. Retain pending/ready response contracts, polling, and retries.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. Add regression tests for local-scope isolation, stale generations, stop authorization, cloud media binding, pure capability reads, timeline conversion, available-local selection and stale async results. Run tests before implementation and verify the intended failures.
|
||||
2. Add tested playback helpers; route skip/undo/progress/quality/subtitle calculations through the common absolute timeline; guard cloud transcode results and fix local fallback choice.
|
||||
3. Extend existing transcoder/stream methods with an optional scope, preserve legacy internal calls, isolate task keys/output directories, and pass caller scope from web and Emby handlers. Bind web local HLS URLs and stop requests to captured session IDs.
|
||||
4. Enforce real-media authorization on cloud child requests and authenticated ownership on local stop.
|
||||
5. Split read-only cloud capability lookup from explicit cloud enqueue. Update existing cloud tests to use the explicit start operation and test no push during reads.
|
||||
6. Run focused and full Go tests, frontend tests, TypeScript/Vite build, lint diagnostics and review the diff for security, lifecycle and API compatibility. Fix regressions before reporting completion.
|
||||
|
||||
## Verification commands
|
||||
|
||||
- `node --experimental-strip-types --test src/pages/hlsPlayback.test.ts src/pages/directPlaySeek.test.ts src/pages/playerPageModel.test.ts src/pages/playbackQuality.test.ts src/pages/playbackRequestGuard.test.ts src/utils/skipSegments.test.ts`
|
||||
- `go test ./internal/service ./internal/handler ./internal/middleware -count=1`
|
||||
- `go test ./...`
|
||||
- `npm run build` and targeted ESLint for changed frontend files.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New remux/audio-only ffmpeg pipelines, speculative browser codec policy changes, player replacement, broad UI refactors, performance improvement claims without measurements, and production deployment.
|
||||
|
||||
## Verification outcome
|
||||
|
||||
- Six frontend playback regression scripts pass; targeted ESLint and the TypeScript/Vite production build pass. The existing bundle-size warning remains.
|
||||
- Playback/stream/transcode/progress tests in service, handler and middleware pass, including scoped output reads, stale generation rejection, scoped stops, device-to-segment propagation, read-only capability queries and concurrent cloud start deduplication.
|
||||
- Whole-repository tests pass with only `TestQueueLibraryRootScanImportsNewLibraryMedia` and `TestSQLiteRestoreStagesWithoutChangingLiveDatabase` excluded. Both were run independently and reproduce Windows file-lock / SQLite errors in unmodified scan/database code. They are not silently disabled in source.
|
||||
- FFmpeg is not installed in this environment, so the existing real-transcode lifecycle test skips. Actual video, upstream 115 and browser end-to-end playback remain unverified.
|
||||
- Clients with no playback-session or device identifier retain per-user legacy isolation; new web requests always carry an independent session ID.
|
||||
- No commit, push or deployment was performed.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -71,8 +71,8 @@ func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) err
|
||||
// index row size ... exceeds btree version 4 maximum 2704
|
||||
//
|
||||
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
|
||||
// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的
|
||||
// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。
|
||||
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
|
||||
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
|
||||
func ensureMediaPathIndex(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable("media") {
|
||||
return nil
|
||||
@@ -84,37 +84,39 @@ func ensureMediaPathIndex(db *gorm.DB) 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, found, err := postgresIndexIsUnique(db, "idx_media_path")
|
||||
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && unique {
|
||||
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(path)`).Error
|
||||
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
|
||||
}
|
||||
|
||||
// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。
|
||||
func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err 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
|
||||
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
|
||||
return false, "", false, err
|
||||
}
|
||||
if row.IsUnique == nil {
|
||||
return false, false, nil
|
||||
return false, "", false, nil
|
||||
}
|
||||
return *row.IsUnique, true, nil
|
||||
return *row.IsUnique, row.Method, true, nil
|
||||
}
|
||||
|
||||
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
var sqliteRestoreMu sync.Mutex
|
||||
|
||||
func sqliteRestorePath(cfg *config.Config) (string, error) {
|
||||
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
|
||||
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
|
||||
}
|
||||
return filepath.Abs(cfg.Database.DBPath)
|
||||
}
|
||||
|
||||
func sqliteRestoreDSN(path, mode string) string {
|
||||
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
|
||||
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func validateSQLiteRestore(ctx context.Context, path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errors.New("备份必须是常规文件")
|
||||
}
|
||||
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
var result string
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
|
||||
return fmt.Errorf("SQLite 备份校验失败: %w", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
return fmt.Errorf("SQLite 备份校验失败: %s", result)
|
||||
}
|
||||
var tables int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
|
||||
return err
|
||||
}
|
||||
if tables == 0 {
|
||||
return errors.New("备份不包含 MeBox 数据表")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StageSQLiteRestore leaves the live database and its WAL untouched. The
|
||||
// validated copy is applied on the next startup, before any database is opened.
|
||||
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
|
||||
sqliteRestoreMu.Lock()
|
||||
defer sqliteRestoreMu.Unlock()
|
||||
dbPath, err := sqliteRestorePath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pending := dbPath + ".restore-pending"
|
||||
if _, err := os.Lstat(pending); err == nil {
|
||||
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := validateSQLiteRestore(ctx, source); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := out.Name()
|
||||
defer os.Remove(tmp)
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSQLiteRestore(ctx, tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, pending)
|
||||
}
|
||||
|
||||
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
|
||||
// HTTP listeners start. The previous database is retained alongside the file.
|
||||
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
dialect := normalizeDatabaseType(cfg.Database.Type)
|
||||
if dialect == "auto" {
|
||||
dialect = effectiveAutoDatabaseType(cfg)
|
||||
}
|
||||
if dialect != "sqlite" {
|
||||
return nil
|
||||
}
|
||||
sqliteRestoreMu.Lock()
|
||||
defer sqliteRestoreMu.Unlock()
|
||||
dbPath, err := sqliteRestorePath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pending := dbPath + ".restore-pending"
|
||||
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSQLiteRestore(ctx, pending); err != nil {
|
||||
return err
|
||||
}
|
||||
previous := ""
|
||||
if _, err := os.Lstat(dbPath); err == nil {
|
||||
// A clean checkpoint prevents an old WAL from being replayed into the
|
||||
// replacement database. A busy checkpoint leaves all files untouched.
|
||||
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var busy, pages, copied int
|
||||
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
|
||||
closeErr := live.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if busy != 0 {
|
||||
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
|
||||
}
|
||||
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous = old.Name()
|
||||
if err := old.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(previous); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(dbPath, previous); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(pending, dbPath); err != nil {
|
||||
if previous != "" {
|
||||
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
|
||||
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if log != nil {
|
||||
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = filepath.Join(root, "live.db")
|
||||
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer live.Close()
|
||||
for _, statement := range []string{
|
||||
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
|
||||
"INSERT INTO settings VALUES ('state', 'backup')",
|
||||
} {
|
||||
if _, err := live.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(root, "backup.db")
|
||||
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value string
|
||||
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
|
||||
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
|
||||
}
|
||||
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
|
||||
t.Fatalf("database could not write after staging: %v", err)
|
||||
}
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
|
||||
t.Fatal("second staging should not replace the pending restore")
|
||||
}
|
||||
if err := os.Remove(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := live.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer restored.Close()
|
||||
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
|
||||
t.Fatalf("restore failed: value=%q err=%v", value, err)
|
||||
}
|
||||
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
|
||||
if err != nil || len(previous) != 1 {
|
||||
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
|
||||
}
|
||||
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer old.Close()
|
||||
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
|
||||
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
|
||||
}
|
||||
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
|
||||
t.Fatalf("repeat startup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.DBPath = filepath.Join(root, "live.db")
|
||||
source := filepath.Join(root, "corrupt.db")
|
||||
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
|
||||
t.Fatal("corrupt backup accepted")
|
||||
}
|
||||
link := filepath.Join(root, "link.db")
|
||||
if err := os.Symlink(source, link); err == nil {
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
|
||||
t.Fatal("symlink backup accepted")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed validation left pending data: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Tasks == nil {
|
||||
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
filename := c.Query("filename")
|
||||
path, err := svc.Backup.ResolvePath(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.FileAttachment(path, filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
|
||||
message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
|
||||
if svc.Repo.DB.Dialector.Name() == "sqlite" {
|
||||
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +124,32 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, mediaID) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
c.AbortWithStatus(499)
|
||||
return
|
||||
}
|
||||
err := svc.Cloud115.HLSProxy().ServeChild(
|
||||
actualMediaID, err := svc.Cloud115.HLSProxy().SessionMediaID(c.Param("session"), c.Param("key"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
|
||||
return
|
||||
}
|
||||
if mediaID != actualMediaID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "playback session media mismatch"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, actualMediaID) {
|
||||
return
|
||||
}
|
||||
if svc.Repo == nil || svc.Repo.Media == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
m, findErr := svc.Repo.Media.FindByID(c.Request.Context(), actualMediaID)
|
||||
if findErr != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
err = svc.Cloud115.HLSProxy().ServeChild(
|
||||
c.Request.Context(),
|
||||
c.Writer,
|
||||
c.Request,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -418,7 +418,11 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
|
||||
if errors.Is(err, service.ErrTranscodeDisabled) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
@@ -444,7 +448,11 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope); err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestStopHLSChecksMediaVisibility(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.Transcoder = service.NewTranscoderService(svc.Cfg, zap.NewNop(), svc.Repo, nil)
|
||||
router.DELETE("/api/hls/:id", middleware.AuthRequired(secret), stopTranscodeHandler(svc))
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/hls/nonexistent?session_id=player-a", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("stop of a nonexistent/invisible media: status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
profile := &model.PlayProfile{UserID: "user-1", Name: "restricted", IsDefault: true, AllowedLibraryIDs: `["other-library"]`}
|
||||
if err := svc.Repo.DB.Create(profile).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/hls/media-1?session_id=player-a", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("invisible existing media can be stopped: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
+257
-113
@@ -4,8 +4,10 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -40,6 +42,11 @@ type webLibraryPayload struct {
|
||||
Cards []service.SeriesCardView `json:"cards,omitempty"`
|
||||
}
|
||||
|
||||
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
|
||||
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
|
||||
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
|
||||
var remoteLibraryPreviewBudget = 3 * time.Second
|
||||
|
||||
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
|
||||
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
|
||||
func remoteLibraryItemTypes(collectionType string) string {
|
||||
@@ -176,6 +183,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
}
|
||||
if withPreview && len(allowedViews) > 0 {
|
||||
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
|
||||
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
|
||||
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
|
||||
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
|
||||
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
|
||||
defer cancelPreview()
|
||||
const maxRemotePreviewWorkers = 6
|
||||
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||
var wg sync.WaitGroup
|
||||
@@ -187,11 +200,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
case <-previewCtx.Done():
|
||||
return
|
||||
}
|
||||
helper.Run(svc.Log, "media.remotePreview", func() {
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
@@ -203,10 +216,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
|
||||
}
|
||||
})
|
||||
@@ -556,19 +569,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||
items, total, err := svc.Media.ListMediaVisibleSortedPage(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters, sortSpec, history)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
|
||||
items := service.PaginateMediaItems(grouped, page, size)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": len(grouped),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
@@ -656,6 +667,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
|
||||
if len(items) == 0 {
|
||||
return []T{}
|
||||
}
|
||||
if page-1 > len(items)/size {
|
||||
return []T{}
|
||||
}
|
||||
start := (page - 1) * size
|
||||
if start >= len(items) {
|
||||
return []T{}
|
||||
@@ -670,127 +684,257 @@ func paginateSlice[T any](items []T, page, size int) []T {
|
||||
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
q := c.Query("q")
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
||||
|
||||
fetchRemote := func(limit int) []model.Media {
|
||||
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
|
||||
return nil
|
||||
}
|
||||
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
|
||||
return remoteItems
|
||||
filter := service.MediaSearchFilter{
|
||||
LibraryID: strings.TrimSpace(c.Query("library_id")),
|
||||
YearMin: boundedSearchYear(c.Query("year_min")),
|
||||
YearMax: boundedSearchYear(c.Query("year_max")),
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
for _, genre := range c.QueryArray("genre") {
|
||||
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
|
||||
filter.Genres = append(filter.Genres, genre)
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
if !groupVersions {
|
||||
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(size * 2)
|
||||
all := append(localItems, remoteItems...)
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(size * 2)
|
||||
all := append(localItems, remoteItems...)
|
||||
grouped := service.GroupMediaVersions(all)
|
||||
paged := service.PaginateMediaItems(grouped, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(grouped),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
|
||||
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
if size < 1 {
|
||||
size = 50
|
||||
} else if size > 2000 {
|
||||
size = 2000
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
if limit < 1 {
|
||||
limit = 50
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if !groupVersions {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(limit)
|
||||
all := append(localItems, remoteItems...)
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
if all == nil {
|
||||
all = []model.Media{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
paginated := c.Query("page") != "" || c.Query("page_size") != ""
|
||||
sortBy := normalizeMediaSearchSort(c.Query("sort"))
|
||||
// 远程搜索与本地查询并行发起:本地结果不再排在远程超时之后才返回。
|
||||
remoteCh := startRemoteMediaSearch(ctx, svc, query, visibility, filter)
|
||||
// Sorting and grouping must operate on the same candidate set before
|
||||
// either a page size or the caller's output limit is applied.
|
||||
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(limit)
|
||||
all := append(localItems, remoteItems...)
|
||||
grouped := service.GroupMediaVersions(all)
|
||||
items := service.FirstMediaItems(grouped, limit)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
if remoteCh != nil {
|
||||
for _, item := range <-remoteCh {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
items = service.GroupMediaSeriesItems(items)
|
||||
sortSearchMedia(items, sortBy)
|
||||
writeMediaSearchResults(c, items, page, size, limit, paginated)
|
||||
return
|
||||
}
|
||||
if c.DefaultQuery("group_versions", "1") != "0" {
|
||||
grouped := service.GroupMediaVersions(items)
|
||||
if sortBy != "" {
|
||||
sort.SliceStable(grouped, func(i, j int) bool {
|
||||
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
|
||||
})
|
||||
}
|
||||
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
|
||||
return
|
||||
}
|
||||
sortSearchMedia(items, sortBy)
|
||||
writeMediaSearchResults(c, items, page, size, limit, paginated)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// remoteSearchItemLimit 是每个远程 Emby 挂载点返回的条目上限。此前这里传
|
||||
// 50000(等于让上游做一次全库查询),上游稍慢就会一路跑到 5 秒超时,把每次
|
||||
// 搜索都拖成秒级;搜索场景只需要少量候选,多出来的结果也会被分页丢掉。
|
||||
remoteSearchItemLimit = 100
|
||||
// remoteSearchBudget 限制远程结果最多给一次请求增加多少延迟。
|
||||
remoteSearchBudget = 2 * time.Second
|
||||
// remoteSearchCacheTTL 让同一关键词的翻页与重复搜索复用远程结果,避免每翻
|
||||
// 一页都重新打一遍远程挂载点。
|
||||
remoteSearchCacheTTL = 30 * time.Second
|
||||
// remoteSearchFailureCacheTTL 用于上游超时/失败时的短缓存:部分结果只保留
|
||||
// 几秒,避免反复吃满 remoteSearchBudget,又不至于长期压住后续重试。
|
||||
remoteSearchFailureCacheTTL = 5 * time.Second
|
||||
)
|
||||
|
||||
// startRemoteMediaSearch 并发发起远程搜索,返回一个只投递一次结果的通道。
|
||||
// 不满足远程搜索条件时返回 nil。通道带缓冲,调用方即使提前返回也不会泄漏
|
||||
// goroutine;请求结束时请求上下文被取消,远程调用会随之退出。
|
||||
func startRemoteMediaSearch(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
query string,
|
||||
visibility service.MediaVisibility,
|
||||
filter service.MediaSearchFilter,
|
||||
) <-chan []model.Media {
|
||||
if svc == nil || svc.EmbyRemote == nil || query == "" {
|
||||
return nil
|
||||
}
|
||||
if filter.LibraryID != "" && !service.IsEmbyRemoteID(filter.LibraryID) {
|
||||
return nil
|
||||
}
|
||||
results := make(chan []model.Media, 1)
|
||||
go func() {
|
||||
results <- remoteMediaSearchItems(ctx, svc, query, visibility, filter)
|
||||
}()
|
||||
return results
|
||||
}
|
||||
|
||||
func remoteMediaSearchItems(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
query string,
|
||||
visibility service.MediaVisibility,
|
||||
filter service.MediaSearchFilter,
|
||||
) []model.Media {
|
||||
cacheKey := remoteSearchCacheKey(query, visibility)
|
||||
if svc.Cache != nil {
|
||||
if cached, ok := svc.Cache.GetObject(cacheKey); ok {
|
||||
if rows, ok := cached.([]model.Media); ok {
|
||||
return filterRemoteSearchItems(rows, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
searchCtx, cancel := context.WithTimeout(ctx, remoteSearchBudget)
|
||||
defer cancel()
|
||||
remote, _ := svc.EmbyRemote.RemoteSearchMedia(searchCtx, query, remoteSearchItemLimit, visibility)
|
||||
if svc.Cache != nil {
|
||||
// 空结果也要缓存:否则查不到片名时每次都会重新等到 remoteSearchBudget。
|
||||
if remote == nil {
|
||||
remote = []model.Media{}
|
||||
}
|
||||
ttl := remoteSearchCacheTTL
|
||||
if searchCtx.Err() != nil {
|
||||
ttl = remoteSearchFailureCacheTTL
|
||||
}
|
||||
svc.Cache.SetObject(cacheKey, remote, ttl)
|
||||
}
|
||||
return filterRemoteSearchItems(remote, filter)
|
||||
}
|
||||
|
||||
func filterRemoteSearchItems(remote []model.Media, filter service.MediaSearchFilter) []model.Media {
|
||||
if len(remote) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.Media, 0, len(remote))
|
||||
for _, item := range remote {
|
||||
if remoteSearchMatchesFilter(item, filter) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// remoteSearchCacheKey 按可见范围隔离远程搜索结果,避免把某个用户可见挂载
|
||||
// 点的结果复用给可见范围不同的用户。
|
||||
func remoteSearchCacheKey(query string, visibility service.MediaVisibility) string {
|
||||
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
return fmt.Sprintf("media:obj:remote-search:%t|%s|%s|%s",
|
||||
visibility.IncludeNSFW, strings.Join(allowed, ","), strings.Join(hidden, ","), query)
|
||||
}
|
||||
|
||||
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
|
||||
if items == nil {
|
||||
items = []T{}
|
||||
}
|
||||
if paginated {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paginateSlice(items, page, size),
|
||||
"total": len(items), "page": page, "page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
|
||||
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
|
||||
return false
|
||||
}
|
||||
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
|
||||
return false
|
||||
}
|
||||
if len(filter.Genres) == 0 {
|
||||
return true
|
||||
}
|
||||
normalize := func(value string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
|
||||
}
|
||||
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
|
||||
for _, wanted := range filter.Genres {
|
||||
if normalize(genre) == normalize(wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeMediaSearchSort(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case "title_asc", "year_desc", "rating_desc":
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sortSearchMedia(items []model.Media, sortBy string) {
|
||||
if sortBy == "" {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return mediaSearchSortLess(items[i], items[j], sortBy)
|
||||
})
|
||||
}
|
||||
|
||||
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
|
||||
switch sortBy {
|
||||
case "title_asc":
|
||||
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
case "year_desc":
|
||||
if a.Year != b.Year {
|
||||
return a.Year > b.Year
|
||||
}
|
||||
case "rating_desc":
|
||||
if a.Rating != b.Rating {
|
||||
return a.Rating > b.Rating
|
||||
}
|
||||
}
|
||||
if !a.CreatedAt.Equal(b.CreatedAt) {
|
||||
return a.CreatedAt.After(b.CreatedAt)
|
||||
}
|
||||
return a.ID < b.ID
|
||||
}
|
||||
|
||||
func boundedSearchYear(value string) int {
|
||||
year, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || year < 1870 || year > 2200 {
|
||||
return 0
|
||||
}
|
||||
return year
|
||||
}
|
||||
|
||||
func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
|
||||
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
|
||||
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
|
||||
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
|
||||
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldBudget := remoteLibraryPreviewBudget
|
||||
remoteLibraryPreviewBudget = 200 * time.Millisecond
|
||||
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
|
||||
|
||||
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/Views"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{{
|
||||
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
|
||||
}},
|
||||
})
|
||||
return
|
||||
case strings.HasSuffix(path, "/Users"):
|
||||
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
|
||||
return
|
||||
case r.Method == http.MethodPost:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"AccessToken": "fake-token",
|
||||
"User": map[string]any{"Id": "uid-1"},
|
||||
})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &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)
|
||||
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).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: "acct-1"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig), Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
|
||||
RemoteViewName: "远程动画", CollectionType: "tvshows", Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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())),
|
||||
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "admin-1")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
|
||||
|
||||
start := time.Now()
|
||||
listLibrariesHandler(svc)(c)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
|
||||
}
|
||||
var payload []webLibraryPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
var remote *webLibraryPayload
|
||||
for i := range payload {
|
||||
if payload[i].IsRemoteEmby {
|
||||
remote = &payload[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if remote == nil {
|
||||
t.Fatalf("remote library missing from the bounded response: %#v", payload)
|
||||
}
|
||||
if len(remote.Cards) != 0 || remote.Total != 0 {
|
||||
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func mediaQualityHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
} else if page > 1000000 {
|
||||
page = 1000000
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
} else if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
issue := c.DefaultQuery("issue", "unmatched")
|
||||
if issue != "unmatched" && issue != "poster" && issue != "missing" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
|
||||
return
|
||||
}
|
||||
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"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 TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := make([]model.Media, 2001)
|
||||
for i := range rows {
|
||||
rows[i] = model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
|
||||
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
|
||||
}
|
||||
}
|
||||
rows[2000].Year = 2025
|
||||
rows[2000].Genres = "Drama"
|
||||
if err := db.CreateInBatches(rows, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
|
||||
cases := []struct {
|
||||
name, query, expectedID string
|
||||
count, total, page, size int
|
||||
}{
|
||||
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
|
||||
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
|
||||
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
|
||||
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
|
||||
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
|
||||
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
ctx.Set(middleware.CtxUserID, "admin")
|
||||
ctx.Set(middleware.CtxUserRole, "admin")
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
|
||||
searchMediaHandler(svc)(ctx)
|
||||
var response struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"page_size"`
|
||||
}
|
||||
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
|
||||
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
|
||||
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
|
||||
}
|
||||
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
|
||||
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestSearchMediaRemoteSearchBoundedAndCached 防止远程搜索再次退化:
|
||||
// 1. 每个挂载点的 Limit 必须是 remoteSearchItemLimit,而不是历史全库查询的 50000;
|
||||
// 2. 同一关键词的重复搜索应命中缓存,不再打上游;
|
||||
// 3. 指定本地媒体库时不应枚举远程挂载点。
|
||||
func TestSearchMediaRemoteSearchBoundedAndCached(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var mu sync.Mutex
|
||||
searchCalls := 0
|
||||
searchLimits := make([]string, 0, 4)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if term := r.URL.Query().Get("SearchTerm"); term != "" {
|
||||
mu.Lock()
|
||||
searchCalls++
|
||||
searchLimits = append(searchLimits, r.URL.Query().Get("Limit"))
|
||||
mu.Unlock()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{{
|
||||
"Id": "remote-1",
|
||||
"Name": "远方剧集",
|
||||
"Type": "Series",
|
||||
"ProductionYear": 2024,
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
|
||||
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &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)
|
||||
if err := repos.DB.Create(&model.User{Base: model.Base{ID: "user-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "流浪地球", Path: "/media/movies/流浪地球.mkv",
|
||||
}).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: "acct"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig), Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
|
||||
RemoteViewName: "远程剧集", CollectionType: "tvshows", Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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())),
|
||||
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
|
||||
}
|
||||
|
||||
search := func(params string) []service.MediaItem {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?"+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 []service.MediaItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response.Items
|
||||
}
|
||||
callCount := func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return searchCalls
|
||||
}
|
||||
|
||||
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
|
||||
t.Fatalf("expected remote result, got %#v", items)
|
||||
}
|
||||
if calls := callCount(); calls != 1 {
|
||||
t.Fatalf("expected exactly one upstream search, got %d", calls)
|
||||
}
|
||||
mu.Lock()
|
||||
limit := searchLimits[0]
|
||||
mu.Unlock()
|
||||
if limit != "100" {
|
||||
t.Fatalf("remote search Limit=%q, want bounded 100 (50000 means a full-library query)", limit)
|
||||
}
|
||||
|
||||
// 同一关键词再来一次(例如刷新/重复搜索)应复用缓存,不再请求上游。
|
||||
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
|
||||
t.Fatalf("cached search lost the remote result: %#v", items)
|
||||
}
|
||||
if calls := callCount(); calls != 1 {
|
||||
t.Fatalf("expected cached remote search to skip upstream, got %d calls", calls)
|
||||
}
|
||||
|
||||
// 指定本地媒体库时不应枚举远程挂载点。
|
||||
if items := search("q=流浪&page=1&page_size=48&library_id=" + lib.ID); len(items) != 1 || items[0].ID != "local-1" {
|
||||
t.Fatalf("expected local-only result, got %#v", items)
|
||||
}
|
||||
if calls := callCount(); calls != 1 {
|
||||
t.Fatalf("local library filter must not hit upstream, got %d calls", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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"},
|
||||
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
|
||||
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &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
|
||||
}
|
||||
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
|
||||
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
|
||||
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
|
||||
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -557,7 +559,10 @@ func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
|
||||
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &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) {
|
||||
|
||||
@@ -149,10 +149,10 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
|
||||
registerAdminUserRoutes(admin, svc)
|
||||
admin.GET("/tasks", backgroundTasksHandler(svc))
|
||||
admin.GET("/quality", mediaQualityHandler(svc))
|
||||
registerAdminPermissionRoutes(admin, svc)
|
||||
registerAdminSystemRoutes(admin, svc)
|
||||
registerAdminBackupRoutes(admin, svc)
|
||||
@@ -50,6 +52,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))
|
||||
@@ -142,6 +145,7 @@ func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/backups", listBackupsHandler(svc))
|
||||
admin.POST("/backups", createBackupHandler(svc))
|
||||
admin.GET("/backups/download", downloadBackupHandler(svc))
|
||||
admin.DELETE("/backups", deleteBackupHandler(svc))
|
||||
admin.POST("/backups/restore", restoreBackupHandler(svc))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
}
|
||||
|
||||
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
// These streams carry operator tasks, including filesystem paths and scan
|
||||
// errors. Their payloads are not scoped to an individual user's libraries.
|
||||
authed.GET("/ws", middleware.AdminRequired(), wsHandler(svc))
|
||||
authed.GET("/events", middleware.AdminRequired(), sseHandler(svc))
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
|
||||
if errors.Is(err, service.ErrHLSGenerationSuperseded) {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": "hls_generation_superseded", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -62,7 +70,11 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -72,11 +84,39 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
svc.Transcoder.StopJob(c.Param("id"))
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
scope, ok := localHLSPlaybackScope(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if svc.Transcoder != nil {
|
||||
svc.Transcoder.StopPlayback(m.ID, scope)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func localHLSPlaybackScope(c *gin.Context) (service.HLSPlaybackScope, bool) {
|
||||
userID := currentUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing playback owner"})
|
||||
return service.HLSPlaybackScope{}, false
|
||||
}
|
||||
scope, err := service.HLSPlaybackScopeForRequest(c.Request, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return service.HLSPlaybackScope{}, false
|
||||
}
|
||||
return scope, true
|
||||
}
|
||||
|
||||
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := c.Query("url")
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Package handler — WebSocket endpoint.
|
||||
//
|
||||
// Clients connect to /api/ws?token=... (the token is the same JWT used for
|
||||
// REST calls). The first message they send is a JSON {"action":"subscribe",
|
||||
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
|
||||
// arrive as {"topic":"...","payload":{...}}.
|
||||
// Administrators connect to /api/ws?token=... with their REST access token.
|
||||
// Operator events arrive as {"topic":"...","payload":{...}}.
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -52,8 +50,11 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
sub := svc.WSHub.Subscribe(id, nil)
|
||||
defer svc.WSHub.Unsubscribe(id)
|
||||
|
||||
// Reader: accept ping + subscription updates.
|
||||
// Reading processes WebSocket control frames and detects disconnects.
|
||||
done := make(chan struct{})
|
||||
conn.SetReadLimit(64 << 10)
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -67,6 +68,8 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case ev, ok := <-sub.Out:
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func realtimeTestRouter(hub *service.Hub) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
authed := router.Group("/api")
|
||||
authed.Use(middleware.AuthRequired("realtime-test"))
|
||||
registerAuthedRealtimeRoutes(authed, &service.Container{WSHub: hub})
|
||||
return router
|
||||
}
|
||||
|
||||
func realtimeTestToken(t *testing.T, role string) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
|
||||
UserID: "viewer", Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
|
||||
}).SignedString([]byte("realtime-test"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestRealtimeStreamsRejectOrdinaryUsers(t *testing.T) {
|
||||
router := realtimeTestRouter(nil)
|
||||
for _, path := range []string{"/api/ws", "/api/events"} {
|
||||
for _, role := range []string{"", "user"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
want := http.StatusUnauthorized
|
||||
if role != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+realtimeTestToken(t, role))
|
||||
want = http.StatusForbidden
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("%s role %q: status %d, want %d", path, role, recorder.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWebSocketReceivesTaskEvents(t *testing.T) {
|
||||
hub := service.NewHub(zap.NewNop())
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
server := httptest.NewServer(realtimeTestRouter(hub))
|
||||
defer server.Close()
|
||||
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/ws?token=" + realtimeTestToken(t, "admin")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
hub.Publish("task", service.BackgroundTask{ID: "task-1", SourcePath: "/media/private"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var event service.Event
|
||||
if err := conn.ReadJSON(&event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Topic != "task" || event.Payload.(map[string]any)["source_path"] != "/media/private" {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// BackgroundTaskRecord persists the operator-facing lifecycle of scan,
|
||||
// organize, and other long-running application tasks.
|
||||
type BackgroundTaskRecord struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
Kind string `gorm:"index;size:32;not null" json:"kind"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Status string `gorm:"index;size:24;not null" json:"status"`
|
||||
Stage string `gorm:"size:64" json:"stage"`
|
||||
SourcePath string `gorm:"size:2048" json:"source_path"`
|
||||
DestPath string `gorm:"size:2048" json:"dest_path"`
|
||||
Message string `gorm:"type:text" json:"message"`
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
Details string `gorm:"type:text" json:"details"`
|
||||
Metrics string `gorm:"type:text" json:"metrics"`
|
||||
Items string `gorm:"type:text" json:"items"`
|
||||
StartedAt time.Time `gorm:"index" json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
|
||||
type Library struct {
|
||||
Base
|
||||
@@ -26,12 +28,15 @@ 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"`
|
||||
AvailabilityState string `gorm:"index;size:16;not null;default:available" json:"availability_state"`
|
||||
MissingSince *time.Time `json:"missing_since,omitempty"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
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 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
|
||||
|
||||
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
|
||||
&APIConfig{},
|
||||
&UserPermission{},
|
||||
&RefreshToken{},
|
||||
&AuthSession{},
|
||||
&PlayProfile{},
|
||||
&RegistrationCode{},
|
||||
&SignIn{},
|
||||
@@ -60,6 +61,7 @@ func AllModels() []interface{} {
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&BackgroundTaskRecord{},
|
||||
&EmbyMount{},
|
||||
&ReaderBookSource{},
|
||||
&ReaderSourceState{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -178,14 +178,6 @@ func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter
|
||||
return min, max, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = backend.IndexMedia(ctx, []model.Media{media})
|
||||
}
|
||||
|
||||
// FindByID returns the media row or (nil, nil).
|
||||
func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media, error) {
|
||||
var m model.Media
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -95,12 +97,34 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
|
||||
|
||||
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
|
||||
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
unique := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
|
||||
r.indexMediaBestEffort(ctx, *fresh)
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
}
|
||||
// Read committed rows and submit one bulk per bounded batch. Keep network
|
||||
// work outside the transaction and below SQLite's parameter limit.
|
||||
const batchSize = 500
|
||||
for start := 0; start < len(unique); start += batchSize {
|
||||
batch := unique[start:min(start+batchSize, len(unique))]
|
||||
rows, err := r.ListMediaByIDs(ctx, batch)
|
||||
if err == nil {
|
||||
err = backend.IndexMedia(ctx, rows)
|
||||
}
|
||||
if err != nil {
|
||||
// The primary write has committed; do not report a failed upsert and
|
||||
// make scanners repeat it. Surface the separate index failure instead.
|
||||
r.db.Logger.Error(ctx, "media saved but search index sync failed for %d items: %v", len(batch), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +164,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
existing.Path = m.Path
|
||||
existing.DeletedAt = gorm.DeletedAt{}
|
||||
}
|
||||
if _, ok := updates["availability_state"]; ok {
|
||||
existing.AvailabilityState = "available"
|
||||
existing.MissingSince = nil
|
||||
now := updates["last_seen_at"].(time.Time)
|
||||
existing.LastSeenAt = &now
|
||||
}
|
||||
*m = existing
|
||||
return existing.ID, nil
|
||||
}
|
||||
@@ -193,14 +223,23 @@ 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
|
||||
now := time.Now()
|
||||
m.AvailabilityState = "available"
|
||||
m.MissingSince = nil
|
||||
m.LastSeenAt = &now
|
||||
// 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
|
||||
@@ -237,6 +276,11 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
|
||||
|
||||
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
|
||||
updates := map[string]any{}
|
||||
if existing.AvailabilityState != "available" || existing.MissingSince != nil {
|
||||
updates["availability_state"] = "available"
|
||||
updates["missing_since"] = nil
|
||||
updates["last_seen_at"] = time.Now()
|
||||
}
|
||||
addMediaFileScanUpdates(updates, existing, incoming)
|
||||
addMediaTitleUpdates(updates, existing, incoming)
|
||||
addMediaExternalIDUpdates(updates, existing, incoming)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -122,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")
|
||||
}
|
||||
@@ -133,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -135,14 +137,113 @@ func (b *OpenSearchMediaBackend) EnsureIndex(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Media) error {
|
||||
const batchSize = 500
|
||||
var failures []error
|
||||
for start := 0; start < len(rows); start += batchSize {
|
||||
if err := b.indexMediaBatch(ctx, rows[start:min(start+batchSize, len(rows))]); err != nil {
|
||||
failures = append(failures, err)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
type openSearchBulkResponse struct {
|
||||
Errors bool `json:"errors"`
|
||||
Items []struct {
|
||||
Index struct {
|
||||
ID string `json:"_id"`
|
||||
Status int `json:"status"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
} `json:"index"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) indexMediaBatch(ctx context.Context, rows []model.Media) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
pending := rows
|
||||
var failures []error
|
||||
const attempts = 3
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.Join(append(failures, err)...)
|
||||
}
|
||||
if attempt > 0 {
|
||||
timer := time.NewTimer(time.Duration(100*(1<<(attempt-1))) * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return errors.Join(append(failures, ctx.Err())...)
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
response, err := b.submitMediaBulk(ctx, pending)
|
||||
if err != nil {
|
||||
if retryableOpenSearchError(err) && attempt < attempts-1 {
|
||||
continue
|
||||
}
|
||||
return errors.Join(append(failures, err)...)
|
||||
}
|
||||
if len(response.Items) != len(pending) {
|
||||
return errors.Join(append(failures, fmt.Errorf("opensearch bulk returned %d item results for %d documents", len(response.Items), len(pending)))...)
|
||||
}
|
||||
var retry []model.Media
|
||||
failed := false
|
||||
for i, item := range response.Items {
|
||||
result := item.Index
|
||||
if result.Status >= 200 && result.Status < 300 && result.Error == nil {
|
||||
continue
|
||||
}
|
||||
failed = true
|
||||
if retryableOpenSearchStatus(result.Status) && attempt < attempts-1 {
|
||||
retry = append(retry, pending[i])
|
||||
continue
|
||||
}
|
||||
errorType := "unknown"
|
||||
if result.Error != nil {
|
||||
errorType = result.Error.Type
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("opensearch bulk document %q failed: status %d (%s)", pending[i].ID, result.Status, errorType))
|
||||
}
|
||||
if response.Errors && !failed {
|
||||
failures = append(failures, errors.New("opensearch bulk reported errors without failed item results"))
|
||||
}
|
||||
if len(retry) == 0 {
|
||||
break
|
||||
}
|
||||
pending = retry
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func retryableOpenSearchStatus(status int) bool {
|
||||
return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500
|
||||
}
|
||||
|
||||
func retryableOpenSearchError(err error) bool {
|
||||
var statusError *openSearchHTTPError
|
||||
if errors.As(err, &statusError) {
|
||||
return retryableOpenSearchStatus(statusError.status)
|
||||
}
|
||||
var networkError net.Error
|
||||
return errors.As(err, &networkError)
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) submitMediaBulk(ctx context.Context, rows []model.Media) (*openSearchBulkResponse, error) {
|
||||
if len(rows) == 0 {
|
||||
return &openSearchBulkResponse{}, nil
|
||||
}
|
||||
var bulk bytes.Buffer
|
||||
enc := json.NewEncoder(&bulk)
|
||||
for _, row := range rows {
|
||||
if err := enc.Encode(map[string]any{"index": map[string]any{"_index": b.index, "_id": row.ID}}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Encode(map[string]any{
|
||||
"id": row.ID,
|
||||
@@ -155,10 +256,24 @@ func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Me
|
||||
"deleted": row.DeletedAt.Valid,
|
||||
"created_at": row.CreatedAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", nil)
|
||||
var response openSearchBulkResponse
|
||||
if err := b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
type openSearchHTTPError struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *openSearchHTTPError) Error() string {
|
||||
return fmt.Sprintf("opensearch %s %s returned %d", e.method, e.path, e.status)
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) doJSON(ctx context.Context, method, path string, body any, out any) error {
|
||||
@@ -190,7 +305,7 @@ func (b *OpenSearchMediaBackend) do(ctx context.Context, method, path string, bo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("opensearch %s %s returned %d", method, path, resp.StatusCode)
|
||||
return &openSearchHTTPError{method: method, path: path, status: resp.StatusCode}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
|
||||
@@ -55,3 +66,156 @@ func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
|
||||
t.Fatalf("ids=%#v total=%d", ids, total)
|
||||
}
|
||||
}
|
||||
|
||||
func bulkRequestIDs(r *http.Request) []string {
|
||||
var ids []string
|
||||
scanner := bufio.NewScanner(r.Body)
|
||||
for scanner.Scan() {
|
||||
var action struct {
|
||||
Index struct {
|
||||
ID string `json:"_id"`
|
||||
} `json:"index"`
|
||||
}
|
||||
_ = json.Unmarshal(scanner.Bytes(), &action)
|
||||
ids = append(ids, action.Index.ID)
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func writeBulkResults(w http.ResponseWriter, ids []string, statuses map[string]int) {
|
||||
items := make([]any, 0, len(ids))
|
||||
failed := false
|
||||
for _, id := range ids {
|
||||
status := statuses[id]
|
||||
if status == 0 {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
result := map[string]any{"_id": id, "status": status}
|
||||
if status >= 400 {
|
||||
failed = true
|
||||
result["error"] = map[string]any{"type": "test_failure"}
|
||||
}
|
||||
items = append(items, map[string]any{"index": result})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errors": failed, "items": items})
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkRetriesOnlyTransientFailedDocuments(t *testing.T) {
|
||||
var requests [][]string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ids := bulkRequestIDs(r)
|
||||
requests = append(requests, ids)
|
||||
statuses := map[string]int{}
|
||||
if len(requests) == 1 {
|
||||
statuses["retry"] = http.StatusTooManyRequests
|
||||
statuses["invalid"] = http.StatusBadRequest
|
||||
}
|
||||
writeBulkResults(w, ids, statuses)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
rows := []model.Media{{Base: model.Base{ID: "ok"}}, {Base: model.Base{ID: "retry"}}, {Base: model.Base{ID: "invalid"}}}
|
||||
err := backend.IndexMedia(t.Context(), rows)
|
||||
if err == nil || !strings.Contains(err.Error(), `"invalid"`) {
|
||||
t.Fatalf("error = %v, want permanent document failure", err)
|
||||
}
|
||||
if len(requests) != 2 || strings.Join(requests[1], ",") != "retry" {
|
||||
t.Fatalf("requests = %#v; successful/permanent documents must not be retried", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkBoundsRetriesAndRejectsMalformedResponses(t *testing.T) {
|
||||
for _, scenario := range []string{"transient-item", "transient-http", "invalid-http", "missing-results"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
switch scenario {
|
||||
case "transient-item":
|
||||
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusServiceUnavailable})
|
||||
case "transient-http":
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
case "invalid-http":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case "missing-results":
|
||||
fmt.Fprint(w, `{"errors":false,"items":[]}`)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
if err := backend.IndexMedia(t.Context(), []model.Media{{Base: model.Base{ID: "m1"}}}); err == nil {
|
||||
t.Fatal("failed indexing was reported as successful")
|
||||
}
|
||||
want := 1
|
||||
if strings.HasPrefix(scenario, "transient") {
|
||||
want = 3
|
||||
}
|
||||
if calls != want {
|
||||
t.Fatalf("calls = %d, want %d", calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkHonorsCancellationDuringRetry(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusTooManyRequests})
|
||||
cancel()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
err := backend.IndexMedia(ctx, []model.Media{{Base: model.Base{ID: "m1"}}})
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("error = %v, calls = %d", err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaUpsertBatchIndexesCommittedRowsTogether(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
ids := bulkRequestIDs(r)
|
||||
if len(ids) != 5 {
|
||||
t.Errorf("bulk contains %d documents, want 5", len(ids))
|
||||
}
|
||||
// With only one DB connection this also detects network work inside the
|
||||
// transaction: a separate read could not observe the committed batch.
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&model.Media{}).Count(&count).Error; err != nil || count != 5 {
|
||||
t.Errorf("committed rows = %d, error = %v", count, err)
|
||||
}
|
||||
writeBulkResults(w, ids, nil)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
repos.Media.SetSearchBackend(NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL}))
|
||||
var rows []*model.Media
|
||||
for i := 0; i < 5; i++ {
|
||||
rows = append(rows, &model.Media{LibraryID: "lib", Path: fmt.Sprintf("/media/%d.mkv", i)})
|
||||
}
|
||||
if err := repos.Media.UpsertBatch(ctx, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("index requests = %d, want one bulk", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -24,6 +24,14 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string)
|
||||
switch provider {
|
||||
case "tmdb":
|
||||
return s.testTMDb(cfg)
|
||||
case "bangumi":
|
||||
return s.testBangumi(ctx, cfg)
|
||||
case "thetvdb":
|
||||
return s.testTheTVDB(ctx, cfg)
|
||||
case "fanart":
|
||||
return s.testFanart(ctx, cfg)
|
||||
case "douban":
|
||||
return s.testDouban(ctx, cfg)
|
||||
case "openai":
|
||||
return s.testOpenAI(cfg)
|
||||
case "deepseek":
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// Bangumi's public metadata endpoints work without a token. If one was saved,
|
||||
// also check /v0/me: public endpoints alone may accept an expired token.
|
||||
func (s *ApiConfigService) testBangumi(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
||||
req, err := newMetadataProbeRequest(ctx, cfg, "https://api.bgm.tv", "/v0/subjects/253", http.MethodGet, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
if token := strings.TrimSpace(cfg.APIKey); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
identityReq := req.Clone(ctx)
|
||||
identityURL := *req.URL
|
||||
identityURL.Path = strings.TrimSuffix(identityURL.Path, "/subjects/253") + "/me"
|
||||
identityReq.URL = &identityURL
|
||||
var identity struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if result, err := testMetadataProbeJSON(identityReq, "Bangumi", &identity); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if identity.ID <= 0 {
|
||||
return "error", errors.New("Bangumi returned an invalid user response")
|
||||
}
|
||||
}
|
||||
var subject struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if result, err := testMetadataProbeJSON(req, "Bangumi", &subject); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if subject.ID != 253 || strings.TrimSpace(subject.Name) == "" {
|
||||
return "error", errors.New("Bangumi returned an invalid subject response")
|
||||
}
|
||||
return "success", nil
|
||||
}
|
||||
|
||||
func (s *ApiConfigService) testTheTVDB(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
if key == "" {
|
||||
return "error", errors.New("TheTVDB API key is required")
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"apikey": key})
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
req, err := newMetadataProbeRequest(ctx, cfg, "https://api4.thetvdb.com/v4", "/login", http.MethodPost, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if result, err := testMetadataProbeJSON(req, "TheTVDB", &response); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if response.Status != "success" || strings.TrimSpace(response.Data.Token) == "" {
|
||||
return "error", errors.New("TheTVDB login did not return a valid token")
|
||||
}
|
||||
return "success", nil
|
||||
}
|
||||
|
||||
func (s *ApiConfigService) testFanart(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
if key == "" {
|
||||
return "error", errors.New("Fanart API key is required")
|
||||
}
|
||||
req, err := newMetadataProbeRequest(ctx, cfg, "https://webservice.fanart.tv/v3", "/movies/550", http.MethodGet, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
query := req.URL.Query()
|
||||
query.Set("api_key", key)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
var response struct {
|
||||
TMDbID json.Number `json:"tmdb_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if result, err := testMetadataProbeJSON(req, "Fanart", &response); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if response.TMDbID.String() != "550" || strings.TrimSpace(response.Name) == "" {
|
||||
return "error", errors.New("Fanart returned an invalid movie response")
|
||||
}
|
||||
return "success", nil
|
||||
}
|
||||
|
||||
func (s *ApiConfigService) testDouban(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
||||
req, err := newMetadataProbeRequest(ctx, cfg, "https://movie.douban.com", "/j/subject_suggest", http.MethodGet, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
query := req.URL.Query()
|
||||
query.Set("q", "Inception")
|
||||
req.URL.RawQuery = query.Encode()
|
||||
req.Header.Set("User-Agent", userAgents[0])
|
||||
req.Header.Set("Referer", "https://movie.douban.com/")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
if cookie := strings.TrimSpace(cfg.APIKey); cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
var suggestions []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if result, err := testMetadataProbeJSON(req, "Douban", &suggestions); err != nil {
|
||||
return result, err
|
||||
}
|
||||
// An empty array is a valid search result; null or an error object is not.
|
||||
if suggestions == nil {
|
||||
return "error", errors.New("Douban returned an invalid search response (login or verification may be required)")
|
||||
}
|
||||
for _, suggestion := range suggestions {
|
||||
if suggestion.ID == "" || strings.TrimSpace(suggestion.Title) == "" {
|
||||
return "error", errors.New("Douban returned an invalid search suggestion")
|
||||
}
|
||||
}
|
||||
return "success", nil
|
||||
}
|
||||
|
||||
func newMetadataProbeRequest(ctx context.Context, cfg *model.APIConfig, defaultBase, path, method string, body io.Reader) (*http.Request, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultBase
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, base+path, body)
|
||||
if err != nil {
|
||||
// The saved base URL may contain credentials. Never echo it to the UI.
|
||||
return nil, fmt.Errorf("%s: invalid base URL", cfg.Provider)
|
||||
}
|
||||
req.Header.Set("User-Agent", "MeBox/0.1 (https://github.com/truewhile/MeBox)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Decode a bounded, complete JSON document rather than accepting any HTTP 200
|
||||
// (reverse proxies and anti-bot/login pages often return 200 as well).
|
||||
func testMetadataProbeJSON(req *http.Request, provider string, out any) (string, error) {
|
||||
client := NewExternalHTTPClient(10 * time.Second)
|
||||
defer client.CloseIdleConnections()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// url.Error includes the full request URL, including Fanart's API key.
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
err = urlErr.Err
|
||||
}
|
||||
return "error", fmt.Errorf("%s connection failed: %w", provider, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "invalid", fmt.Errorf("%s authentication failed (HTTP 401): check configured credentials", provider)
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return "error", fmt.Errorf("%s access denied (HTTP 403): check credentials, permissions or anti-bot verification", provider)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "error", fmt.Errorf("%s API returned status %d", provider, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("%s response read failed: %w", provider, err)
|
||||
}
|
||||
if len(body) > 1<<20 {
|
||||
return "error", fmt.Errorf("%s response exceeds 1 MiB", provider)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
// Do not include the response body: it may echo credentials.
|
||||
return "error", fmt.Errorf("%s returned invalid JSON (login or verification page may have intercepted the request)", provider)
|
||||
}
|
||||
return "success", nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type metadataProbeFixture struct {
|
||||
provider string
|
||||
path string
|
||||
key string
|
||||
body string
|
||||
}
|
||||
|
||||
var metadataProbeFixtures = []metadataProbeFixture{
|
||||
{"bangumi", "/v0/subjects/253", "", `{"id":253,"name":"カウボーイビバップ","type":2}`},
|
||||
{"thetvdb", "/login", "tvdb-test-secret", `{"status":"success","data":{"token":"test-jwt"}}`},
|
||||
{"fanart", "/movies/550", "fanart+test&secret", `{"name":"Fight Club","tmdb_id":"550","imdb_id":"tt0137523","movieposter":[]}`},
|
||||
{"douban", "/j/subject_suggest", "bid=test-cookie", `[{"id":"3541415","title":"盗梦空间","year":"2010","type":"movie"}]`},
|
||||
}
|
||||
|
||||
func newMetadataConnectionTestService(t *testing.T) *ApiConfigService {
|
||||
t.Helper()
|
||||
log := zap.NewNop()
|
||||
return NewApiConfigService(&config.Config{}, log, repository.New(newServiceTestDB(t)), NewCryptoService("metadata-test-secret", log))
|
||||
}
|
||||
|
||||
func saveMetadataProbeConfig(t *testing.T, svc *ApiConfigService, provider, baseURL, key string) {
|
||||
t.Helper()
|
||||
if _, err := svc.Upsert(t.Context(), provider, key, baseURL, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Removing a dispatch branch, ignoring the saved URL/key, or using the wrong
|
||||
// endpoint must fail this integration test (real database and HTTP server).
|
||||
func TestMetadataConnectionUsesSavedConfig(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
for _, fixture := range metadataProbeFixtures {
|
||||
t.Run(fixture.provider, func(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/proxy"+fixture.path {
|
||||
t.Errorf("request path = %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
t.Error("missing User-Agent")
|
||||
}
|
||||
if fixture.provider == "thetvdb" {
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if r.Method != http.MethodPost || body["apikey"] != fixture.key || r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("TheTVDB login must POST the saved, decrypted API key as JSON")
|
||||
}
|
||||
} else if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if fixture.provider == "fanart" && r.URL.Query().Get("api_key") != fixture.key {
|
||||
t.Error("Fanart API key was not escaped/passed correctly")
|
||||
}
|
||||
if fixture.provider == "douban" {
|
||||
if r.Header.Get("Cookie") != fixture.key || r.Header.Get("Referer") != "https://movie.douban.com/" || r.URL.Query().Get("q") == "" {
|
||||
t.Error("Douban search must carry the configured cookie, referer and query")
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(fixture.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
saveMetadataProbeConfig(t, svc, fixture.provider, " "+server.URL+"/proxy/ ", fixture.key)
|
||||
result, err := svc.TestConnection(t.Context(), fixture.provider)
|
||||
if err != nil || result != "success" {
|
||||
t.Fatalf("TestConnection = %q, %v, want success", result, err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("HTTP calls = %d, want 1", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataConnectionRejectsBadResponses(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
for _, fixture := range metadataProbeFixtures {
|
||||
for _, response := range []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
result string
|
||||
}{
|
||||
{"unauthorized", 401, `{"error":"denied"}`, "invalid"},
|
||||
{"forbidden", 403, `<html>Access denied</html>`, "error"},
|
||||
{"rate limited", 429, `{}`, "error"},
|
||||
{"upstream error", 503, `{}`, "error"},
|
||||
{"challenge page", 200, `<html>Please log in or complete verification</html>`, "error"},
|
||||
{"empty body", 200, ``, "error"},
|
||||
{"wrong schema", 200, `{"error":"intercepted"}`, "error"},
|
||||
{"null", 200, `null`, "error"},
|
||||
{"trailing garbage", 200, fixture.body + `<html>blocked</html>`, "error"},
|
||||
} {
|
||||
t.Run(fixture.provider+"/"+response.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(response.status)
|
||||
_, _ = w.Write([]byte(response.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key)
|
||||
result, err := svc.TestConnection(t.Context(), fixture.provider)
|
||||
if err == nil || result != response.result {
|
||||
t.Fatalf("TestConnection = %q, %v, want %q and error", result, err, response.result)
|
||||
}
|
||||
if fixture.key != "" && strings.Contains(err.Error(), fixture.key) {
|
||||
t.Error("error exposes configured credentials")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataConnectionRequiresAPIKeys(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
for _, provider := range []string{"thetvdb", "fanart"} {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("must reject missing key before making a request")
|
||||
}))
|
||||
defer server.Close()
|
||||
saveMetadataProbeConfig(t, svc, provider, server.URL, " ")
|
||||
result, err := svc.TestConnection(t.Context(), provider)
|
||||
if result != "error" || err == nil || !strings.Contains(err.Error(), "API key") {
|
||||
t.Fatalf("TestConnection = %q, %v, want missing API key error", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataConnectionBangumiValidatesConfiguredToken(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
for _, valid := range []bool{true, false} {
|
||||
name := "valid"
|
||||
if !valid {
|
||||
name = "invalid"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
meCalls, subjectCalls := 0, 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer bangumi-test-token" {
|
||||
t.Error("missing saved Bangumi bearer token")
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v0/me":
|
||||
meCalls++
|
||||
if !valid {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":42,"username":"test-user"}`))
|
||||
case "/v0/subjects/253":
|
||||
subjectCalls++
|
||||
_, _ = w.Write([]byte(`{"id":253,"name":"カウボーイビバップ"}`))
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
saveMetadataProbeConfig(t, svc, "bangumi", server.URL, "bangumi-test-token")
|
||||
result, err := svc.TestConnection(t.Context(), "bangumi")
|
||||
if valid && (result != "success" || err != nil || subjectCalls != 1) {
|
||||
t.Fatalf("valid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls)
|
||||
}
|
||||
if !valid && (result != "invalid" || err == nil || subjectCalls != 0) {
|
||||
t.Fatalf("invalid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls)
|
||||
}
|
||||
if meCalls != 1 {
|
||||
t.Errorf("identity requests = %d, want 1", meCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataConnectionDoubanDoesNotRequireCookie(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Cookie") != "" {
|
||||
t.Error("unexpected Cookie for public search")
|
||||
}
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
saveMetadataProbeConfig(t, svc, "douban", server.URL, "")
|
||||
result, err := svc.TestConnection(t.Context(), "douban")
|
||||
if result != "success" || err != nil {
|
||||
t.Fatalf("public search with empty results = %q, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataConnectionHonorsContext(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
for _, fixture := range metadataProbeFixtures {
|
||||
t.Run(fixture.provider, func(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
close(started)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-started:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
result, err := svc.TestConnection(ctx, fixture.provider)
|
||||
if result != "error" || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled request = %q, %v, want context.Canceled", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataConnectionTransportErrorsHideKeys(t *testing.T) {
|
||||
svc := newMetadataConnectionTestService(t)
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
server.Close()
|
||||
key := "sensitive-fanart-key"
|
||||
saveMetadataProbeConfig(t, svc, "fanart", server.URL, key)
|
||||
result, err := svc.TestConnection(t.Context(), "fanart")
|
||||
if result != "error" || err == nil || strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("transport failure = %q, %v, want error without credentials", result, err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+81
-37
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// 两种方言的备份形态不同:
|
||||
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
|
||||
// 扩展名 .db;恢复即「替换库文件 + 重启」。
|
||||
// 扩展名 .db;恢复先暂存,在下次启动打开数据库前替换文件。
|
||||
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
|
||||
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
|
||||
package service
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
)
|
||||
|
||||
// BackupService manages database backups.
|
||||
@@ -44,6 +45,7 @@ type BackupInfo struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Automatic bool `json:"automatic"`
|
||||
}
|
||||
|
||||
func (b *BackupService) backupDir() string {
|
||||
@@ -69,6 +71,23 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ResolvePath returns the absolute path of a listed backup after applying the
|
||||
// same filename and directory checks used by restore and delete operations.
|
||||
func (b *BackupService) ResolvePath(filename string) (string, error) {
|
||||
path, err := b.backupFilePath(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("backup is not a regular file")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// isPostgres reports whether the service is backed by PostgreSQL.
|
||||
//
|
||||
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
|
||||
@@ -80,11 +99,23 @@ func (b *BackupService) isPostgres() bool {
|
||||
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
|
||||
// a logical dump.
|
||||
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
||||
return b.create(ctx, false)
|
||||
}
|
||||
|
||||
func (b *BackupService) CreateAutomatic(ctx context.Context) (*BackupInfo, error) {
|
||||
return b.create(ctx, true)
|
||||
}
|
||||
|
||||
func (b *BackupService) create(ctx context.Context, automatic bool) (*BackupInfo, error) {
|
||||
dir := b.backupDir()
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := time.Now().Format("20060102_150405")
|
||||
now := time.Now()
|
||||
ts := fmt.Sprintf("%s_%09d", now.Format("20060102_150405"), now.Nanosecond())
|
||||
if automatic {
|
||||
ts = "auto_" + ts
|
||||
}
|
||||
|
||||
var (
|
||||
dst string
|
||||
@@ -108,15 +139,24 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
||||
Path: dst,
|
||||
Size: stat.Size(),
|
||||
CreatedAt: stat.ModTime(),
|
||||
Automatic: automatic,
|
||||
}, 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 {
|
||||
tmp := dst + ".tmp"
|
||||
defer os.Remove(tmp)
|
||||
// Publish only completed backups, with credentials protected by file mode.
|
||||
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", tmp).Error; err != nil {
|
||||
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
@@ -132,7 +172,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
||||
}
|
||||
out := make([]BackupInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !isBackupFilename(e.Name()) {
|
||||
if !e.Type().IsRegular() || !isBackupFilename(e.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
@@ -144,6 +184,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
||||
Path: filepath.Join(dir, e.Name()),
|
||||
Size: info.Size(),
|
||||
CreatedAt: info.ModTime(),
|
||||
Automatic: strings.HasPrefix(e.Name(), "mebox_auto_"),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -152,6 +193,36 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Prune keeps maxCount automatic backups for the current database dialect.
|
||||
// Manual backups and backups from another dialect are never pruned.
|
||||
// A value below one disables pruning so an invalid setting cannot erase all
|
||||
// recoverable copies.
|
||||
func (b *BackupService) Prune(maxCount int) error {
|
||||
if maxCount < 1 {
|
||||
return nil
|
||||
}
|
||||
items, err := b.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eligible := items[:0]
|
||||
for _, item := range items {
|
||||
if item.Automatic && strings.HasSuffix(item.Filename, postgresBackupSuffix) == b.isPostgres() {
|
||||
eligible = append(eligible, item)
|
||||
}
|
||||
}
|
||||
items = eligible
|
||||
if maxCount >= len(items) {
|
||||
return nil
|
||||
}
|
||||
for _, item := range items[maxCount:] {
|
||||
if err := b.Delete(item.Filename); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single backup file.
|
||||
func (b *BackupService) Delete(filename string) error {
|
||||
path, err := b.backupFilePath(filename)
|
||||
@@ -161,12 +232,10 @@ func (b *BackupService) Delete(filename string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Restore stages SQLite backups for the next startup. PostgreSQL restores
|
||||
// rebuild every table inside a single transaction.
|
||||
func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
||||
src, err := b.backupFilePath(filename)
|
||||
src, err := b.ResolvePath(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,35 +251,10 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
||||
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")
|
||||
}
|
||||
// Strategy: rename live → live.old, copy backup → live, delete old.
|
||||
old := dbPath + ".before_restore"
|
||||
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
if err := database.StageSQLiteRestore(ctx, b.cfg, src); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
|
||||
if err != nil {
|
||||
// Revert
|
||||
_ = os.Rename(old, dbPath)
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
|
||||
_ = os.Rename(old, dbPath)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(old)
|
||||
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
|
||||
_ = os.Remove(dbPath + "-wal")
|
||||
_ = os.Remove(dbPath + "-shm")
|
||||
b.log.Warn("database restored from backup — restart the server",
|
||||
zap.String("backup", filename))
|
||||
b.log.Warn("SQLite backup staged; restart to apply", zap.String("backup", filename))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -86,7 +88,8 @@ func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[str
|
||||
// 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)
|
||||
tmp := dst + ".tmp"
|
||||
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -95,7 +98,7 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
|
||||
_ = file.Close()
|
||||
if !complete {
|
||||
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
|
||||
_ = os.Remove(dst)
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -108,17 +111,30 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
|
||||
}); 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
|
||||
var options []*sql.TxOptions
|
||||
if b.isPostgres() {
|
||||
options = append(options, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
}
|
||||
// Every table must come from the same snapshot, including while background
|
||||
// workers are inserting related rows during an automatic backup.
|
||||
if err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
snapshot := *b
|
||||
snapshot.db = tx
|
||||
for _, m := range model.AllModels() {
|
||||
table, sch, err := parseModelSchema(tx, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tx.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if err := snapshot.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, options...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return "", fmt.Errorf("压缩备份失败: %w", err)
|
||||
@@ -126,6 +142,12 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
|
||||
if err := file.Sync(); err != nil {
|
||||
return "", fmt.Errorf("写入备份失败: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
complete = true
|
||||
return dst, nil
|
||||
}
|
||||
@@ -271,7 +293,7 @@ func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) e
|
||||
if err != nil {
|
||||
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
|
||||
}
|
||||
if err := tx.Unscoped().CreateInBatches(rows, backupRestoreBatchSize).Error; err != nil {
|
||||
if err := database.InsertPreservingValues(tx.Unscoped(), rows, backupRestoreBatchSize); err != nil {
|
||||
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupRetentionPreservesManualAndOtherDialectBackups(t *testing.T) {
|
||||
svc, _ := newBackupTestService(t)
|
||||
names := []string{"mebox_auto_new.db", "mebox_auto_old.db", "mebox_manual.db", "mebox_auto_postgres.json.gz"}
|
||||
for i, name := range names {
|
||||
path := filepath.Join(svc.backupDir(), name)
|
||||
if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
when := time.Now().Add(-time.Duration(i) * time.Hour)
|
||||
if err := os.Chtimes(path, when, when); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := svc.Prune(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range names {
|
||||
_, err := os.Stat(filepath.Join(svc.backupDir(), name))
|
||||
if name == "mebox_auto_old.db" {
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("old automatic backup was retained: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("backup %s should have been preserved: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupCreateUsesUniquePrivateCompletedFiles(t *testing.T) {
|
||||
svc, _ := newBackupTestService(t)
|
||||
manual, err := svc.Create(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
automatic, err := svc.CreateAutomatic(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manual.Filename == automatic.Filename || manual.Automatic || !automatic.Automatic {
|
||||
t.Fatalf("invalid backup identities: manual=%#v automatic=%#v", manual, automatic)
|
||||
}
|
||||
items, err := svc.List()
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("completed backup list: items=%#v err=%v", items, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
info, err := os.Stat(item.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("backup permissions are not private: %s err=%v", item.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCloud115HLSChildRejectsMismatchedSessionMediaBeforeFetch(t *testing.T) {
|
||||
fetched := false
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
proxy.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
fetched = true
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"video/mp2t"}}, Body: io.NopCloser(strings.NewReader("private-segment")), Request: req}, nil
|
||||
})}
|
||||
proxy.sessions["session-b"] = &cloud115HLSSession{
|
||||
ID: "session-b", MediaID: "media-b", ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"segment": "https://cpats01.115.com/seg.ts"},
|
||||
}
|
||||
for _, query := range []string{"?media_id=media-a", ""} {
|
||||
fetched = false
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/session-b/segment"+query, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
err := proxy.ServeChild(context.Background(), rec, req, "session-b", "segment")
|
||||
if err == nil || fetched {
|
||||
t.Errorf("query %q must reject the wrong/missing media before fetch; err=%v fetched=%v", query, err, fetched)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
|
||||
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
|
||||
ErrCloud115HLSSessionNotFound = errors.New("115 hls session not found")
|
||||
ErrCloud115HLSUpstreamExpired = errors.New("115 hls upstream expired")
|
||||
ErrCloud115HLSSessionMediaMismatch = errors.New("115 hls session media mismatch")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -131,6 +132,9 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
|
||||
if !ok {
|
||||
return ErrCloud115HLSSessionNotFound
|
||||
}
|
||||
if r == nil || strings.TrimSpace(r.URL.Query().Get("media_id")) != session.MediaID {
|
||||
return ErrCloud115HLSSessionMediaMismatch
|
||||
}
|
||||
resp, err := p.fetchUpstream(ctx, upstream, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -186,6 +190,15 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionMediaID resolves the server-owned media identity before handler authorization.
|
||||
func (p *Cloud115HLSProxy) SessionMediaID(sessionID, key string) (string, error) {
|
||||
session, _, ok := p.lookup(sessionID, key)
|
||||
if !ok {
|
||||
return "", ErrCloud115HLSSessionNotFound
|
||||
}
|
||||
return session.MediaID, nil
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) newSession(mediaID string, definition int) *cloud115HLSSession {
|
||||
now := time.Now()
|
||||
return &cloud115HLSSession{
|
||||
|
||||
@@ -104,11 +104,13 @@ func (s *Cloud115PlaybackService) HLSProxy() *Cloud115HLSProxy {
|
||||
return s.proxy
|
||||
}
|
||||
|
||||
// PlaybackInfo 返回媒体在 direct/115-cloud/local 三种模式下的能力描述。
|
||||
//
|
||||
// requestedDefinition 为空或 0 时默认选择 1080P;该档位不可用时返回
|
||||
// transcoding 状态(并只触发一次加速转码,避免轮询风暴)。
|
||||
// PlaybackInfo only reads direct/115-cloud/local playback capabilities.
|
||||
// Cloud work is queued exclusively by StartTranscode, never by polling.
|
||||
func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID string, requestedDefinition int) (*PlaybackInfo, error) {
|
||||
return s.playbackInfo(ctx, mediaID, requestedDefinition, false)
|
||||
}
|
||||
|
||||
func (s *Cloud115PlaybackService) playbackInfo(ctx context.Context, mediaID string, requestedDefinition int, startTranscode bool) (*PlaybackInfo, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||
return nil, ErrMediaNotFound
|
||||
}
|
||||
@@ -179,13 +181,26 @@ func (s *Cloud115PlaybackService) PlaybackInfo(ctx context.Context, mediaID stri
|
||||
return info, nil
|
||||
}
|
||||
|
||||
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
|
||||
if startTranscode {
|
||||
info.Transcode = s.ensureCloudTranscode(ctx, account.ID, pickCode, selected, client)
|
||||
} else {
|
||||
info.Transcode = Cloud115TranscodeState{
|
||||
State: "idle", Definition: selected.ID, Message: "该清晰度尚未就绪,可请求 115 云端转码", RetryAfterSec: 5,
|
||||
}
|
||||
s.mu.Lock()
|
||||
if last, ok := s.pushState[account.ID+":"+pickCode]; ok {
|
||||
info.Transcode.State = "transcoding"
|
||||
info.Transcode.Message = last.message
|
||||
info.Transcode.StartedAt = last.at.Unix()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// StartTranscode 显式触发某个清晰度的云端转码,返回最新状态。
|
||||
func (s *Cloud115PlaybackService) StartTranscode(ctx context.Context, mediaID string, definition int) (*PlaybackInfo, error) {
|
||||
return s.PlaybackInfo(ctx, mediaID, definition)
|
||||
return s.playbackInfo(ctx, mediaID, definition, true)
|
||||
}
|
||||
|
||||
// ResolveCloud115URL 返回指定清晰度的 115 云端 m3u8 地址。
|
||||
@@ -258,6 +273,9 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reserve the in-flight attempt before releasing the lock. Two simultaneous
|
||||
// explicit starts must not both submit a push while the first awaits 115.
|
||||
s.pushState[key] = cloud115PushAttempt{at: now, message: "正在提交 115 云端转码请求…"}
|
||||
s.mu.Unlock()
|
||||
|
||||
err := client.SubmitVideoPush(ctx, pickCode, "vip_push")
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
|
||||
func TestCloud115PlaybackInfoIsReadOnlyAndStartTriggersTranscodeOnce(t *testing.T) {
|
||||
var playCalls int32
|
||||
var pushCalls int32
|
||||
var ready int32
|
||||
@@ -93,9 +93,16 @@ func TestCloud115PlaybackInfoTriggersTranscodeOnce(t *testing.T) {
|
||||
}
|
||||
svc := NewCloud115PlaybackService(nil, zap.NewNop(), repos, strm)
|
||||
|
||||
info, err := svc.PlaybackInfo(ctx, media.ID, 0)
|
||||
if _, err := svc.PlaybackInfo(ctx, media.ID, 0); err != nil {
|
||||
t.Fatalf("read playback info: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&pushCalls); got != 0 {
|
||||
t.Fatalf("read-only playback info triggered %d cloud transcode pushes", got)
|
||||
}
|
||||
|
||||
info, err := svc.StartTranscode(ctx, media.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("first playback info: %v", err)
|
||||
t.Fatalf("start transcode: %v", err)
|
||||
}
|
||||
if info.Provider != model.StrmProvider115 || info.DefaultQuality != "4" {
|
||||
t.Fatalf("unexpected playback info: %#v", info)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestCloud115ConcurrentStartsDoNotDuplicateInFlightPush(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
var pushCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if pushCalls.Add(1) == 1 {
|
||||
close(started)
|
||||
}
|
||||
<-release
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"state": true, "code": 0, "data": []any{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
oldBase := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = server.URL
|
||||
defer func() { cloud115.ProAPIBase = oldBase }()
|
||||
client := cloud115.NewOpenClient("review-app", "access", "refresh")
|
||||
svc := NewCloud115PlaybackService(nil, zap.NewNop(), nil, nil)
|
||||
quality := PlaybackQuality{ID: "4", Label: "1080P", Source: "cloud"}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
first := make(chan struct{})
|
||||
go func() { defer close(first); svc.ensureCloudTranscode(ctx, "account", "pick", quality, client) }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("first push did not reach the local server")
|
||||
}
|
||||
second := make(chan Cloud115TranscodeState, 1)
|
||||
go func() { second <- svc.ensureCloudTranscode(ctx, "account", "pick", quality, client) }()
|
||||
secondReceived := false
|
||||
select {
|
||||
case state := <-second:
|
||||
secondReceived = true
|
||||
if state.State != "transcoding" {
|
||||
t.Errorf("in-flight request should be reported as transcoding: %#v", state)
|
||||
}
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
t.Error("second start issued another request instead of reusing the in-flight push")
|
||||
}
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
select {
|
||||
case <-first:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("first push did not finish")
|
||||
}
|
||||
// The duplicate path is awaited before restoring the package-wide fake API.
|
||||
if !secondReceived {
|
||||
select {
|
||||
case <-second:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("second start did not finish")
|
||||
}
|
||||
}
|
||||
if got := pushCalls.Load(); got != 1 {
|
||||
t.Fatalf("pushes=%d, want 1", 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var ErrInvalidHLSPlaybackScope = errors.New("invalid HLS playback scope")
|
||||
var ErrHLSGenerationSuperseded = errors.New("HLS playback generation superseded")
|
||||
|
||||
// HLSPlaybackScope separates a media's independent users and player instances.
|
||||
// UserID must come from server authentication, never from a request parameter.
|
||||
type HLSPlaybackScope struct {
|
||||
UserID string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
func HLSPlaybackScopeForRequest(r *http.Request, userID string) (HLSPlaybackScope, error) {
|
||||
scope := HLSPlaybackScope{UserID: strings.TrimSpace(userID)}
|
||||
if scope.UserID == "" || r == nil {
|
||||
return scope, ErrInvalidHLSPlaybackScope
|
||||
}
|
||||
for _, key := range []string{"session_id", "PlaySessionId", "playSessionId", "DeviceId", "deviceId"} {
|
||||
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
||||
scope.SessionID = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if scope.SessionID == "" {
|
||||
if device := strings.TrimSpace(r.Header.Get("X-Emby-Device-Id")); device != "" {
|
||||
scope.SessionID = device
|
||||
}
|
||||
}
|
||||
if len(scope.SessionID) > 256 || strings.IndexFunc(scope.SessionID, unicode.IsControl) >= 0 {
|
||||
return scope, ErrInvalidHLSPlaybackScope
|
||||
}
|
||||
// Clients without a session/device identifier remain compatible but only
|
||||
// share a legacy task with the same authenticated user, never other users.
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (scope HLSPlaybackScope) jobKey(mediaID string) string {
|
||||
if scope.UserID == "" && scope.SessionID == "" {
|
||||
return mediaID // Existing internal callers/tests retain their legacy API.
|
||||
}
|
||||
sum := sha256.Sum256([]byte(scope.UserID + "\x00" + mediaID + "\x00" + scope.SessionID))
|
||||
return "player-" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hlsPlaybackJobKey(mediaID string, scopes []HLSPlaybackScope) string {
|
||||
if len(scopes) == 0 {
|
||||
return mediaID
|
||||
}
|
||||
return scopes[0].jobKey(mediaID)
|
||||
}
|
||||
|
||||
func (job *hlsJob) key() string {
|
||||
if job.jobKey != "" {
|
||||
return job.jobKey
|
||||
}
|
||||
return job.mediaID
|
||||
}
|
||||
|
||||
func (t *TranscoderService) jobPlaylistPath(job *hlsJob) string {
|
||||
dir := job.outputDir
|
||||
if dir == "" {
|
||||
dir = t.HLSDir(job.key())
|
||||
}
|
||||
return filepath.Join(dir, "index.m3u8")
|
||||
}
|
||||
|
||||
// hlsOutputDir captures one immutable run directory. Even if a new run starts
|
||||
// after this check, the request cannot accidentally open its replacement files.
|
||||
func (t *TranscoderService) hlsOutputDir(key string, generation int64) (string, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
job := t.jobs[key]
|
||||
if job == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
if job.seekGen != generation {
|
||||
return "", ErrHLSGenerationSuperseded
|
||||
}
|
||||
t.touchJobLocked(key)
|
||||
if job.outputDir != "" {
|
||||
return job.outputDir, nil
|
||||
}
|
||||
return t.HLSDir(key), nil
|
||||
}
|
||||
|
||||
func (t *TranscoderService) StopPlayback(mediaID string, scope HLSPlaybackScope) {
|
||||
key := scope.jobKey(mediaID)
|
||||
unlock := t.lockJobStart(key)
|
||||
defer unlock()
|
||||
t.mu.Lock()
|
||||
job := t.detachJobLocked(key)
|
||||
t.mu.Unlock()
|
||||
waitJobExit(job, 12*time.Second)
|
||||
// Keep cleanup under the same gate as start: a delayed stop must not wipe
|
||||
// an output directory created by a concurrent replacement request.
|
||||
_ = os.RemoveAll(t.HLSDir(key))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 cachedScopedHLS(t *testing.T, scopes ...HLSPlaybackScope) (*StreamService, *TranscoderService, map[string]string) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
cfg.Transcoder.Enabled = true
|
||||
db := newServiceTestDB(t)
|
||||
if err := db.Create(&model.Media{Base: model.Base{ID: "movie"}, Path: "unused-cached-source", DurationSec: 60}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), nil)
|
||||
paths := map[string]string{}
|
||||
for _, scope := range scopes {
|
||||
key := scope.jobKey("movie")
|
||||
dir := filepath.Join(transcoder.HLSDir(key), "run-cached")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playlist := filepath.Join(dir, "index.m3u8")
|
||||
if err := os.WriteFile(playlist, []byte("#EXTM3U\n#EXTINF:4,\nseg_00000.ts\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte(scope.UserID+"/"+scope.SessionID), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, subtitleStream: -1, startedAt: time.Now(), cancel: func() {}}
|
||||
paths[key] = playlist
|
||||
}
|
||||
return NewStreamService(cfg, zap.NewNop(), repository.New(db), transcoder), transcoder, paths
|
||||
}
|
||||
|
||||
func TestHLSPlaybackEnsureReusesOnlyMatchingOwnerAndSession(t *testing.T) {
|
||||
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
|
||||
_, transcoder, paths := cachedScopedHLS(t, a, b)
|
||||
for _, scope := range []HLSPlaybackScope{a, b} {
|
||||
playlist, err := transcoder.EnsureJobFromSubtitleQuality(t.Context(), "movie", 0, 0, -1, "", scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if playlist != paths[scope.jobKey("movie")] {
|
||||
t.Fatalf("scope %#v reused another session's playlist %q", scope, playlist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackStopRemovesOnlyItsSessionOutput(t *testing.T) {
|
||||
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
|
||||
_, transcoder, paths := cachedScopedHLS(t, a, b)
|
||||
transcoder.StopPlayback("movie", a)
|
||||
if _, err := os.Stat(paths[a.jobKey("movie")]); !os.IsNotExist(err) {
|
||||
t.Fatalf("stopped session output must be cleaned up, err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths[b.jobKey("movie")]); err != nil {
|
||||
t.Fatalf("another player's output was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackIdleCleanupRemovesExpiredSessionOutput(t *testing.T) {
|
||||
a := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "viewer", SessionID: "tab-b"}
|
||||
_, transcoder, paths := cachedScopedHLS(t, a, b)
|
||||
job := transcoder.jobs[a.jobKey("movie")]
|
||||
job.lastAccess = time.Now().Add(-2 * time.Minute)
|
||||
if !transcoder.stopIdleJob(job, time.Minute) {
|
||||
t.Fatal("expired session was not stopped")
|
||||
}
|
||||
if _, err := os.Stat(paths[a.jobKey("movie")]); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired output was retained: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths[b.jobKey("movie")]); err != nil {
|
||||
t.Fatalf("active session output was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackPropagatesHeaderDeviceIdentityToSegments(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/index.m3u8?token=test", nil)
|
||||
req.Header.Set("X-Emby-Device-Id", "legacy-device")
|
||||
scope, err := HLSPlaybackScopeForRequest(req, "viewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, _, _ := cachedScopedHLS(t, scope)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSPlaylist(rec, req, "movie", scope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var segment string
|
||||
for _, line := range strings.Split(rec.Body.String(), "\n") {
|
||||
if strings.HasPrefix(line, "seg_") {
|
||||
segment = line
|
||||
break
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(segment)
|
||||
if err != nil || u.Query().Get("session_id") == "" {
|
||||
t.Fatalf("playlist must carry the header-only device scope to child URLs: %q err=%v", segment, err)
|
||||
}
|
||||
child := httptest.NewRequest(http.MethodGet, "/api/hls/movie/"+segment, nil)
|
||||
childScope, err := HLSPlaybackScopeForRequest(child, "viewer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if childScope.jobKey("movie") != scope.jobKey("movie") {
|
||||
t.Fatal("child request changed its owner's device scope")
|
||||
}
|
||||
childRec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSSegment(childRec, child, "movie", "seg_00000.ts", childScope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if childRec.Body.String() != "viewer/"+scope.SessionID {
|
||||
t.Fatalf("wrong scoped segment: %s", childRec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestHLSPlaybackScopesIsolateSegmentReadsAndStop(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
transcoder := NewTranscoderService(cfg, zap.NewNop(), nil, nil)
|
||||
svc := NewStreamService(cfg, zap.NewNop(), nil, transcoder)
|
||||
a := HLSPlaybackScope{UserID: "user-a", SessionID: "tab-a"}
|
||||
b := HLSPlaybackScope{UserID: "user-b", SessionID: "tab-a"}
|
||||
c := HLSPlaybackScope{UserID: "user-a", SessionID: "tab-c"}
|
||||
for _, scope := range []HLSPlaybackScope{a, b, c} {
|
||||
key := scope.jobKey("movie")
|
||||
dir := filepath.Join(cfg.Cache.CacheDir, key, "run-current")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte(scope.UserID+"/"+scope.SessionID), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, seekGen: 100, cancel: func() {}}
|
||||
}
|
||||
for _, scope := range []HLSPlaybackScope{a, b, c} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/seg_00000.ts?_seek=100", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSSegment(rec, req, "movie", "seg_00000.ts", scope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := rec.Body.String(), scope.UserID+"/"+scope.SessionID; got != want {
|
||||
t.Fatalf("segment=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
transcoder.StopPlayback("movie", a)
|
||||
if transcoder.jobs[a.jobKey("movie")] != nil {
|
||||
t.Fatal("stopped scope is still active")
|
||||
}
|
||||
if transcoder.jobs[b.jobKey("movie")] == nil || transcoder.jobs[c.jobKey("movie")] == nil {
|
||||
t.Fatal("stopping one player affected another user/tab")
|
||||
}
|
||||
// A delayed stop from a previous local-playback session must not affect a replacement.
|
||||
transcoder.StopPlayback("movie", a)
|
||||
if transcoder.jobs[c.jobKey("movie")] == nil {
|
||||
t.Fatal("old stop cancelled a new session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackRejectsStaleSegmentGeneration(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
transcoder := NewTranscoderService(cfg, zap.NewNop(), nil, nil)
|
||||
svc := NewStreamService(cfg, zap.NewNop(), nil, transcoder)
|
||||
scope := HLSPlaybackScope{UserID: "viewer", SessionID: "tab"}
|
||||
key := scope.jobKey("movie")
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "seg_00000.ts"), []byte("new-generation"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transcoder.jobs[key] = &hlsJob{mediaID: "movie", jobKey: key, outputDir: dir, seekGen: 200, cancel: func() {}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/hls/movie/seg_00000.ts?_seek=100", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := svc.ServeHLSSegment(rec, req, "movie", "seg_00000.ts", scope); err == nil {
|
||||
t.Fatal("stale segment request read a replacement generation")
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatal("stale request exposed replacement bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaybackScopeRequiresServerOwner(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/?session_id=tab&user_id=attacker-controlled", nil)
|
||||
if _, err := HLSPlaybackScopeForRequest(req, ""); err == nil {
|
||||
t.Fatal("missing authenticated owner was accepted")
|
||||
}
|
||||
a, err := HLSPlaybackScopeForRequest(req, "real-owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.UserID != "real-owner" {
|
||||
t.Fatal("client parameter supplied the owner")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/?PlaySessionId=emby-session", nil)
|
||||
b, err := HLSPlaybackScopeForRequest(req, "real-owner")
|
||||
if err != nil || b.SessionID == "" {
|
||||
t.Fatalf("Emby session must stay scoped: %#v %v", b, err)
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,12 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -51,6 +49,10 @@ type ImageProxy struct {
|
||||
resizeSemMu sync.Mutex
|
||||
resizeSem chan struct{}
|
||||
|
||||
// resizeQueueWait 是一次请求最多为一格解码槽排队的时长。为 0 时用
|
||||
// defaultResizeQueueWait;测试会把它调小以免真的等待两秒。
|
||||
resizeQueueWait time.Duration
|
||||
|
||||
// libraryRootsFn returns the configured media library roots so that
|
||||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||||
@@ -88,6 +90,12 @@ const (
|
||||
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
|
||||
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
|
||||
imageRenditionCacheSubdir = "renditions"
|
||||
|
||||
// defaultResizeQueueWait 是请求为一格解码槽排队的最长时间。一张 3MP
|
||||
// 海报的解码+缩放约 0.15s,2 核机器两格槽位足以在几秒内消化一整屏
|
||||
// 海报;等待远好于「槽位忙就回退多兆字节原图」(那样每张图在外部网络
|
||||
// 下要十几秒)。超时后仍回退原图,保证请求不会被无限期挂住。
|
||||
defaultResizeQueueWait = 3 * time.Second
|
||||
)
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
@@ -103,53 +111,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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -138,8 +139,8 @@ func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
|
||||
|
||||
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
|
||||
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
|
||||
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示源图
|
||||
// 已经是可直接下发的紧凑字节,调用方应直接输出原始字节(不重复编码)。
|
||||
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
|
||||
if len(data) == 0 || !o.active() {
|
||||
return data, detectContentType(data), true, nil
|
||||
@@ -151,12 +152,21 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, "", false, errors.New("invalid image dimensions")
|
||||
}
|
||||
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
|
||||
// 先用 DecodeConfig 判断是否需要缩放。尺寸已在目标框内时仍要重编码:
|
||||
// 刮削来源的海报常是 1000×1500、1MB 以上的 PNG,直接原样下发会让一处
|
||||
// 海报墙就从「几十 KB 缩略图」变成「几十 MB 原图」——外部网络下每张
|
||||
// 都要十几秒。只有本来就是紧凑 Web 格式(JPEG/WebP)且体积很小的源图
|
||||
// 才值得零成本直出。
|
||||
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
|
||||
if dstW == cfg.Width && dstH == cfg.Height {
|
||||
unchangedSize := dstW == cfg.Width && dstH == cfg.Height
|
||||
if unchangedSize && isCompactWebFormat(format) && len(data) <= compactImageSkipBytes {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
|
||||
if unchangedSize {
|
||||
// 尺寸已满足但像素量超出解码预算:宁可直出原图,也不冒 OOM 风险。
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
return nil, "", false, errImageResizeTooLarge
|
||||
}
|
||||
|
||||
@@ -165,7 +175,12 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
|
||||
return nil, "", false, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
resizeScaled(dst, src, cfg.Width, cfg.Height)
|
||||
if unchangedSize {
|
||||
// 不缩放时省掉一次全图重采样,只做格式转换。
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
} else {
|
||||
resizeScaled(dst, src, cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
|
||||
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
|
||||
@@ -231,9 +246,12 @@ func isOpaqueImage(img *image.RGBA) bool {
|
||||
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
|
||||
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
|
||||
// 从已缓存的大图缩小,而不是重新解码原图)。
|
||||
//
|
||||
// 前缀里的 v3 是缓存格式版本:v2 时期「尺寸已在目标框内」的 PNG 会原样直出、
|
||||
// 不写缓存,而现在同样条件会重编码成 JPEG,旧缓存与新结果语义不同,必须换键。
|
||||
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
|
||||
_, _ = fmt.Fprintf(h, "v3|%s", sourceID)
|
||||
if stat != nil {
|
||||
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
@@ -356,13 +374,53 @@ func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
|
||||
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
|
||||
func (p *ImageProxy) resizeQueueTimeout() time.Duration {
|
||||
if p != nil && p.resizeQueueWait > 0 {
|
||||
return p.resizeQueueWait
|
||||
}
|
||||
return defaultResizeQueueWait
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对紧凑小图只尝试一次槽位,其余情况最多排队
|
||||
// resizeQueueTimeout。
|
||||
//
|
||||
// 从前这里对 1.5MB 以下的图一律「槽位忙就跳过缩放」,结果是并发加载海报墙
|
||||
// 时几乎每张图都回退成 1MB 上下的 PNG 原图:一页要传几十 MB,外部网络下单张
|
||||
// 图就要二十多秒。缩略图本身只有几十 KB,排队几百毫秒明显更划算;只有真正
|
||||
// 大到会拖慢页面的原图才值得在超时后直出。
|
||||
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
|
||||
if size > 0 && size <= resizeQueueBypassBytes {
|
||||
if size > 0 && size <= compactImageSkipBytes {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
return p.acquireResizeSlot(ctx)
|
||||
return p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
|
||||
}
|
||||
|
||||
// acquireResizeSlotWithin 在 timeout 内等待一格解码槽;超时或请求取消返回
|
||||
// false,调用方回退原图。
|
||||
func (p *ImageProxy) acquireResizeSlotWithin(ctx context.Context, timeout time.Duration) (func(), bool) {
|
||||
if p == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
p.resizeSemMu.Lock()
|
||||
if p.resizeSem == nil {
|
||||
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
}
|
||||
sem := p.resizeSem
|
||||
p.resizeSemMu.Unlock()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return func() { <-sem }, true
|
||||
case <-timer.C:
|
||||
return nil, false
|
||||
case <-ctx.Done():
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
@@ -472,10 +530,6 @@ func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o im
|
||||
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
|
||||
const compactImageSkipBytes = 200 * 1024
|
||||
|
||||
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
|
||||
// 以免把未压缩的剧照直接发给客户端。
|
||||
const resizeQueueBypassBytes = 1536 * 1024
|
||||
|
||||
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||
@@ -504,6 +558,17 @@ func isCompactWebImage(header []byte) bool {
|
||||
string(header[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
// isCompactWebFormat 判断 image.DecodeConfig 报告的格式是否已经适合直接下发
|
||||
// 给浏览器。PNG/BMP/GIF 即使尺寸很小也可能有几百 KB,需要重编码成 JPEG。
|
||||
func isCompactWebFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "jpeg", "jpg", "webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
|
||||
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
|
||||
|
||||
@@ -123,17 +123,58 @@ func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
|
||||
// 尺寸已经在目标框内的 PNG 也必须转成 JPEG。刮削来的海报常是 1000×1500、
|
||||
// 1MB 级的 PNG;此前这种情况原样直出,一处海报墙就要下载几十 MB,外部网络下
|
||||
// 每张图要十几秒(线上实测单张 1.3MB / 20 多秒)。
|
||||
func TestResizeImageDataReencodesPNGWithinBounds(t *testing.T) {
|
||||
data := encodeTestPNG(t, 200, 300, 255)
|
||||
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600, Quality: 80})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("PNG within the target box must still be re-encoded as a compact web format")
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode re-encoded image: %v", err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %q, want jpeg", format)
|
||||
}
|
||||
if cfg.Width != 200 || cfg.Height != 300 {
|
||||
t.Fatalf("re-encoded to %dx%d, want the original 200x300 (no upscaling)", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// 已经是紧凑 Web 格式且体积很小的源图仍然零成本直出。
|
||||
func TestResizeImageDataLeavesSmallCompactSourceUntouched(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 200, 300))
|
||||
for y := 0; y < 300; y++ {
|
||||
for x := 0; x < 200; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode test jpeg: %v", err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
if len(data) > compactImageSkipBytes {
|
||||
t.Fatalf("test jpeg is %d bytes, expected a compact source", len(data))
|
||||
}
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if !unchanged {
|
||||
t.Fatal("expected an image already within bounds to be returned unchanged")
|
||||
t.Fatal("a compact JPEG already within bounds should be served unchanged")
|
||||
}
|
||||
if !bytes.Equal(out, data) {
|
||||
t.Fatal("expected the original bytes to be returned verbatim")
|
||||
if !bytes.Equal(out, data) || ctype != "image/jpeg" {
|
||||
t.Fatalf("expected the original jpeg bytes back, got %d bytes of %q", len(out), ctype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +343,9 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
// 排队上限:槽位一直不空时,请求最多等 resizeQueueWait,然后回退原图,
|
||||
// 不会被无限期挂住。这条约束保护的是请求本身,而不是「不等就直出原图」。
|
||||
func TestServeResizedFromFileFallsBackWhenResizeQueueStaysBusy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "backdrop.jpg"
|
||||
data := make([]byte, compactImageSkipBytes+1024)
|
||||
@@ -310,7 +353,7 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
if err := os.WriteFile(src, data, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache", resizeQueueWait: 50 * time.Millisecond}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
@@ -327,10 +370,77 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
|
||||
if time.Since(start) > 200*time.Millisecond {
|
||||
t.Fatal("resize slot was busy but the request still waited in the queue")
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("request waited %v with every slot held; the queue wait must be bounded", elapsed)
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
|
||||
t.Fatal("expected a permanently busy queue to fall back so the caller can serve the original")
|
||||
}
|
||||
}
|
||||
|
||||
// 槽位只是短暂忙碌时必须排队等待并产出缩略图,而不是直出多兆字节原图。
|
||||
// 这是线上「海报墙每张图 20 多秒」的根因:并发加载时槽位全忙,旧逻辑把
|
||||
// 1MB 级 PNG 原样下发。
|
||||
// encodeNoisyPNG 生成难以压缩的 PNG:规则色块会被压到 1KB 级别,测不出
|
||||
// 「大体积 PNG 被原样下发」这条回归。
|
||||
func encodeNoisyPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
seed := uint32(0x9e3779b9)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
seed = seed*1664525 + 1013904223
|
||||
img.Set(x, y, color.RGBA{R: uint8(seed >> 24), G: uint8(seed >> 16), B: uint8(seed >> 8), A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode noisy png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileWaitsForSlotInsteadOfServingOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "poster.png"
|
||||
original := encodeNoisyPNG(t, 378, 540)
|
||||
if len(original) <= compactImageSkipBytes {
|
||||
t.Fatalf("test poster is %d bytes, want a source above the compact threshold", len(original))
|
||||
}
|
||||
if err := os.WriteFile(src, original, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("expected a resize slot")
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
releases[0]()
|
||||
}()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
|
||||
t.Fatal("expected the request to wait for the slot and serve a thumbnail")
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if bytes.Equal(body, original) {
|
||||
t.Fatal("served the multi-hundred-KB PNG original instead of a re-encoded thumbnail")
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
// 同一请求第二次必须直接命中缩放缓存。
|
||||
rec2 := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec2, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
|
||||
t.Fatal("expected the cached thumbnail to be reused")
|
||||
}
|
||||
if !bytes.Equal(rec2.Body.Bytes(), body) {
|
||||
t.Fatal("expected the cached thumbnail bytes to be identical")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user