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 <[email protected]>
This commit is contained in:
1 parent
9887d8b311
commit
17cf3cbf97
16 files changed
+587
-107
No files matched your search
@@ -162,16 +162,34 @@ export async function authorizeMove(
|
||||
body: Parameters<typeof requestKey>[0],
|
||||
entityId: string | null,
|
||||
): Promise<MoveAuthorization> {
|
||||
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<typeof requestKey>[0],
|
||||
sources: { label: string; entityId: string | null }[],
|
||||
opts: { consume?: boolean } = {},
|
||||
): Promise<MoveAuthorizationMulti> {
|
||||
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<string, { ok: true; id: string } | { ok: false; reasons: string[] }>();
|
||||
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<string>();
|
||||
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 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,7 +322,13 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
|
||||
|
||||
// ── 身份 / 授权 ──
|
||||
|
||||
/**
|
||||
* 口径(验收 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");
|
||||
|
||||
// ── 小工具 ──
|
||||
|
||||
@@ -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<Response> {
|
||||
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);
|
||||
|
||||
@@ -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<string, FileOverrideInput>;
|
||||
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);
|
||||
|
||||
@@ -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<string[]> {
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<yyyymmdd>/<uuid>/<原相对路径>,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<ReturnType<typeof checkItem>>[] = [];
|
||||
@@ -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<void> {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user