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:
rootandClaude Opus 5.5 committed 2026-09-28 00:45:56 +08:00
1 parent 9887d8b311
commit 17cf3cbf97
16 files changed
+587 -107

No files matched your search

+25 -15
View File
@@ -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("<movie/>");
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")