From 17cf3cbf97d8b2190003f8c62b42976b7f976b8e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 00:45:56 +0800 Subject: [PATCH] fix(files,security): S-1 per-source Move authorization, S-2 session-only annotation, S-5 restore guards S-1 (/files/move, /files/organize action=move): - organize-guard: authorizeMoveEntities checks every source's media entities (declared entity plus entities of registered assets under the source); one-time approval still covers the whole request; any source that cannot be attributed to an entity only passes with an approval; denial lists every unauthorized item; optional non-consuming pre-check - transfer: entities per source (directory = registered assets beneath it) - organize: all move groups pre-checked before any group executes; any denial -> whole request 403 approval_required S-2: - api.ts: GET /files/download and POST /files/trash/purge marked auth:"session"; files routes attach requireAdminSession from the registry annotation; handler-side purge check removed, download keeps a same-rule defense check; permanent delete uses isAdminSession - DEV key rule written down: equals an admin-scope key for session-only endpoints, still admin for Move/delete approval bypass (as authorizeMove) - api-registry.test: session list updated; new reverse assertion that no non-session endpoint answers 403 to an admin-scope bearer S-5 (trash restore): - restore-to requires a writable managed library root (isWritableTarget) - in-place restore rebuilds missing parents level by level without following symlinks (mkdirWithin), realpath-checked per level - cross-volume restore capped by FILES_TRASH_CROSS_MAX_BYTES; cross-volume copies (trash in / restore out) go through fsproxy Tests: multi-entity move 403 + listing, unattributed source, organize foreign asset 403, download/purge 403 for DEV and admin-scope keys, restore target not writable, symlinked parent, rebuild parents, cross-volume cap. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/http/auth.ts | 5 +- .../services/organize-guard.ts | 143 +++++++++++++----- .../server/src/modules/file-manager/common.ts | 6 + .../src/modules/file-manager/download.ts | 6 +- .../src/modules/file-manager/organize.ts | 54 +++++-- .../src/modules/file-manager/records.ts | 19 +++ .../server/src/modules/file-manager/routes.ts | 16 +- .../src/modules/file-manager/transfer.ts | 26 ++-- apps/server/src/modules/file-manager/trash.ts | 65 ++++++-- apps/server/test/api-registry.test.ts | 34 +++++ apps/server/test/file-manager/harness.ts | 36 ++++- .../file-manager/identify-organize.test.ts | 47 +++++- apps/server/test/file-manager/trash.test.ts | 115 +++++++++++++- apps/server/test/file-manager/w5a2.test.ts | 40 +++-- apps/server/test/file-manager/write.test.ts | 76 ++++++++++ packages/contracts/src/api.ts | 6 +- 16 files changed, 587 insertions(+), 107 deletions(-) diff --git a/apps/server/src/http/auth.ts b/apps/server/src/http/auth.ts index 02af3319..b9109675 100644 --- a/apps/server/src/http/auth.ts +++ b/apps/server/src/http/auth.ts @@ -215,7 +215,10 @@ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler { }; } -/** 只允许控制台管理员会话(或开发模式 DEV_ADMIN_KEY),拒绝外部 API Key —— 用于改密、TOTP 等账号操作 */ +/** + * 只允许控制台管理员会话,拒绝外部 API Key 与开发模式 DEV_ADMIN_KEY(DEV key 等同 admin scope 的 Key)—— + * 用于注册表标 auth:"session" 的端点(改密、TOTP、审批、文件下载、清空回收站)。 + */ export function requireAdminSession(): MiddlewareHandler { return async (c, next) => { const p = c.get("principal"); diff --git a/apps/server/src/modules/acquisition-organize/services/organize-guard.ts b/apps/server/src/modules/acquisition-organize/services/organize-guard.ts index e306670a..638a5caf 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-guard.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-guard.ts @@ -162,16 +162,34 @@ export async function authorizeMove( body: Parameters[0], entityId: string | null, ): Promise { + const r = await authorizeMoveEntities(d, principal, body, [{ label: "", entityId }]); + return r.allowed ? r : { allowed: false, reason: r.reason }; +} + +export type MoveAuthorizationMulti = + | { allowed: true; via: "admin_session" | "grant" | "approval"; id?: string; grantIds?: string[] } + | { allowed: false; reason: string; unauthorized: { label: string; entityId: string | null; reason: string }[] }; + +/** + * 多源 Move 授权(验收 07 S-1):一次性审批覆盖整个请求;否则**每个源**所属作品都必须被某条持续授权覆盖, + * 任一源无法归属作品(无从核对 grant 范围)或作品不在任何 grant 范围内 → 整体拒绝,并列出未授权项。 + * 同一源可以对应多个作品(目录里登记了多个作品的资产),每个都要覆盖。 + */ +export async function authorizeMoveEntities( + d: AoDeps, + principal: Principal | undefined, + body: Parameters[0], + sources: { label: string; entityId: string | null }[], + opts: { consume?: boolean } = {}, +): Promise { + const consume = opts.consume !== false; if (principal?.kind === "admin_session" || principal?.kind === "dev_admin") return { allowed: true, via: "admin_session" }; const db = d.ctx.db; const now = d.ports.now(); - const entity = entityId - ? await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "=", entityId).executeTakeFirst() - : undefined; const reasons: string[] = []; - // 1) 一次性审批 + // 1) 一次性审批(绑定整个请求) const key = requestKey(body); const approvals = await db .selectFrom("approval as a") @@ -190,6 +208,8 @@ export async function authorizeMove( reasons.push(`审批 ${a.id} 绑定的计划内容已变化`); continue; } + // 预检(不消费):执行阶段会再调一次并原子消费 + if (!consume) return { allowed: true, via: "approval", id: a.id }; const used = await db .updateTable("approval") .set({ status: "consumed" }) @@ -199,45 +219,94 @@ export async function authorizeMove( if (Number(used.numUpdatedRows ?? 0n) === 1) return { allowed: true, via: "approval", id: a.id }; } - // 2) 持续授权 - if (!entity) { - reasons.push("无法确定目标作品,持续授权无从核对范围"); - } else { - const grants = await db - .selectFrom("automation_grant as g") - .leftJoin("policy_revision as p", "p.id", "g.policy_revision_id") - .select(["g.id", "g.scope", "g.policy_revision_id", "p.status as policy_status"]) - .where("g.status", "=", "active") - .where("g.action", "in", GRANT_ACTIONS) - .where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)])) - .orderBy("g.created_at", "desc") - .execute(); - for (const g of grants) { - if (g.policy_revision_id && g.policy_status !== "published") { - reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`); - continue; + // 2) 持续授权:逐个作品核对 + const grants = await db + .selectFrom("automation_grant as g") + .leftJoin("policy_revision as p", "p.id", "g.policy_revision_id") + .select(["g.id", "g.scope", "g.policy_revision_id", "p.status as policy_status"]) + .where("g.status", "=", "active") + .where("g.action", "in", GRANT_ACTIONS) + .where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)])) + .orderBy("g.created_at", "desc") + .execute(); + const ids = [...new Set(sources.map((s) => s.entityId).filter((x): x is string => !!x))]; + const kinds = new Map( + ids.length + ? (await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", ids).execute()).map((e) => [ + e.id, + e.kind, + ]) + : [], + ); + const coverCache = new Map(); + const cover = (entityId: string) => { + const hit = coverCache.get(entityId); + if (hit) return hit; + const kind = kinds.get(entityId); + const why: string[] = []; + let res: { ok: true; id: string } | { ok: false; reasons: string[] } | null = null; + if (!kind) why.push("作品不存在"); + else + for (const g of grants) { + if (g.policy_revision_id && g.policy_status !== "published") { + why.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`); + continue; + } + const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] }; + if (scope.capabilities?.length && !scope.capabilities.includes("organize.execute")) { + why.push(`授权 ${g.id} 不含 organize.execute`); + continue; + } + if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(entityId)) { + why.push(`授权 ${g.id} 的对象范围不含目标作品`); + continue; + } + if (scope.mediaKinds?.length && !kindCovered(scope.mediaKinds, kind)) { + why.push(`授权 ${g.id} 的媒体类型范围不含 ${kind}`); + continue; + } + res = { ok: true, id: g.id }; + break; } - const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] }; - if (scope.capabilities?.length && !scope.capabilities.includes("organize.execute")) { - reasons.push(`授权 ${g.id} 不含 organize.execute`); - continue; - } - if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(entity.id)) { - reasons.push(`授权 ${g.id} 的对象范围不含目标作品`); - continue; - } - if (scope.mediaKinds?.length && !kindCovered(scope.mediaKinds, entity.kind)) { - reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${entity.kind}`); - continue; - } - return { allowed: true, via: "grant", id: g.id }; + const out = res ?? { ok: false as const, reasons: why }; + coverCache.set(entityId, out); + return out; + }; + + const unauthorized: { label: string; entityId: string | null; reason: string }[] = []; + const used = new Set(); + for (const s of sources) { + if (!s.entityId) { + unauthorized.push({ + label: s.label, + entityId: null, + reason: "无法确定所属作品,持续授权无从核对范围(只接受一次性审批)", + }); + continue; } + const c = cover(s.entityId); + if (c.ok) used.add(c.id); + else + unauthorized.push({ + label: s.label, + entityId: s.entityId, + reason: c.reasons.length ? c.reasons.join(";") : "没有覆盖该作品的持续授权", + }); } + if (sources.length > 0 && unauthorized.length === 0) { + const grantIds = [...used]; + return { allowed: true, via: "grant", id: grantIds[0]!, grantIds }; + } + const single = sources.length === 1 && !sources[0]!.label; + const detail = single + ? [...reasons, ...unauthorized.map((u) => u.reason)] + : [...reasons, ...unauthorized.map((u) => `${u.label}(${u.entityId ?? "未归属作品"}):${u.reason}`)]; return { allowed: false, + unauthorized, reason: - reasons.length > 0 - ? `Move 需要授权:${reasons.join(";")}` + detail.length > 0 && (reasons.length > 0 || grants.length > 0 || !single || unauthorized[0]?.entityId === null) + ? `Move 需要授权:${detail.join(";")}` : "Move 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批", }; } diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts index e0c2016d..811b31d6 100644 --- a/apps/server/src/modules/file-manager/common.ts +++ b/apps/server/src/modules/file-manager/common.ts @@ -322,7 +322,13 @@ export async function recheck(abs: string, rootReal: string): Promise { // ── 身份 / 授权 ── +/** + * 口径(验收 07 S-2): + * - isAdmin:免授权/免审批的"管理员"身份(Move 授权、API Key 删除审批),与 authorizeMove 一致,开发模式 DEV key 视同管理员; + * - isAdminSession:"仅管理员控制台会话"(注册表 auth:"session"、永久删除),与 requireAdminSession 中间件一致,DEV key 拒绝。 + */ export const isAdmin = (p: Principal | undefined) => p?.kind === "admin_session" || p?.kind === "dev_admin"; +export const isAdminSession = (p: Principal | undefined) => p?.kind === "admin_session"; export const actorOfP = (p: Principal | undefined) => (p ? `${p.kind}:${p.id}` : "system"); // ── 小工具 ── diff --git a/apps/server/src/modules/file-manager/download.ts b/apps/server/src/modules/file-manager/download.ts index 96ab51b8..483dcc06 100644 --- a/apps/server/src/modules/file-manager/download.ts +++ b/apps/server/src/modules/file-manager/download.ts @@ -1,7 +1,7 @@ import { basename, extname } from "node:path"; import type { FilesDownloadQuery } from "@umt/contracts"; import type { Principal } from "../../http/auth.ts"; -import { FilesError, type FmDeps, isAdmin, recheck, resolveIn } from "./common.ts"; +import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; import { audit } from "./ledger.ts"; import { kindOfName } from "./list.ts"; @@ -41,7 +41,9 @@ export async function downloadFile( principal: Principal | undefined, q: FilesDownloadQuery, ): Promise { - if (!isAdmin(principal)) throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key 不可用)"); + // 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御) + if (!isAdminSession(principal)) + throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key / DEV key 不可用)"); const r = await resolveIn(d, q.root, q.path); if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录"); const name = basename(r.abs); diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index 0a30986e..41ce943a 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -5,7 +5,7 @@ import type { Principal } from "../../http/auth.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts"; import { type FileOverrideInput, planImport } from "../acquisition-organize/services/organize.ts"; import { - authorizeMove, + authorizeMoveEntities, hnrBlockForMove, managedTasksUnder, PathGuardError, @@ -15,6 +15,7 @@ import { import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts"; import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; import { isWritableTarget } from "./list.ts"; +import { entitiesUnder } from "./records.ts"; // 整理向导落地:按 (mediaEntityId, contentUnitId, 操作, 目标目录, Slot) 分组,每组走 planImport(与 POST /organization-records // 同一实现,同格式整理记录)。逐项季/集/单元覆写以 fileOverrides 传入 planImport(计划前后路径即反映覆写), @@ -47,6 +48,8 @@ interface Group { targetLibraryRootId: string | null; slotId: string | null; paths: string[]; + /** 源的库根相对位置(Move 授权按源下已登记资产的作品逐一核对) */ + srcs: { label: string; lib: string | null; rel: string; isDir: boolean }[]; overrides: Record; warnings: string[]; } @@ -189,26 +192,59 @@ export async function organizeFiles( targetLibraryRootId: targetLib, slotId, paths: [], + srcs: [], overrides: {}, warnings: [], } satisfies Group); g.paths.push(r.abs); + g.srcs.push({ label: r.rel, lib: r.def.libraryRootId, rel: r.rel, isDir }); if (Object.keys(ov).length > 0) g.overrides[r.abs] = ov; g.warnings.push(...warnings); groups.set(key, g); } const guard = await sourceGuard(db); + + // Move 授权(验收 07 S-1):每组的每个源 = 声明作品 ∪ 源下已登记资产所属作品,都要被覆盖; + // 先整体核对(不消费审批),任一组有未授权项 → 整体 403 并列出,不执行任何一组 + const moveAuthSources = async (g: Group) => { + const out: { label: string; entityId: string | null }[] = []; + for (const s of g.srcs) { + const ids = new Set([g.entity, ...(await entitiesUnder(db, s.lib, s.rel, s.isDir))]); + for (const id of ids) out.push({ label: s.label, entityId: id }); + } + return out; + }; + const reqOf = (g: Group): Req => ({ + sourcePaths: g.paths, + mediaEntityId: g.entity, + ...(g.unit ? { contentUnitId: g.unit } : {}), + action: g.action, + ...(g.targetLibraryRootId ? { targetLibraryRootId: g.targetLibraryRootId } : {}), + }); + if (!body.dryRun && !isAdmin(principal)) { + const denied: string[] = []; + const unauthorized: unknown[] = []; + for (const g of groups.values()) { + if (g.action !== "move") continue; + const auth = await authorizeMoveEntities(d.ao, principal, reqOf(g), await moveAuthSources(g), { + consume: false, + }); + if (!auth.allowed) { + denied.push(auth.reason); + unauthorized.push(...auth.unauthorized); + } + } + if (denied.length > 0) { + await audit(d, principal, "organize_move_denied", { reasons: denied, unauthorized }); + throw new FilesError("approval_required", denied.join(" | ")); + } + } + const out: FilesOrganizeGroup[] = []; const recordIds: string[] = []; for (const g of groups.values()) { - const req: Req = { - sourcePaths: g.paths, - mediaEntityId: g.entity, - ...(g.unit ? { contentUnitId: g.unit } : {}), - action: g.action, - ...(g.targetLibraryRootId ? { targetLibraryRootId: g.targetLibraryRootId } : {}), - }; + const req = reqOf(g); const hasOverrides = Object.keys(g.overrides).length > 0; const reqHash = hashJson({ key: requestKey(req), @@ -283,7 +319,7 @@ export async function organizeFiles( if (g.action === "move") { const managed = await managedTasksUnder(db, req.sourcePaths); if (!isAdmin(principal)) { - const auth = await authorizeMove(d.ao, principal, req, g.entity); + const auth = await authorizeMoveEntities(d.ao, principal, req, await moveAuthSources(g)); if (!auth.allowed) { await audit(d, principal, "organize_move_denied", { reason: auth.reason, planId }); throw new FilesError("approval_required", auth.reason); diff --git a/apps/server/src/modules/file-manager/records.ts b/apps/server/src/modules/file-manager/records.ts index a46cd7b8..049bbf5a 100644 --- a/apps/server/src/modules/file-manager/records.ts +++ b/apps/server/src/modules/file-manager/records.ts @@ -42,6 +42,25 @@ export async function relocatePreview(exec: Exec, lib: string, rel: string, isDi return { locations: Number(locs?.n ?? 0), bindingIds: binds.map((b) => b.id) }; } +/** 源(文件或目录)下已登记资产所属的作品集合(Move 按作品逐一核对授权,验收 07 S-1) */ +export async function entitiesUnder(exec: Exec, lib: string | null, rel: string, isDir: boolean): Promise { + if (!lib) return []; + const rows = await exec + .selectFrom("asset_location as l") + .innerJoin("media_asset as a", "a.id", "l.media_asset_id") + .select(["a.media_entity_id"]) + .distinct() + .where("l.library_root_id", "=", lib) + .where("l.status", "<>", "removed") + .where((eb) => + isDir + ? eb.or([eb("l.relative_path", "=", rel), eb("l.relative_path", "like", likePrefix(rel))]) + : eb("l.relative_path", "=", rel), + ) + .execute(); + return rows.map((r) => r.media_entity_id).filter((x): x is string => !!x); +} + export interface RecordFile { src: string; dst: string | null; diff --git a/apps/server/src/modules/file-manager/routes.ts b/apps/server/src/modules/file-manager/routes.ts index 8afcbc08..b943184b 100644 --- a/apps/server/src/modules/file-manager/routes.ts +++ b/apps/server/src/modules/file-manager/routes.ts @@ -2,11 +2,11 @@ import { createRoute } from "@hono/zod-openapi"; import { Api, apiResponse, type EndpointDef } from "@umt/contracts"; import type { Context } from "hono"; import type { z } from "zod"; -import { type AuthEnv, requireScope } from "../../http/auth.ts"; +import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts"; import { errorBody } from "../../http/errors.ts"; import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts"; import type { ModuleApp } from "../types.ts"; -import { FilesError, type FmDeps, fsErrorToFiles, isAdmin } from "./common.ts"; +import { FilesError, type FmDeps, fsErrorToFiles } from "./common.ts"; import { downloadFile } from "./download.ts"; import { identify } from "./identify.ts"; import { idempotent } from "./ledger.ts"; @@ -31,7 +31,8 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler, raw = false) path: def.path, tags: ["files"], summary: def.summary, - middleware: [requireScope(def.scope)] as const, + // auth:"session" 的端点(download / trash purge)由中间件统一只放行管理员控制台会话,与注册表标注同源 + middleware: [requireScope(def.scope), ...(def.auth === "session" ? [requireAdminSession()] : [])] as const, request: { ...(def.query ? { query: def.query as z.ZodObject } : {}), ...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}), @@ -97,11 +98,10 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) { mount(app, F.trashRestore, async (c, { body }) => idem(d, c, "trash_restore", body, () => restoreTrash(d, c.get("principal"), body)), ); - mount(app, F.trashPurge, async (c, { body }) => { - // 授权矩阵:清空回收站仅 admin 会话,API Key(含 admin scope)一律拒绝 - if (!isAdmin(c.get("principal"))) throw new FilesError("forbidden", "清空回收站只允许控制台管理员会话"); - return idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body)); - }); + // 授权矩阵:清空回收站仅 admin 会话(注册表 auth:"session",中间件执行),API Key 与 DEV key 一律拒绝 + mount(app, F.trashPurge, async (c, { body }) => + idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body)), + ); mount(app, F.identify, async (c, { body }) => identify(d, c.get("principal"), body)); mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body))); mount(app, F.probe, async (_c, { body }) => probeFiles(d, body)); diff --git a/apps/server/src/modules/file-manager/transfer.ts b/apps/server/src/modules/file-manager/transfer.ts index 567c75d7..705a423f 100644 --- a/apps/server/src/modules/file-manager/transfer.ts +++ b/apps/server/src/modules/file-manager/transfer.ts @@ -4,7 +4,7 @@ import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationS import type { Principal } from "../../http/auth.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts"; import { planImport } from "../acquisition-organize/services/organize.ts"; -import { authorizeMove, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts"; +import { authorizeMoveEntities, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts"; import type { TransferSpec } from "../acquisition-organize/services/organize-transfer.ts"; import { actorOfP, @@ -19,7 +19,7 @@ import { } from "./common.ts"; import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts"; -import { relocatePreview } from "./records.ts"; +import { entitiesUnder, relocatePreview } from "./records.ts"; // 移动/复制到目录(W5-A2):与整理共用 planImport(原样搬运变体 transfer)、冻结计划存储与 plan_stale 校验。 // dryRun:冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)+ planImport 逐文件前后路径 → planId; @@ -234,16 +234,20 @@ export async function transfer( `源属于纳管下载任务(${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")}),裸移动会让下载器丢失文件;请用整理入库(Move 会按 H&R 判定并移交)`, ); if (!isAdmin(principal)) { - const entity = await d.ctx.db - .selectFrom("asset_location as l") - .innerJoin("media_asset as a", "a.id", "l.media_asset_id") - .select(["a.media_entity_id"]) - .where("l.library_root_id", "=", sources[0]!.def.libraryRootId ?? "00000000-0000-0000-0000-000000000000") - .where("l.relative_path", "=", sources[0]!.rel) - .executeTakeFirst(); - const auth = await authorizeMove(d.ao, principal, request, entity?.media_entity_id ?? null); + // 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项 + const perSource: { label: string; entityId: string | null }[] = []; + for (const [i, s] of sources.entries()) { + const ids = await entitiesUnder(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir); + if (ids.length === 0) perSource.push({ label: s.rel, entityId: null }); + else for (const id of ids) perSource.push({ label: s.rel, entityId: id }); + } + const auth = await authorizeMoveEntities(d.ao, principal, request, perSource); if (!auth.allowed) { - await audit(d, principal, "move_denied", { reason: auth.reason, planId: plan.id }); + await audit(d, principal, "move_denied", { + reason: auth.reason, + planId: plan.id, + unauthorized: auth.unauthorized, + }); throw new FilesError("approval_required", auth.reason); } } diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index 6710a148..b410abc9 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { lstat, mkdir, readdir, rm } from "node:fs/promises"; +import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; -import { copyWithVerify, moveSafe } from "@umt/adapters"; +import { moveSafe } from "@umt/adapters"; import type { FileDeleteCheck, FilesDeleteRequest, @@ -17,6 +17,7 @@ import type { import { sql, type TrashItemRow } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; import { upsertDecision } from "../acquisition-organize/services/common.ts"; +import { fsRuntime } from "../acquisition-organize/services/organize.ts"; import { actorOfP, errMessage, @@ -25,6 +26,7 @@ import { type FmDeps, hashJson, isAdmin, + isAdminSession, jsonb, onlineRoot, recheck, @@ -33,7 +35,7 @@ import { within, } from "./common.ts"; import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; -import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts"; +import { bindingsAt, downloadsFor, isDownloading, isWritableTarget, managedDownloads } from "./list.ts"; import { type RecordFile, writeRecord } from "./records.ts"; // 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash///<原相对路径>,O(1) rename; @@ -170,7 +172,7 @@ export async function deleteFiles( if (body.mode === "permanent") { if (!d.options.allowPermanentDelete) throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站"); - if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话"); + if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话"); } const downloads = await managedDownloads(d); const results: Awaited>[] = []; @@ -354,14 +356,17 @@ function recordFilesOf( return inner.map((f) => ({ src: join(abs, f), dst: where ? join(where, f) : null, size: 0, status })); } -async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") { +/** 跨卷复制(进回收站 / 从回收站还原):逐文件经 fsproxy(挂死隔离:停滞超时 → SIGKILL → TIMEOUT),复制后核验 */ +async function copyTree(d: FmDeps, src: string, dst: string, files: string[]) { + const proxy = fsRuntime(d.ao).proxy; + const hash = d.ao.ports.options.verifyHash; const st = await lstat(src); if (!st.isDirectory()) { - await copyWithVerify(src, dst, { hash, createParents: true }); + await proxy.copy(src, dst, { hash, createParents: true }); return; } await mkdir(dst, { recursive: true }); - for (const f of files) await copyWithVerify(join(src, f), join(dst, f), { hash, createParents: true }); + for (const f of files) await proxy.copy(join(src, f), join(dst, f), { hash, createParents: true }); } async function moveToTrash( @@ -396,7 +401,7 @@ async function moveToTrash( await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined); trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path); const { files } = await sizeOf(abs); - await copyTree(abs, trashPath, files, d.ao.ports.options.verifyHash); + await copyTree(d, abs, trashPath, files); await rm(abs, { recursive: true }); } return d.ctx.db @@ -466,6 +471,30 @@ function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean { return (inRoot || inData) && !row.trash_path.split("/").includes(".."); } +/** + * 在根内逐级建目录:每一级先 lstat,已存在的必须是真目录(符号链接一律拒绝,哪怕指向根内),不存在才 mkdir(非递归)。 + * 避免 mkdir -p 跟随中间的符号链接在根外建出目录(验收 07 S-5)。 + */ +export async function mkdirWithin(rootReal: string, rel: string): Promise { + if (rel === "" || rel === ".") return; + let cur = rootReal; + for (const seg of rel.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") throw new FilesError("path_escape", `路径含 ..: ${rel}`); + cur = join(cur, seg); + const st = await lstat(cur).catch((e) => { + if (errnoOf(e) === "ENOENT") return null; + throw e; + }); + if (st?.isSymbolicLink()) + throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,拒绝`); + if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`); + if (!st) await mkdir(cur); + if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径逃出根: ${rel}`); + } +} +const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1)); + export async function restoreTrash( d: FmDeps, principal: Principal | undefined, @@ -478,6 +507,9 @@ export async function restoreTrash( const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null; if (target && (!target.lst?.isDirectory() || target.isSymlink)) throw new FilesError("bad_request", "还原目标必须是已存在的目录"); + // "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5) + if (target && !(await isWritableTarget(target.def, target.rootReal))) + throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写(仅 managed 库根可作为目标)`); for (const id of body.ids) { try { const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst(); @@ -495,11 +527,9 @@ export async function restoreTrash( rootReal = await onlineRoot(d, def); const parent = await resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }).catch( async (e) => { - // 父目录已不存在:在根内重建后再解析 + // 父目录已不存在:在根内逐级重建(每级先 lstat,已存在的必须是真目录,不跟随符号链接)后再解析 if (e instanceof FilesError && e.reason === "not_found") { - const p = join(rootReal, dirname(row.original_path)); - if (!within(resolve(p), rootReal)) throw e; - await mkdir(p, { recursive: true }); + await mkdirWithin(rootReal, dirname(row.original_path)); return resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }); } throw e; @@ -511,8 +541,15 @@ export async function restoreTrash( const r = await moveSafe(row.trash_path, dst); if (r.status === "exists") throw new FilesError("exists", "原位置已被占用"); if (r.status === "exdev") { - const { files } = await sizeOf(row.trash_path); - await copyTree(row.trash_path, dst, files, d.ao.ports.options.verifyHash); + // 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本 + const { size, files } = await sizeOf(row.trash_path); + const bytes = Math.max(size, Number(row.size_bytes)); + if (bytes > d.options.trashCrossMaxBytes) + throw new FilesError( + "too_large", + `跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes};请在同卷还原,或手工处理 ${row.trash_path}`, + ); + await copyTree(d, row.trash_path, dst, files); await rm(row.trash_path, { recursive: true }); } await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute(); diff --git a/apps/server/test/api-registry.test.ts b/apps/server/test/api-registry.test.ts index 11f9a619..539cced9 100644 --- a/apps/server/test/api-registry.test.ts +++ b/apps/server/test/api-registry.test.ts @@ -60,10 +60,12 @@ describe("Api 注册表鉴权标注", () => { .sort(); expect(session).toEqual([ "GET /auth/totp", + "GET /files/download", "PATCH /approvals/{id}", "POST /approvals", "POST /auth/password", "POST /auth/totp", + "POST /files/trash/purge", ]); for (const e of listEndpoints().filter((x) => x.auth === "session")) expect(e.scope).toBe("admin"); }); @@ -88,4 +90,36 @@ describe("Api 注册表鉴权标注", () => { expect(res.status, `${e.method} ${e.path}`).toBe(403); } }); + // 验收 07 S-2 反向:没有标 auth:"session" 的端点,不得在中间件或处理器里暗中只认管理员会话。 + // DEV key 带 admin scope,对这些端点不应拿到 403。会话中间件排在入参校验之前,所以即使空 body/query 校验失败, + // 暗挂的 requireAdminSession 也会先 403 暴露出来;ctx 为空壳,处理器多半 400/500,那无所谓——只要不是 403。 + it("未标 auth:'session' 的非公开端点,admin scope 的 Bearer 不会被 403(标注与中间件不漂移)", async () => { + const app = createApp({ + config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, + logger: memoryLogger().logger, + bus: new EventBus(5), + role: "all", + version: "0.0.0-test", + modules: SERVER_MODULES, + ctx: { role: "all" } as unknown as ServerContext, + }); + const forbidden: string[] = []; + for (const e of listEndpoints().filter((x) => x.auth !== "session" && x.auth !== "public")) { + const path = e.path.replace(/\{[^}]+\}/g, "00000000-0000-4000-8000-000000000000"); + const ctl = new AbortController(); + const res = await Promise.race([ + app.request(`${API_PREFIX}${path}`, { + method: e.method.toUpperCase(), + headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" }, + signal: ctl.signal, + ...(e.method === "get" ? {} : { body: "{}" }), + }), + new Promise((r) => setTimeout(() => r(null), 2000)), + ]).catch(() => null); + ctl.abort(); + await res?.body?.cancel().catch(() => undefined); + if (res?.status === 403) forbidden.push(`${e.method.toUpperCase()} ${e.path}`); + } + expect(forbidden, `以下端点实际只认会话,请在 api.ts 标 auth:"session":\n${forbidden.join("\n")}`).toEqual([]); + }, 60_000); }); diff --git a/apps/server/test/file-manager/harness.ts b/apps/server/test/file-manager/harness.ts index 0bcd8093..3a484ee9 100644 --- a/apps/server/test/file-manager/harness.ts +++ b/apps/server/test/file-manager/harness.ts @@ -2,7 +2,14 @@ import { randomUUID } from "node:crypto"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { capabilitySpec, DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts"; +import { + CSRF_HEADER, + capabilitySpec, + DEFAULT_SETTINGS, + GlobalSettings, + SESSION_COOKIE, + type SettingsKey, +} from "@umt/contracts"; import { type DB, migrateToLatest } from "@umt/db"; import { normalizeTitle } from "@umt/domain"; import { Kysely, PostgresDialect, sql } from "kysely"; @@ -10,7 +17,7 @@ import pg from "pg"; import { ConfigSchema } from "../../src/config.ts"; import { createOutboxPort } from "../../src/context.ts"; import { createApp } from "../../src/http/app.ts"; -import { hashApiKey } from "../../src/http/auth.ts"; +import { deriveCsrfToken, hashApiKey, hashSessionToken } from "../../src/http/auth.ts"; import { EventBus } from "../../src/http/sse.ts"; import { createLogger } from "../../src/logger.ts"; import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts"; @@ -25,9 +32,14 @@ export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost: export const DEV_KEY = "dev-admin-fm-test-key"; export const READ_KEY = "fm-organize-read-key"; export const EXEC_KEY = "fm-organize-exec-key"; +export const ADMIN_KEY = "fm-admin-scope-key"; +/** 传给 api()/raw() 的 key:改用管理员控制台会话 cookie(+CSRF),而不是 Bearer */ +export const SESSION = "__admin_session__"; +const SESSION_TOKEN = "fm-test-admin-session-token-0123456789"; const KEYS = [ { key: READ_KEY, scopes: ["organize.read"] }, { key: EXEC_KEY, scopes: ["organize.execute"] }, + { key: ADMIN_KEY, scopes: ["admin"] }, ]; export async function pgAvailable() { @@ -188,8 +200,24 @@ export async function createFmHarness( return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null; }, async touch() {}, + async findSession(hash) { + if (hash !== hashSessionToken(SESSION_TOKEN)) return null; + const now = new Date(); + return { + id: "00000000-0000-4000-8000-00000000a001", + adminUserId: "00000000-0000-4000-8000-00000000a002", + username: "admin", + expiresAt: new Date(now.getTime() + 3600_000), + revokedAt: null, + lastSeenAt: now, + }; + }, }, }); + const authHeaders = (key: string): Record => + key === SESSION + ? { cookie: `${SESSION_COOKIE}=${SESSION_TOKEN}`, [CSRF_HEADER]: deriveCsrfToken(SESSION_TOKEN) } + : { authorization: `Bearer ${key}` }; const root = await db .insertInto("library_root") @@ -219,7 +247,7 @@ export async function createFmHarness( const res = await app.request(`/api/v1${path}`, { method, headers: { - authorization: `Bearer ${key}`, + ...authHeaders(key), ...(body !== undefined ? { "content-type": "application/json" } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), @@ -227,7 +255,7 @@ export async function createFmHarness( return { status: res.status, body: await res.json() }; }, raw(method, path, key = DEV_KEY) { - return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } })); + return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) })); }, async close() { await db.destroy(); diff --git a/apps/server/test/file-manager/identify-organize.test.ts b/apps/server/test/file-manager/identify-organize.test.ts index 3c73faaa..5eae5b0b 100644 --- a/apps/server/test/file-manager/identify-organize.test.ts +++ b/apps/server/test/file-manager/identify-organize.test.ts @@ -1,7 +1,16 @@ import { appendFile, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity } from "./harness.ts"; +import { + createFmHarness, + EXEC_KEY, + type FmHarness, + key, + pgAvailable, + READ_KEY, + seedAsset, + seedEntity, +} from "./harness.ts"; // identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、 // probe(已登记写回 media_info)、scrape(仅已登记作品,入队 refreshMetadata;NFO 待 W5)。 @@ -114,6 +123,42 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => { expect(rec).toEqual({ trigger: "manual", action: "copy", media_entity_id: film }); }); + it("organize Move(验收 07 S-1):源是别的作品的已登记资产 → 只授权声明作品不够,整体 403 并列出", async () => { + const other = await seedEntity(h.db, { kind: "movie", title: "Other Film", year: 2020 }); + await mkdir(join(h.lib, "Stolen"), { recursive: true }); + await writeFile(join(h.lib, "Stolen", "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "someone-else"); + await seedAsset(h.db, h, { entityId: other, rel: "Stolen/Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }); + const grant = await h.db + .insertInto("automation_grant") + .values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [film] }), granted_by: "test" }) + .returning("id") + .executeTakeFirstOrThrow(); + const items = [{ root: h.libRoot, path: "Stolen/Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }]; + const dry = await h.api( + "POST", + "/files/organize", + { idempotencyKey: key(), items, action: "move", dryRun: true }, + EXEC_KEY, + ); + expect(dry.status).toBe(200); + const r = await h.api( + "POST", + "/files/organize", + { idempotencyKey: key(), items, action: "move", dryRun: false, planIds: [dry.body.data.groups[0].planId] }, + EXEC_KEY, + ); + expect(r.status).toBe(403); + expect(r.body.error.message).toMatch(/^approval_required:/); + expect(r.body.error.message).toContain(other); + const recs = await h.db + .selectFrom("organization_record") + .select("id") + .where("source_path", "like", "%Stolen%") + .execute(); + expect(recs).toEqual([]); + await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", grant.id).execute(); + }); + it("organize:路径逃逸同样拒绝", async () => { const r = await h.api("POST", "/files/organize", { idempotencyKey: key(), diff --git a/apps/server/test/file-manager/trash.test.ts b/apps/server/test/file-manager/trash.test.ts index 7a6f3945..cf7faefa 100644 --- a/apps/server/test/file-manager/trash.test.ts +++ b/apps/server/test/file-manager/trash.test.ts @@ -1,12 +1,15 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { findCrossDeviceBase } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { + ADMIN_KEY, createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, + SESSION, seedAsset, seedBinding, seedDownload, @@ -155,7 +158,10 @@ describe.skipIf(skip)("files 删除与回收站", () => { .execute(); const denied = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, EXEC_KEY); expect(denied.status).toBe(403); - const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }); + // 验收 07 S-2:DEV key 与 admin scope Key 同样 403,只认管理员会话 + expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() })).status).toBe(403); + expect((await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, ADMIN_KEY)).status).toBe(403); + const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION); expect(r.status).toBe(200); expect(r.body.data.purged).toEqual([oldId]); expect(await exists(a.body.data.trashed[0].trashPath)).toBe(false); @@ -212,3 +218,106 @@ describe.skipIf(skip)("files 删除与回收站", () => { expect(await exists(join(h.lib, "keep.txt"))).toBe(true); }); }); + +// 验收 07 S-5:还原到… 的目标根可写检查;原位还原逐级建目录不跟随符号链接;跨卷还原受上限、经 fsproxy。 +const xdev = findCrossDeviceBase(); +describe.skipIf(skip)("files 回收站还原守卫", () => { + let h: FmHarness; + beforeAll(async () => { + h = await createFmHarness("trash5", { options: { trashCrossMaxBytes: 10 } }); + }); + afterAll(async () => h?.close()); + + const trashOne = async (rel: string, content: string) => { + await mkdir(join(h.lib, dirname(rel)), { recursive: true }); + await writeFile(join(h.lib, rel), content); + const d = await h.api("POST", "/files/delete", { + idempotencyKey: key(), + items: [{ root: h.libRoot, path: rel }], + dryRun: false, + }); + expect(d.status).toBe(200); + return d.body.data.trashed[0].id as string; + }; + + it("还原到下载根 / register_only 库根 → 403 not_writable,回收站条目不动", async () => { + const id = await trashOne("ro/a.txt", "a"); + await mkdir(join(h.dl, "in"), { recursive: true }); + const toDl = await h.api("POST", "/files/trash/restore", { + idempotencyKey: key(), + ids: [id], + target: { root: h.dlRoot, path: "in" }, + }); + expect(toDl.status).toBe(403); + expect(toDl.body.error.message).toMatch(/^not_writable:/); + + const roDir = await mkdtemp(join(h.base, "ro-lib-")); + await writeFile(join(roDir, ".uhd-root"), "sentinel\n"); + const ro = await h.db + .insertInto("library_root") + .values({ name: "只登记库", media_kind: "mixed", local_path: roDir, management_mode: "register_only" }) + .returning("id") + .executeTakeFirstOrThrow(); + const toRo = await h.api("POST", "/files/trash/restore", { + idempotencyKey: key(), + ids: [id], + target: { root: `lib:${ro.id}`, path: "" }, + }); + expect(toRo.status).toBe(403); + expect(await exists(join(roDir, "a.txt"))).toBe(false); + const left = await h.db.selectFrom("trash_item").select("id").where("id", "=", id).execute(); + expect(left).toHaveLength(1); + }); + + it("原位还原:父目录被换成指向根外的符号链接 → path_escape,根外不会被建出目录", async () => { + const id = await trashOne("Esc/sub/file.txt", "f"); + await rm(join(h.lib, "Esc"), { recursive: true, force: true }); + await symlink(h.outside, join(h.lib, "Esc")); + const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] }); + expect(r.status).toBe(200); + expect(r.body.data.restored).toEqual([]); + expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "path_escape" })]); + expect(await exists(join(h.outside, "sub"))).toBe(false); + await rm(join(h.lib, "Esc")); + }); + + it("原位还原:父目录不存在时在根内逐级重建", async () => { + const id = await trashOne("Deep/er/x.txt", "x"); + await rm(join(h.lib, "Deep"), { recursive: true, force: true }); + const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] }); + expect(r.body.data.restored).toEqual([{ id, root: h.libRoot, path: "Deep/er/x.txt" }]); + expect(await readFile(join(h.lib, "Deep", "er", "x.txt"), "utf8")).toBe("x"); + }); + + it.skipIf(!xdev)( + "跨卷还原:超过 FILES_TRASH_CROSS_MAX_BYTES → too_large 不动;小文件经 fsproxy 复制后核验", + async () => { + const xlib = await mkdtemp(join(xdev!, "fm-xrestore-")); + try { + await writeFile(join(xlib, ".uhd-root"), "sentinel\n"); + const x = await h.db + .insertInto("library_root") + .values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" }) + .returning("id") + .executeTakeFirstOrThrow(); + const big = await trashOne("xv/big.txt", "0123456789ABCDEF"); + const small = await trashOne("xv/small.txt", "abc"); + const r = await h.api("POST", "/files/trash/restore", { + idempotencyKey: key(), + ids: [big, small], + target: { root: `lib:${x.id}`, path: "" }, + }); + expect(r.status).toBe(200); + expect(r.body.data.failed).toEqual([expect.objectContaining({ id: big, code: "too_large" })]); + expect(r.body.data.restored).toEqual([{ id: small, root: `lib:${x.id}`, path: "small.txt" }]); + expect(await readFile(join(xlib, "small.txt"), "utf8")).toBe("abc"); + expect(await exists(join(xlib, "big.txt"))).toBe(false); + const left = await h.db.selectFrom("trash_item").select("id").execute(); + expect(left.map((l) => l.id)).toContain(big); + expect(left.map((l) => l.id)).not.toContain(small); + } finally { + await rm(xlib, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/server/test/file-manager/w5a2.test.ts b/apps/server/test/file-manager/w5a2.test.ts index 46be3f88..1b9772a0 100644 --- a/apps/server/test/file-manager/w5a2.test.ts +++ b/apps/server/test/file-manager/w5a2.test.ts @@ -6,12 +6,14 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts"; import { fsErrorToFiles } from "../../src/modules/file-manager/common.ts"; import { + ADMIN_KEY, createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, READ_KEY, + SESSION, seedAsset, seedBinding, seedEntity, @@ -328,13 +330,18 @@ describe.skipIf(skip)("files W5-A2", () => { target_path: "gone", }); - const perm = await h.api("POST", "/files/delete", { - idempotencyKey: key(), - items: [{ root: h.libRoot, path: "gone/x.srt" }], - mode: "permanent", - confirmName: "x.srt", - dryRun: false, - }); + const perm = await h.api( + "POST", + "/files/delete", + { + idempotencyKey: key(), + items: [{ root: h.libRoot, path: "gone/x.srt" }], + mode: "permanent", + confirmName: "x.srt", + dryRun: false, + }, + SESSION, + ); expect(perm.status).toBe(200); const prec = await h.db .selectFrom("organization_record") @@ -357,29 +364,32 @@ describe.skipIf(skip)("files W5-A2", () => { const url = (path: string, extra = "") => `/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}${extra}`; - const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true")); + const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"), SESSION); expect(ok.status).toBe(200); expect(await ok.text()).toBe("JPEGDATA"); expect(ok.headers.get("content-type")).toBe("image/jpeg"); expect(ok.headers.get("content-disposition")).toMatch(/^inline; .*filename\*=UTF-8''%E6%B5%B7%E6%8A%A5\.jpg/); expect(ok.headers.get("x-content-type-options")).toBe("nosniff"); - const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true")); + const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"), SESSION); expect(nfo.headers.get("content-disposition")).toMatch(/^attachment;/); expect(await nfo.text()).toBe(""); expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, READ_KEY)).status).toBe(403); expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403); - const video = await h.api("GET", url("dl-test/v.mkv")); + // 验收 07 S-2:admin scope 的 Key 与 DEV key 同样 403(注册表 auth:"session",中间件执行) + expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, ADMIN_KEY)).status).toBe(403); + expect((await h.api("GET", url("dl-test/movie.nfo"))).status).toBe(403); + const video = await h.api("GET", url("dl-test/v.mkv"), undefined, SESSION); expect(video.status).toBe(403); - const big = await h.api("GET", url("dl-test/big.srt")); + const big = await h.api("GET", url("dl-test/big.srt"), undefined, SESSION); expect(big.status).toBe(413); expect(big.body.error.message).toMatch(/^too_large:/); - const leak = await h.api("GET", url("dl-test/leak.nfo")); + const leak = await h.api("GET", url("dl-test/leak.nfo"), undefined, SESSION); expect(leak.status).toBe(400); expect(leak.body.error.message).toMatch(/^path_escape:/); - expect((await h.api("GET", url("dl-test"))).status).toBe(400); - expect((await h.api("GET", url("dl-test/none.nfo"))).status).toBe(404); - expect((await h.api("GET", url("../outside/secret.txt"))).status).toBeGreaterThanOrEqual(400); + expect((await h.api("GET", url("dl-test"), undefined, SESSION)).status).toBe(400); + expect((await h.api("GET", url("dl-test/none.nfo"), undefined, SESSION)).status).toBe(404); + expect((await h.api("GET", url("../outside/secret.txt"), undefined, SESSION)).status).toBeGreaterThanOrEqual(400); const audits = await h.db .selectFrom("audit_event") diff --git a/apps/server/test/file-manager/write.test.ts b/apps/server/test/file-manager/write.test.ts index c45ec07e..ff413e5a 100644 --- a/apps/server/test/file-manager/write.test.ts +++ b/apps/server/test/file-manager/write.test.ts @@ -348,4 +348,80 @@ describe.skipIf(skip)("files 写操作", () => { expect(await readFile(join(h.lib, "A", "CopyMe.mkv"), "utf8")).toBe("copy"); expect(await exists(join(h.dl, "CopyMe.mkv"))).toBe(true); }); + it("多源 Move(验收 07 S-1):两个作品只授权一个 → 整体 403 并列出未授权项;都授权后放行;未归属作品的源只认审批", async () => { + const e1 = await seedEntity(h.db, { kind: "movie", title: "授权作品", year: 2011 }); + const e2 = await seedEntity(h.db, { kind: "movie", title: "未授权作品", year: 2012 }); + await mkdir(join(h.lib, "S1")); + await mkdir(join(h.lib, "S1dst")); + await mkdir(join(h.lib, "S1", "Two")); + await writeFile(join(h.lib, "S1", "One.2011.mkv"), "one"); + await writeFile(join(h.lib, "S1", "Two", "Two.2012.mkv"), "two"); + await seedAsset(h.db, h, { entityId: e1, rel: "S1/One.2011.mkv" }); + await seedAsset(h.db, h, { entityId: e2, rel: "S1/Two/Two.2012.mkv" }); + const g1 = await h.db + .insertInto("automation_grant") + .values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e1] }), granted_by: "test" }) + .returning("id") + .executeTakeFirstOrThrow(); + const body = { + items: [ + { root: h.libRoot, path: "S1/One.2011.mkv" }, + { root: h.libRoot, path: "S1/Two" }, + ], + target: { root: h.libRoot, path: "S1dst" }, + mode: "move", + }; + const run = async () => { + const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY); + expect(dry.status).toBe(200); + return h.api( + "POST", + "/files/move", + { ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId }, + EXEC_KEY, + ); + }; + const denied = await run(); + expect(denied.status).toBe(403); + expect(denied.body.error.message).toMatch(/^approval_required:/); + expect(denied.body.error.message).toContain("S1/Two"); + expect(denied.body.error.message).toContain(e2); + expect(denied.body.error.message).not.toContain("S1/One.2011.mkv("); + // 整体拒绝:两个源都没动 + expect(await exists(join(h.lib, "S1", "One.2011.mkv"))).toBe(true); + expect(await exists(join(h.lib, "S1", "Two", "Two.2012.mkv"))).toBe(true); + const audit = await h.db + .selectFrom("audit_event") + .select(["payload"]) + .where("action", "=", "files.move_denied") + .orderBy("at", "desc") + .executeTakeFirst(); + expect(JSON.stringify(audit?.payload)).toContain(e2); + + // 未登记文件(无法归属作品)混入:即使其余都有授权也拒绝 + await writeFile(join(h.lib, "S1", "Loose.mkv"), "loose"); + const g2 = await h.db + .insertInto("automation_grant") + .values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e2] }), granted_by: "test" }) + .returning("id") + .executeTakeFirstOrThrow(); + const loose = { ...body, items: [...body.items, { root: h.libRoot, path: "S1/Loose.mkv" }] }; + const ldry = await h.api("POST", "/files/move", { ...loose, idempotencyKey: key(), dryRun: true }, EXEC_KEY); + const ldenied = await h.api( + "POST", + "/files/move", + { ...loose, idempotencyKey: key(), dryRun: false, planId: ldry.body.data.planId }, + EXEC_KEY, + ); + expect(ldenied.status).toBe(403); + expect(ldenied.body.error.message).toContain("S1/Loose.mkv"); + expect(ldenied.body.error.message).toContain("一次性审批"); + + // 两个作品都被覆盖 → 放行 + const ok = await run(); + expect(ok.status).toBe(200); + expect(await exists(join(h.lib, "S1dst", "One.2011.mkv"))).toBe(true); + expect(await exists(join(h.lib, "S1dst", "Two", "Two.2012.mkv"))).toBe(true); + await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "in", [g1.id, g2.id]).execute(); + }); }); diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index c2b7f4d3..7cf25314 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1149,7 +1149,8 @@ export const Api = { method: "post", path: "/files/trash/purge", scope: "admin", - summary: "清空回收站(缺省只清过期项;服务端另要求 admin 控制台会话,API Key 403)", + auth: "session", + summary: "清空回收站(缺省只清过期项;仅管理员控制台会话,API Key 与 DEV key 一律 403)", body: files.FilesTrashPurgeRequest, response: files.FilesTrashPurgeResult, }), @@ -1181,7 +1182,8 @@ export const Api = { method: "get", path: "/files/download", scope: "admin", - summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;服务端另要求 admin 控制台会话;审计)", + auth: "session", + summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;仅管理员控制台会话;审计)", query: files.FilesDownloadQuery, response: files.FilesDownloadResponse, }),