diff --git a/apps/api/migrations/045_file_delete_setting.sql b/apps/api/migrations/045_file_delete_setting.sql new file mode 100644 index 0000000..a7984de --- /dev/null +++ b/apps/api/migrations/045_file_delete_setting.sql @@ -0,0 +1 @@ +ALTER TABLE groups ADD COLUMN member_delete_own boolean NOT NULL DEFAULT true; diff --git a/apps/api/migrations/046_file_folders.sql b/apps/api/migrations/046_file_folders.sql new file mode 100644 index 0000000..304f314 --- /dev/null +++ b/apps/api/migrations/046_file_folders.sql @@ -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; diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index b21d806..823432b 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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 }; } } diff --git a/apps/api/src/group-files/group-files.controller.ts b/apps/api/src/group-files/group-files.controller.ts index 9bc2a78..92cd932 100644 --- a/apps/api/src/group-files/group-files.controller.ts +++ b/apps/api/src/group-files/group-files.controller.ts @@ -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 { + 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): Promise { + 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 { + 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): Promise { + 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> { 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> { + 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, ): Promise { 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 { + 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 { + 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 { + 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 { @@ -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 { + 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, + @Headers("range") range: string | undefined, + @Res() response: Response, + ): Promise { + 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. diff --git a/apps/api/src/group-files/group-files.folders.test.ts b/apps/api/src/group-files/group-files.folders.test.ts new file mode 100644 index 0000000..700d5c8 --- /dev/null +++ b/apps/api/src/group-files/group-files.folders.test.ts @@ -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; +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) => 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 { + 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 }); + }); +}); diff --git a/apps/api/src/group-files/group-files.integration.test.ts b/apps/api/src/group-files/group-files.integration.test.ts index 464c40f..2c8fdd2 100644 --- a/apps/api/src/group-files/group-files.integration.test.ts +++ b/apps/api/src/group-files/group-files.integration.test.ts @@ -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 { + 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); + }); + }); diff --git a/apps/api/src/group-files/group-files.module.ts b/apps/api/src/group-files/group-files.module.ts index 071b53f..acbeeb9 100644 --- a/apps/api/src/group-files/group-files.module.ts +++ b/apps/api/src/group-files/group-files.module.ts @@ -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], }) diff --git a/apps/api/src/group-files/group-files.service.test.ts b/apps/api/src/group-files/group-files.service.test.ts index 86bb36d..bb4a523 100644 --- a/apps/api/src/group-files/group-files.service.test.ts +++ b/apps/api/src/group-files/group-files.service.test.ts @@ -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 { + 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; chunks: Buffer[]; status: () => number } { + const headers: Record = {}; + 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); + }); +}); diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index 5ff0d9a..42f3b7d 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -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(); 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 { + 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( + `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): Promise { + 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( + `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 { + 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( + "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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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(`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 { + 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 { + 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 { + 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 { 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 { + 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 { 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 }; - 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 { + 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 { @@ -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 { + 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 { + return this.revokeFile(workspaceId, itemId, actor, true); + } + + private async revokeFile(workspaceId: string, itemId: string, actor: ActorRef, deleting: boolean): Promise { 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( `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 { 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 { - 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 { 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/.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 { + 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 { + 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 { - 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 { - 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 { 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, 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 { diff --git a/apps/api/src/group-files/group-files.test.ts b/apps/api/src/group-files/group-files.test.ts index 8d05a78..29ed6ae 100644 --- a/apps/api/src/group-files/group-files.test.ts +++ b/apps/api/src/group-files/group-files.test.ts @@ -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); diff --git a/apps/api/src/group-files/group-files.types.ts b/apps/api/src/group-files/group-files.types.ts new file mode 100644 index 0000000..5adc229 --- /dev/null +++ b/apps/api/src/group-files/group-files.types.ts @@ -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 { + groupId: string | null; + ownerId: string | null; + folderId: string | null; + createdBy: string | null; + shared: boolean; + origin: Exclude | { kind: "upload"; sharedFrom?: "personal" }; + file: Omit & { 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; +} diff --git a/apps/api/src/group-files/group-files.validation.ts b/apps/api/src/group-files/group-files.validation.ts index 1f0ffaf..558a57b 100644 --- a/apps/api/src/group-files/group-files.validation.ts +++ b/apps/api/src/group-files/group-files.validation.ts @@ -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; +} diff --git a/apps/api/src/group-files/playback-range.ts b/apps/api/src/group-files/playback-range.ts new file mode 100644 index 0000000..04135a4 --- /dev/null +++ b/apps/api/src/group-files/playback-range.ts @@ -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])); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index cb07e8e..644c4f8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -25,7 +25,7 @@ async function bootstrap(): Promise { 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); diff --git a/apps/api/src/oidc/oidc.refresh-store.ts b/apps/api/src/oidc/oidc.refresh-store.ts index 6a0d8d7..b32825b 100644 --- a/apps/api/src/oidc/oidc.refresh-store.ts +++ b/apps/api/src/oidc/oidc.refresh-store.ts @@ -37,7 +37,7 @@ export interface RefreshIssueInput { export interface OidcRefreshStore { issue(input: RefreshIssueInput): Promise; lookupByHash(tokenHash: string): Promise; - consume(record: RefreshTokenRecord): Promise; + consume(record: RefreshTokenRecord): Promise; /** Reuse detection: delete the whole chain; returns rows removed. */ revokeFamily(workspaceId: string, familyId: string): Promise; /** 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 { - await withTenant(record.workspaceId, (client) => + /** Single-use consumption: true when THIS call won the rotation race. */ + async consume(record: RefreshTokenRecord): Promise { + 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 { @@ -145,8 +147,11 @@ export class MemoryRefreshStore implements OidcRefreshStore { return this.rows.get(tokenHash) ?? null; } - async consume(record: RefreshTokenRecord): Promise { - for (const row of this.rows.values()) if (row.id === record.id) row.consumedAt = new Date(); + async consume(record: RefreshTokenRecord): Promise { + 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 { diff --git a/apps/api/src/oidc/oidc.service.ts b/apps/api/src/oidc/oidc.service.ts index fc1a598..7de836c 100644 --- a/apps/api/src/oidc/oidc.service.ts +++ b/apps/api/src/oidc/oidc.service.ts @@ -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); diff --git a/apps/api/src/storage/storage.test.ts b/apps/api/src/storage/storage.test.ts index 380c8cb..eea5bb2 100644 --- a/apps/api/src/storage/storage.test.ts +++ b/apps/api/src/storage/storage.test.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/api/src/storage/storage.ts b/apps/api/src/storage/storage.ts index 1322db2..61f5d01 100644 --- a/apps/api/src/storage/storage.ts +++ b/apps/api/src/storage/storage.ts @@ -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; + /** Direct-to-R2: fetch only the validated 135-byte SPENC2 header. */ + openDirectHeaderBlob(key: string): Promise; /** Direct-to-R2: raw still-sealed object bytes — the CEK header lives only in the sealed form. */ getSealedObject(key: string): Promise; /** 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; @@ -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 { + 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 { + 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 { 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 { 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); } diff --git a/apps/api/src/video/video.service.ts b/apps/api/src/video/video.service.ts index 4a13252..4a3dfb0 100644 --- a/apps/api/src/video/video.service.ts +++ b/apps/api/src/video/video.service.ts @@ -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 {