chore: sync lockfile/workspace after W1; add auth reference and plan updates
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
cc8b3a7df4
commit
9853d11a19
4 files changed
+1375
-8
No files matched your search
@@ -0,0 +1,250 @@
|
||||
# 管理员登录门禁 与 单容器打包 —— uhd_doc 调研
|
||||
|
||||
来源:`/opt/uhd_doc`(Next.js 15 + Bun + SQLite/Drizzle + Docker,单容器自托管文档 CMS)。只读调研,目的是给 `/opt/uhd_media_tool`(Node 24 + Hono + Kysely/pg 后端,React Router 7 SPA 前端)抄作业。全部路径均为 `/opt/uhd_doc/` 下相对路径。
|
||||
|
||||
## 1. 登录 / 会话
|
||||
|
||||
### 1.1 密码哈希:Argon2id(`src/lib/password.ts`)
|
||||
|
||||
用 `Bun.password`(内置,无原生依赖)生成/校验标准 PHC 字符串,OWASP 参数:
|
||||
|
||||
```ts
|
||||
const ARGON_OPTS = { algorithm: "argon2id", memoryCost: 19_456, timeCost: 2 };
|
||||
export function hashPassword(password: string) { return bunPassword.hash(password, ARGON_OPTS); }
|
||||
export async function verifyPassword(password: string, digest: string) {
|
||||
try { return await bunPassword.verify(password, digest); } catch { return false; }
|
||||
}
|
||||
```
|
||||
|
||||
uhd_media_tool 是纯 Node 24,没有 `Bun.password`。**建议照搬参数、不照搬实现**:改用 `node:crypto` 无内置 argon2,需引入 `@node-rs/argon2`(原生插件,Node 24 有预编译二进制)或退回 `bcrypt`/`node:crypto scrypt`。曾经该项目就是用 `@node-rs/argon2` 后来才切到 Bun 内置版(见 password.ts 注释),说明 `@node-rs/argon2` 产出的哈希格式与 Bun 版兼容,是稳妥选择。
|
||||
|
||||
### 1.2 首次初始化管理员:一次性 Setup Token,不是 env 密码
|
||||
|
||||
- `ADMIN_USERNAME` 是 env 配置(稳定),但**密码从不经 env**,只能通过浏览器首次访问 `/admin/login` 时的「安装向导」设置,写入数据库。
|
||||
- 首启防抢注:`src/lib/env.ts` 的 `resolveInitToken()` 在没显式配置 `ADMIN_INIT_TOKEN` 时自动生成 16 字节随机 token,持久化到 `data/config/runtime-secrets.json`(权限 `0600`),**从不打印到应用日志**。
|
||||
- 运维通过容器命令行取 token:`docker exec -it uhd-docs bun run admin:show-init-token`(`src/scripts/admin-show-init-token.ts`,已初始化则拒绝显示)。
|
||||
- 前端 setup 表单(用户名+密码+确认密码+token)提交到 `POST /api/admin-auth/setup`(`src/app/api/admin-auth/setup/route.ts`):校验 `ADMIN_USERNAME` 匹配、token 常量时间比较(`timingSafeEqual`)、`initializeAdminPassword()` 用**条件 UPSERT**(`WHERE admin_credential.password_hash IS NULL`)保证并发首启只有一个请求能设成功;成功后 `rotateAdminInitToken()` 让旧 token 失效。
|
||||
- 忘记密码的恢复路径是容器内命令行脚本 `src/scripts/admin-set-password.ts`(交互式或参数传入新密码,同时禁用 TOTP 并吊销其它会话),不走 Web UI——这是刻意设计,恢复权限只给到有容器 shell 的人。
|
||||
|
||||
**建议照搬**:一次性 setup token + 密码只落库不落 env + CLI 兜底重置,这套模型完全适用于 Hono 后端(用 `pnpm --filter @umt/server exec tsx scripts/admin-set-password.ts` 之类等价脚本)。
|
||||
|
||||
### 1.3 会话实现:服务端 session 表 + HttpOnly Cookie(不是 JWT)
|
||||
|
||||
`src/server/admin-auth/session.ts` + `src/db/schema.ts` 的 `admin_sessions` 表:
|
||||
|
||||
```ts
|
||||
export const adminSessions = sqliteTable("admin_sessions", {
|
||||
id: id(),
|
||||
sessionIdHash: text("session_id_hash").notNull(), // 只存哈希,原始 id 只在 cookie 里
|
||||
adminUsername: text("admin_username").notNull(),
|
||||
createdAt: createdAt(),
|
||||
lastSeenAt: integer(...).notNull(),
|
||||
expiresAt: integer(...).notNull(),
|
||||
revokedAt: integer(...), // 软删除吊销
|
||||
}, (t) => [uniqueIndex("admin_sessions_hash_uq").on(t.sessionIdHash)]);
|
||||
```
|
||||
|
||||
- `createAdminSession()`:生成 32 字节随机 id,库里只存 `sha256Hex(sessionId)`(泄库拿不到可用 cookie 值,类似 bcrypt 但用 sha256 因为 id 本身已是高熵随机数,不需要慢哈希),cookie 明文是原始 id。
|
||||
- Cookie 属性:`httpOnly: true, secure: await isHttpsRequest(), sameSite: "lax", path: "/", expires: <会话过期时间>`。Cookie 名 `admin_session`(`src/lib/cookies.ts` 的 `COOKIES.adminSession`)。
|
||||
- `secure` 判定不简单看 `req.protocol`:`isHttpsRequest()` 优先信任 **`APP_URL` 是否 `https://` 开头**(运维必须配对),兜底看 `X-Forwarded-Proto`/`CF-Visitor`/`X-Forwarded-Ssl`,这是因为反代链路可能把协议头丢掉或改写,`APP_URL` 是唯一权威来源。
|
||||
- 过期与续期:固定 TTL(`ADMIN_SESSION_TTL_HOURS`,默认 12 小时),**没有滑动续期**,只在 `getAdminSession()` 校验时机会性节流更新 `lastSeenAt`(超过 60 秒才写库,减少写压力)。没有"记住我"选项。
|
||||
- 登出/吊销:`destroyAdminSession()` 把该行 `revokedAt` 置为当前时间(数据库软删除)+ 清 cookie(session、csrf 两个)。`revokeOtherSessions(username, keepRawId?)` 用于改密后强制下线其它所有会话(留当前会话)。
|
||||
- 校验逻辑:`getAdminSession()` 查询条件是 `sessionIdHash 匹配 AND revokedAt IS NULL AND expiresAt > now`,三者缺一都视为未登录,不抛异常。
|
||||
|
||||
**建议照搬**:服务端 session 表(而非无状态 JWT)对这类"单管理员、低 QPS"场景明显更合适——吊销、剔线、审计都是一条 SQL,JWT 做不到即时吊销。Kysely/pg 版对应建一张 `admin_session` 表,字段照抄即可(pg 用 `timestamptz`)。
|
||||
|
||||
### 1.4 鉴权中间件:Next Route Handler wrapper,对应 Hono 的中间件写法
|
||||
|
||||
`src/server/api/wrappers.ts` 里 `withAdminApi()` 是核心:
|
||||
|
||||
```ts
|
||||
export function withAdminApi<P>(handler) {
|
||||
return async (req, ctx) => {
|
||||
const session = await getAdminSession();
|
||||
if (!session) throw new ApiError(ErrorCode.ADMIN_REQUIRED, "需要管理员登录");
|
||||
if (WRITE_METHODS.has(req.method) && !(await verifyCsrf(req))) {
|
||||
throw new ApiError(ErrorCode.CSRF_INVALID, "CSRF 校验失败,请刷新后重试");
|
||||
}
|
||||
return handler(req, ctx, session);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
每个 admin API route 用 `export const POST = withAdminApi(async (req, ctx, session) => {...})` 包一层,而不是全局 middleware——Next App Router 没有"路由组中间件"的干净写法,只能靠这种高阶函数在每个 handler 外面套。
|
||||
|
||||
**uhd_media_tool 的对应做法(Hono)**:这一层在 Hono 里就是标准 middleware,比 Next 的 wrapper 模式更直接。参照现有 `apps/server/src/http/auth.ts` 的 `apiKeyAuth()` / `requireScope()` 写法,新增一个 `adminSessionAuth(): MiddlewareHandler` 挂在 `app.use("/api/v1/admin/*", adminSessionAuth)`,内部读 `admin_session` cookie → 查表 → 校验过期/吊销 → `c.set("adminSession", ...)`;写方法上再叠一层 `requireCsrf` middleware。不需要 Next 那种"每个 route 手动包一层"的 workaround,直接用 Hono 路由分组即可,这是 Hono 优于 Next Route Handler 的地方,**不需要照搬 wrapper 形式,只照搬校验逻辑**。
|
||||
|
||||
### 1.5 网络边界快速拦截:Next middleware → Hono 等价物
|
||||
|
||||
`src/proxy.ts`(这个项目把 Next 的 `middleware.ts` 改名/复用为 `proxy.ts`,`config.matcher` 命中 `/admin/:path*` 等路径):只做**轻量**判断——cookie 是否存在(不查库,不校验有效性),命中 `/admin/*` 且无 cookie → 302 到 `/admin/login?next=...`;已登录还访问登录页 → 302 回 `/admin`。真正的会话有效性校验留给上面第 1.4 节的 API wrapper 和 admin 布局组件。
|
||||
|
||||
值得抄的两个细节:
|
||||
- **相对路径重定向**而不是 `NextResponse.redirect(绝对 URL)`:反代场景下 Next 内部 origin(如 `http://localhost:3001`)和外部真实 host 不一致,直接构造绝对 URL 会把浏览器导向不可达地址。它对 `/admin` 命中场景用 `originRedirect()` 从 `X-Forwarded-Host`/`Host` 头重建 URL,而不是相对路径——但注释解释了这里选相对还是绝对是有权衡的(浏览器对相对 Location 会按当前请求地址解析,更安全)。
|
||||
- `next` 参数做了严格校验(`/^\/(?!\/)/` 且不含 `\\`),防 Open Redirect。
|
||||
|
||||
**对应做法**:React Router 7 SPA 是纯前端路由,没有 Next middleware 这种服务端网络层拦截点。等价方案是在 SPA 的根 loader / 一个 `RequireAdmin` 路由包裹组件里,挂载时先带 cookie 发一个轻量 `GET /api/v1/admin/session`(或者复用 whoami)判断是否登录,未登录 `navigate("/admin/login?next=...")`。这一层拦截**不能省**,否则未登录用户会先看到页面骨架再跳转,体验和安全性都打折——但载体从"边缘 middleware"变成"SPA 路由 loader + 后端一个薄 session 探测接口"。
|
||||
|
||||
### 1.6 CSRF:HMAC 派生的 Double-Submit Cookie
|
||||
|
||||
`src/server/admin-auth/csrf.ts`:
|
||||
|
||||
```ts
|
||||
export async function setCsrfCookie(sessionId: string) {
|
||||
store.set(COOKIES.csrf, deriveCsrfToken(sessionId), {
|
||||
httpOnly: false, secure: await isHttpsRequest(), sameSite: "lax", path: "/",
|
||||
});
|
||||
}
|
||||
export async function verifyCsrf(req: Request) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) return false;
|
||||
const provided = req.headers.get("x-csrf-token");
|
||||
return provided ? safeEqual(provided, deriveCsrfToken(session.sessionId)) : false;
|
||||
}
|
||||
```
|
||||
|
||||
`deriveCsrfToken(sessionId) = HMAC-SHA256(SESSION_SECRET, "csrf:" + sessionId)`(`src/lib/crypto.ts`)。CSRF cookie **非 HttpOnly**(前端 JS 要读出来塞进请求头),伪造需要拿到 HttpOnly 的 session id,而 XSS/CSRF 单独都拿不到两者组合。只在写方法(POST/PUT/PATCH/DELETE)校验,登录成功时和 session 一起签发。
|
||||
|
||||
**建议照搬**:这套 HMAC 派生 double-submit 比"随机 token 存表"更轻(不用额外建表/查库),`SESSION_SECRET` 换成 pg 项目自己的签名密钥即可,逻辑原样搬。
|
||||
|
||||
### 1.7 登录限速 / 失败锁定
|
||||
|
||||
`src/server/admin-auth/login.ts`,双重限速:
|
||||
- **按 IP**:5 分钟窗口内失败 ≥5 次 → 锁(`MAX_PER_IP = 5`)。
|
||||
- **按账号**(IP 无关兜底):同窗口失败 ≥15 次 → 锁(`MAX_PER_ACCOUNT = 15`),防止攻击者靠伪造/轮换 `X-Real-IP` 绕过 IP 限速。
|
||||
- 查询用 `adminLoginAttempts` 表按 `success=false AND createdAt >= now-5min` 计数,不是内存令牌桶——多进程/重启都一致。
|
||||
- 已经被限速的请求**不再写审计**,防止扫描器把 `audit_logs` 撑爆(注释原话强调这是刻意的磁盘防护)。
|
||||
- 密码正确但 TOTP 未提供,**不计入失败次数**(不烧限速预算),因为这只是流程的第二步。
|
||||
|
||||
此外还有一层**人机验证**(见 §1.8)在触发密码校验前拦截。
|
||||
|
||||
**建议照搬**:IP + 账号 双维度限速、失败计数落表而非内存,这是防"分布式撞库/凭据填充"最实用的一层,pg 版直接建等价 `admin_login_attempt` 表。
|
||||
|
||||
### 1.8 人机验证(PoW,非传统验证码)
|
||||
|
||||
登录/初始化/公开密码解锁/反馈投票等敏感端点都要求先过 **Cap**(自托管工作量证明验证码,`capjs-core`,不依赖第三方 CAPTCHA 服务)。前端组件 `src/components/security/proof-of-work.tsx`(`<ProofOfWork endpoint="/api/cap/admin-login/" onTokenChange={setCapToken} />`),拿到 `capToken` 前登录按钮和输入框全部 `disabled`。后端 `src/server/cap/service.ts`:挑战/校验用 `HMAC(SESSION_SECRET, "uhd-docs/capjs-core/v1")` 派生的域分离密钥,redeem token 一次性消费(SQLite 表 `cap_redeem_tokens`,`DELETE ... WHERE token_key=? AND scope=? AND expires_at>?` 原子销毁)。登录路由里**先烧 capToken 再做密码哈希校验**(无论成败都烧掉,一次证明只授权一次尝试)。
|
||||
|
||||
这套东西相对重(引入 `capjs-core` 依赖 + WASM 求解器 + 两张表),**属于"锦上添花"而非"照抄底线"**:如果 uhd_media_tool 的管理端只是内网/团队自用工具而非公网暴露的公共产品,IP+账号限速(§1.7)通常已经够。若确实要抄,思路是"敏感端点前置一次一次性 redeem token",实现上可以先跳过、后续再加。
|
||||
|
||||
### 1.9 TOTP(有)/ Passkey(有,可选跳过)
|
||||
|
||||
TOTP:`src/lib/totp.ts` 纯 `node:crypto` 实现 RFC 6238(SHA1/6 位/30s),**无第三方 OTP 依赖**,含防重放(`totpLastCounter` 记录已消费的最高计数器,`verifyTotpCounter` 拒绝 ≤ 该值的候选),代码 Node 通用可**逐字复用**。Passkey(WebAuthn):`src/server/admin-auth/passkey.ts` + 前端 `@simplewebauthn/browser`,作为密码的替代首因子(不是二次因子),复杂度不低(注册/认证两个 ceremony + `admin_passkeys` 表存 COSE 公钥),**建议后置**,先做密码+session+TOTP。
|
||||
|
||||
### 1.10 密码修改流程
|
||||
|
||||
`src/app/api/admin-auth/password/route.ts`(`withAdminApi` 包裹):校验当前密码 → 写新 `passwordHash` → **吊销除当前会话外的所有其它会话**(`revokeOtherSessions(username, session.sessionId)`)→ 记审计 `admin.password_change`。修改密码不强制改 TOTP/passkey。**建议照搬**整个流程,尤其"改密后踢掉其它设备会话"这一步很容易被忽略。
|
||||
|
||||
### 1.11 审计
|
||||
|
||||
`src/server/audit/log.ts`:一张 `audit_logs` 表,固定的 `AuditAction` 联合类型(枚举式,不是自由字符串),记录 `action/actor/targetType/targetId/detailJson/ipHash`。写入包一层 `try/catch`——**审计失败绝不能影响主流程**。管理相关动作:`admin.login_success / admin.setup / admin.password_change / admin.totp_enable / admin.totp_disable / admin.passkey_register / admin.passkey_remove / admin.passkey_login`。注意:**限速拦截掉的失败尝试不写审计**(见 §1.7),只有真正进入业务逻辑的动作才记。
|
||||
|
||||
**建议照搬**:枚举式 action 类型 + try/catch 兜底,这是最低成本、最高性价比的一项,pg 版直接建 `audit_log` 表照抄字段。
|
||||
|
||||
## 2. 登录页 UI
|
||||
|
||||
- 文件:`src/app/admin/login/page.tsx`(服务端组件,读初始状态:是否已初始化、品牌、passkey 是否可用、`next` 回跳路径)+ `src/app/admin/login/login-client.tsx`(453 行,客户端交互全在这)。
|
||||
- **布局**:桌面端左右两栏(`lg:grid-cols-[minmax(0,1fr)_23.5rem]`)——左侧大幅品牌/标语展示区(纯装饰,`hidden lg:block`),右侧固定宽度(23.5rem)登录卡片;移动端退化为单栏卡片,顶部品牌横排。背景是一张全屏蓝图风格插画(`blueprint-light.webp` / `blueprint-dark.webp`,按 `dark:` 切换)。顶部有一条 `sticky` 品牌 header(logo + 站点名 + 语言切换 + 主题切换)。
|
||||
- **交互状态机**:同一个表单组件承担三种模式——**首启向导**(`setupMode`:多一个"确认密码" + "初始化口令"输入框)、**普通登录**(用户名+密码)、**二次验证**(`needsTotp`:提交密码后如果账号开了 TOTP,后端返回 `TOTP_REQUIRED`,前端原地插入 6 位数字输入框,用户名/密码字段变 `readOnly` 但保留值,不用重新输入)。三态由后端错误码驱动切换,不是前端猜的。
|
||||
- **Passkey 按钮**:仅当 `initialPasskeyAvailable`(HTTPS/localhost 安全上下文 + 已注册过 passkey)且非 setup/非 TOTP 阶段显示,放在密码表单**上方**,用一条 "或" 分隔线隔开,视觉上是主按钮(`variant={showPasskey ? "outline" : "default"}`——有 passkey 时密码登录降级为 outline 按钮,把主按钮位置让给指纹登录)。
|
||||
- **错误提示**:固定高度占位容器 `<div className="min-h-5" aria-live="polite">`(避免报错时页面跳动),图标 + 文案,`role="alert"`;根据后端错误码分流文案(`RATE_LIMITED` / `TOTP_INVALID` / 其它),而不是直接透传后端 message。
|
||||
- **加载态**:提交按钮里 `LoaderCircle` 转圈图标替换文字前缀,同时所有输入框 `disabled={!capToken || loading}`——**没有 PoW token 之前整个表单都是禁用的**,这个"先过人机验证才能操作表单"的顺序是刻意设计。
|
||||
- **没有"记住我"**:固定会话时长(12 小时),无相关 UI 选项。
|
||||
- **密码显示切换**:眼睛图标 `Eye`/`EyeOff` 切换 `type="password"|"text"`,`aria-pressed` 标注状态。
|
||||
- **视觉风格要点**(供前端复用同类布局参考):大圆角(`rounded-lg`)、高输入框(`h-14`,触屏友好)、图标前缀嵌在输入框内(`absolute left-3.5`)、品牌色仅用于强调线和主按钮,大量留白,弱化的 `text-muted-foreground` 二级文案,整体是"极简 SaaS 后台登录页"风格而非"企业系统"风格。
|
||||
|
||||
**建议照搬**:错误码驱动的三态表单(setup/login/TOTP 复用同一组件)、"PoW/前置校验完成前禁用整个表单"的交互顺序、固定高度错误占位防跳动。React Router 7 SPA 落地时可以做成一个 `<AdminLoginPage>` + 内部 state machine,不需要 Next 的服务端组件划分,直接 CSR 请求一个 `GET /api/v1/admin/auth/state` 拿初始状态即可。
|
||||
|
||||
## 3. Docker:单容器打包
|
||||
|
||||
### 3.1 Dockerfile 多阶段结构(`Dockerfile`)
|
||||
|
||||
五个 stage:`bunbin`(只为拿 pin 版 Bun 二进制)→ `base`(Debian trixie-slim + ca-certificates + Bun)→ `deps`(`bun install --frozen-lockfile`)→ `caddyfetch`(下载 pin 版 Caddy 静态二进制,校验 `caddy version`)→ `builder`(`bun run build`,清 `.next/cache`,预编译 `next.config.ts → next.config.js` 让运行时免装编译器)→ `runner`(最终镜像:非 root 用户 `app`(uid 10001),`libgomp1/ca-certificates/fontconfig/gosu/libcap2-bin`,`COPY --chown=app:app` 精确控制目录属主,`setcap cap_net_bind_service=+ep` 让非 root 用户能绑 80/443,`HEALTHCHECK` 打自身 `/api/health`,`VOLUME ["/app/data"]`,`ENTRYPOINT ["docker-entrypoint.sh"]` + `CMD ["bun","--bun","server.ts"]`)。
|
||||
|
||||
关键取舍点(可复用的思路,不是具体命令):
|
||||
- **基础镜像按"最挑剔的依赖"选**(这里是 libsimple 需要 glibc ≥2.38 → trixie,而不是官方 Bun 镜像的 bookworm),迁移到 Node 项目应类比:先看有没有原生依赖(如 argon2、sharp)对 glibc/musl 有版本要求,再定基础镜像。
|
||||
- **构建产物按运行时最小闭包裁剪**:Next standalone 输出 + 手动补回被裁掉但运行时脚本仍需要的包(`next`/`drizzle-orm`/`zod`/`pinyin-pro`)。Node/Hono 项目对应:如果用 `esbuild`/`tsup` 打包成单文件,这一步天然被规避;如果是裸 `tsc` + `node_modules`,要么整包拷贝要么用 `--production` + 显式补运行时脚本依赖。
|
||||
- **非 root + 目录属主用 `COPY --chown` 而非事后 `chown -R`**(省一层镜像体积)。
|
||||
- **内嵌反代二进制(Caddy)做压缩/静态资源缓存**,让镜像开箱即用不必外置 nginx——这是"单容器打包"的核心手法,值得照搬。
|
||||
- **HEALTHCHECK 打应用自己的回环端口**而不是反代端口,因为反代端口会随 `SITE_ADDRESS` 配置切换(3000 或 80/443),回环端口永远稳定。
|
||||
|
||||
### 3.2 docker-entrypoint.sh 做了什么
|
||||
|
||||
```bash
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
mkdir -p /app/data/config /app/data/uploads /app/data/backups
|
||||
chown -R app:app /app/data
|
||||
exec gosu app "$0" "$@" # 降权到 app 用户后重跑自己
|
||||
fi
|
||||
bun src/scripts/migrate.ts # 迁移
|
||||
bun src/scripts/preflight.ts # 启动自检(密钥、扩展加载、非 root 等,不过就退出)
|
||||
bun src/scripts/seed.ts # 空库播种(no-op if docs exist)
|
||||
"$@" & # 前台进程1:Next.js
|
||||
APP_PID=$!
|
||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & # 前台进程2:Caddy
|
||||
CADDY_PID=$!
|
||||
trap shutdown TERM INT
|
||||
wait -n "$APP_PID" "$CADDY_PID" # 任一进程退出,整体退出
|
||||
```
|
||||
|
||||
**核心手法**:容器以 root 启动只为修数据目录权限,然后 `gosu` 降权重跑自身;`migrate → preflight → seed → start` 四段式启动;**双前台进程用 `wait -n` 互相看护**,任一挂了就 kill 另一个再退出,把"半死容器"变成"整体退出",配合 compose 的 `restart: unless-stopped` 做自愈。
|
||||
|
||||
**建议照搬**:四段式启动顺序(迁移→自检→(可选)播种→启动)、root 降权模式、多进程互相看护退出码,这些和技术栈无关,Node/Hono 项目原样适用(把 `bun` 换成 `node`/`tsx`)。
|
||||
|
||||
### 3.3 docker-compose.yml 服务划分
|
||||
|
||||
单服务(`uhd-docs`),没有拆 db/app 两个容器——因为用的是内嵌 SQLite,数据库就是 `/app/data` 里的一个文件,天然单容器。
|
||||
|
||||
**不适用**:uhd_media_tool 后端是 Kysely/**pg**,数据库是独立进程,不能照搬"单容器"这个前提。对应做法应该是 `docker-compose.yml` 拆两个服务(`app` + `postgres`,当前仓库已有 `docker-compose.dev.yml` 大概率就是这个划分,开发环境用),生产部署时 Caddy/入口容器的思路(见下)仍可以照搬,只是数据库连接串指向另一个容器/托管实例而非本机文件。
|
||||
|
||||
日志限制(`logging.driver: json-file, max-size: 10m, max-file: 5`)这条**建议照搬**,防止日志把磁盘写满,和数据库形态无关。
|
||||
|
||||
### 3.4 Caddyfile 作用
|
||||
|
||||
作为容器内反代:压缩(zstd/gzip,排除 `/api/*` 避免小 JSON 响应上的 CPU 浪费)、静态资源长缓存(`/_next/static/*` 一年不可变缓存)、`trusted_proxies static private_ranges` + `trusted_proxies_strict`(只信任私网段来源的 `X-Forwarded-For`,且只取最后一跳,防止客户端自己伪造跳过限速)、请求体大小分级限制(大文件上传路径 600MB,其余 API 16MB)、`SITE_ADDRESS` 驱动的自动 HTTPS(Let's Encrypt,证书存 `/app/data/caddy` 随容器数据卷持久化)。
|
||||
|
||||
**建议照搬**:`trusted_proxies_strict` 这条对任何有登录限速的项目都是**必须**的——没有这条,客户端可以自己塞 `X-Forwarded-For` 头把所有请求伪装成不同 IP,IP 限速形同虚设。uhd_media_tool 无论是否内嵌 Caddy,只要部署在反代后面,这条規則都要在某一层(反代或应用自己解析 `X-Forwarded-For` 的逻辑)体现。
|
||||
|
||||
**部分不适用**:如果 uhd_media_tool 决定不内嵌反代(直接把 Hono 服务暴露给外部 nginx/Caddy),那么压缩/静态缓存这部分职责转移给外部反代,应用只需要正确读 `X-Real-IP`/信任的 `X-Forwarded-Proto`,和 uhd_doc 的 `src/lib/request.ts` 逻辑一致即可(见 §1.3、§1.7 提到的 `getClientIp`)。
|
||||
|
||||
### 3.5 健康检查
|
||||
|
||||
`GET /api/health`(`src/app/api/health/route.ts`):无鉴权,查 `SELECT 1` 证明数据库文件可打开可读,返回 `{ok, version, database}`,`no-store`。Dockerfile 里 `HEALTHCHECK` 直接用 Bun 内置 `fetch` 打这个端点,不额外装 `curl`。
|
||||
|
||||
**建议照搬**:一个无鉴权、极便宜(一次 `SELECT 1`)的健康检查端点 + 用运行时自带的 HTTP 客户端做探测(Node 项目可以用 `node --input-type=module -e "fetch(...)"`,不需要额外装 curl/wget)。
|
||||
|
||||
### 3.6 数据目录挂载与环境变量清单
|
||||
|
||||
单一数据卷 `/app/data`,子目录划分:`docs.db`(数据库文件)、`uploads/`、`backups/`、`config/`(含 `runtime-secrets.json`)、`caddy/`(证书状态)。`VOLUME ["/app/data"]` 声明 + compose 挂到宿主 `./data`。
|
||||
|
||||
`DEPLOY.md`/`.env.example` 归纳的环境变量类别(完整清单见这两个文件,不逐条照抄):
|
||||
- **必须配置类**:`APP_URL`(影响 cookie Secure 判定,生产必须是 `https://`)、`ADMIN_USERNAME`。
|
||||
- **首启一次性类**:`ADMIN_INIT_TOKEN`(可留空自动生成)。
|
||||
- **自动生成免配置类**:`SESSION_SECRET`/`IP_HASH_SECRET`(首启生成,持久化到 `runtime-secrets.json`,要求 ≥32 字符且不能是占位符样式如 `change_me`/`REPLACE_ME`)。
|
||||
- **TTL 类**:`PASSWORD_ACCESS_TTL_HOURS`、`ADMIN_SESSION_TTL_HOURS`。
|
||||
- **容器层旁路变量**(不进 Zod schema,只给 Caddy/entrypoint 用):`SITE_ADDRESS`、`MAX_UPLOAD_BODY`、`SSR_MAX_CONCURRENCY`。
|
||||
|
||||
**建议照搬的设计**:①密钥类("能自动生成就自动生成,但生成后要落盘且警告"),`src/lib/env.ts` 的 `resolveRuntimeSecret()` 这套"占位符检测 + 自动生成 + 持久化到 CONFIG_DIR" 模式值得原样搬到 pg 版的 `env.ts`;②敏感变量分层(应用 schema 内 vs 容器旁路变量)让 `.env.example` 保持可读。
|
||||
|
||||
## 4. 建议照搬 / 不适用(需改造)一览表
|
||||
|
||||
| 项目 | 建议照搬 | 不适用 / 需改造 |
|
||||
|---|---|---|
|
||||
| 密码哈希 | Argon2id 参数(`memoryCost≈19456, timeCost=2`) | Bun 内置 `Bun.password` → 换 `@node-rs/argon2`(Node 24 原生插件) |
|
||||
| 首启管理员 | 一次性 setup token(自动生成+持久化+首启后失效)+ 密码只落库不落 env + CLI 兜底重置 | 无需改造,逻辑与运行时无关 |
|
||||
| 会话 | 服务端 session 表(hash 存库、原文进 cookie)+ 软删除吊销 + 固定 TTL 不续期 | SQLite→pg:表结构原样搬,`integer(timestamp_ms)`→`timestamptz` |
|
||||
| 鉴权中间件位置 | 校验逻辑(查 session→查过期→查吊销) | Next 的"每个 route 手动包 `withAdminApi`" → Hono 用标准 `app.use(path, middleware)`,更简洁,不必照搬包裹形式 |
|
||||
| 未登录跳转 | 轻量 cookie-存在性判断做快速拦截,真正校验留给业务层 | Next `middleware.ts`(边缘层)在 SPA 里没有等价物 → 改为路由 loader + 一个薄 session 探测 API |
|
||||
| CSRF | HMAC 派生 double-submit cookie(`HMAC(secret, "csrf:"+sessionId)`) | 无需改造 |
|
||||
| 登录限速 | IP+账号双维度、失败计数落表、命中限速不写审计 | 无需改造 |
|
||||
| 人机验证(Cap PoW) | 若确实公网暴露且介意撞库自动化,可后期引入 | 复杂度高(WASM 求解器+两张表),团队内网工具**可先跳过** |
|
||||
| TOTP / Passkey | TOTP:`node:crypto` 纯手写 RFC6238,可整段复用;Passkey:替代首因子的模式 | TOTP 无需改造;Passkey 实现成本高,**建议后置**到密码+TOTP 稳定之后 |
|
||||
| 密码修改流程 | 校验旧密码→改密→吊销其它会话→审计 | 无需改造 |
|
||||
| 审计 | 枚举式 action 类型 + try/catch 兜底不影响主流程 | 无需改造 |
|
||||
| 登录页交互 | 错误码驱动三态表单、PoW/校验完成前禁用表单、固定高度错误占位 | Next 服务端组件读初始状态 → SPA 里改成挂载时 `GET` 一个 `/auth/state` 接口 |
|
||||
| Dockerfile | 多阶段构建、非root+`COPY --chown`、`HEALTHCHECK`打自身回环端口、内嵌反代做压缩/静态缓存 | 基础镜像选型逻辑不同(Node 版按自身原生依赖定,而非 libsimple/glibc 那套) |
|
||||
| entrypoint | 四段式启动(迁移→自检→播种→启动)、root降权、多进程 `wait -n` 互相看护 | 无需改造,把 `bun` 换 `node`/`tsx` |
|
||||
| docker-compose | 日志大小限制(`max-size/max-file`) | **单容器前提不成立**:pg 是独立进程,需拆 `app`+`postgres` 两个服务,不能照搬"一个卷搞定一切" |
|
||||
| Caddyfile | `trusted_proxies_strict`(只信任私网段反代的最后一跳)是安全必需项,压缩/静态缓存可选 | 若不内嵌反代,压缩/缓存职责转给外部 nginx/Caddy,应用只需正确读 `X-Real-IP` |
|
||||
| 健康检查 | 无鉴权、`SELECT 1`级别开销、用运行时自带 fetch 探测 | 无需改造 |
|
||||
| 环境变量管理 | 密钥"能自动生成就自动生成+持久化+占位符检测"模式 | 无需改造 |
|
||||
|
||||
关键文件速查(均在 `/opt/uhd_doc/`):`src/lib/{password,totp,crypto,cookies,request,env}.ts`、`src/server/admin-auth/{session,credential,csrf,login,passkey}.ts`、`src/server/{cap/service,audit/log,api/wrappers}.ts`、`src/app/api/admin-auth/{login,logout,setup,password}/route.ts`、`src/proxy.ts`、`src/db/schema.ts`(L382–530 附近)、`src/app/admin/login/{page,login-client}.tsx`、`src/components/security/proof-of-work.tsx`、`Dockerfile`、`docker-entrypoint.sh`、`docker-compose.yml`、`Caddyfile`、`DEPLOY.md`、`.env.example`、`src/scripts/{admin-set-password,admin-show-init-token,migrate,preflight,seed}.ts`。
|
||||
@@ -6,15 +6,15 @@
|
||||
|
||||
| 项 | 决定 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 运行时 | **Node.js 24 LTS + pnpm workspace**,TypeScript 5.9 strict | 用户指定沿用 /opt/uhd_web 技术栈;开发机无 Deno。REQ §9 允许"有明确依赖障碍时评估替代并记录代价"。ADR-001 记录此偏离。 |
|
||||
| 运行时 | **Deno 2.9.7(锁定)+ TypeScript 7.0.2**,deno.json workspace(`nodeModulesDir: auto`,npm 依赖走 `npm:` 说明符,deno.lock 锁定) | 用户 2026-09-27 确认:按 REQ §9 采用最新稳定版 Deno;之前 W0 用 Node 24 + pnpm 搭的底座在 W1 结束后统一迁移(ADR-004 取代 ADR-001)。前端仍用 Vite 7 + React Router 7 构建,由 Deno 执行。 |
|
||||
| 前端 | React 19 + React Router 7(SPA, `ssr:false`)+ Vite 7 + Tailwind v4 + shadcn(new-york, radix-ui 统一包)+ TanStack Query 5 + zod 4 + sonner + lucide | 直接拷贝 uhd_web 的 `app/app.css` 设计 token、`app/components/ui/*`、`app/lib/api.ts`、`app/lib/utils.ts`、`scripts/gen-design-tokens.mjs`、`scripts/check-design-tokens.sh`、`docs/features/{design-tokens,interface-materials,ui-controls}.md`。**不引入 uhd_web 的播放器/WASM 相关内容。** |
|
||||
| 后端 HTTP | Hono 4(`@hono/node-server`),REST + SSE | OpenAPI 由 zod schema 生成(`@hono/zod-openapi`)。 |
|
||||
| 后端 HTTP | Hono 4(`Deno.serve`),REST + SSE | OpenAPI 由 zod schema 生成(`@hono/zod-openapi`)。 |
|
||||
| 数据库 | PostgreSQL 16;Kysely + pg;SQL 迁移文件 + Kysely Migrator;`kysely-codegen` 或手写 DB 类型 | 开发用 docker-compose 起 `postgres:16-alpine`,端口 55433。 |
|
||||
| 持久任务引擎 | 优先 `[email protected]`(peer: postgres.js);**须先通过 spike 验证**(重启恢复/等待信号/取消/重试)。未通过则用 `packages/tasks` 内的 PG 租约式任务表(lease + attempts + dead-letter)。 | 结论写 ADR-002。 |
|
||||
| Agent 内核 | `@earendil-works/[email protected]` + `@earendil-works/[email protected]`,锁版本;通过自有 `AgentRuntimePort` 封装,显式工具白名单,不开放 bash/文件系统/任意网络 | 测试用脚本化 fake 模型 provider;真实上游由设置页配置。 |
|
||||
| 校验/测试 | vitest(server/packages 单元 + 集成);web 沿用 token 守卫脚本;biome 做 lint/format | `pnpm ci` = typecheck + lint + test + check:tokens + build |
|
||||
| 校验/测试 | 测试运行器以 Deno spike 结论为准(vitest via Deno 或 `deno test`);web 沿用 token 守卫脚本;lint/format 用 `deno lint/fmt` 或 biome(spike 定) | `deno task ci` = typecheck(tsgo 7)+ lint + test + check:tokens + build |
|
||||
| 端口 | web dev 5180,API 4380,PG 55433 | 本机 3000/5175/5176/55432 已被占用,不要用 |
|
||||
| 参考源码 | MoviePilot: `/opt/moviepilot-plus/upstream/MoviePilot`(GPLv3,只做行为对照与 TS 重写,不复制文件);MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
|
||||
| 参考源码 | MoviePilot v3: `/opt/moviepilot-plus/upstream/MoviePilot`(提交 e6820a8,GPLv3,只做行为对照与 TS 重写,不复制文件)。**数据来源适配(站点 spider、馒头、RSS、TMDB、qB)照抄 MP v3 实现迁移**(用户 2026-09-27 明确):`app/modules/indexer/spider/__init__.py`、`spider/mtorrent.py`、`app/application/rss.py`、`app/modules/themoviedb/tmdbapi.py`、`app/modules/qbittorrent/qbittorrent.py`;站点定义按 `app/application/site/sites.pyi` 的 schema 手写(MP 的定义文件 `user.sites.v3.bin` 是加密的,无法取用)。**用户 2026-09-27 追加:MP 开源的站点框架全部移植**——所有专用 spider(haidan/hddolby/rousi/sunnypt/tnode/torrentleech/yema/mtorrent)与所有站点类型用户信息解析器(nexus_php/nexus_audiences/nexus_hhanclub/nexus_project/nexus_rabbit/gazelle/unit3d/discuz/ipt/file_list/small_horse/zhixing/bitpt/…),文件与函数一一对应,并维护 `docs/porting/mp-sync-ledger.md` + `scripts/mp-sync` 差异脚本,以后跟随 MP 提交增量移植;MovieClaw 若本机不存在则按 REQ 附录链接对照 | 迁移对照记录写 `docs/porting/` |
|
||||
|
||||
## 2. 仓库结构(pnpm monorepo)
|
||||
|
||||
@@ -56,9 +56,10 @@ scripts dev/ci 辅助脚本
|
||||
7. **入库**:verifyAndImport → Copy/Move(临时文件+校验+提交;跨卷 Copy→核验→删源;EXDEV 不静默降级)、路径绑定固定(已绑定目录不因译名变化改变)、organization_record + 文件级结果、**公开整理记录 API**(API Key、游标增量)。
|
||||
8. **持久任务**:ingestFeed、resolveObservation、evaluateAcquisition、submitDownload、verifyAndImport、refreshMetadata、reconcileLibrary(资产存在性)、cleanupDownloadCache、resolveDecision;outbox;重启恢复;幂等键;取消传播。
|
||||
9. **Agent**:Pi AgentSession 封装;能力注册中心(ID/版本/schema/权限/风险/幂等/预览/处理器),首批能力:catalog.lookup、release.search、release.inspect、identity.resolve、download.plan、download.submit、organize.plan、organize.execute、task.inspect、task.retry、task.cancel、policy.preview、policy.apply、inventory.query;授权校验在服务端;agent_run 持久化 + SSE 增量;后台事件触发有界 Agent 任务(识别歧义、下载失败诊断);设置页可配上游/模型/思考档位/上下文预算/压缩/子代理开关。
|
||||
10. **控制台页面**(REQ §13):首页六区块(自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出)、媒体库(列表+详情:路径/身份映射/元数据来源/Slot/候选拒绝理由/下载历史)、订阅与收录规则、偏好标签(基础 CRUD+抑制记录)、PT 站点工作台(整理视图:列表/搜索/详情/库存叠加/统一下载入口;原站视图首版只留占位与说明)、来源/Feed/快照、下载与任务(含业务时间线)、整理记录、待处理中心、身份与元数据、系统设置(实例/路径映射/API Key/Pi 上游与预算/自动化开关)、Agent 会话页 + 侧栏。
|
||||
10. **控制台页面**(REQ §13):首页六区块(自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出)、媒体库(列表+详情:路径/身份映射/元数据来源/Slot/候选拒绝理由/下载历史)、订阅与收录规则、偏好标签(基础 CRUD+抑制记录)、PT 站点工作台(整理视图:列表/搜索/详情/库存叠加/统一下载入口;原站视图首版只留占位与说明)、来源/Feed/快照、下载与任务(含业务时间线)、整理记录、待处理中心、身份与元数据、系统设置(实例/路径映射/API Key/Pi 上游与预算/自动化开关)、Agent 会话页 + 侧栏、**工作流引擎面板**(用户 2026-09-27 明确:把 OpenWorkflow 自带 Dashboard 的功能用本项目组件复刻并集成进控制台,走管理员登录;runs 列表/筛选、run 详情(步骤/attempts/输入输出/错误/时间线)、workers 与队列统计、cancel/retry 操作;数据经服务端 API 直接查 `openworkflow` schema;参照 `docs/design/openworkflow-dashboard-reference.md`)。
|
||||
11. **可选 Emby 兼容查询**:多实例,查有无/季集/版本,未知≠缺失。
|
||||
12. **运维**:`scripts/backup.sh`(pg_dump + 映射导出)、恢复后只读对账模式开关、docker-compose(生产:server + web(nginx) + pg)、runbook。
|
||||
12. **运维**:`scripts/backup.sh`(pg_dump + 映射导出)、恢复后只读对账模式开关、docker 打包:**除 PostgreSQL 外全部塞进一个容器**(单镜像内跑 API + worker + 托管 web 静态产物,单端口对外;compose 只有 app + pg 两个服务)、runbook。(用户 2026-09-27 明确)
|
||||
14. **管理员登录门禁**(用户 2026-09-27 明确):单管理员账号密码登录(首次启动初始化/设置向导),会话 cookie(HttpOnly、SameSite)+ 登录页 + 未登录跳转;密码哈希(argon2/scrypt)、登录限速与失败锁定、会话吊销、可选 TOTP/Passkey 二次验证;控制台走会话鉴权,外部程序走 API Key。实践参考 `/opt/uhd_doc` 项目(见 `docs/design/auth-reference.md` 调研结论)。
|
||||
13. **测试**:domain 单元测试;adapters 对 mock 服务的集成测试;端到端:mock RSS → 观察 → 识别 → 计划 → mock qB 下载完成 → Copy 入库 → 整理记录 API 可读;Agent e2e:fake 模型按脚本调用能力完成"把 X 电影 1080p 收进来"闭环;崩溃恢复注入(worker 中断后续跑)。
|
||||
|
||||
### 3.2 v0.1 明确不做(留接口/占位)
|
||||
|
||||
Generated
+1116
-2
File diff suppressed because it is too large.
Load diff
@@ -5,5 +5,7 @@ packages:
|
||||
|
||||
# 允许执行安装脚本的依赖(pnpm 11 默认拒绝)
|
||||
allowBuilds:
|
||||
'@google/genai': false
|
||||
esbuild: true
|
||||
msw: false
|
||||
protobufjs: false
|
||||
Reference in new issue
Block a user