fix: logic-audit P0/P1 wave — CORS PUT, chunked cap, presign checksums, logout await, refresh rotation race

From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
  at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
  completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
  x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
  requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
  ignored rowCount) forking the token family; consume now returns whether
  it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
This commit is contained in:
glass beadian 2026-10-18 10:18:02 +00:00
parent e475402b4d
commit b24ebc5862
19 changed files with 1636 additions and 101 deletions

View file

@ -0,0 +1 @@
ALTER TABLE groups ADD COLUMN member_delete_own boolean NOT NULL DEFAULT true;

View file

@ -0,0 +1,65 @@
-- Folder trees and personal files are metadata over the existing payload keys.
CREATE TABLE file_folders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
group_id uuid NULL REFERENCES groups(id) ON DELETE CASCADE,
owner_id uuid NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id uuid NULL REFERENCES file_folders(id) ON DELETE CASCADE,
name text NOT NULL CHECK (btrim(name) <> '' AND char_length(name) <= 255),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT file_folders_scope CHECK ((group_id IS NULL) <> (owner_id IS NULL)),
UNIQUE (id, workspace_id),
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE
);
CREATE TRIGGER trg_file_folders_ws_immutable BEFORE UPDATE ON file_folders
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE file_folders ENABLE ROW LEVEL SECURITY;
ALTER TABLE file_folders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON file_folders
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON file_folders TO sp_app;
ALTER TABLE chat_media_archive
ALTER COLUMN group_id DROP NOT NULL,
ADD COLUMN folder_id uuid NULL REFERENCES file_folders(id) ON DELETE SET NULL,
ADD COLUMN created_by uuid NULL,
-- Required even at the personal root, which has no folder from which to infer ownership.
ADD COLUMN owner_id uuid NULL REFERENCES users(id) ON DELETE CASCADE,
ADD CONSTRAINT chat_media_archive_scope CHECK ((group_id IS NULL) <> (owner_id IS NULL));
-- 045 may already have introduced this setting.
ALTER TABLE groups ADD COLUMN IF NOT EXISTS member_delete_own boolean NOT NULL DEFAULT true;
-- Only exact, known identities are mapped. New authenticated writes supply
-- created_by; Matrix ingest also resolves the configured homeserver in code.
UPDATE chat_media_archive a SET created_by = (
SELECT u.id FROM users u
WHERE (a.sender_mxid = '@' || u.username || ':tmbh.local' OR a.sender_mxid = u.external_id)
AND EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = a.workspace_id AND m.user_id = u.id)
ORDER BY (a.sender_mxid = u.external_id) DESC, u.id LIMIT 1
) WHERE a.created_by IS NULL;
-- Other ingest clients (including archive-bot) write this table directly.
-- Keep exact known sender mappings attributed on those inserts too. Unknown
-- remote identities stay NULL, so they never acquire member delete rights.
CREATE FUNCTION archive_file_creator() RETURNS trigger
LANGUAGE plpgsql SET search_path = public AS $$
BEGIN
IF NEW.created_by IS NULL THEN
SELECT u.id INTO NEW.created_by FROM users u
WHERE (NEW.sender_mxid = '@' || u.username || ':tmbh.local' OR NEW.sender_mxid = u.external_id)
AND EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = NEW.workspace_id AND m.user_id = u.id)
ORDER BY (NEW.sender_mxid = u.external_id) DESC, u.id LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_chat_media_archive_creator BEFORE INSERT ON chat_media_archive
FOR EACH ROW EXECUTE FUNCTION archive_file_creator();
CREATE INDEX file_folders_scope_parent_idx ON file_folders (workspace_id, group_id, owner_id, parent_id);
CREATE INDEX chat_media_archive_folder_idx ON chat_media_archive (workspace_id, folder_id);
CREATE INDEX chat_media_archive_personal_idx ON chat_media_archive (workspace_id, owner_id, created_at DESC, id DESC)
WHERE group_id IS NULL;

View file

@ -51,12 +51,13 @@ export class AuthController {
/** Signed session present ⇒ its server-side row is revoked (phase 2). Legacy tokens: client-side only. */
@Post("logout")
@HttpCode(200)
logout(@Req() request: Request): { ok: true } {
async logout(@Req() request: Request): Promise<{ ok: true }> {
const authorization = request.headers.authorization;
const session = authorization !== undefined && authorization.startsWith("Bearer ")
? verifySession(authorization.slice("Bearer ".length))
: null;
void this.auth.logout(session);
if (session === null) return { ok: true };
await this.auth.logout(session);
return { ok: true };
}
}

View file

@ -1,10 +1,11 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { BadRequestException, Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js";
import { GroupFilesService, type FilePage } from "./group-files.service.js";
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
import type { FileFolder, GroupFile } from "./group-files.types.js";
import { FileRangeNotSatisfiableException } from "./playback-range.js";
import { verifyWhileStreaming } from "./verify-stream.js";
const MAX_FILE_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
@ -23,6 +24,9 @@ interface FileUpload {
* GET /files/direct/config direct-upload capability + server public JWK
* POST /files/direct/init presigned PUT for a browser-sealed upload
* POST /files/direct/complete HEAD-checked commit + index row
* POST /files/:id/playback mint a 10-minute playback capability
* GET /files/play/:ticket public capability-authenticated byte range
* DELETE /files/:id delete own upload (group setting) or Owner/Admin
* POST /files/:id/direct-download presigned GET + unwrapped CEK
* POST /files/groups/:groupId/upload direct web upload (≤2 GiB, multipart)
* GET /files/:id one GroupFile
@ -53,6 +57,61 @@ export class GroupFilesController {
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
}
@Post("folders")
@RequirePermission("media.view")
async createFolder(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<FileFolder> {
const s = requireSession(request);
return this.files.createFolder(s.workspaceId, s, requireObject(body, "body"));
}
@Get("folders")
@RequirePermission("media.view")
async listFolders(@Req() request: Request & AuthenticatedRequest, @Query() query: Record<string, unknown>): Promise<FileFolder[]> {
const s = requireSession(request);
return this.files.listFolders(s.workspaceId, s, query);
}
@Patch("folders/:id")
@RequirePermission("media.view")
async updateFolder(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<FileFolder> {
const s = requireSession(request);
return this.files.updateFolder(s.workspaceId, id, s, requireObject(body, "body"));
}
@Delete("folders/:id")
@RequirePermission("media.view")
async deleteFolder(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<{ ok: true }> {
const s = requireSession(request);
return this.files.deleteFolder(s.workspaceId, id, s);
}
@Get("groups/:groupId/settings")
@RequirePermission("media.view")
async groupSettings(@Req() request: Request & AuthenticatedRequest, @Param("groupId", ParseUUIDPipe) groupId: string): Promise<{ memberDeleteOwn: boolean }> {
const s = requireSession(request);
return this.files.getGroupFileSettings(s.workspaceId, groupId, s);
}
@Patch("groups/:groupId/settings")
@RequirePermission("media.view")
async updateGroupSettings(@Req() request: Request & AuthenticatedRequest, @Param("groupId", ParseUUIDPipe) groupId: string, @Body() body: unknown): Promise<{ memberDeleteOwn: boolean }> {
const s = requireSession(request);
return this.files.setGroupFileSettings(s.workspaceId, groupId, s, requireObject(body, "body"));
}
@Post("personal/upload")
@RequirePermission("media.view")
@UseInterceptors(FileInterceptor("file", {
limits: { fileSize: MAX_FILE_UPLOAD_BYTES, files: 1, fields: 3, fieldSize: 1024, parts: 4 },
}))
async uploadPersonal(@Req() request: Request & AuthenticatedRequest, @UploadedFile() file?: FileUpload, @Body() body?: Record<string, unknown>): Promise<GroupFile> {
const s = requireSession(request);
if (file === undefined) throw new BadRequestException("a file is required (field: file)");
return this.files.uploadForGroup(s.workspaceId, null, s,
{ buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname },
{ personal: true, folderId: body?.folderId });
}
/**
* Direct-upload capability. Declared before the :id routes so the literal
* "direct/config" path wins route matching over ":id".
@ -71,13 +130,15 @@ export class GroupFilesController {
): Promise<ReturnType<GroupFilesService["initDirectUpload"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.files.initDirectUpload(session.workspaceId, requireUuidValue(dto.groupId, "groupId"), {
return this.files.initDirectUpload(session.workspaceId, dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"), {
userId: session.userId,
role: session.role,
}, {
filename: requireString(dto.filename, "filename"),
mimeType: requireString(dto.mimeType, "mimeType"),
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
personal: dto.personal,
folderId: dto.folderId,
});
}
@ -92,7 +153,9 @@ export class GroupFilesController {
return this.files.completeDirectUpload(session.workspaceId, { userId: session.userId, role: session.role }, {
uploadId: requireUuidValue(dto.uploadId, "uploadId"),
objectKey: requireString(dto.objectKey, "objectKey"),
groupId: requireUuidValue(dto.groupId, "groupId"),
groupId: dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"),
personal: dto.personal,
folderId: dto.folderId,
filename: requireString(dto.filename, "filename"),
mimeType: requireString(dto.mimeType, "mimeType"),
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
@ -111,6 +174,20 @@ export class GroupFilesController {
return this.files.openDirectDownload(session.workspaceId, session.userId, session.role, id);
}
@Post(":id/playback")
@HttpCode(200)
@RequirePermission("media.view")
async openPlayback(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<ReturnType<GroupFilesService["openRangePlayback"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.files.openRangePlayback(session.workspaceId, session.userId, session.role, id,
requireNumber(dto.start, "start"), requireNumber(dto.length, "length"));
}
@Post("groups/:groupId/upload")
@RequirePermission("media.view")
@UseInterceptors(FileInterceptor("file", {
@ -120,6 +197,7 @@ export class GroupFilesController {
@Req() request: Request & AuthenticatedRequest,
@Param("groupId", ParseUUIDPipe) groupId: string,
@UploadedFile() file?: FileUpload,
@Body() body?: Record<string, unknown>,
): Promise<GroupFile> {
const session = requireSession(request);
if (file === undefined) throw new BadRequestException("a file is required (field: file)");
@ -128,9 +206,32 @@ export class GroupFilesController {
groupId,
{ userId: session.userId, role: session.role },
{ buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname },
{ folderId: body?.folderId },
);
}
@Post(":id/move")
@HttpCode(200)
@RequirePermission("media.view")
async move(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<GroupFile> {
const s = requireSession(request);
return this.files.moveFile(s.workspaceId, id, s, requireObject(body, "body"));
}
@Post(":id/copy")
@RequirePermission("media.view")
async copy(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<GroupFile> {
const s = requireSession(request);
return this.files.copyFile(s.workspaceId, id, s, requireObject(body, "body"));
}
@Post(":id/share-to-group")
@RequirePermission("media.view")
async shareToGroup(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<GroupFile> {
const s = requireSession(request);
return this.files.shareToGroup(s.workspaceId, id, s, requireObject(body, "body"));
}
@Get(":id")
@RequirePermission("media.view")
async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
@ -159,6 +260,13 @@ export class GroupFilesController {
response.end();
}
@Delete(":id")
@RequirePermission("media.view")
async deleteFile(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.deleteFile(session.workspaceId, id, { userId: session.userId, role: session.role });
}
@Post(":id/retention")
@RequirePermission("media.manage")
async setRetention(
@ -215,6 +323,40 @@ export class GroupFilesController {
}
}
/** No session guard: the short-lived, single-file ticket is the credential. */
@Controller("files/play")
export class FilePlaybackController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get(":ticket")
async play(
@Param("ticket") ticket: string,
@Query() query: Record<string, unknown>,
@Headers("range") range: string | undefined,
@Res() response: Response,
): Promise<void> {
response.setHeader("Cache-Control", "no-store");
response.setHeader("Accept-Ranges", "bytes");
try {
const slice = await this.files.readPlaybackTicket(ticket, query.start, query.end, range);
response.status(206);
response.setHeader("Content-Range", `bytes ${slice.start}-${slice.end}/${slice.total}`);
response.setHeader("Content-Type", slice.item.mimeType);
response.setHeader("Content-Length", String(slice.bytes.length));
response.flushHeaders();
// The storage reader verifies each segment's GCM tag before returning bytes.
response.write(slice.bytes);
response.end();
} catch (error) {
if (!(error instanceof FileRangeNotSatisfiableException)) throw error;
response.status(416);
response.setHeader("Content-Range", `bytes */${error.total}`);
response.setHeader("Content-Length", "0");
response.end();
}
}
}
/**
* Public share-claim surface: NO RbacGuard (the claimer holds only the
* token). Token lives in the path — same shape as the invite claim routes.

View file

@ -0,0 +1,372 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import type { PoolClient } from "pg";
import { GroupFilesService, type ActorRef, type ArchiveEntryInput } from "./group-files.service.js";
import { GroupFilesController } from "./group-files.controller.js";
import { PATH_METADATA, METHOD_METADATA } from "@nestjs/common/constants.js";
import { RequestMethod } from "@nestjs/common";
import type { Request } from "express";
import { directStorageDisabled, type StorageService } from "../storage/storage.js";
const ws = "22222222-2222-4222-8222-222222222222";
const group = "33333333-3333-4333-8333-333333333333";
const otherGroup = "44444444-4444-4444-8444-444444444444";
const alice = "55555555-5555-4555-8555-555555555555";
const bob = "66666666-6666-4666-8666-666666666666";
const now = new Date("2026-01-02T03:04:05Z");
const member: ActorRef = { userId: alice, role: "Publisher" };
type Row = Record<string, unknown>;
let folders: Row[];
let files: Row[];
let statements: { sql: string; values: unknown[] }[];
let memberOf: string[];
let memberDeleteOwn: boolean;
let storage: StorageService;
let service: GroupFilesService;
let mappedCreator: string | null;
function folder(patch: Row = {}): Row {
return { id: randomUUID(), workspace_id: ws, group_id: group, owner_id: null, parent_id: null, name: "Footage", created_at: now, ...patch };
}
function file(patch: Row = {}): Row {
return { id: randomUUID(), workspace_id: ws, group_id: group, owner_id: null, folder_id: null, created_by: alice,
room_id: null, event_id: null, origin: "upload", sender_mxid: "@alice:tmbh.local", matrix_event_json: { kind: "upload" },
original_name: "clip.mp4", safe_name: "clip.mp4", mime: "video/mp4", mime_class: "video", byte_size: "10",
sha256: "a".repeat(64), ocis_resource_id: "sha256/same.mp4", ocis_resource_version: null, storage_key: "sha256/same.mp4",
created_at: now, indexed_at: now, available: true, retention_mode: "keep", legal_hold: false, retention_until: null,
share_access: null, share_expires_at: null, share_revoked_at: null, ...patch };
}
// Stateful metadata fake: exercise the service through public operations and
// independently inspect row identity, placement, provenance and storage calls.
async function query(sql: string, values: unknown[] = []): Promise<{ rows: Row[]; rowCount: number }> {
statements.push({ sql, values });
let rows: Row[] = [];
if (sql.includes("pg_advisory_xact_lock") || sql.startsWith("SELECT audit_append")) rows = [{}];
else if (sql.startsWith("SELECT 1 FROM groups")) rows = values[0] === group || values[0] === otherGroup ? [{}] : [];
else if (sql.includes("FROM group_members")) rows = memberOf.includes(String(values[0])) ? [{}] : [];
else if (sql.includes("member_delete_own FROM groups")) rows = [{ member_delete_own: memberDeleteOwn }];
else if (sql.startsWith("UPDATE groups SET member_delete_own")) { memberDeleteOwn = Boolean(values[1]); rows = [{}]; }
else if (sql.startsWith("SELECT u.id FROM users")) rows = mappedCreator === null ? [] : [{ id: mappedCreator }];
else if (sql.startsWith("SELECT username FROM users")) rows = [{ username: "alice" }];
else if (sql.startsWith("SELECT * FROM file_folders WHERE id")) rows = folders.filter((f) => f.id === values[0]);
else if (sql.startsWith("SELECT * FROM file_folders WHERE workspace_id")) rows = folders.filter((f) => f.group_id === values[1] && f.owner_id === values[2]);
else if (sql.startsWith("INSERT INTO file_folders")) {
const f = folder({ workspace_id: values[0], group_id: values[1], owner_id: values[2], parent_id: values[3], name: values[4] });
folders.push(f); rows = [f];
} else if (sql.startsWith("UPDATE file_folders")) {
rows = folders.filter((f) => f.id === values[0]);
rows.forEach((f) => Object.assign(f, { name: values[1], parent_id: values[2] }));
} else if (sql.includes("WITH RECURSIVE descendants")) {
const ids = new Set([values[0]]);
let size = 0;
while (ids.size !== size) { size = ids.size; folders.forEach((f) => { if (ids.has(f.parent_id)) ids.add(f.id); }); }
rows = ids.has(values[1]) ? [{ id: values[1] }] : [];
} else if (sql.startsWith("SELECT id FROM file_folders WHERE parent_id")) {
rows = [...folders.filter((f) => f.parent_id === values[0]), ...files.filter((f) => f.folder_id === values[0])];
} else if (sql.startsWith("DELETE FROM file_folders")) {
folders = folders.filter((f) => f.id !== values[0]);
files.filter((f) => f.folder_id === values[0]).forEach((f) => { f.folder_id = null; });
} else if (sql.includes("FROM chat_media_archive a")) {
if (sql.includes("WHERE a.id = $1")) rows = files.filter((f) => f.id === values[0]);
else rows = files; // Listing predicates are asserted separately below.
} else if (sql.startsWith("UPDATE chat_media_archive SET folder_id")) {
files.filter((f) => f.id === values[0]).forEach((f) => { f.folder_id = values[1]; });
} else if (sql.startsWith("UPDATE chat_media_archive SET available")) {
files.filter((f) => f.id === values[0]).forEach((f) => { f.available = false; });
} else if (sql.startsWith("INSERT INTO chat_media_archive")) {
let f: Row;
if (sql.includes("SELECT workspace_id")) {
const source = files.find((item) => item.id === values[0])!;
f = { ...source, id: randomUUID(), group_id: values[1], owner_id: values[2], folder_id: values[3], created_by: values[4],
room_id: null, event_id: null, origin: values[5], matrix_event_json: JSON.parse(String(values[6])), original_name: values[7], safe_name: values[8] };
} else {
f = file({ workspace_id: values[0], group_id: values[1], room_id: values[2], event_id: values[3], origin: values[4], sender_mxid: values[5],
matrix_event_json: JSON.parse(String(values[6])), original_name: values[7], safe_name: values[8], mime: values[9], mime_class: values[10], byte_size: values[11],
sha256: values[12], ocis_resource_id: values[13], ocis_resource_version: values[14], created_at: values[15], storage_key: values[16], folder_id: values[17], created_by: values[18], owner_id: values[19] });
}
files.push(f); rows = [{ id: f.id }];
} else if (sql.startsWith("UPDATE group_file_shares")) rows = [];
else throw new Error(`unhandled SQL: ${sql}`);
return { rows, rowCount: rows.length };
}
vi.mock("../db/client.js", () => ({
withTenant: async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) => fn({ query } as unknown as PoolClient),
withSystem: vi.fn(),
}));
beforeEach(() => {
folders = []; files = []; statements = []; memberOf = [group]; memberDeleteOwn = true; mappedCreator = null;
storage = { ...directStorageDisabled(), putContent: vi.fn(async () => ({ key: "sha256/same.mp4", hash: "a".repeat(64), sizeBytes: 10, contentType: "video/mp4", deduplicated: true })),
getObject: vi.fn(), hasContent: vi.fn(), presign: vi.fn(), getSealedObject: vi.fn() };
service = new GroupFilesService({ storage });
});
afterEach(() => { vi.unstubAllEnvs(); });
describe("folder trees", () => {
it("creates, lists, renames, reparents and deletes empty folders", async () => {
const parent = await service.createFolder(ws, member, { groupId: group, name: " Parent " });
const child = await service.createFolder(ws, member, { groupId: group, name: "Child", parentId: parent.id });
expect(parent.name).toBe("Parent");
expect(child).toMatchObject({ parentId: parent.id, groupId: group, ownerId: null });
expect(await service.listFolders(ws, member, { scope: "group", groupId: group })).toHaveLength(2);
await expect(service.deleteFolder(ws, parent.id, member)).rejects.toMatchObject({ status: 409 });
const renamed = await service.updateFolder(ws, child.id, member, { name: "New" });
expect(renamed).toMatchObject({ name: "New", parentId: parent.id });
expect(await service.updateFolder(ws, child.id, member, { parentId: null })).toMatchObject({ parentId: null, name: "New" });
await expect(service.deleteFolder(ws, parent.id, member)).resolves.toEqual({ ok: true });
expect(folders.map((f) => f.id)).toEqual([child.id]);
});
it.each(["Publisher", "Owner", "Admin"])("allows %s group folder CRUD with the group ACL", async (role) => {
const actor = { userId: alice, role };
if (role !== "Publisher") memberOf = [];
const f = await service.createFolder(ws, actor, { groupId: group, name: "Test" });
expect(await service.listFolders(ws, actor, { scope: "group", groupId: group })).toHaveLength(1);
await service.updateFolder(ws, f.id, actor, { name: "Changed" });
await expect(service.deleteFolder(ws, f.id, actor)).resolves.toEqual({ ok: true });
});
it.each(["create", "list", "update", "delete"])("denies non-member folder %s", async (operation) => {
const f = folder(); folders.push(f); memberOf = [];
const actions = {
create: () => service.createFolder(ws, member, { groupId: group, name: "Test" }),
list: () => service.listFolders(ws, member, { scope: "group", groupId: group }),
update: () => service.updateFolder(ws, String(f.id), member, { name: "Changed" }),
delete: () => service.deleteFolder(ws, String(f.id), member),
};
await expect(actions[operation as keyof typeof actions]()).rejects.toMatchObject({ status: 403 });
expect(statements.some((s) => /^(INSERT|UPDATE|DELETE)/.test(s.sql))).toBe(false);
});
it("keeps personal trees private, including from other workspace Owner/Admin", async () => {
const f = await service.createFolder(ws, member, { personal: true, name: "Private" });
expect(f).toMatchObject({ groupId: null, ownerId: alice });
expect(await service.listFolders(ws, member, { scope: "personal" })).toHaveLength(1);
for (const role of ["Publisher", "Owner", "Admin"]) {
const actor = { userId: bob, role };
expect(await service.listFolders(ws, actor, { scope: "personal" })).toEqual([]);
await expect(service.updateFolder(ws, f.id, actor, { name: "Stolen" })).rejects.toMatchObject({ status: 403 });
await expect(service.deleteFolder(ws, f.id, actor)).rejects.toMatchObject({ status: 403 });
}
await service.updateFolder(ws, f.id, member, { name: "Renamed" });
await expect(service.deleteFolder(ws, f.id, member)).resolves.toEqual({ ok: true });
});
it("rejects cycles, cross-scope parents, invalid names, missing folders and nonempty deletion", async () => {
const parent = await service.createFolder(ws, member, { groupId: group, name: "Parent" });
const child = await service.createFolder(ws, member, { groupId: group, name: "Child", parentId: parent.id });
await expect(service.updateFolder(ws, parent.id, member, { parentId: parent.id })).rejects.toMatchObject({ status: 409 });
await expect(service.updateFolder(ws, parent.id, member, { parentId: child.id })).rejects.toMatchObject({ status: 409 });
const personal = await service.createFolder(ws, member, { personal: true, name: "Private" });
await expect(service.updateFolder(ws, parent.id, member, { parentId: personal.id })).rejects.toMatchObject({ status: 400 });
await expect(service.createFolder(ws, member, { groupId: group, parentId: personal.id, name: "Wrong" })).rejects.toMatchObject({ status: 400 });
for (const name of [" ", "a/b", "a\\b", "a".repeat(256)]) {
await expect(service.createFolder(ws, member, { groupId: group, name })).rejects.toMatchObject({ status: 400 });
}
await expect(service.updateFolder(ws, randomUUID(), member, { name: "X" })).rejects.toMatchObject({ status: 404 });
files.push(file({ folder_id: child.id }));
await expect(service.deleteFolder(ws, child.id, member)).rejects.toMatchObject({ status: 409 });
files[0]!.available = false;
await expect(service.deleteFolder(ws, child.id, member)).rejects.toMatchObject({ status: 409 });
});
});
describe("file scopes, moves, copies and sharing", () => {
it("binds personal listings to caller and folder, and default listings to group roots", async () => {
const f = folder({ group_id: null, owner_id: alice }); folders.push(f);
await service.listFiles(ws, alice, "Owner", { scope: "personal", folderId: f.id });
const listing = statements.find((s) => s.sql.includes("FROM chat_media_archive a"))!;
expect(listing.sql).toContain("a.group_id IS NULL AND a.owner_id = $2::uuid");
expect(listing.sql).toContain("a.folder_id = $3::uuid");
expect(listing.values).toEqual([ws, alice, f.id, 51]);
statements = [];
await service.listFiles(ws, alice, "Owner", {});
const roots = statements.find((s) => s.sql.includes("FROM chat_media_archive a"))!;
expect(roots.sql).toContain("a.group_id IS NOT NULL");
expect(roots.sql).toContain("a.folder_id IS NULL");
await expect(service.listFiles(ws, alice, "Owner", { scope: "personal", groupId: group })).rejects.toMatchObject({ status: 400 });
await expect(service.listFiles(ws, bob, "Owner", { scope: "personal", folderId: f.id })).rejects.toMatchObject({ status: 403 });
});
it.each(["Publisher", "Owner", "Admin"])("allows %s metadata moves in groups and returns to root", async (role) => {
const f = folder(); folders.push(f); const source = file(); files.push(source);
const moved = await service.moveFile(ws, String(source.id), { userId: alice, role }, { folderId: f.id });
expect(moved.folderId).toBe(f.id);
expect(await service.moveFile(ws, String(source.id), { userId: alice, role }, { folderId: null })).toMatchObject({ folderId: null });
expect(files).toHaveLength(1);
expect(storage.putContent).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
});
it("copies a new row with identical payload key and checksum while move preserves identity", async () => {
const f = folder(); folders.push(f); const source = file(); files.push(source);
const snapshot = structuredClone(source);
const copy = await service.copyFile(ws, String(source.id), member, { targetFolderId: f.id });
expect(copy).toMatchObject({ name: "clip.mp4 (copy)", folderId: f.id, createdBy: alice, checksum: { value: source.sha256 }, file: { storageKey: source.storage_key } });
expect(copy.id).not.toBe(source.id); expect(files).toHaveLength(2); expect(source).toEqual(snapshot);
await service.moveFile(ws, String(source.id), member, { folderId: f.id });
expect(files).toHaveLength(2); expect(source.id).toBe(snapshot.id); expect(source.folder_id).toBe(f.id);
expect(storage.putContent).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
});
it("copies into another accessible group root and personal folder", async () => {
memberOf.push(otherGroup);
const source = file(); files.push(source);
expect(await service.copyFile(ws, String(source.id), member, { targetGroupId: otherGroup })).toMatchObject({ groupId: otherGroup, folderId: null });
const personal = folder({ group_id: null, owner_id: alice }); folders.push(personal);
expect(await service.copyFile(ws, String(source.id), member, { targetFolderId: personal.id })).toMatchObject({ groupId: null, ownerId: alice, folderId: personal.id });
await expect(service.copyFile(ws, String(source.id), member, {})).rejects.toMatchObject({ status: 400 });
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: group, targetFolderId: personal.id })).rejects.toMatchObject({ status: 400 });
});
it("shares a personal file into group root, preserving original and payload", async () => {
const source = file({ group_id: null, owner_id: alice }); files.push(source);
const snapshot = structuredClone(source);
const shared = await service.shareToGroup(ws, String(source.id), member, { groupId: group });
expect(shared).toMatchObject({ groupId: group, ownerId: null, folderId: null, shared: true, origin: { kind: "upload", sharedFrom: "personal" }, name: source.original_name,
checksum: { value: source.sha256 }, file: { storageKey: source.storage_key }, createdBy: alice });
expect(shared.id).not.toBe(source.id); expect(files).toHaveLength(2); expect(source).toEqual(snapshot);
expect(storage.putContent).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
});
it("enforces source and destination ACLs, same-scope moves and personal ownership", async () => {
const source = file(); files.push(source);
const foreign = folder({ group_id: otherGroup }); folders.push(foreign);
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: otherGroup })).rejects.toMatchObject({ status: 403 });
await expect(service.moveFile(ws, String(source.id), member, { folderId: foreign.id })).rejects.toMatchObject({ status: 403 });
memberOf.push(otherGroup);
await expect(service.moveFile(ws, String(source.id), member, { folderId: foreign.id })).rejects.toMatchObject({ status: 400 });
await expect(service.moveFile(ws, String(source.id), member, {})).rejects.toMatchObject({ status: 400 });
await expect(service.shareToGroup(ws, String(source.id), member, { groupId: group })).rejects.toMatchObject({ status: 400 });
memberOf = [];
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: otherGroup })).rejects.toMatchObject({ status: 403 });
const personal = file({ group_id: null, owner_id: alice }); files.push(personal);
for (const role of ["Publisher", "Owner", "Admin"]) {
const stranger = { userId: bob, role };
await expect(service.getFile(ws, bob, role, String(personal.id))).rejects.toMatchObject({ status: 403 });
await expect(service.moveFile(ws, String(personal.id), stranger, { folderId: null })).rejects.toMatchObject({ status: 403 });
await expect(service.copyFile(ws, String(personal.id), stranger, { targetGroupId: group })).rejects.toMatchObject({ status: 403 });
await expect(service.shareToGroup(ws, String(personal.id), stranger, { groupId: group })).rejects.toMatchObject({ status: 403 });
await expect(service.deleteFile(ws, String(personal.id), stranger)).rejects.toMatchObject({ status: 403 });
}
await expect(service.shareToGroup(ws, String(personal.id), member, { groupId: group })).rejects.toMatchObject({ status: 403 });
const privateFolder = folder({ group_id: null, owner_id: alice }); folders.push(privateFolder);
expect(await service.moveFile(ws, String(personal.id), member, { folderId: privateFolder.id })).toMatchObject({ folderId: privateFolder.id });
memberDeleteOwn = false;
await expect(service.deleteFile(ws, String(personal.id), member)).resolves.toMatchObject({ status: "revoked" });
});
it.each([{ available: false }, { retention_mode: "until", retention_until: new Date("2020-01-01") }])("refuses copies/moves/shares of unavailable files: %j", async (patch) => {
const source = file({ group_id: null, owner_id: alice, ...patch }); files.push(source);
await expect(service.moveFile(ws, String(source.id), member, { folderId: null })).rejects.toMatchObject({ status: 410 });
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: group })).rejects.toMatchObject({ status: 410 });
await expect(service.shareToGroup(ws, String(source.id), member, { groupId: group })).rejects.toMatchObject({ status: 410 });
expect(files).toHaveLength(1);
});
});
describe("creator attribution, upload destinations and delete settings", () => {
function entry(patch: Partial<ArchiveEntryInput> = {}): ArchiveEntryInput {
return { groupId: group, roomId: null, eventId: null, origin: { kind: "upload" }, originKind: "upload", senderMxid: "@alice:tmbh.local",
originalName: "clip.mp4", safeName: "clip.mp4", mime: "video/mp4", mimeClass: "video", byteSize: 10, sha256: "a".repeat(64),
ocisResourceId: "sha256/same.mp4", storageKey: "sha256/same.mp4", ocisResourceVersion: null, createdAt: now, ...patch };
}
it("maps sender identities for ingest and preserves explicit authenticated creators", async () => {
mappedCreator = alice;
const { id } = await service.registerArchiveEntry(ws, entry({ originKind: "matrix" }));
expect(files.find((f) => f.id === id)?.created_by).toBe(alice);
const mapping = statements.find((s) => s.sql.startsWith("SELECT u.id"))!;
expect(mapping.values).toEqual([ws, "@alice:tmbh.local", "alice"]);
mappedCreator = null;
const explicit = await service.registerArchiveEntry(ws, entry({ createdBy: bob }));
expect(files.find((f) => f.id === explicit.id)?.created_by).toBe(bob);
const unknown = await service.registerArchiveEntry(ws, entry({ senderMxid: "@alice:foreign.example" }));
expect(files.find((f) => f.id === unknown.id)?.created_by).toBeNull();
});
it("uploads into group/personal folders or roots and attributes the authenticated creator", async () => {
const f = folder(); const personal = folder({ group_id: null, owner_id: alice }); folders.push(f, personal);
const input = { buffer: Buffer.from("hello"), mimetype: "text/plain", originalName: "hello.txt" };
expect(await service.uploadForGroup(ws, group, member, input, { folderId: f.id })).toMatchObject({ groupId: group, folderId: f.id, createdBy: alice });
expect(await service.uploadForGroup(ws, null, member, input, { personal: true, folderId: personal.id })).toMatchObject({ groupId: null, ownerId: alice, folderId: personal.id, createdBy: alice });
expect(await service.uploadForGroup(ws, null, member, input, { personal: true })).toMatchObject({ groupId: null, ownerId: alice, folderId: null });
await expect(service.uploadForGroup(ws, group, member, input, { folderId: personal.id })).rejects.toMatchObject({ status: 400 });
expect(storage.putContent).toHaveBeenCalledTimes(3);
});
it("resolves configured Hub Matrix identities without trusting foreign localparts", async () => {
vi.stubEnv("MATRIX_SSO_HOMESERVER_URL", "https://chat.example.org");
vi.stubEnv("MATRIX_SSO_LOCALPART_PREFIX", "hub_");
mappedCreator = alice;
await service.registerArchiveEntry(ws, entry({ senderMxid: "@hub_alice:chat.example.org", originKind: "matrix" }));
const own = statements.find((s) => s.sql.startsWith("SELECT u.id"))!;
expect(own.values).toEqual([ws, "@hub_alice:chat.example.org", "alice"]);
statements = [];
await service.registerArchiveEntry(ws, entry({ senderMxid: "@hub_alice:foreign.example", originKind: "matrix" }));
const foreign = statements.find((s) => s.sql.startsWith("SELECT u.id"))!;
expect(foreign.values).toEqual([ws, "@hub_alice:foreign.example", null]);
});
it("accepts personal direct upload init/complete and rejects cross-scope destinations", async () => {
const f = folder({ group_id: null, owner_id: alice }); folders.push(f);
storage.directPublicJwk = vi.fn(async () => ({ kty: "EC" as const, crv: "P-256" as const, x: "x", y: "y" }));
storage.presignPut = vi.fn(async () => "https://storage.example/put");
storage.headObject = vi.fn(async () => ({ exists: true, contentLength: 177 }));
const upload = await service.initDirectUpload(ws, null, member, { filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 177, personal: true, folderId: f.id });
const result = await service.completeDirectUpload(ws, member, { ...upload, personal: true, folderId: f.id, filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 177, sha256: "a".repeat(64) });
expect(result).toMatchObject({ groupId: null, ownerId: alice, folderId: f.id, createdBy: alice, file: { storageKey: upload.objectKey } });
await expect(service.initDirectUpload(ws, group, member, { filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 177, folderId: f.id })).rejects.toMatchObject({ status: 400 });
});
it.each(["matrix", "bridge", "pad-export", "upload"])("allows deleting creator-owned %s rows regardless of sender text", async (origin) => {
const source = file({ origin, created_by: alice, sender_mxid: "@someone:else.example" }); files.push(source);
await expect(service.deleteFile(ws, String(source.id), member)).resolves.toMatchObject({ status: "revoked" });
expect(files).toHaveLength(1); expect(source.storage_key).toBe("sha256/same.mp4");
});
it("gives clear disabled/other-creator errors and exposes settings only to authorized callers", async () => {
const source = file(); files.push(source);
memberDeleteOwn = false;
await expect(service.deleteFile(ws, String(source.id), member)).rejects.toThrow("member file deletion is disabled");
expect(await service.getGroupFileSettings(ws, group, member)).toEqual({ memberDeleteOwn: false });
await expect(service.setGroupFileSettings(ws, group, member, { memberDeleteOwn: true })).rejects.toMatchObject({ status: 403 });
expect(await service.setGroupFileSettings(ws, group, { userId: alice, role: "Admin" }, { memberDeleteOwn: true })).toEqual({ memberDeleteOwn: true });
source.created_by = bob;
await expect(service.deleteFile(ws, String(source.id), member)).rejects.toThrow("members may only delete files they created");
source.created_by = null;
await expect(service.deleteFile(ws, String(source.id), member)).rejects.toMatchObject({ status: 403 });
await expect(service.setGroupFileSettings(ws, group, { userId: alice, role: "Owner" }, { memberDeleteOwn: "false" })).rejects.toMatchObject({ status: 400 });
memberOf = [];
await expect(service.getGroupFileSettings(ws, group, member)).rejects.toMatchObject({ status: 403 });
});
});
describe("controller contract", () => {
it("declares literal folder routes ahead of file-id routes and binds the expected verbs", () => {
const prototype = GroupFilesController.prototype;
expect(Object.getOwnPropertyNames(prototype).indexOf("listFolders")).toBeLessThan(Object.getOwnPropertyNames(prototype).indexOf("get"));
for (const [method, path, verb] of [
["createFolder", "folders", RequestMethod.POST], ["listFolders", "folders", RequestMethod.GET],
["updateFolder", "folders/:id", RequestMethod.PATCH], ["deleteFolder", "folders/:id", RequestMethod.DELETE],
["move", ":id/move", RequestMethod.POST], ["copy", ":id/copy", RequestMethod.POST], ["shareToGroup", ":id/share-to-group", RequestMethod.POST],
] as const) {
expect(Reflect.getMetadata(PATH_METADATA, prototype[method])).toBe(path);
expect(Reflect.getMetadata(METHOD_METADATA, prototype[method])).toBe(verb);
}
});
it("forwards authenticated scope and optional upload folder from both multipart routes", async () => {
const stub = { uploadForGroup: vi.fn(async (..._args: unknown[]) => ({})), initDirectUpload: vi.fn(async () => ({})), completeDirectUpload: vi.fn(async () => ({})) };
const controller = new GroupFilesController(stub as unknown as GroupFilesService);
const session = { workspaceId: ws, userId: alice, role: "Publisher" };
const request = { session } as unknown as Request;
const upload = { buffer: Buffer.from("x"), originalname: "x.txt", mimetype: "text/plain" };
const folderId = randomUUID();
await controller.upload(request, group, upload, { folderId });
await controller.uploadPersonal(request, upload, { folderId });
expect(stub.uploadForGroup.mock.calls[0]?.slice(0, 3)).toEqual([ws, group, member]);
expect(stub.uploadForGroup.mock.calls[0]?.[4]).toEqual({ folderId });
expect(stub.uploadForGroup.mock.calls[1]?.[4]).toEqual({ personal: true, folderId });
});
});

View file

@ -255,4 +255,88 @@ describe.skipIf(appUrl === undefined || ownerUrl === undefined)("group-files met
});
await expect(svc.getFile(wsA, owner, "Owner", otherTenant)).rejects.toMatchObject({ status: 404 });
});
it("persists folder trees, blocks cycles/nonempty deletion and protects personal owners", async () => {
const actor = { userId: publisher, role: "Publisher" };
const parent = await svc.createFolder(wsA, actor, { groupId: groupA1, name: "Parent" });
const child = await svc.createFolder(wsA, actor, { groupId: groupA1, parentId: parent.id, name: "Child" });
expect(await svc.listFolders(wsA, actor, { scope: "group", groupId: groupA1 })).toEqual(expect.arrayContaining([parent, child]));
await expect(svc.updateFolder(wsA, parent.id, actor, { parentId: child.id })).rejects.toMatchObject({ status: 409 });
await expect(svc.deleteFolder(wsA, parent.id, actor)).rejects.toMatchObject({ status: 409 });
await svc.updateFolder(wsA, child.id, actor, { parentId: null, name: "Renamed" });
await svc.deleteFolder(wsA, parent.id, actor);
const personal = await svc.createFolder(wsA, actor, { personal: true, name: "Private" });
await expect(svc.updateFolder(wsA, personal.id, { userId: owner, role: "Owner" }, { name: "No" })).rejects.toMatchObject({ status: 403 });
await expect(svc.createFolder(wsA, actor, { groupId: groupA2, name: "No" })).rejects.toMatchObject({ status: 403 });
await svc.deleteFolder(wsA, personal.id, actor);
await svc.deleteFolder(wsA, child.id, actor);
});
async function personalEntry(): Promise<string> {
const { id } = await svc.registerArchiveEntry(wsA, {
groupId: null, ownerId: publisher, createdBy: publisher, roomId: null, eventId: null,
origin: { kind: "upload" }, originKind: "upload", senderMxid: "@publisher:tmbh.local",
originalName: "private.txt", safeName: "private.txt", mime: "text/plain", mimeClass: "document", byteSize: 3,
sha256: "a".repeat(64), ocisResourceId: "sha256/metadata-only.txt", storageKey: "sha256/metadata-only.txt",
ocisResourceVersion: null, createdAt: new Date(),
});
return id;
}
it("moves one row, copies/shares new rows with identical keys, and leaves the personal original intact", async () => {
const actor = { userId: publisher, role: "Publisher" };
const id = await personalEntry();
const folder = await svc.createFolder(wsA, actor, { personal: true, name: "Personal files" });
const original = await svc.getFile(wsA, publisher, "Publisher", id);
const copy = await svc.copyFile(wsA, id, actor, { targetFolderId: folder.id });
expect(copy.id).not.toBe(id);
expect(copy).toMatchObject({ name: "private.txt (copy)", folderId: folder.id, file: original.file, checksum: original.checksum });
const shared = await svc.shareToGroup(wsA, id, actor, { groupId: groupA1 });
expect(shared).toMatchObject({ groupId: groupA1, ownerId: null, shared: true, origin: { kind: "upload", sharedFrom: "personal" }, file: { storageKey: original.file.storageKey }, checksum: original.checksum });
expect(await svc.getFile(wsA, publisher, "Publisher", id)).toEqual(original);
await expect(svc.getFile(wsA, owner, "Owner", id)).rejects.toMatchObject({ status: 403 });
expect((await svc.moveFile(wsA, id, actor, { folderId: folder.id })).id).toBe(id);
const root = await svc.listFiles(wsA, publisher, "Publisher", { scope: "personal" });
expect(root.items.some((f) => f.id === id)).toBe(false);
const inFolder = await svc.listFiles(wsA, publisher, "Publisher", { scope: "personal", folderId: folder.id });
expect(inFolder.items.map((f) => f.id).sort()).toEqual([id, copy.id].sort());
await expect(svc.deleteFolder(wsA, folder.id, actor)).rejects.toMatchObject({ status: 409 });
const count = await inTenant(wsA, (c) => c.query("SELECT count(*)::int AS n FROM chat_media_archive WHERE storage_key = $1", [original.file.storageKey]));
expect(count.rows[0]?.n).toBe(3);
});
it("enforces member creator/settings deletion while Owner/Admin keep soft-revoke access", async () => {
const actor = { userId: publisher, role: "Publisher" };
const personal = await personalEntry();
const shared = await svc.shareToGroup(wsA, personal, actor, { groupId: groupA1 });
await svc.setGroupFileSettings(wsA, groupA1, { userId: owner, role: "Owner" }, { memberDeleteOwn: false });
await expect(svc.deleteFile(wsA, shared.id, actor)).rejects.toMatchObject({ status: 403 });
await svc.setGroupFileSettings(wsA, groupA1, { userId: owner, role: "Admin" }, { memberDeleteOwn: true });
await expect(svc.deleteFile(wsA, shared.id, actor)).resolves.toMatchObject({ status: "revoked" });
const other = await svc.copyFile(wsA, personal, { userId: publisher, role: "Publisher" }, { targetGroupId: groupA1 });
await inTenant(wsA, (c) => c.query("UPDATE chat_media_archive SET created_by = $2 WHERE id = $1", [other.id, owner]));
await expect(svc.deleteFile(wsA, other.id, actor)).rejects.toMatchObject({ status: 403 });
await svc.setGroupFileSettings(wsA, groupA1, { userId: owner, role: "Owner" }, { memberDeleteOwn: false });
await expect(svc.deleteFile(wsA, other.id, { userId: owner, role: "Owner" })).resolves.toMatchObject({ status: "revoked" });
const raw = await inTenant(wsA, (c) => c.query("SELECT available, storage_key FROM chat_media_archive WHERE id = $1", [shared.id]));
expect(raw.rows[0]).toEqual({ available: false, storage_key: "sha256/metadata-only.txt" });
});
it("applies FORCE tenant RLS to folders and attributes direct database inserts", async () => {
const folder = await svc.createFolder(wsB, { userId: owner, role: "Owner" }, { groupId: groupB, name: "Other tenant" });
await expect(svc.updateFolder(wsA, folder.id, { userId: owner, role: "Owner" }, { name: "No" })).rejects.toMatchObject({ status: 404 });
const withoutTenant = await m.db.pool.query("SELECT id FROM file_folders WHERE id = $1", [folder.id]);
expect(withoutTenant.rows).toEqual([]);
const id = await personalEntry();
const username = await inTenant(wsA, (c) => c.query("SELECT username FROM users WHERE id = $1", [publisher]));
const inserted = await inTenant(wsA, (c) => c.query(
`INSERT INTO chat_media_archive (workspace_id, group_id, origin, sender_mxid, original_name, safe_name,
mime, mime_class, byte_size, sha256, ocis_resource_id, created_at)
SELECT workspace_id, $2, origin, $3, original_name, safe_name, mime, mime_class, byte_size, sha256, ocis_resource_id, now()
FROM chat_media_archive WHERE id = $1 RETURNING created_by`,
[id, groupA1, `@${String(username.rows[0]?.username)}:tmbh.local`],
));
expect(inserted.rows[0]?.created_by).toBe(publisher);
});
});

View file

@ -1,5 +1,5 @@
import { Module } from "@nestjs/common";
import { GroupFilesController, PublicFileShareController } from "./group-files.controller.js";
import { GroupFilesController, FilePlaybackController, PublicFileShareController } from "./group-files.controller.js";
import { GroupFilesService } from "./group-files.service.js";
import { DriveController } from "./drive.controller.js";
@ -21,7 +21,7 @@ import { DriveController } from "./drive.controller.js";
* deployment is absent instead of breaking startup.
*/
@Module({
controllers: [GroupFilesController, PublicFileShareController, DriveController],
controllers: [FilePlaybackController, GroupFilesController, PublicFileShareController, DriveController],
providers: [GroupFilesService],
exports: [GroupFilesService],
})

View file

@ -1,7 +1,13 @@
import type { PoolClient } from "pg";
import { GroupFilesService } from "./group-files.service.js";
import type { Response } from "express";
import { GUARDS_METADATA } from "@nestjs/common/constants.js";
import { FilePlaybackController, GroupFilesController } from "./group-files.controller.js";
import { RbacGuard } from "../rbac/rbac.guard.js";
import { FileRangeNotSatisfiableException } from "./playback-range.js";
import { directStorageDisabled, type StorageService } from "../storage/storage.js";
import { hashShareToken } from "./share-token.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common";
/** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */
@ -355,3 +361,214 @@ describe("GroupFilesService invariants", () => {
await expect(service.claimPublicDownload("nope")).rejects.toBeInstanceOf(NotFoundException);
});
});
describe("range playback capabilities", () => {
const payload = Buffer.from("0123456789");
let service: GroupFilesService;
let storage: StorageService;
beforeEach(() => {
behavior = () => ({ rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 });
storage = {
...directStorageDisabled(),
putContent: vi.fn(), hasContent: vi.fn(), presign: vi.fn(), getObject: vi.fn(), getSealedObject: vi.fn(),
readDirectRange: vi.fn(async (_key, start, length) => payload.subarray(start, start + length)),
};
service = new GroupFilesService({ storage });
});
afterEach(() => { vi.useRealTimers(); });
async function issue(): Promise<string> {
const result = await service.openRangePlayback(row.workspace_id, "user-1", "Publisher", row.id, 2, 4);
return result.ticket.token;
}
function responseFixture(): { response: Response; headers: Record<string, string>; chunks: Buffer[]; status: () => number } {
const headers: Record<string, string> = {};
const chunks: Buffer[] = [];
let code = 0;
const response = {
status(value: number) { code = value; return this; },
setHeader(key: string, value: string) { headers[key] = value; return this; },
flushHeaders: vi.fn(), write: (bytes: Buffer) => chunks.push(bytes), end: vi.fn(),
} as unknown as Response;
return { response, headers, chunks, status: () => code };
}
it("issues a 256-bit single-file ticket with exactly a 10-minute TTL without reading payloads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-18T12:00:00Z"));
const { ticket } = await service.openRangePlayback(row.workspace_id, "user-1", "Publisher", row.id, 2, 4);
expect(ticket).toEqual({ token: expect.stringMatching(/^[a-f0-9]{64}$/), itemId: row.id, expiresAt: "2026-10-18T12:10:00.000Z" });
expect(storage.readDirectRange).not.toHaveBeenCalled();
// The ticket covers its one file, allowing later seeks beyond the initial range.
const slice = await service.readPlaybackTicket(ticket.token, "6", "8");
expect(slice.bytes.toString()).toBe("678");
expect(slice.item.id).toBe(row.id);
expect(storage.readDirectRange).toHaveBeenCalledWith("direct/video.enc", 6, 3);
expect(storage.getObject).not.toHaveBeenCalled();
});
it("expires tickets at the 10-minute boundary and rejects unknown tokens before any DB/storage access", async () => {
vi.useFakeTimers();
const token = await issue();
vi.advanceTimersByTime(10 * 60_000 - 1);
await expect(service.readPlaybackTicket(token, "0", "1")).resolves.toMatchObject({ start: 0, end: 1 });
const count = statements.length;
vi.advanceTimersByTime(1);
await expect(service.readPlaybackTicket(token, "0", "1")).rejects.toBeInstanceOf(NotFoundException);
await expect(service.readPlaybackTicket("unknown", "0", "1")).rejects.toBeInstanceOf(NotFoundException);
expect(statements).toHaveLength(count);
expect(storage.readDirectRange).toHaveBeenCalledTimes(1);
});
it("checks group membership before issuance and again on use, and rejects legacy objects", async () => {
behavior = (sql) => sql.includes("FROM group_members")
? { rows: [], rowCount: 0 } : { rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 };
await expect(issue()).rejects.toBeInstanceOf(ForbiddenException);
behavior = () => ({ rows: [{ ...row, storage_key: "sha256/legacy.mp4" }], rowCount: 1 });
await expect(issue()).rejects.toBeInstanceOf(BadRequestException);
behavior = () => ({ rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 });
const token = await issue();
behavior = (sql) => sql.includes("FROM group_members")
? { rows: [], rowCount: 0 } : { rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 };
await expect(service.readPlaybackTicket(token, "0", "1")).rejects.toBeInstanceOf(ForbiddenException);
expect(storage.readDirectRange).not.toHaveBeenCalled();
});
it.each([
{ available: false },
{ retention_mode: "until", retention_until: new Date("2020-01-01T00:00:00Z") },
])("stops playback of unavailable files: %j", async (patch) => {
const token = await issue();
behavior = () => ({ rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc", ...patch }], rowCount: 1 });
await expect(service.readPlaybackTicket(token, "0", "1")).rejects.toBeInstanceOf(GoneException);
await expect(issue()).rejects.toBeInstanceOf(GoneException);
expect(storage.readDirectRange).not.toHaveBeenCalled();
});
it("serves 206 with plaintext size (sealed metadata overhead removed) and no auth guard", async () => {
expect(Reflect.getMetadata(GUARDS_METADATA, FilePlaybackController)).toBeUndefined();
expect(Reflect.getMetadata(GUARDS_METADATA, FilePlaybackController.prototype.play)).toBeUndefined();
expect(Reflect.getMetadata(GUARDS_METADATA, GroupFilesController)).toEqual([RbacGuard]);
const token = await issue();
const out = responseFixture();
await new FilePlaybackController(service).play(token, { start: "2", end: "5" }, undefined, out.response);
expect(out.status()).toBe(206);
expect(out.headers).toEqual({
"Cache-Control": "no-store", "Accept-Ranges": "bytes", "Content-Type": "video/mp4",
"Content-Range": "bytes 2-5/10", "Content-Length": "4",
});
expect(Buffer.concat(out.chunks).toString()).toBe("2345");
expect(out.response.flushHeaders).toHaveBeenCalled();
expect(out.response.end).toHaveBeenCalled();
});
it.each([
["bytes=7-99", "789", 7, 9],
["bytes=8-", "89", 8, 9],
["bytes=-3", "789", 7, 9],
["bytes=-100", "0123456789", 0, 9],
[undefined, "0123456789", 0, 9],
])("supports browser Range header %s and clips to EOF", async (range, expected, start, end) => {
const slice = await service.readPlaybackTicket(await issue(), undefined, undefined, range);
expect(slice.bytes.toString()).toBe(expected);
expect(slice).toMatchObject({ start, end, total: 10 });
});
it.each([
{ start: "10", end: "20" }, { start: "4", end: "3" }, { start: "-1", end: "4" },
{ start: "0.5", end: "4" }, { start: "9007199254740992" }, { start: ["0", "1"] },
])("returns 416 and bytes */total for invalid/unsatisfiable query %j", async (query) => {
const out = responseFixture();
await new FilePlaybackController(service).play(await issue(), query, undefined, out.response);
expect(out.status()).toBe(416);
expect(out.headers["Content-Range"]).toBe("bytes */10");
expect(out.headers["Accept-Ranges"]).toBe("bytes");
expect(out.headers["Content-Length"]).toBe("0");
expect(out.chunks).toEqual([]);
expect(storage.readDirectRange).not.toHaveBeenCalled();
});
it.each(["bytes=10-", "bytes=3-2", "bytes=-0", "bytes=", "bytes=0-1,3-4", "bytes=a-b"])("rejects invalid/unsatisfiable Range %s", async (range) => {
await expect(service.readPlaybackTicket(await issue(), undefined, undefined, range)).rejects.toBeInstanceOf(FileRangeNotSatisfiableException);
expect(storage.readDirectRange).not.toHaveBeenCalled();
});
it("returns 416 before storage for an unsatisfiable initial range", async () => {
await expect(service.openRangePlayback(row.workspace_id, "user-1", "Owner", row.id, 10, 1)).rejects.toMatchObject({ status: 416 });
expect(storage.readDirectRange).not.toHaveBeenCalled();
});
it("unwraps browser-download CEKs from a header-only fetch", async () => {
storage.presign = vi.fn(async () => "https://storage.example/video");
storage.openDirectHeaderBlob = vi.fn(async () => Buffer.alloc(135));
storage.openDirectHeader = vi.fn(async () => Buffer.alloc(32, 1));
const ticket = await service.openDirectDownload(row.workspace_id, "u", "Owner", row.id);
expect(ticket.frame).toBe("SPENC2");
expect(storage.openDirectHeaderBlob).toHaveBeenCalledWith("direct/video.enc");
expect(storage.getSealedObject).not.toHaveBeenCalled();
});
});
describe("member-delete-own permissions", () => {
// Default-true is enforced by migrations 045/046; both explicit setting values
// are exercised here for members and the Owner/Admin override.
const cases = ["Publisher", "Editor", "Owner", "Admin"].flatMap((role) =>
[true, false].flatMap((enabled) => [true, false].map((own) => ({ role, enabled, own,
allowed: role === "Owner" || role === "Admin" || (enabled && own) }))));
it.each(cases)("$role setting=$enabled own=$own => allowed=$allowed", async ({ role, enabled, own, allowed }) => {
let revoked = false;
behavior = (sql) => {
if (sql.includes("SELECT g.member_delete_own")) return { rows: [{ member_delete_own: enabled, username: "alice" }], rowCount: 1 };
if (sql.includes("FROM group_members")) return { rows: [{}], rowCount: 1 };
if (sql.startsWith("UPDATE chat_media_archive")) revoked = true;
return { rows: [{ ...row, origin: "upload", created_by: own ? "alice-id" : "bob-id", sender_mxid: own ? "@alice:tmbh.local" : "@bob:tmbh.local", available: !revoked }], rowCount: 1 };
};
const service = new GroupFilesService();
const result = service.deleteFile(row.workspace_id, row.id, { userId: "alice-id", role });
if (allowed) {
await expect(result).resolves.toMatchObject({ status: "revoked" });
expect(statements.some((q) => q.sql.startsWith("UPDATE group_file_shares SET revoked_at"))).toBe(true);
expect(statements.find((q) => q.sql.startsWith("SELECT audit_append"))?.values[3]).toBe("file.deleted");
} else {
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
expect(statements.some((q) => q.sql.startsWith("UPDATE"))).toBe(false);
}
});
it.each(["matrix", "bridge", "pad-export"])("does not infer ownership of %s attachments from sender_mxid", async (origin) => {
behavior = (sql) => {
if (sql.includes("SELECT g.member_delete_own")) return { rows: [{ member_delete_own: true, username: "alice" }], rowCount: 1 };
return { rows: [{ ...row, origin, sender_mxid: "@alice:tmbh.local" }], rowCount: 1 };
};
await expect(new GroupFilesService().deleteFile(row.workspace_id, row.id, { userId: "alice-id", role: "Publisher" }))
.rejects.toBeInstanceOf(ForbiddenException);
});
it("denies former/non-members even for their own uploads", async () => {
behavior = (sql) => sql.includes("FROM group_members") ? { rows: [], rowCount: 0 }
: { rows: [{ ...row, origin: "upload", sender_mxid: "@alice:tmbh.local" }], rowCount: 1 };
await expect(new GroupFilesService().deleteFile(row.workspace_id, row.id, { userId: "alice-id", role: "Publisher" }))
.rejects.toBeInstanceOf(ForbiddenException);
expect(statements.some((q) => q.sql.startsWith("UPDATE"))).toBe(false);
});
it("applies the setting to the revoke route too", async () => {
behavior = (sql) => sql.includes("SELECT g.member_delete_own")
? { rows: [{ member_delete_own: false, username: "alice" }], rowCount: 1 }
: { rows: [{ ...row, origin: "upload", sender_mxid: "@alice:tmbh.local" }], rowCount: 1 };
await expect(new GroupFilesService().markRevoked(row.workspace_id, row.id, { userId: "alice-id", role: "Publisher" }))
.rejects.toBeInstanceOf(ForbiddenException);
});
it.each(["Owner", "Admin"])("preserves legal hold even for %s deletion", async (role) => {
behavior = () => ({ rows: [{ ...row, legal_hold: true }], rowCount: 1 });
await expect(new GroupFilesService().deleteFile(row.workspace_id, row.id, { userId: "u", role }))
.rejects.toThrow("legal hold");
expect(statements.some((q) => q.sql.startsWith("UPDATE"))).toBe(false);
});
});

View file

@ -20,17 +20,20 @@
* (audit_append), the house pattern.
*/
import { BadRequestException, ConflictException, ForbiddenException, GoneException, Injectable, NotFoundException, Optional, Inject, ServiceUnavailableException } from "@nestjs/common";
import { createHash, randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import type { PoolClient } from "pg";
import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core";
import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention } from "@tactical-media-broadcast-hub/core";
import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
import { withSystem, withTenant } from "../db/client.js";
import { isUuid, parseLimit } from "../http/validation.js";
import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js";
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
import { S3Storage, directPlaintextSize, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
import type { PayloadDownload } from "./verify-stream.js";
import { hashShareToken, mintShareToken } from "./share-token.js";
import { playbackRange, requestedPlaybackRange } from "./playback-range.js";
import { hubUsernameFor } from "../matrix-sso/mxid-display.js";
import type { FileFolder, FileScopeInput, GroupFile } from "./group-files.types.js";
import { MIME_CLASSES, ORIGIN_KINDS, parseRetentionInput, type MimeClass, type OriginKind } from "./group-files.validation.js";
export const GROUP_FILES_DEPENDENCIES: unique symbol = Symbol("GROUP_FILES_DEPENDENCIES");
@ -91,6 +94,30 @@ export interface DirectDownloadTicket {
sizeBytes: number;
}
export interface PlaybackTicket {
token: string;
itemId: string;
expiresAt: string;
}
interface PlaybackGrant {
ticket: PlaybackTicket;
workspaceId: string;
userId: string;
role: string;
}
export interface PlaybackSlice {
item: GroupFile;
bytes: Buffer;
start: number;
end: number;
total: number;
}
const PLAYBACK_TTL_MS = 10 * 60 * 1000;
const MAX_PLAYBACK_TICKETS = 1000;
export const DIRECT_MAX_BYTES = 2_147_483_648;
const DIRECT_PUT_TTL_SECONDS = 900;
const DIRECT_GET_TTL_SECONDS = 600;
@ -106,8 +133,11 @@ const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
interface ArchiveRow {
id: string;
workspace_id: string;
group_id: string;
room_id: string;
group_id: string | null;
owner_id: string | null;
folder_id: string | null;
created_by: string | null;
room_id: string | null;
event_id: string | null;
origin: string;
sender_mxid: string;
@ -141,6 +171,23 @@ interface ShareRow {
revoked_at: Date | null;
}
interface FolderRow {
id: string;
workspace_id: string;
group_id: string | null;
owner_id: string | null;
parent_id: string | null;
name: string;
created_at: Date;
}
interface FileScope { groupId: string | null; ownerId: string | null }
function folderToWire(row: FolderRow): FileFolder {
return { id: row.id, workspaceId: row.workspace_id, groupId: row.group_id, ownerId: row.owner_id,
parentId: row.parent_id, name: row.name, createdAt: iso(row.created_at) };
}
const ARCHIVE_SELECT = `SELECT a.*, s.access AS share_access, s.expires_at AS share_expires_at, s.revoked_at AS share_revoked_at
FROM chat_media_archive a
LEFT JOIN LATERAL (
@ -175,7 +222,7 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
if (row.origin === "pad-export") {
return { kind: "pad-export", padId: String(payload.padId ?? ""), revision: String(payload.revision ?? "") };
}
return { kind: "upload" };
return { kind: "upload", ...(payload.sharedFrom === "personal" ? { sharedFrom: "personal" as const } : {}) };
})();
const retention: FileRetention =
row.retention_mode === "until" && row.retention_until !== null
@ -190,6 +237,10 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
id: row.id,
workspaceId: row.workspace_id,
groupId: row.group_id,
ownerId: row.owner_id ?? null,
folderId: row.folder_id ?? null,
createdBy: row.created_by ?? null,
shared: payload.shared === true,
roomId: row.room_id,
senderId: row.sender_mxid,
eventId: row.event_id,
@ -220,6 +271,10 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
@Injectable()
export class GroupFilesService {
private store: StorageService | null = null;
// Process-local capabilities: bounded to 1000 entries, expire after 10 minutes,
// and disappear on restart. Multi-process deployments need sticky routing or
// a shared ticket store. Every use rechecks the issuing user's file ACL.
private readonly playbackTickets = new Map<string, PlaybackGrant>();
constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { storage?: StorageService }) {}
@ -237,6 +292,224 @@ export class GroupFilesService {
return base === "" ? resourceId : `${base}/f/${encodeURIComponent(resourceId)}`;
}
// -------------------------------------------------------------------------
// Folders and metadata-only file placement
// -------------------------------------------------------------------------
async createFolder(workspaceId: string, actor: ActorRef, input: FileScopeInput & { name?: unknown; parentId?: unknown }): Promise<FileFolder> {
const scope = parseWriteScope(input, actor.userId);
const name = folderName(input.name);
const parentId = optionalUuid(input.parentId, "parentId");
return withTenant(workspaceId, async (client) => {
await this.lockFolderTree(client, workspaceId);
await this.requireScopeAccess(client, scope, actor);
if (parentId !== null) this.requireSameScope(await this.requireFolder(client, parentId, actor), scopeRow(scope));
const inserted = await client.query<FolderRow>(
`INSERT INTO file_folders (workspace_id, group_id, owner_id, parent_id, name)
VALUES ($1,$2,$3,$4,$5) RETURNING *`, [workspaceId, scope.groupId, scope.ownerId, parentId, name],
);
const folder = inserted.rows[0];
if (folder === undefined) throw new Error("folder insert returned no row");
await this.audit(client, workspaceId, actor, { action: "file.folder_created", targetId: folder.id, diff: { name, parentId } });
return folderToWire(folder);
});
}
/** Flat list of every folder in the scope; parentId reconstructs the tree. */
async listFolders(workspaceId: string, actor: ActorRef, query: Record<string, unknown>): Promise<FileFolder[]> {
const scope = parseListScope(query, actor.userId);
if (scope.ownerId === null && scope.groupId === null) throw new BadRequestException("groupId is required for group folders");
return withTenant(workspaceId, async (client) => {
await this.requireScopeAccess(client, scope, actor);
const result = await client.query<FolderRow>(
`SELECT * FROM file_folders WHERE workspace_id = $1
AND group_id IS NOT DISTINCT FROM $2::uuid AND owner_id IS NOT DISTINCT FROM $3::uuid
ORDER BY name, id`, [workspaceId, scope.groupId, scope.ownerId],
);
return result.rows.map(folderToWire);
});
}
async updateFolder(workspaceId: string, folderId: string, actor: ActorRef, input: { name?: unknown; parentId?: unknown }): Promise<FileFolder> {
const name = input.name === undefined ? undefined : folderName(input.name);
if (name === undefined && input.parentId === undefined) throw new BadRequestException("pass name and/or parentId");
const parentId = input.parentId === undefined ? undefined : optionalUuid(input.parentId, "parentId");
return withTenant(workspaceId, async (client) => {
await this.lockFolderTree(client, workspaceId);
const before = await this.requireFolder(client, folderId, actor);
if (parentId !== undefined && parentId !== null) {
this.requireSameScope(await this.requireFolder(client, parentId, actor), before);
const descendants = await client.query(
`WITH RECURSIVE descendants AS (
SELECT id FROM file_folders WHERE id = $1
UNION SELECT f.id FROM file_folders f JOIN descendants d ON f.parent_id = d.id
) SELECT id FROM descendants WHERE id = $2`, [folderId, parentId],
);
if (descendants.rows.length > 0) throw new ConflictException("a folder cannot be moved into itself or a descendant");
}
const updated = await client.query<FolderRow>(
"UPDATE file_folders SET name = $2, parent_id = $3 WHERE id = $1 RETURNING *",
[folderId, name ?? before.name, parentId === undefined ? before.parent_id : parentId],
);
const folder = updated.rows[0];
if (folder === undefined) throw new NotFoundException("folder not found");
await this.audit(client, workspaceId, actor, { action: "file.folder_updated", targetId: folderId, diff: { name: folder.name, parentId: folder.parent_id } });
return folderToWire(folder);
});
}
async deleteFolder(workspaceId: string, folderId: string, actor: ActorRef): Promise<{ ok: true }> {
return withTenant(workspaceId, async (client) => {
await this.lockFolderTree(client, workspaceId);
await this.requireFolder(client, folderId, actor, true);
const children = await client.query(
`SELECT id FROM file_folders WHERE parent_id = $1
UNION ALL SELECT id FROM chat_media_archive WHERE folder_id = $1 LIMIT 1`, [folderId],
);
if (children.rows.length > 0) throw new ConflictException("folder must be empty of files and subfolders before deletion");
await client.query("DELETE FROM file_folders WHERE id = $1", [folderId]);
await this.audit(client, workspaceId, actor, { action: "file.folder_deleted", targetId: folderId });
return { ok: true };
});
}
async moveFile(workspaceId: string, itemId: string, actor: ActorRef, input: { folderId?: unknown }): Promise<GroupFile> {
if (input.folderId === undefined) throw new BadRequestException("folderId is required (use null for root)");
const folderId = optionalUuid(input.folderId, "folderId");
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
await this.requireFileAccess(client, before, actor);
this.requireAvailable(before);
if (folderId !== null) this.requireSameScope(await this.requireFolder(client, folderId, actor), before);
await client.query("UPDATE chat_media_archive SET folder_id = $2 WHERE id = $1", [itemId, folderId]);
await this.audit(client, workspaceId, actor, { action: "file.moved", targetId: itemId, diff: { from: before.folder_id, to: folderId } });
return rowToGroupFile(await this.requireRow(client, itemId), (key) => this.libraryUrlFor(key));
});
}
async copyFile(workspaceId: string, itemId: string, actor: ActorRef, input: { targetFolderId?: unknown; targetGroupId?: unknown }): Promise<GroupFile> {
const folderId = optionalUuid(input.targetFolderId, "targetFolderId");
const groupId = optionalUuid(input.targetGroupId, "targetGroupId");
if ((folderId === null) === (groupId === null)) throw new BadRequestException("provide exactly one of targetFolderId or targetGroupId");
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
await this.requireFileAccess(client, before, actor);
this.requireAvailable(before);
const scope = folderId === null ? { groupId, ownerId: null } : folderScope(await this.requireFolder(client, folderId, actor));
await this.requireScopeAccess(client, scope, actor);
return this.duplicateFile(client, workspaceId, before, actor, scope, folderId, false);
});
}
async shareToGroup(workspaceId: string, itemId: string, actor: ActorRef, input: { groupId?: unknown }): Promise<GroupFile> {
const groupId = optionalUuid(input.groupId);
if (groupId === null) throw new BadRequestException("groupId is required");
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
await this.requireFileAccess(client, before, actor);
if (before.group_id !== null) throw new BadRequestException("only personal files can be shared into a group");
this.requireAvailable(before);
const scope = { groupId, ownerId: null };
await this.requireScopeAccess(client, scope, actor);
return this.duplicateFile(client, workspaceId, before, actor, scope, null, true);
});
}
private async duplicateFile(client: PoolClient, workspaceId: string, before: ArchiveRow, actor: ActorRef, scope: FileScope, folderId: string | null, shared: boolean): Promise<GroupFile> {
const name = shared ? before.original_name : `${before.original_name.slice(0, 1017)} (copy)`;
const safeName = shared ? before.safe_name : `${before.safe_name.slice(0, 248)} (copy)`;
const payload = shared ? { kind: "upload", sharedFrom: "personal", shared: true } : before.matrix_event_json;
const inserted = await client.query<{ id: string }>(
`INSERT INTO chat_media_archive
(workspace_id, group_id, owner_id, folder_id, created_by, room_id, event_id, origin, sender_mxid,
matrix_event_json, original_name, safe_name, mime, mime_class, byte_size, sha256,
ocis_resource_id, ocis_resource_version, storage_key, created_at, retention_mode, legal_hold, retention_until)
SELECT workspace_id, $2, $3, $4, $5, NULL, NULL, $6, sender_mxid, $7::jsonb, $8, $9,
mime, mime_class, byte_size, sha256, ocis_resource_id, ocis_resource_version, storage_key, now(),
retention_mode, legal_hold, retention_until
FROM chat_media_archive WHERE id = $1 RETURNING id`,
[before.id, scope.groupId, scope.ownerId, folderId, actor.userId, shared ? "upload" : before.origin,
JSON.stringify(payload), name, safeName],
);
const id = inserted.rows[0]?.id;
if (id === undefined) throw new NotFoundException("source file not found");
await this.audit(client, workspaceId, actor, { action: shared ? "file.shared_to_group" : "file.copied", targetId: id, diff: { sourceId: before.id, groupId: scope.groupId, folderId } });
return rowToGroupFile(await this.requireRow(client, id), (key) => this.libraryUrlFor(key));
}
async getGroupFileSettings(workspaceId: string, groupId: string, actor: ActorRef): Promise<{ memberDeleteOwn: boolean }> {
return withTenant(workspaceId, async (client) => {
await this.requireScopeAccess(client, { groupId, ownerId: null }, actor);
const result = await client.query<{ member_delete_own: boolean }>("SELECT member_delete_own FROM groups WHERE id = $1", [groupId]);
const setting = result.rows[0];
if (setting === undefined) throw new NotFoundException("group not found");
return { memberDeleteOwn: setting.member_delete_own };
});
}
async setGroupFileSettings(workspaceId: string, groupId: string, actor: ActorRef, input: { memberDeleteOwn?: unknown }): Promise<{ memberDeleteOwn: boolean }> {
if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) throw new ForbiddenException("only Owner/Admin can change group file settings");
if (typeof input.memberDeleteOwn !== "boolean") throw new BadRequestException("memberDeleteOwn must be a boolean");
const memberDeleteOwn = input.memberDeleteOwn;
return withTenant(workspaceId, async (client) => {
await this.requireScopeAccess(client, { groupId, ownerId: null }, actor);
const updated = await client.query("UPDATE groups SET member_delete_own = $2 WHERE id = $1", [groupId, memberDeleteOwn]);
if ((updated.rowCount ?? 0) === 0) throw new NotFoundException("group not found");
await this.audit(client, workspaceId, actor, { action: "file.group_settings_changed", targetId: groupId, diff: { memberDeleteOwn } });
return { memberDeleteOwn };
});
}
private async lockFolderTree(client: PoolClient, workspaceId: string): Promise<void> {
// Serialize parent edits so two concurrent moves cannot form a cycle.
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 46))", [workspaceId]);
}
private async folderRow(client: PoolClient, id: string, deleting = false): Promise<FolderRow> {
if (!isUuid(id)) throw new BadRequestException("folderId must be a UUID");
// FK writers take KEY SHARE too; DELETE's UPDATE lock waits for them.
const result = await client.query<FolderRow>(`SELECT * FROM file_folders WHERE id = $1 ${deleting ? "FOR UPDATE" : "FOR KEY SHARE"}`, [id]);
if (result.rows[0] === undefined) throw new NotFoundException("folder not found");
return result.rows[0];
}
private async requireFolder(client: PoolClient, id: string, actor: ActorRef, deleting = false): Promise<FolderRow> {
const folder = await this.folderRow(client, id, deleting);
await this.requireScopeAccess(client, folderScope(folder), actor);
return folder;
}
private requireSameScope(a: { group_id: string | null; owner_id: string | null }, b: { group_id: string | null; owner_id: string | null }): void {
if (a.group_id !== b.group_id || a.owner_id !== b.owner_id) throw new BadRequestException("destination must belong to the same scope");
}
private async requireScopeAccess(client: PoolClient, scope: FileScope, actor: ActorRef): Promise<void> {
if (scope.ownerId !== null) {
if (scope.ownerId !== actor.userId) throw new ForbiddenException("personal folders are private to their owner");
return;
}
if (scope.groupId === null || !isUuid(scope.groupId)) throw new BadRequestException("groupId must be a UUID");
const group = await client.query("SELECT 1 FROM groups WHERE id = $1", [scope.groupId]);
if (group.rows.length === 0) throw new NotFoundException("group not found");
await this.requireAccess(client, actor.userId, actor.role, scope.groupId);
}
private async validateUploadScope(workspaceId: string, scope: FileScope, rawFolderId: unknown, actor: ActorRef): Promise<string | null> {
const folderId = optionalUuid(rawFolderId, "folderId");
await withTenant(workspaceId, async (client) => {
await this.requireScopeAccess(client, scope, actor);
if (folderId !== null) this.requireSameScope(await this.requireFolder(client, folderId, actor), scopeRow(scope));
});
return folderId;
}
private requireAvailable(row: ArchiveRow): void {
if (!row.available) throw new GoneException("file is revoked");
if (row.retention_mode === "until" && row.retention_until !== null && new Date(row.retention_until).getTime() <= Date.now()) {
throw new GoneException("file retention has expired");
}
}
// -------------------------------------------------------------------------
// Read paths
// -------------------------------------------------------------------------
@ -267,7 +540,21 @@ export class GroupFilesService {
"a.available = true",
"(a.retention_mode <> 'until' OR a.retention_until > now())",
];
if (!WORKSPACE_WIDE_ROLES.includes(role)) {
const scope = parseListScope(query, userId);
const folderId = optionalUuid(query.folderId, "folderId");
if (folderId !== null) {
const folder = await this.requireFolder(client, folderId, { userId, role });
if (scope.ownerId !== folder.owner_id || (scope.groupId !== null && scope.groupId !== folder.group_id)) {
throw new BadRequestException("folderId does not belong to the requested scope");
}
}
if (scope.ownerId !== null) {
values.push(userId);
conds.push(`a.group_id IS NULL AND a.owner_id = $${values.length}::uuid`);
} else {
conds.push("a.group_id IS NOT NULL");
}
if (scope.ownerId === null && !WORKSPACE_WIDE_ROLES.includes(role)) {
values.push(userId);
conds.push(`a.group_id IN (SELECT group_id FROM group_members WHERE user_id = $${values.length})`);
}
@ -275,6 +562,12 @@ export class GroupFilesService {
values.push(filters.groupId);
conds.push(`a.group_id = $${values.length}::uuid`);
}
if (folderId === null) {
conds.push("a.folder_id IS NULL");
} else {
values.push(folderId);
conds.push(`a.folder_id = $${values.length}::uuid`);
}
if (filters.senderMxid !== null) {
values.push(filters.senderMxid);
conds.push(`a.sender_mxid = $${values.length}`);
@ -330,7 +623,7 @@ export class GroupFilesService {
async getFile(workspaceId: string, userId: string, role: string, itemId: string): Promise<GroupFile> {
return withTenant(workspaceId, async (client) => {
const row = await this.requireRow(client, itemId);
await this.requireAccess(client, userId, role, row.group_id);
await this.requireFileAccess(client, row, { userId, role });
if (!row.available) throw new GoneException("file is revoked");
return rowToGroupFile(row, (resourceId) => this.libraryUrlFor(resourceId));
});
@ -353,17 +646,71 @@ export class GroupFilesService {
* be sliced, so the whole-file route serves them.
*/
async readRange(workspaceId: string, userId: string, role: string, itemId: string, offset: number, length: number): Promise<{ bytes: Buffer; mime: string; total: number | null }> {
const item = await this.requireDirectPlaybackItem(workspaceId, userId, role, itemId);
const bounds = playbackRange(item.sizeBytes, offset, offset + length - 1);
const bytes = await this.readPlaybackSlice(item, bounds.start, bounds.end);
return { bytes, mime: item.mimeType, total: item.sizeBytes };
}
/** Mint a file-scoped capability after the same ACL/availability checks as download. */
async openRangePlayback(
workspaceId: string, userId: string, role: string, itemId: string, start: number, length: number,
): Promise<{ ticket: PlaybackTicket }> {
const item = await this.requireDirectPlaybackItem(workspaceId, userId, role, itemId);
playbackRange(item.sizeBytes, start, start + length - 1);
this.prunePlaybackTickets();
if (this.playbackTickets.size >= MAX_PLAYBACK_TICKETS) {
throw new ServiceUnavailableException("playback ticket capacity reached");
}
const ticket: PlaybackTicket = {
token: randomBytes(32).toString("hex"), itemId,
expiresAt: new Date(Date.now() + PLAYBACK_TTL_MS).toISOString(),
};
this.playbackTickets.set(ticket.token, { ticket, workspaceId, userId, role });
return { ticket: { ...ticket } };
}
/** The capability is the auth; callers cannot select another file or tenant. */
async readPlaybackTicket(token: string, start?: unknown, end?: unknown, range?: string): Promise<PlaybackSlice> {
this.prunePlaybackTickets();
const grant = this.playbackTickets.get(token);
if (grant === undefined) throw new NotFoundException("playback ticket is invalid or expired");
const item = await this.requireDirectPlaybackItem(grant.workspaceId, grant.userId, grant.role, grant.ticket.itemId);
const bounds = requestedPlaybackRange(item.sizeBytes, start, end, range);
const bytes = await this.readPlaybackSlice(item, bounds.start, bounds.end);
return { item, bytes, ...bounds, total: item.sizeBytes };
}
private prunePlaybackTickets(): void {
const now = Date.now();
for (const [token, grant] of this.playbackTickets) {
if (Date.parse(grant.ticket.expiresAt) <= now) this.playbackTickets.delete(token);
}
}
private async requireDirectPlaybackItem(workspaceId: string, userId: string, role: string, itemId: string): Promise<GroupFile> {
const item = await this.getFile(workspaceId, userId, role, itemId);
if (item.status !== "available") throw new GoneException(`file is ${item.status}`);
const storageKey = item.file.storageKey;
if (storageKey === undefined || !storageKey.startsWith("direct/")) {
if (!item.file.storageKey?.startsWith(DIRECT_KEY_PREFIX)) {
throw new BadRequestException("range reads are only available for direct objects");
}
const storage = this.storage() as StorageService & { readDirectRange(key: string, start: number, length: number): Promise<Buffer> };
const bytes = await storage.readDirectRange(storageKey, offset, length).catch(() => {
// /direct/complete stores the HEAD-verified sealed frame size. Its fixed
// 135-byte header and 32-byte segment overhead must not count as media.
try {
return { ...item, sizeBytes: directPlaintextSize(item.sizeBytes) };
} catch {
throw new ServiceUnavailableException("file size metadata is invalid");
}
}
private async readPlaybackSlice(item: GroupFile, start: number, end: number): Promise<Buffer> {
try {
const bytes = await this.storage().readDirectRange(item.file.storageKey!, start, end - start + 1);
if (bytes.length !== end - start + 1) throw new Error("incomplete plaintext range");
return bytes;
} catch {
throw new ServiceUnavailableException("file storage is unavailable");
});
return { bytes, mime: item.mimeType, total: item.sizeBytes };
}
}
private async openPayload(storageKey: string | null | undefined, mime: string): Promise<PayloadDownload> {
@ -382,6 +729,7 @@ export class GroupFilesService {
const retention = parseRetentionInput(retentionInput);
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
await this.requireFileAccess(client, before, actor);
await client.query(
`UPDATE chat_media_archive
SET retention_mode = $2, legal_hold = $3, retention_until = $4
@ -404,13 +752,24 @@ export class GroupFilesService {
/** Redaction/membership-removal path: revoke links, stop serving. No deletion. */
async markRevoked(workspaceId: string, itemId: string, actor: ActorRef): Promise<GroupFile> {
return this.revokeFile(workspaceId, itemId, actor, false);
}
/** API deletion stops serving and revokes shares; payload retention remains separate. */
async deleteFile(workspaceId: string, itemId: string, actor: ActorRef): Promise<GroupFile> {
return this.revokeFile(workspaceId, itemId, actor, true);
}
private async revokeFile(workspaceId: string, itemId: string, actor: ActorRef, deleting: boolean): Promise<GroupFile> {
return withTenant(workspaceId, async (client) => {
const before = await this.requireRow(client, itemId);
await this.requireDeleteAccess(client, before, actor);
if (deleting && before.legal_hold) throw new ForbiddenException("file is under legal hold");
if (!before.available) throw new ConflictException("file is already revoked");
await client.query("UPDATE chat_media_archive SET available = false WHERE id = $1", [itemId]);
await client.query("UPDATE group_file_shares SET revoked_at = now() WHERE item_id = $1 AND revoked_at IS NULL", [itemId]);
await this.audit(client, workspaceId, actor, {
action: "file.revoked",
action: deleting ? "file.deleted" : "file.revoked",
targetId: itemId,
diff: { name: before.safe_name, roomId: before.room_id, eventId: before.event_id },
});
@ -440,6 +799,8 @@ export class GroupFilesService {
const expiresAt = new Date(Date.now() + hours * 3_600_000);
return withTenant(workspaceId, async (client) => {
const row = await this.requireRow(client, itemId);
await this.requireFileAccess(client, row, actor);
this.requireAvailable(row);
const inserted = await client.query<ShareRow>(
`INSERT INTO group_file_shares (workspace_id, item_id, access, token_hash, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, workspace_id, item_id, access, expires_at, revoked_at`,
@ -467,6 +828,9 @@ export class GroupFilesService {
async revokeShare(workspaceId: string, shareId: string, actor: ActorRef): Promise<void> {
await withTenant(workspaceId, async (client) => {
if (!isUuid(shareId)) throw new BadRequestException("shareId must be a UUID");
const found = await client.query<{ item_id: string }>("SELECT item_id FROM group_file_shares WHERE id = $1 AND workspace_id = $2", [shareId, workspaceId]);
if (found.rows[0] === undefined) throw new NotFoundException(`share ${shareId} not found`);
await this.requireFileAccess(client, await this.requireRow(client, found.rows[0].item_id), actor);
const updated = await client.query(
"UPDATE group_file_shares SET revoked_at = now() WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL RETURNING item_id",
[shareId, workspaceId],
@ -535,20 +899,37 @@ export class GroupFilesService {
? null
: attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId);
return withTenant(workspaceId, async (client) => {
const ownerId = entry.ownerId ?? null;
if ((entry.groupId === null) === (ownerId === null)) throw new BadRequestException("file must have exactly one group or personal owner");
if (entry.folderId != null) {
const folder = await this.folderRow(client, entry.folderId, true);
this.requireSameScope(folder, { group_id: entry.groupId, owner_id: ownerId });
}
const senderUsername = /^@([^:]+):tmbh\.local$/.exec(entry.senderMxid)?.[1] ?? hubUsernameFor(entry.senderMxid);
let creatorId = entry.createdBy ?? null;
if (creatorId === null) {
const creator = await client.query<{ id: string }>(
`SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id
WHERE m.workspace_id = $1 AND (u.external_id = $2 OR u.username = $3)
ORDER BY (u.external_id = $2) DESC, u.id LIMIT 1`,
[workspaceId, entry.senderMxid, senderUsername],
);
creatorId = creator.rows[0]?.id ?? null;
}
let inserted: { rows: Array<{ id: string }> };
try {
inserted = await client.query<{ id: string }>(
`INSERT INTO chat_media_archive
(workspace_id, group_id, room_id, event_id, origin, sender_mxid, matrix_event_json,
original_name, safe_name, mime, mime_class, byte_size, sha256,
ocis_resource_id, ocis_resource_version, created_at, storage_key)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
ocis_resource_id, ocis_resource_version, created_at, storage_key, folder_id, created_by, owner_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
RETURNING id`,
[
workspaceId, entry.groupId, entry.roomId, entry.eventId, entry.originKind, entry.senderMxid,
JSON.stringify(entry.origin), entry.originalName, entry.safeName, entry.mime, entry.mimeClass,
entry.byteSize, entry.sha256, entry.ocisResourceId, entry.ocisResourceVersion, entry.createdAt,
entry.storageKey ?? null,
entry.storageKey ?? null, entry.folderId ?? null, creatorId, ownerId,
],
);
} catch (err) {
@ -593,17 +974,17 @@ export class GroupFilesService {
*/
async initDirectUpload(
workspaceId: string,
groupId: string,
groupId: string | null,
actor: ActorRef,
input: { filename: string; mimeType: string; sizeBytes: number },
input: { filename: string; mimeType: string; sizeBytes: number; personal?: unknown; folderId?: unknown },
): Promise<DirectUploadInit> {
if (!isUuid(groupId)) throw new BadRequestException("groupId must be a UUID");
const scope = parseWriteScope({ groupId, personal: input.personal }, actor.userId);
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1 || input.sizeBytes > DIRECT_MAX_BYTES) {
throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`);
}
validateDirectFilename(input.filename);
validateDirectMime(input.mimeType);
await this.requireGroupMembership(workspaceId, groupId, actor);
await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
if ((await this.storage().directPublicJwk()) === null) {
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
}
@ -625,10 +1006,10 @@ export class GroupFilesService {
async completeDirectUpload(
workspaceId: string,
actor: ActorRef,
input: { uploadId: string; objectKey: string; groupId: string; filename: string; mimeType: string; sizeBytes: number; sha256: string },
input: { uploadId: string; objectKey: string; groupId?: string | null; personal?: unknown; folderId?: unknown; filename: string; mimeType: string; sizeBytes: number; sha256: string },
): Promise<GroupFile> {
if (!isUuid(input.uploadId)) throw new BadRequestException("uploadId must be a UUID");
if (!isUuid(input.groupId)) throw new BadRequestException("groupId must be a UUID");
const scope = parseWriteScope(input, actor.userId);
if (input.objectKey !== `${DIRECT_KEY_PREFIX}${input.uploadId}${DIRECT_OBJECT_SUFFIX}`) {
throw new BadRequestException("objectKey must be direct/<uploadId>.enc");
}
@ -638,7 +1019,7 @@ export class GroupFilesService {
}
const safe = validateDirectFilename(input.filename);
const mimeType = validateDirectMime(input.mimeType);
await this.requireGroupMembership(workspaceId, input.groupId, actor);
const folderId = await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
const head = await this.storage().headObject(input.objectKey).catch(() => {
throw new ServiceUnavailableException("file storage is unavailable");
@ -650,7 +1031,10 @@ export class GroupFilesService {
const mimeClass = mimeClassForDeclared(mimeType);
const username = await this.usernameFor(workspaceId, actor.userId);
const { id } = await this.registerArchiveEntry(workspaceId, {
groupId: input.groupId,
groupId: scope.groupId,
ownerId: scope.ownerId,
folderId,
createdBy: actor.userId,
roomId: null,
eventId: null,
origin: { kind: "upload" },
@ -685,7 +1069,7 @@ export class GroupFilesService {
const storage = this.storage();
const [url, frame] = await Promise.all([
storage.presign(storageKey, DIRECT_GET_TTL_SECONDS),
storage.getSealedObject(storageKey).then((stored) => storage.openDirectHeader(stored)),
storage.openDirectHeaderBlob(storageKey).then((header) => storage.openDirectHeader(header)),
]).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); });
return { url, cekB64: frame.toString("base64"), frame: "SPENC2", sizeBytes: item.sizeBytes };
}
@ -717,6 +1101,29 @@ export class GroupFilesService {
if ((result.rowCount ?? 0) === 0) throw new ForbiddenException("file belongs to a group you are not a member of");
}
/** Personal ownership is private even from workspace Owner/Admin. */
private async requireFileAccess(client: PoolClient, row: ArchiveRow, actor: ActorRef): Promise<void> {
if (row.group_id === null) {
if (row.owner_id !== actor.userId) throw new ForbiddenException("personal files are private to their owner");
return;
}
await this.requireAccess(client, actor.userId, actor.role, row.group_id);
}
private async requireDeleteAccess(client: PoolClient, row: ArchiveRow, actor: ActorRef): Promise<void> {
await this.requireFileAccess(client, row, actor);
if (row.group_id === null || WORKSPACE_WIDE_ROLES.includes(actor.role)) return;
const result = await client.query<{ member_delete_own: boolean }>(
"SELECT g.member_delete_own FROM groups g WHERE g.id = $1 FOR SHARE", [row.group_id],
);
if (result.rows[0]?.member_delete_own !== true) {
throw new ForbiddenException("member file deletion is disabled for this group");
}
if (row.created_by !== actor.userId) {
throw new ForbiddenException("members may only delete files they created");
}
}
private async audit(
client: PoolClient,
workspaceId: string,
@ -736,23 +1143,20 @@ export class GroupFilesService {
/**
* Direct web upload: ACL (active member of the group, or Owner/Admin),
* stream the buffer to oCIS (magic-byte sniff decides mime/class, the
* store the buffer in R2 (magic-byte sniff decides mime/class, the
* stored name is library-safe), index the entry, return the wire shape.
* Attribution uses a synthetic MXID — the Hub session carries no chat
* identity (docs/occupymedia-storage.md, direct-upload origin).
*/
async uploadForGroup(
workspaceId: string,
groupId: string,
groupId: string | null,
actor: { userId: string; role: string },
file: { buffer: Buffer; mimetype: string; originalName: string },
options: { folderId?: unknown; personal?: unknown } = {},
): Promise<GroupFile> {
if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) {
const membership = await withTenant(workspaceId, (client) =>
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", [groupId, actor.userId]),
);
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
}
const scope = parseWriteScope({ groupId, personal: options.personal }, actor.userId);
const folderId = await this.validateUploadScope(workspaceId, scope, options.folderId, actor);
const sniff = sniffMime(file.buffer.subarray(0, SNIFF_BYTES));
const safe = safeFileName(file.originalName, sniff.extension);
// Object storage is the only payload path (user directive 2026-10-06):
@ -763,7 +1167,10 @@ export class GroupFilesService {
});
const username = await this.usernameFor(workspaceId, actor.userId);
const { id } = await this.registerArchiveEntry(workspaceId, {
groupId,
groupId: scope.groupId,
ownerId: scope.ownerId,
folderId,
createdBy: actor.userId,
roomId: null,
eventId: null,
origin: { kind: "upload" },
@ -783,15 +1190,6 @@ export class GroupFilesService {
return this.getFile(workspaceId, actor.userId, actor.role, id);
}
/** Owner/Admin pass; everyone else must be an active member of the group. */
private async requireGroupMembership(workspaceId: string, groupId: string, actor: ActorRef): Promise<void> {
if (WORKSPACE_WIDE_ROLES.includes(actor.role)) return;
const membership = await withTenant(workspaceId, (client) =>
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", [groupId, actor.userId]),
);
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
}
private async usernameFor(workspaceId: string, userId: string): Promise<string> {
const row = await withTenant(workspaceId, (client) =>
client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]),
@ -806,7 +1204,10 @@ export interface ActorRef {
}
export interface ArchiveEntryInput {
groupId: string;
groupId: string | null;
ownerId?: string | null;
folderId?: string | null;
createdBy?: string | null;
/** Matrix room when mirrored from a room; null for direct web uploads. */
roomId: string | null;
eventId: string | null;
@ -882,12 +1283,48 @@ function mimeClassForDeclared(mimeType: string): MimeClass {
return "other";
}
function optionalUuid(value: unknown): string | null {
function optionalUuid(value: unknown, field = "groupId"): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException("groupId must be a UUID");
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException(`${field} must be a UUID`);
return value;
}
function scopeRow(scope: FileScope): { group_id: string | null; owner_id: string | null } {
return { group_id: scope.groupId, owner_id: scope.ownerId };
}
function folderScope(row: FolderRow): FileScope {
return { groupId: row.group_id, ownerId: row.owner_id };
}
function parseWriteScope(input: FileScopeInput, userId: string): FileScope {
if (input.personal !== undefined && typeof input.personal !== "boolean") throw new BadRequestException("personal must be a boolean");
const groupId = optionalUuid(input.groupId);
if (input.personal === true) {
if (groupId !== null) throw new BadRequestException("personal and groupId cannot be combined");
return { groupId: null, ownerId: userId };
}
if (groupId === null) throw new BadRequestException("provide groupId or personal: true");
return { groupId, ownerId: null };
}
function parseListScope(query: Record<string, unknown>, userId: string): FileScope {
if (query.scope !== undefined && query.scope !== "group" && query.scope !== "personal") throw new BadRequestException("scope must be group or personal");
const groupId = optionalUuid(query.groupId);
if (query.scope === "personal") {
if (groupId !== null) throw new BadRequestException("personal scope cannot include groupId");
return { groupId: null, ownerId: userId };
}
return { groupId, ownerId: null };
}
function folderName(value: unknown): string {
if (typeof value !== "string" || value.trim() === "" || value.trim().length > 255 || /[\u0000-\u001f/\\]/.test(value)) {
throw new BadRequestException("name must be 1-255 characters without slashes or control characters");
}
return value.trim();
}
const ISO_BOUND = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
function parseBound(value: unknown, field: string): string | null {

View file

@ -129,6 +129,7 @@ describe("group-files runtime validation (shared contracts)", () => {
id: "b3a1c2d4-1111-4222-8333-444455556666",
workspaceId: "c3a1c2d4-1111-4222-8333-444455556666",
groupId: "d3a1c2d4-1111-4222-8333-444455556666",
ownerId: null, folderId: null, createdBy: null, shared: false,
roomId: "!r:example.org",
senderId: "@s:example.org",
eventId: "$evt",
@ -146,6 +147,11 @@ describe("group-files runtime validation (shared contracts)", () => {
status: "available",
};
expect(parseGroupFile(value)).toEqual(value);
const personal = { ...value, groupId: null, ownerId: value.id, folderId: value.id, createdBy: value.id,
file: { ...value.file, spaceId: null, storageKey: "sha256/key.mp4" } };
expect(parseGroupFile(personal)).toEqual(personal);
const shared = { ...value, shared: true, origin: { kind: "upload", sharedFrom: "personal" } };
expect(parseGroupFile(shared)).toEqual(shared);
expect(() => parseGroupFile({ ...value, sizeBytes: -1 })).toThrow(BadRequestException);
expect(() => parseGroupFile({ ...value, senderId: "Bob" })).toThrow(BadRequestException);
expect(() => parseGroupFile({ ...value, checksum: { algorithm: "sha256", value: "zz" } })).toThrow(BadRequestException);

View file

@ -0,0 +1,28 @@
import type { GroupFile as CoreGroupFile } from "@tactical-media-broadcast-hub/core";
/** Files wire shape, including personal roots and folder placement. */
export interface GroupFile extends Omit<CoreGroupFile, "groupId" | "file" | "origin"> {
groupId: string | null;
ownerId: string | null;
folderId: string | null;
createdBy: string | null;
shared: boolean;
origin: Exclude<CoreGroupFile["origin"], { kind: "upload" }> | { kind: "upload"; sharedFrom?: "personal" };
file: Omit<CoreGroupFile["file"], "spaceId"> & { spaceId: string | null };
}
export interface FileFolder {
id: string;
workspaceId: string;
groupId: string | null;
ownerId: string | null;
parentId: string | null;
name: string;
createdAt: string;
}
export interface FileScopeInput {
groupId?: unknown;
personal?: unknown;
folderId?: unknown;
}

View file

@ -16,12 +16,11 @@ import { BadRequestException } from "@nestjs/common";
import { MIME_CLASSES } from "@tactical-media-broadcast-hub/core";
import type {
FileRetention,
GroupFile,
GroupFileMapping,
MatrixAttachmentCandidate,
MimeClass,
SharedFileOrigin,
} from "@tactical-media-broadcast-hub/core";
import type { GroupFile } from "./group-files.types.js";
// FileRetention (packages/core/src/shared-files.ts): mode is keep|until and
// legalHold is an orthogonal boolean flag — NOT a third mode.
@ -142,7 +141,7 @@ export function requireIsoTimestamp(value: unknown, field: string): string {
}
/** The four SharedFileOrigin variants, discriminated by `kind`. */
export function parseSharedFileOrigin(value: unknown, field = "origin"): SharedFileOrigin {
export function parseSharedFileOrigin(value: unknown, field = "origin"): GroupFile["origin"] {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const kind = enumField(value, "kind", ORIGIN_KINDS, `${field}.kind`);
if (kind === "matrix") return { kind, mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`) };
@ -154,7 +153,10 @@ export function parseSharedFileOrigin(value: unknown, field = "origin"): SharedF
...(value.remoteEventId === undefined || value.remoteEventId === null ? {} : { remoteEventId: stringField(value, "remoteEventId", `${field}.remoteEventId`) }),
};
}
if (kind === "upload") return { kind };
if (kind === "upload") {
if (value.sharedFrom !== undefined && value.sharedFrom !== "personal") throw new BadRequestException(`${field}.sharedFrom must be personal`);
return { kind, ...(value.sharedFrom === "personal" ? { sharedFrom: "personal" as const } : {}) };
}
return { kind: "pad-export", padId: stringField(value, "padId", `${field}.padId`), revision: stringField(value, "revision", `${field}.revision`) };
}
@ -215,7 +217,11 @@ export function parseGroupFile(value: unknown, field = "item"): GroupFile {
return {
id: stringField(value, "id", `${field}.id`),
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
groupId: stringField(value, "groupId", `${field}.groupId`),
groupId: value.groupId === null ? null : stringField(value, "groupId", `${field}.groupId`),
ownerId: nullableUuid(value.ownerId, `${field}.ownerId`),
folderId: nullableUuid(value.folderId, `${field}.folderId`),
createdBy: nullableUuid(value.createdBy, `${field}.createdBy`),
shared: value.shared === undefined ? false : booleanField(value.shared, `${field}.shared`),
roomId: value.roomId === undefined || value.roomId === null ? null : requireRoomId(value.roomId, `${field}.roomId`),
senderId: requireMxid(value.senderId, `${field}.senderId`),
eventId: eventId === undefined || eventId === null ? null : eventId,
@ -228,9 +234,10 @@ export function parseGroupFile(value: unknown, field = "item"): GroupFile {
createdAt: isoField(value, "createdAt", `${field}.createdAt`),
indexedAt: isoField(value, "indexedAt", `${field}.indexedAt`),
file: {
spaceId: stringField(file, "spaceId", `${field}.file.spaceId`),
spaceId: file.spaceId === null ? null : stringField(file, "spaceId", `${field}.file.spaceId`),
resourceId: stringField(file, "resourceId", `${field}.file.resourceId`),
version: stringField(file, "version", `${field}.file.version`),
...(file.storageKey === undefined ? {} : { storageKey: stringField(file, "storageKey", `${field}.file.storageKey`) }),
},
shareLink: {
url: stringField(shareLink, "url", `${field}.shareLink.url`),
@ -241,3 +248,16 @@ export function parseGroupFile(value: unknown, field = "item"): GroupFile {
status: enumField(value, "status", FILE_STATUSES, `${field}.status`),
};
}
function nullableUuid(value: unknown, field: string): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new BadRequestException(`${field} must be a UUID or null`);
}
return value;
}
function booleanField(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new BadRequestException(`${field} must be a boolean`);
return value;
}

View file

@ -0,0 +1,37 @@
import { HttpException, HttpStatus } from "@nestjs/common";
/** Carries the plaintext size for the controller’s unsatisfied Content-Range header. */
export class FileRangeNotSatisfiableException extends HttpException {
constructor(readonly total: number) {
super("requested range is not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
}
/** Inclusive HTTP byte bounds, clipped to the plaintext EOF. */
export function playbackRange(total: number, start: number, end: number): { start: number; end: number } {
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= total || end < start) {
throw new FileRangeNotSatisfiableException(total);
}
return { start, end: Math.min(end, total - 1) };
}
/** Query bounds take precedence; also accept the single Range browsers send. */
export function requestedPlaybackRange(total: number, start: unknown, end: unknown, range?: string): { start: number; end: number } {
const fail = (): never => { throw new FileRangeNotSatisfiableException(total); };
const integer = (value: unknown): number => {
if (typeof value !== "string" || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) return fail();
return Number(value);
};
if (start !== undefined || end !== undefined) {
return playbackRange(total, start === undefined ? 0 : integer(start), end === undefined ? total - 1 : integer(end));
}
if (range === undefined) return playbackRange(total, 0, total - 1);
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (match === null || (match[1] === "" && match[2] === "")) return fail();
if (match[1] === "") {
const suffix = integer(match[2]);
if (suffix === 0) return fail();
return playbackRange(total, Math.max(0, total - suffix), total - 1);
}
return playbackRange(total, integer(match[1]), match[2] === "" ? total - 1 : integer(match[2]));
}

View file

@ -25,7 +25,7 @@ async function bootstrap(): Promise<void> {
app.enableCors({
origin: corsOrigins(),
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Authorization", "Content-Type"],
});
const port = Number(process.env.PORT ?? 3000);

View file

@ -37,7 +37,7 @@ export interface RefreshIssueInput {
export interface OidcRefreshStore {
issue(input: RefreshIssueInput): Promise<void>;
lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
consume(record: RefreshTokenRecord): Promise<void>;
consume(record: RefreshTokenRecord): Promise<boolean>;
/** Reuse detection: delete the whole chain; returns rows removed. */
revokeFamily(workspaceId: string, familyId: string): Promise<number>;
/** The minting Hub session must still be live (not revoked, not expired). */
@ -90,10 +90,12 @@ export class PgRefreshStore implements OidcRefreshStore {
return row === undefined ? null : fromRow(row);
}
async consume(record: RefreshTokenRecord): Promise<void> {
await withTenant(record.workspaceId, (client) =>
/** Single-use consumption: true when THIS call won the rotation race. */
async consume(record: RefreshTokenRecord): Promise<boolean> {
const result = await withTenant(record.workspaceId, (client) =>
client.query("UPDATE oidc_refresh_tokens SET consumed_at = now() WHERE id = $1 AND consumed_at IS NULL", [record.id]),
);
return (result.rowCount ?? 0) === 1;
}
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
@ -145,8 +147,11 @@ export class MemoryRefreshStore implements OidcRefreshStore {
return this.rows.get(tokenHash) ?? null;
}
async consume(record: RefreshTokenRecord): Promise<void> {
for (const row of this.rows.values()) if (row.id === record.id) row.consumedAt = new Date();
async consume(record: RefreshTokenRecord): Promise<boolean> {
const row = this.rows.get(record.id);
if (row === undefined || row.consumedAt !== null) return false;
row.consumedAt = new Date();
return true;
}
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {

View file

@ -382,7 +382,12 @@ export class OidcService implements OnModuleInit {
throw new OAuthError("invalid_grant", "membership is no longer active");
}
await this.deps.refreshStore.consume(record);
if (!(await this.deps.refreshStore.consume(record))) {
// A concurrent refresh won the rotation: this presentation is already used,
// which is exactly the reuse-detection case — revoke the family.
await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId);
throw new OAuthError("invalid_grant", "refresh token reuse detected; the chain has been revoked", HttpStatus.UNAUTHORIZED);
}
const scopes = record.scope.split(" ").filter((s) => s !== "");
const keyring = await this.keyring();
const iat = Math.floor(this.deps.now() / 1000);

View file

@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHash, randomBytes } from "node:crypto";
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
const s3Calls = vi.hoisted(() => ({ sent: [] as string[] }));
const s3Calls = vi.hoisted(() => ({
sent: [] as string[],
ranges: [] as string[],
sealed: null as Buffer | null,
}));
vi.mock("@aws-sdk/client-s3", () => {
class FakeCommand {
@ -27,13 +31,20 @@ vi.mock("@aws-sdk/client-s3", () => {
s3Calls.sent.push(cmd.marker);
if (cmd.marker === "Create") throw new S3ServiceException("Access Denied", 403, "AccessDenied");
if (cmd.marker === "Head") throw new S3ServiceException("Not Found", 404, "NotFound");
if (cmd.marker === "Get" && s3Calls.sealed !== null) {
const range = (cmd.input as { Range: string }).Range;
s3Calls.ranges.push(range);
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
const body = s3Calls.sealed.subarray(Number(match[1]), Number(match[2]) + 1);
return { Body: { transformToByteArray: async () => body } };
}
return {};
}
},
};
});
import { S3Storage, extensionFor, keyForHash, open, seal, sha256 } from "./storage.js";
import { DIRECT_SEGMENT_BYTES, S3Storage, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
const TEST_KEK = createHash("sha256").update("test-kek").digest();
@ -116,3 +127,76 @@ describe("S3Storage with object-scoped credentials", () => {
expect(s3Calls.sent).toEqual(["Create", "Head", "Put"]);
});
});
describe("SPENC2 range reads", () => {
beforeEach(() => {
s3Calls.sent.length = 0;
s3Calls.ranges.length = 0;
s3Calls.sealed = null;
});
async function fixture(plaintext: Buffer): Promise<S3Storage> {
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const storage = new S3Storage({ mediaKek: TEST_KEK, directPrivateKey: privateKey });
const jwk = (await storage.directPublicJwk())!;
s3Calls.sealed = sealForDirect(plaintext, jwk);
expect(directPlaintextSize(s3Calls.sealed.length)).toBe(plaintext.length);
return storage;
}
it("recovers plaintext size at full-segment boundaries", () => {
for (const size of [1, DIRECT_SEGMENT_BYTES - 1, DIRECT_SEGMENT_BYTES, DIRECT_SEGMENT_BYTES + 1]) {
const sealedSize = 135 + size + Math.ceil(size / DIRECT_SEGMENT_BYTES) * 32;
expect(directPlaintextSize(sealedSize)).toBe(size);
}
expect(() => directPlaintextSize(167)).toThrow("invalid SPENC2 sealed size");
expect(() => directPlaintextSize(135 + DIRECT_SEGMENT_BYTES + 33)).toThrow("invalid SPENC2 sealed size");
});
it("slices mid-segment bounds across three segments without fetching earlier segments", async () => {
const plaintext = randomBytes(4 * DIRECT_SEGMENT_BYTES);
const storage = await fixture(plaintext);
const start = DIRECT_SEGMENT_BYTES + 321;
const end = 3 * DIRECT_SEGMENT_BYTES + 789;
const bytes = await storage.readDirectRange("direct/clip.enc", start, end - start);
expect(bytes.equals(plaintext.subarray(start, end))).toBe(true);
const stride = DIRECT_SEGMENT_BYTES + 32;
expect(s3Calls.ranges).toEqual(["bytes=0-134", `bytes=${135 + stride}-${135 + 4 * stride - 1}`]);
expect(s3Calls.sent).toEqual(["Get", "Get"]);
});
it("clips the sealed fetch at EOF and slices a final partial segment", async () => {
const plaintext = randomBytes(2 * DIRECT_SEGMENT_BYTES + 1234);
const storage = await fixture(plaintext);
const start = DIRECT_SEGMENT_BYTES + 17;
expect((await storage.readDirectRange("direct/clip.enc", start, plaintext.length - start))
.equals(plaintext.subarray(start))).toBe(true);
expect(s3Calls.ranges).toEqual(["bytes=0-134",
`bytes=${135 + DIRECT_SEGMENT_BYTES + 32}-${135 + 3 * (DIRECT_SEGMENT_BYTES + 32) - 1}`]);
});
it("validates the magic on every read before fetching any segments", async () => {
const storage = await fixture(Buffer.from("video payload"));
await storage.readDirectRange("direct/clip.enc", 0, 3);
s3Calls.sealed![0] = 0;
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow("magic mismatch");
expect(s3Calls.ranges).toEqual(["bytes=0-134", `bytes=135-${135 + DIRECT_SEGMENT_BYTES + 32 - 1}`, "bytes=0-134"]);
});
it("refuses tampered ciphertext and truncated segments", async () => {
const storage = await fixture(Buffer.from("authenticated payload"));
const last = s3Calls.sealed!.length - 1;
s3Calls.sealed![last] = (s3Calls.sealed![last] ?? 0) ^ 1;
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow();
s3Calls.sealed = s3Calls.sealed!.subarray(0, 140);
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow("truncated");
});
it("rejects ranges beyond the segment count or final plaintext length", async () => {
const storage = await fixture(Buffer.from("short"));
await expect(storage.readDirectRange("direct/clip.enc", DIRECT_SEGMENT_BYTES, 1)).rejects.toThrow(RangeError);
expect(s3Calls.ranges).toEqual(["bytes=0-134"]);
await expect(storage.readDirectRange("direct/clip.enc", 4, 5)).rejects.toThrow("object length");
});
});

View file

@ -48,6 +48,8 @@ export interface StorageService {
headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }>;
/** Direct-to-R2: unwrap the frame's CEK (header only) for /files/:id/direct-download. */
openDirectHeader(stored: Buffer): Promise<Buffer>;
/** Direct-to-R2: fetch only the validated 135-byte SPENC2 header. */
openDirectHeaderBlob(key: string): Promise<Buffer>;
/** Direct-to-R2: raw still-sealed object bytes — the CEK header lives only in the sealed form. */
getSealedObject(key: string): Promise<Buffer>;
/** Direct-to-R2: decrypt an arbitrary PLAINTEXT byte range (segment-aligned fetches). */
@ -57,7 +59,7 @@ export interface StorageService {
/** The direct-only members of StorageService, as one spreadable group. */
export type DirectStorageMembers = Pick<
StorageService,
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "getSealedObject" | "readDirectRange"
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange"
>;
/**
@ -74,6 +76,7 @@ export function directStorageDisabled(): DirectStorageMembers {
presignPut: unavailable,
headObject: unavailable,
openDirectHeader: unavailable,
openDirectHeaderBlob: unavailable,
getSealedObject: unavailable,
readDirectRange: unavailable,
};
@ -166,6 +169,16 @@ const DIRECT_WRAP_INFO = Buffer.from("tmbh-direct-cek-wrap", "ascii");
/** Segment plaintext ceiling. Mirrors the web side, which seals 10 MiB chunks. */
export const DIRECT_SEGMENT_BYTES = 10 * 1024 * 1024;
/** Direct upload metadata records sealed bytes; HTTP ranges address plaintext. */
export function directPlaintextSize(sealedBytes: number): number {
const count = Math.ceil((sealedBytes - SEGMENTS_OFFSET) / (DIRECT_SEGMENT_BYTES + 32));
const plaintextBytes = sealedBytes - SEGMENTS_OFFSET - count * 32;
if (!Number.isSafeInteger(sealedBytes) || count < 1 || plaintextBytes <= (count - 1) * DIRECT_SEGMENT_BYTES) {
throw new Error("storage: invalid SPENC2 sealed size");
}
return plaintextBytes;
}
/** Server direct keypair: P-256 private key from DIRECT_EC_PRIVATE_KEY (base64 DER). */
export interface DirectKeyPair {
privateKey: ReturnType<typeof createPrivateKey>;
@ -239,6 +252,9 @@ function parseDirectHeader(stored: Buffer): DirectHeader {
if (stored.length < SEGMENTS_OFFSET) {
throw new Error("storage: SPENC2 frame is truncated");
}
if (!stored.subarray(0, DIRECT_FRAME_MAGIC.length).equals(DIRECT_FRAME_MAGIC)) {
throw new Error("storage: not a SPENC2 frame (magic mismatch)");
}
const ephemeralPoint = stored.subarray(EPHEMERAL_PUBLIC_OFFSET, WRAP_NONCE_OFFSET);
if (ephemeralPoint[0] !== 0x04) throw new Error("storage: SPENC2 ephemeral key is not an uncompressed point");
const wrapNonce = stored.subarray(WRAP_NONCE_OFFSET, WRAPPED_CEK_OFFSET);
@ -408,6 +424,7 @@ export class S3Storage implements StorageService {
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region ?? "us-east-1",
requestChecksumCalculation: "WHEN_REQUIRED",
credentials:
config.accessKeyId !== undefined && config.secretAccessKey !== undefined
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
@ -521,6 +538,21 @@ export class S3Storage implements StorageService {
return unwrapDirectCek(stored, this.directKey.privateKey);
}
async openDirectHeaderBlob(key: string): Promise<Buffer> {
const header = await this.getSealedRange(key, 0, SEGMENTS_OFFSET);
parseDirectHeader(header);
return header;
}
/** S3 uses an inclusive end; callers supply an exclusive end. */
private async getSealedRange(key: string, start: number, end: number): Promise<Buffer> {
const res = await this.client.send(new GetObjectCommand({
Bucket: this.bucket, Key: key, Range: `bytes=${start}-${end - 1}`,
}));
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
return Buffer.from(await res.Body.transformToByteArray());
}
async getSealedObject(key: string): Promise<Buffer> {
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
@ -535,39 +567,38 @@ export class S3Storage implements StorageService {
*/
async readDirectRange(key: string, start: number, length: number): Promise<Buffer> {
if (this.directKey === null) throw missingDirectKey();
if (length <= 0) return Buffer.alloc(0);
if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(length) || length < 0
|| !Number.isSafeInteger(start + length)) {
throw new RangeError("storage: invalid plaintext range");
}
const header = await this.openDirectHeaderBlob(key);
const { segmentCount } = parseDirectHeader(header);
const cek = unwrapDirectCek(header, this.directKey.privateKey);
if (length === 0) return Buffer.alloc(0);
const first = Math.floor(start / DIRECT_SEGMENT_BYTES);
const last = Math.floor((start + length - 1) / DIRECT_SEGMENT_BYTES);
// ONE ranged GET covering the header (the CEK wraps there) + every segment
// through `last`. Segments before `first` are walked but skipped, so the
// sealed offsets stay deterministic; all non-final segments carry a full
// 10 MiB plaintext, which makes the sealed end computable up front.
const segSealedStart = (i: number): number => SEGMENTS_OFFSET + i * (4 + 12 + DIRECT_SEGMENT_BYTES + 16);
const fetchEnd = segSealedStart(last) + 4 + 12 + DIRECT_SEGMENT_BYTES + 16;
const res = await this.client.send(new GetObjectCommand({
Bucket: this.bucket, Key: key, Range: `bytes=0-${fetchEnd - 1}`,
}));
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
const stored = Buffer.from(await res.Body.transformToByteArray());
const cek = unwrapDirectCek(stored, this.directKey.privateKey);
if (last >= segmentCount) throw new RangeError("storage: plaintext range exceeds segment count");
const sealedStride = 4 + 12 + DIRECT_SEGMENT_BYTES + 16;
// Fetch only covering segments. S3 clips the final partial segment to EOF.
const stored = await this.getSealedRange(key,
SEGMENTS_OFFSET + first * sealedStride, SEGMENTS_OFFSET + (last + 1) * sealedStride);
const parts: Buffer[] = [];
let filled = 0;
let offset = SEGMENTS_OFFSET;
const segmentCount = stored.readUInt32BE(SEGMENT_COUNT_OFFSET);
for (let i = 0; i < segmentCount && filled < length; i += 1) {
let offset = 0;
for (let i = first; i <= last; i += 1) {
if (offset + 4 + 12 > stored.length) throw new Error("storage: SPENC2 segment table is truncated");
const ctLen = stored.readUInt32BE(offset); offset += 4;
if (ctLen < 16 || offset + 12 + ctLen > stored.length) break;
if (ctLen < 17 || ctLen > DIRECT_SEGMENT_BYTES + 16 || offset + 12 + ctLen > stored.length
|| (i < segmentCount - 1 && ctLen !== DIRECT_SEGMENT_BYTES + 16)) {
throw new Error("storage: SPENC2 segment length is out of range");
}
const iv = stored.subarray(offset, offset + 12); offset += 12;
const ciphertext = stored.subarray(offset, offset + ctLen); offset += ctLen;
const segStart = i * DIRECT_SEGMENT_BYTES;
const segEnd = segStart + ctLen - 16;
if (segEnd <= start) continue;
if (segStart >= start + length) break;
const plain = aesGcmOpen(cek, iv, ciphertext);
const from = Math.max(0, start - segStart);
const to = Math.min(plain.length, start + length - segStart);
parts.push(plain.subarray(from, to));
filled += to - from;
const segStart = i * DIRECT_SEGMENT_BYTES;
parts.push(plain.subarray(Math.max(0, start - segStart), Math.min(plain.length, start + length - segStart)));
}
if (parts.reduce((sum, part) => sum + part.length, 0) !== length) {
throw new RangeError("storage: plaintext range exceeds object length");
}
return Buffer.concat(parts);
}

View file

@ -632,7 +632,7 @@ function validateUpload(storageKey: string, mimeType: string, sizeBytes: number)
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1) {
throw new BadRequestException("sizeBytes must be a positive safe integer");
}
if (sizeBytes > MAX_VIDEO_UPLOAD_BYTES) throw new BadRequestException("video exceeds the 2 GiB upload limit");
if (sizeBytes > MAX_CHUNKED_UPLOAD_BYTES) throw new BadRequestException("video exceeds the chunked upload limit");
}
function requiredRow(row: unknown, description: string): Record<string, unknown> {