fix: logic-audit P0/P1 wave — CORS PUT, chunked cap, presign checksums, logout await, refresh rotation race
From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
ignored rowCount) forking the token family; consume now returns whether
it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
This commit is contained in:
parent
e475402b4d
commit
b24ebc5862
19 changed files with 1636 additions and 101 deletions
1
apps/api/migrations/045_file_delete_setting.sql
Normal file
1
apps/api/migrations/045_file_delete_setting.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE groups ADD COLUMN member_delete_own boolean NOT NULL DEFAULT true;
|
||||
65
apps/api/migrations/046_file_folders.sql
Normal file
65
apps/api/migrations/046_file_folders.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- Folder trees and personal files are metadata over the existing payload keys.
|
||||
CREATE TABLE file_folders (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
group_id uuid NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
owner_id uuid NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_id uuid NULL REFERENCES file_folders(id) ON DELETE CASCADE,
|
||||
name text NOT NULL CHECK (btrim(name) <> '' AND char_length(name) <= 255),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT file_folders_scope CHECK ((group_id IS NULL) <> (owner_id IS NULL)),
|
||||
UNIQUE (id, workspace_id),
|
||||
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_file_folders_ws_immutable BEFORE UPDATE ON file_folders
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
ALTER TABLE file_folders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE file_folders FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON file_folders
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON file_folders TO sp_app;
|
||||
|
||||
ALTER TABLE chat_media_archive
|
||||
ALTER COLUMN group_id DROP NOT NULL,
|
||||
ADD COLUMN folder_id uuid NULL REFERENCES file_folders(id) ON DELETE SET NULL,
|
||||
ADD COLUMN created_by uuid NULL,
|
||||
-- Required even at the personal root, which has no folder from which to infer ownership.
|
||||
ADD COLUMN owner_id uuid NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT chat_media_archive_scope CHECK ((group_id IS NULL) <> (owner_id IS NULL));
|
||||
|
||||
-- 045 may already have introduced this setting.
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS member_delete_own boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- Only exact, known identities are mapped. New authenticated writes supply
|
||||
-- created_by; Matrix ingest also resolves the configured homeserver in code.
|
||||
UPDATE chat_media_archive a SET created_by = (
|
||||
SELECT u.id FROM users u
|
||||
WHERE (a.sender_mxid = '@' || u.username || ':tmbh.local' OR a.sender_mxid = u.external_id)
|
||||
AND EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = a.workspace_id AND m.user_id = u.id)
|
||||
ORDER BY (a.sender_mxid = u.external_id) DESC, u.id LIMIT 1
|
||||
) WHERE a.created_by IS NULL;
|
||||
|
||||
-- Other ingest clients (including archive-bot) write this table directly.
|
||||
-- Keep exact known sender mappings attributed on those inserts too. Unknown
|
||||
-- remote identities stay NULL, so they never acquire member delete rights.
|
||||
CREATE FUNCTION archive_file_creator() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = public AS $$
|
||||
BEGIN
|
||||
IF NEW.created_by IS NULL THEN
|
||||
SELECT u.id INTO NEW.created_by FROM users u
|
||||
WHERE (NEW.sender_mxid = '@' || u.username || ':tmbh.local' OR NEW.sender_mxid = u.external_id)
|
||||
AND EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = NEW.workspace_id AND m.user_id = u.id)
|
||||
ORDER BY (NEW.sender_mxid = u.external_id) DESC, u.id LIMIT 1;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER trg_chat_media_archive_creator BEFORE INSERT ON chat_media_archive
|
||||
FOR EACH ROW EXECUTE FUNCTION archive_file_creator();
|
||||
|
||||
CREATE INDEX file_folders_scope_parent_idx ON file_folders (workspace_id, group_id, owner_id, parent_id);
|
||||
CREATE INDEX chat_media_archive_folder_idx ON chat_media_archive (workspace_id, folder_id);
|
||||
CREATE INDEX chat_media_archive_personal_idx ON chat_media_archive (workspace_id, owner_id, created_at DESC, id DESC)
|
||||
WHERE group_id IS NULL;
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||
import { BadRequestException, Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import type { Request, Response } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js";
|
||||
import { GroupFilesService, type FilePage } from "./group-files.service.js";
|
||||
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
|
||||
import type { FileFolder, GroupFile } from "./group-files.types.js";
|
||||
import { FileRangeNotSatisfiableException } from "./playback-range.js";
|
||||
import { verifyWhileStreaming } from "./verify-stream.js";
|
||||
|
||||
const MAX_FILE_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
|
@ -23,6 +24,9 @@ interface FileUpload {
|
|||
* GET /files/direct/config direct-upload capability + server public JWK
|
||||
* POST /files/direct/init presigned PUT for a browser-sealed upload
|
||||
* POST /files/direct/complete HEAD-checked commit + index row
|
||||
* POST /files/:id/playback mint a 10-minute playback capability
|
||||
* GET /files/play/:ticket public capability-authenticated byte range
|
||||
* DELETE /files/:id delete own upload (group setting) or Owner/Admin
|
||||
* POST /files/:id/direct-download presigned GET + unwrapped CEK
|
||||
* POST /files/groups/:groupId/upload direct web upload (≤2 GiB, multipart)
|
||||
* GET /files/:id one GroupFile
|
||||
|
|
@ -53,6 +57,61 @@ export class GroupFilesController {
|
|||
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
|
||||
}
|
||||
|
||||
@Post("folders")
|
||||
@RequirePermission("media.view")
|
||||
async createFolder(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<FileFolder> {
|
||||
const s = requireSession(request);
|
||||
return this.files.createFolder(s.workspaceId, s, requireObject(body, "body"));
|
||||
}
|
||||
|
||||
@Get("folders")
|
||||
@RequirePermission("media.view")
|
||||
async listFolders(@Req() request: Request & AuthenticatedRequest, @Query() query: Record<string, unknown>): Promise<FileFolder[]> {
|
||||
const s = requireSession(request);
|
||||
return this.files.listFolders(s.workspaceId, s, query);
|
||||
}
|
||||
|
||||
@Patch("folders/:id")
|
||||
@RequirePermission("media.view")
|
||||
async updateFolder(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<FileFolder> {
|
||||
const s = requireSession(request);
|
||||
return this.files.updateFolder(s.workspaceId, id, s, requireObject(body, "body"));
|
||||
}
|
||||
|
||||
@Delete("folders/:id")
|
||||
@RequirePermission("media.view")
|
||||
async deleteFolder(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<{ ok: true }> {
|
||||
const s = requireSession(request);
|
||||
return this.files.deleteFolder(s.workspaceId, id, s);
|
||||
}
|
||||
|
||||
@Get("groups/:groupId/settings")
|
||||
@RequirePermission("media.view")
|
||||
async groupSettings(@Req() request: Request & AuthenticatedRequest, @Param("groupId", ParseUUIDPipe) groupId: string): Promise<{ memberDeleteOwn: boolean }> {
|
||||
const s = requireSession(request);
|
||||
return this.files.getGroupFileSettings(s.workspaceId, groupId, s);
|
||||
}
|
||||
|
||||
@Patch("groups/:groupId/settings")
|
||||
@RequirePermission("media.view")
|
||||
async updateGroupSettings(@Req() request: Request & AuthenticatedRequest, @Param("groupId", ParseUUIDPipe) groupId: string, @Body() body: unknown): Promise<{ memberDeleteOwn: boolean }> {
|
||||
const s = requireSession(request);
|
||||
return this.files.setGroupFileSettings(s.workspaceId, groupId, s, requireObject(body, "body"));
|
||||
}
|
||||
|
||||
@Post("personal/upload")
|
||||
@RequirePermission("media.view")
|
||||
@UseInterceptors(FileInterceptor("file", {
|
||||
limits: { fileSize: MAX_FILE_UPLOAD_BYTES, files: 1, fields: 3, fieldSize: 1024, parts: 4 },
|
||||
}))
|
||||
async uploadPersonal(@Req() request: Request & AuthenticatedRequest, @UploadedFile() file?: FileUpload, @Body() body?: Record<string, unknown>): Promise<GroupFile> {
|
||||
const s = requireSession(request);
|
||||
if (file === undefined) throw new BadRequestException("a file is required (field: file)");
|
||||
return this.files.uploadForGroup(s.workspaceId, null, s,
|
||||
{ buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname },
|
||||
{ personal: true, folderId: body?.folderId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-upload capability. Declared before the :id routes so the literal
|
||||
* "direct/config" path wins route matching over ":id".
|
||||
|
|
@ -71,13 +130,15 @@ export class GroupFilesController {
|
|||
): Promise<ReturnType<GroupFilesService["initDirectUpload"]>> {
|
||||
const session = requireSession(request);
|
||||
const dto = requireObject(body, "body");
|
||||
return this.files.initDirectUpload(session.workspaceId, requireUuidValue(dto.groupId, "groupId"), {
|
||||
return this.files.initDirectUpload(session.workspaceId, dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"), {
|
||||
userId: session.userId,
|
||||
role: session.role,
|
||||
}, {
|
||||
filename: requireString(dto.filename, "filename"),
|
||||
mimeType: requireString(dto.mimeType, "mimeType"),
|
||||
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
|
||||
personal: dto.personal,
|
||||
folderId: dto.folderId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +153,9 @@ export class GroupFilesController {
|
|||
return this.files.completeDirectUpload(session.workspaceId, { userId: session.userId, role: session.role }, {
|
||||
uploadId: requireUuidValue(dto.uploadId, "uploadId"),
|
||||
objectKey: requireString(dto.objectKey, "objectKey"),
|
||||
groupId: requireUuidValue(dto.groupId, "groupId"),
|
||||
groupId: dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"),
|
||||
personal: dto.personal,
|
||||
folderId: dto.folderId,
|
||||
filename: requireString(dto.filename, "filename"),
|
||||
mimeType: requireString(dto.mimeType, "mimeType"),
|
||||
sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"),
|
||||
|
|
@ -111,6 +174,20 @@ export class GroupFilesController {
|
|||
return this.files.openDirectDownload(session.workspaceId, session.userId, session.role, id);
|
||||
}
|
||||
|
||||
@Post(":id/playback")
|
||||
@HttpCode(200)
|
||||
@RequirePermission("media.view")
|
||||
async openPlayback(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<ReturnType<GroupFilesService["openRangePlayback"]>> {
|
||||
const session = requireSession(request);
|
||||
const dto = requireObject(body, "body");
|
||||
return this.files.openRangePlayback(session.workspaceId, session.userId, session.role, id,
|
||||
requireNumber(dto.start, "start"), requireNumber(dto.length, "length"));
|
||||
}
|
||||
|
||||
@Post("groups/:groupId/upload")
|
||||
@RequirePermission("media.view")
|
||||
@UseInterceptors(FileInterceptor("file", {
|
||||
|
|
@ -120,6 +197,7 @@ export class GroupFilesController {
|
|||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("groupId", ParseUUIDPipe) groupId: string,
|
||||
@UploadedFile() file?: FileUpload,
|
||||
@Body() body?: Record<string, unknown>,
|
||||
): Promise<GroupFile> {
|
||||
const session = requireSession(request);
|
||||
if (file === undefined) throw new BadRequestException("a file is required (field: file)");
|
||||
|
|
@ -128,9 +206,32 @@ export class GroupFilesController {
|
|||
groupId,
|
||||
{ userId: session.userId, role: session.role },
|
||||
{ buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname },
|
||||
{ folderId: body?.folderId },
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/move")
|
||||
@HttpCode(200)
|
||||
@RequirePermission("media.view")
|
||||
async move(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<GroupFile> {
|
||||
const s = requireSession(request);
|
||||
return this.files.moveFile(s.workspaceId, id, s, requireObject(body, "body"));
|
||||
}
|
||||
|
||||
@Post(":id/copy")
|
||||
@RequirePermission("media.view")
|
||||
async copy(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<GroupFile> {
|
||||
const s = requireSession(request);
|
||||
return this.files.copyFile(s.workspaceId, id, s, requireObject(body, "body"));
|
||||
}
|
||||
|
||||
@Post(":id/share-to-group")
|
||||
@RequirePermission("media.view")
|
||||
async shareToGroup(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string, @Body() body: unknown): Promise<GroupFile> {
|
||||
const s = requireSession(request);
|
||||
return this.files.shareToGroup(s.workspaceId, id, s, requireObject(body, "body"));
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission("media.view")
|
||||
async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
|
||||
|
|
@ -159,6 +260,13 @@ export class GroupFilesController {
|
|||
response.end();
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission("media.view")
|
||||
async deleteFile(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
|
||||
const session = requireSession(request);
|
||||
return this.files.deleteFile(session.workspaceId, id, { userId: session.userId, role: session.role });
|
||||
}
|
||||
|
||||
@Post(":id/retention")
|
||||
@RequirePermission("media.manage")
|
||||
async setRetention(
|
||||
|
|
@ -215,6 +323,40 @@ export class GroupFilesController {
|
|||
}
|
||||
}
|
||||
|
||||
/** No session guard: the short-lived, single-file ticket is the credential. */
|
||||
@Controller("files/play")
|
||||
export class FilePlaybackController {
|
||||
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
|
||||
|
||||
@Get(":ticket")
|
||||
async play(
|
||||
@Param("ticket") ticket: string,
|
||||
@Query() query: Record<string, unknown>,
|
||||
@Headers("range") range: string | undefined,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("Accept-Ranges", "bytes");
|
||||
try {
|
||||
const slice = await this.files.readPlaybackTicket(ticket, query.start, query.end, range);
|
||||
response.status(206);
|
||||
response.setHeader("Content-Range", `bytes ${slice.start}-${slice.end}/${slice.total}`);
|
||||
response.setHeader("Content-Type", slice.item.mimeType);
|
||||
response.setHeader("Content-Length", String(slice.bytes.length));
|
||||
response.flushHeaders();
|
||||
// The storage reader verifies each segment's GCM tag before returning bytes.
|
||||
response.write(slice.bytes);
|
||||
response.end();
|
||||
} catch (error) {
|
||||
if (!(error instanceof FileRangeNotSatisfiableException)) throw error;
|
||||
response.status(416);
|
||||
response.setHeader("Content-Range", `bytes */${error.total}`);
|
||||
response.setHeader("Content-Length", "0");
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public share-claim surface: NO RbacGuard (the claimer holds only the
|
||||
* token). Token lives in the path — same shape as the invite claim routes.
|
||||
|
|
|
|||
372
apps/api/src/group-files/group-files.folders.test.ts
Normal file
372
apps/api/src/group-files/group-files.folders.test.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { GroupFilesService, type ActorRef, type ArchiveEntryInput } from "./group-files.service.js";
|
||||
import { GroupFilesController } from "./group-files.controller.js";
|
||||
import { PATH_METADATA, METHOD_METADATA } from "@nestjs/common/constants.js";
|
||||
import { RequestMethod } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { directStorageDisabled, type StorageService } from "../storage/storage.js";
|
||||
|
||||
const ws = "22222222-2222-4222-8222-222222222222";
|
||||
const group = "33333333-3333-4333-8333-333333333333";
|
||||
const otherGroup = "44444444-4444-4444-8444-444444444444";
|
||||
const alice = "55555555-5555-4555-8555-555555555555";
|
||||
const bob = "66666666-6666-4666-8666-666666666666";
|
||||
const now = new Date("2026-01-02T03:04:05Z");
|
||||
const member: ActorRef = { userId: alice, role: "Publisher" };
|
||||
type Row = Record<string, unknown>;
|
||||
let folders: Row[];
|
||||
let files: Row[];
|
||||
let statements: { sql: string; values: unknown[] }[];
|
||||
let memberOf: string[];
|
||||
let memberDeleteOwn: boolean;
|
||||
let storage: StorageService;
|
||||
let service: GroupFilesService;
|
||||
let mappedCreator: string | null;
|
||||
|
||||
function folder(patch: Row = {}): Row {
|
||||
return { id: randomUUID(), workspace_id: ws, group_id: group, owner_id: null, parent_id: null, name: "Footage", created_at: now, ...patch };
|
||||
}
|
||||
function file(patch: Row = {}): Row {
|
||||
return { id: randomUUID(), workspace_id: ws, group_id: group, owner_id: null, folder_id: null, created_by: alice,
|
||||
room_id: null, event_id: null, origin: "upload", sender_mxid: "@alice:tmbh.local", matrix_event_json: { kind: "upload" },
|
||||
original_name: "clip.mp4", safe_name: "clip.mp4", mime: "video/mp4", mime_class: "video", byte_size: "10",
|
||||
sha256: "a".repeat(64), ocis_resource_id: "sha256/same.mp4", ocis_resource_version: null, storage_key: "sha256/same.mp4",
|
||||
created_at: now, indexed_at: now, available: true, retention_mode: "keep", legal_hold: false, retention_until: null,
|
||||
share_access: null, share_expires_at: null, share_revoked_at: null, ...patch };
|
||||
}
|
||||
|
||||
// Stateful metadata fake: exercise the service through public operations and
|
||||
// independently inspect row identity, placement, provenance and storage calls.
|
||||
async function query(sql: string, values: unknown[] = []): Promise<{ rows: Row[]; rowCount: number }> {
|
||||
statements.push({ sql, values });
|
||||
let rows: Row[] = [];
|
||||
if (sql.includes("pg_advisory_xact_lock") || sql.startsWith("SELECT audit_append")) rows = [{}];
|
||||
else if (sql.startsWith("SELECT 1 FROM groups")) rows = values[0] === group || values[0] === otherGroup ? [{}] : [];
|
||||
else if (sql.includes("FROM group_members")) rows = memberOf.includes(String(values[0])) ? [{}] : [];
|
||||
else if (sql.includes("member_delete_own FROM groups")) rows = [{ member_delete_own: memberDeleteOwn }];
|
||||
else if (sql.startsWith("UPDATE groups SET member_delete_own")) { memberDeleteOwn = Boolean(values[1]); rows = [{}]; }
|
||||
else if (sql.startsWith("SELECT u.id FROM users")) rows = mappedCreator === null ? [] : [{ id: mappedCreator }];
|
||||
else if (sql.startsWith("SELECT username FROM users")) rows = [{ username: "alice" }];
|
||||
else if (sql.startsWith("SELECT * FROM file_folders WHERE id")) rows = folders.filter((f) => f.id === values[0]);
|
||||
else if (sql.startsWith("SELECT * FROM file_folders WHERE workspace_id")) rows = folders.filter((f) => f.group_id === values[1] && f.owner_id === values[2]);
|
||||
else if (sql.startsWith("INSERT INTO file_folders")) {
|
||||
const f = folder({ workspace_id: values[0], group_id: values[1], owner_id: values[2], parent_id: values[3], name: values[4] });
|
||||
folders.push(f); rows = [f];
|
||||
} else if (sql.startsWith("UPDATE file_folders")) {
|
||||
rows = folders.filter((f) => f.id === values[0]);
|
||||
rows.forEach((f) => Object.assign(f, { name: values[1], parent_id: values[2] }));
|
||||
} else if (sql.includes("WITH RECURSIVE descendants")) {
|
||||
const ids = new Set([values[0]]);
|
||||
let size = 0;
|
||||
while (ids.size !== size) { size = ids.size; folders.forEach((f) => { if (ids.has(f.parent_id)) ids.add(f.id); }); }
|
||||
rows = ids.has(values[1]) ? [{ id: values[1] }] : [];
|
||||
} else if (sql.startsWith("SELECT id FROM file_folders WHERE parent_id")) {
|
||||
rows = [...folders.filter((f) => f.parent_id === values[0]), ...files.filter((f) => f.folder_id === values[0])];
|
||||
} else if (sql.startsWith("DELETE FROM file_folders")) {
|
||||
folders = folders.filter((f) => f.id !== values[0]);
|
||||
files.filter((f) => f.folder_id === values[0]).forEach((f) => { f.folder_id = null; });
|
||||
} else if (sql.includes("FROM chat_media_archive a")) {
|
||||
if (sql.includes("WHERE a.id = $1")) rows = files.filter((f) => f.id === values[0]);
|
||||
else rows = files; // Listing predicates are asserted separately below.
|
||||
} else if (sql.startsWith("UPDATE chat_media_archive SET folder_id")) {
|
||||
files.filter((f) => f.id === values[0]).forEach((f) => { f.folder_id = values[1]; });
|
||||
} else if (sql.startsWith("UPDATE chat_media_archive SET available")) {
|
||||
files.filter((f) => f.id === values[0]).forEach((f) => { f.available = false; });
|
||||
} else if (sql.startsWith("INSERT INTO chat_media_archive")) {
|
||||
let f: Row;
|
||||
if (sql.includes("SELECT workspace_id")) {
|
||||
const source = files.find((item) => item.id === values[0])!;
|
||||
f = { ...source, id: randomUUID(), group_id: values[1], owner_id: values[2], folder_id: values[3], created_by: values[4],
|
||||
room_id: null, event_id: null, origin: values[5], matrix_event_json: JSON.parse(String(values[6])), original_name: values[7], safe_name: values[8] };
|
||||
} else {
|
||||
f = file({ workspace_id: values[0], group_id: values[1], room_id: values[2], event_id: values[3], origin: values[4], sender_mxid: values[5],
|
||||
matrix_event_json: JSON.parse(String(values[6])), original_name: values[7], safe_name: values[8], mime: values[9], mime_class: values[10], byte_size: values[11],
|
||||
sha256: values[12], ocis_resource_id: values[13], ocis_resource_version: values[14], created_at: values[15], storage_key: values[16], folder_id: values[17], created_by: values[18], owner_id: values[19] });
|
||||
}
|
||||
files.push(f); rows = [{ id: f.id }];
|
||||
} else if (sql.startsWith("UPDATE group_file_shares")) rows = [];
|
||||
else throw new Error(`unhandled SQL: ${sql}`);
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
vi.mock("../db/client.js", () => ({
|
||||
withTenant: async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) => fn({ query } as unknown as PoolClient),
|
||||
withSystem: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
folders = []; files = []; statements = []; memberOf = [group]; memberDeleteOwn = true; mappedCreator = null;
|
||||
storage = { ...directStorageDisabled(), putContent: vi.fn(async () => ({ key: "sha256/same.mp4", hash: "a".repeat(64), sizeBytes: 10, contentType: "video/mp4", deduplicated: true })),
|
||||
getObject: vi.fn(), hasContent: vi.fn(), presign: vi.fn(), getSealedObject: vi.fn() };
|
||||
service = new GroupFilesService({ storage });
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
describe("folder trees", () => {
|
||||
it("creates, lists, renames, reparents and deletes empty folders", async () => {
|
||||
const parent = await service.createFolder(ws, member, { groupId: group, name: " Parent " });
|
||||
const child = await service.createFolder(ws, member, { groupId: group, name: "Child", parentId: parent.id });
|
||||
expect(parent.name).toBe("Parent");
|
||||
expect(child).toMatchObject({ parentId: parent.id, groupId: group, ownerId: null });
|
||||
expect(await service.listFolders(ws, member, { scope: "group", groupId: group })).toHaveLength(2);
|
||||
await expect(service.deleteFolder(ws, parent.id, member)).rejects.toMatchObject({ status: 409 });
|
||||
const renamed = await service.updateFolder(ws, child.id, member, { name: "New" });
|
||||
expect(renamed).toMatchObject({ name: "New", parentId: parent.id });
|
||||
expect(await service.updateFolder(ws, child.id, member, { parentId: null })).toMatchObject({ parentId: null, name: "New" });
|
||||
await expect(service.deleteFolder(ws, parent.id, member)).resolves.toEqual({ ok: true });
|
||||
expect(folders.map((f) => f.id)).toEqual([child.id]);
|
||||
});
|
||||
|
||||
it.each(["Publisher", "Owner", "Admin"])("allows %s group folder CRUD with the group ACL", async (role) => {
|
||||
const actor = { userId: alice, role };
|
||||
if (role !== "Publisher") memberOf = [];
|
||||
const f = await service.createFolder(ws, actor, { groupId: group, name: "Test" });
|
||||
expect(await service.listFolders(ws, actor, { scope: "group", groupId: group })).toHaveLength(1);
|
||||
await service.updateFolder(ws, f.id, actor, { name: "Changed" });
|
||||
await expect(service.deleteFolder(ws, f.id, actor)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it.each(["create", "list", "update", "delete"])("denies non-member folder %s", async (operation) => {
|
||||
const f = folder(); folders.push(f); memberOf = [];
|
||||
const actions = {
|
||||
create: () => service.createFolder(ws, member, { groupId: group, name: "Test" }),
|
||||
list: () => service.listFolders(ws, member, { scope: "group", groupId: group }),
|
||||
update: () => service.updateFolder(ws, String(f.id), member, { name: "Changed" }),
|
||||
delete: () => service.deleteFolder(ws, String(f.id), member),
|
||||
};
|
||||
await expect(actions[operation as keyof typeof actions]()).rejects.toMatchObject({ status: 403 });
|
||||
expect(statements.some((s) => /^(INSERT|UPDATE|DELETE)/.test(s.sql))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps personal trees private, including from other workspace Owner/Admin", async () => {
|
||||
const f = await service.createFolder(ws, member, { personal: true, name: "Private" });
|
||||
expect(f).toMatchObject({ groupId: null, ownerId: alice });
|
||||
expect(await service.listFolders(ws, member, { scope: "personal" })).toHaveLength(1);
|
||||
for (const role of ["Publisher", "Owner", "Admin"]) {
|
||||
const actor = { userId: bob, role };
|
||||
expect(await service.listFolders(ws, actor, { scope: "personal" })).toEqual([]);
|
||||
await expect(service.updateFolder(ws, f.id, actor, { name: "Stolen" })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.deleteFolder(ws, f.id, actor)).rejects.toMatchObject({ status: 403 });
|
||||
}
|
||||
await service.updateFolder(ws, f.id, member, { name: "Renamed" });
|
||||
await expect(service.deleteFolder(ws, f.id, member)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects cycles, cross-scope parents, invalid names, missing folders and nonempty deletion", async () => {
|
||||
const parent = await service.createFolder(ws, member, { groupId: group, name: "Parent" });
|
||||
const child = await service.createFolder(ws, member, { groupId: group, name: "Child", parentId: parent.id });
|
||||
await expect(service.updateFolder(ws, parent.id, member, { parentId: parent.id })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(service.updateFolder(ws, parent.id, member, { parentId: child.id })).rejects.toMatchObject({ status: 409 });
|
||||
const personal = await service.createFolder(ws, member, { personal: true, name: "Private" });
|
||||
await expect(service.updateFolder(ws, parent.id, member, { parentId: personal.id })).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.createFolder(ws, member, { groupId: group, parentId: personal.id, name: "Wrong" })).rejects.toMatchObject({ status: 400 });
|
||||
for (const name of [" ", "a/b", "a\\b", "a".repeat(256)]) {
|
||||
await expect(service.createFolder(ws, member, { groupId: group, name })).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
await expect(service.updateFolder(ws, randomUUID(), member, { name: "X" })).rejects.toMatchObject({ status: 404 });
|
||||
files.push(file({ folder_id: child.id }));
|
||||
await expect(service.deleteFolder(ws, child.id, member)).rejects.toMatchObject({ status: 409 });
|
||||
files[0]!.available = false;
|
||||
await expect(service.deleteFolder(ws, child.id, member)).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("file scopes, moves, copies and sharing", () => {
|
||||
it("binds personal listings to caller and folder, and default listings to group roots", async () => {
|
||||
const f = folder({ group_id: null, owner_id: alice }); folders.push(f);
|
||||
await service.listFiles(ws, alice, "Owner", { scope: "personal", folderId: f.id });
|
||||
const listing = statements.find((s) => s.sql.includes("FROM chat_media_archive a"))!;
|
||||
expect(listing.sql).toContain("a.group_id IS NULL AND a.owner_id = $2::uuid");
|
||||
expect(listing.sql).toContain("a.folder_id = $3::uuid");
|
||||
expect(listing.values).toEqual([ws, alice, f.id, 51]);
|
||||
statements = [];
|
||||
await service.listFiles(ws, alice, "Owner", {});
|
||||
const roots = statements.find((s) => s.sql.includes("FROM chat_media_archive a"))!;
|
||||
expect(roots.sql).toContain("a.group_id IS NOT NULL");
|
||||
expect(roots.sql).toContain("a.folder_id IS NULL");
|
||||
await expect(service.listFiles(ws, alice, "Owner", { scope: "personal", groupId: group })).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.listFiles(ws, bob, "Owner", { scope: "personal", folderId: f.id })).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it.each(["Publisher", "Owner", "Admin"])("allows %s metadata moves in groups and returns to root", async (role) => {
|
||||
const f = folder(); folders.push(f); const source = file(); files.push(source);
|
||||
const moved = await service.moveFile(ws, String(source.id), { userId: alice, role }, { folderId: f.id });
|
||||
expect(moved.folderId).toBe(f.id);
|
||||
expect(await service.moveFile(ws, String(source.id), { userId: alice, role }, { folderId: null })).toMatchObject({ folderId: null });
|
||||
expect(files).toHaveLength(1);
|
||||
expect(storage.putContent).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies a new row with identical payload key and checksum while move preserves identity", async () => {
|
||||
const f = folder(); folders.push(f); const source = file(); files.push(source);
|
||||
const snapshot = structuredClone(source);
|
||||
const copy = await service.copyFile(ws, String(source.id), member, { targetFolderId: f.id });
|
||||
expect(copy).toMatchObject({ name: "clip.mp4 (copy)", folderId: f.id, createdBy: alice, checksum: { value: source.sha256 }, file: { storageKey: source.storage_key } });
|
||||
expect(copy.id).not.toBe(source.id); expect(files).toHaveLength(2); expect(source).toEqual(snapshot);
|
||||
await service.moveFile(ws, String(source.id), member, { folderId: f.id });
|
||||
expect(files).toHaveLength(2); expect(source.id).toBe(snapshot.id); expect(source.folder_id).toBe(f.id);
|
||||
expect(storage.putContent).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies into another accessible group root and personal folder", async () => {
|
||||
memberOf.push(otherGroup);
|
||||
const source = file(); files.push(source);
|
||||
expect(await service.copyFile(ws, String(source.id), member, { targetGroupId: otherGroup })).toMatchObject({ groupId: otherGroup, folderId: null });
|
||||
const personal = folder({ group_id: null, owner_id: alice }); folders.push(personal);
|
||||
expect(await service.copyFile(ws, String(source.id), member, { targetFolderId: personal.id })).toMatchObject({ groupId: null, ownerId: alice, folderId: personal.id });
|
||||
await expect(service.copyFile(ws, String(source.id), member, {})).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: group, targetFolderId: personal.id })).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("shares a personal file into group root, preserving original and payload", async () => {
|
||||
const source = file({ group_id: null, owner_id: alice }); files.push(source);
|
||||
const snapshot = structuredClone(source);
|
||||
const shared = await service.shareToGroup(ws, String(source.id), member, { groupId: group });
|
||||
expect(shared).toMatchObject({ groupId: group, ownerId: null, folderId: null, shared: true, origin: { kind: "upload", sharedFrom: "personal" }, name: source.original_name,
|
||||
checksum: { value: source.sha256 }, file: { storageKey: source.storage_key }, createdBy: alice });
|
||||
expect(shared.id).not.toBe(source.id); expect(files).toHaveLength(2); expect(source).toEqual(snapshot);
|
||||
expect(storage.putContent).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces source and destination ACLs, same-scope moves and personal ownership", async () => {
|
||||
const source = file(); files.push(source);
|
||||
const foreign = folder({ group_id: otherGroup }); folders.push(foreign);
|
||||
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: otherGroup })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.moveFile(ws, String(source.id), member, { folderId: foreign.id })).rejects.toMatchObject({ status: 403 });
|
||||
memberOf.push(otherGroup);
|
||||
await expect(service.moveFile(ws, String(source.id), member, { folderId: foreign.id })).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.moveFile(ws, String(source.id), member, {})).rejects.toMatchObject({ status: 400 });
|
||||
await expect(service.shareToGroup(ws, String(source.id), member, { groupId: group })).rejects.toMatchObject({ status: 400 });
|
||||
memberOf = [];
|
||||
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: otherGroup })).rejects.toMatchObject({ status: 403 });
|
||||
const personal = file({ group_id: null, owner_id: alice }); files.push(personal);
|
||||
for (const role of ["Publisher", "Owner", "Admin"]) {
|
||||
const stranger = { userId: bob, role };
|
||||
await expect(service.getFile(ws, bob, role, String(personal.id))).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.moveFile(ws, String(personal.id), stranger, { folderId: null })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.copyFile(ws, String(personal.id), stranger, { targetGroupId: group })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.shareToGroup(ws, String(personal.id), stranger, { groupId: group })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.deleteFile(ws, String(personal.id), stranger)).rejects.toMatchObject({ status: 403 });
|
||||
}
|
||||
await expect(service.shareToGroup(ws, String(personal.id), member, { groupId: group })).rejects.toMatchObject({ status: 403 });
|
||||
const privateFolder = folder({ group_id: null, owner_id: alice }); folders.push(privateFolder);
|
||||
expect(await service.moveFile(ws, String(personal.id), member, { folderId: privateFolder.id })).toMatchObject({ folderId: privateFolder.id });
|
||||
memberDeleteOwn = false;
|
||||
await expect(service.deleteFile(ws, String(personal.id), member)).resolves.toMatchObject({ status: "revoked" });
|
||||
});
|
||||
|
||||
it.each([{ available: false }, { retention_mode: "until", retention_until: new Date("2020-01-01") }])("refuses copies/moves/shares of unavailable files: %j", async (patch) => {
|
||||
const source = file({ group_id: null, owner_id: alice, ...patch }); files.push(source);
|
||||
await expect(service.moveFile(ws, String(source.id), member, { folderId: null })).rejects.toMatchObject({ status: 410 });
|
||||
await expect(service.copyFile(ws, String(source.id), member, { targetGroupId: group })).rejects.toMatchObject({ status: 410 });
|
||||
await expect(service.shareToGroup(ws, String(source.id), member, { groupId: group })).rejects.toMatchObject({ status: 410 });
|
||||
expect(files).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creator attribution, upload destinations and delete settings", () => {
|
||||
function entry(patch: Partial<ArchiveEntryInput> = {}): ArchiveEntryInput {
|
||||
return { groupId: group, roomId: null, eventId: null, origin: { kind: "upload" }, originKind: "upload", senderMxid: "@alice:tmbh.local",
|
||||
originalName: "clip.mp4", safeName: "clip.mp4", mime: "video/mp4", mimeClass: "video", byteSize: 10, sha256: "a".repeat(64),
|
||||
ocisResourceId: "sha256/same.mp4", storageKey: "sha256/same.mp4", ocisResourceVersion: null, createdAt: now, ...patch };
|
||||
}
|
||||
|
||||
it("maps sender identities for ingest and preserves explicit authenticated creators", async () => {
|
||||
mappedCreator = alice;
|
||||
const { id } = await service.registerArchiveEntry(ws, entry({ originKind: "matrix" }));
|
||||
expect(files.find((f) => f.id === id)?.created_by).toBe(alice);
|
||||
const mapping = statements.find((s) => s.sql.startsWith("SELECT u.id"))!;
|
||||
expect(mapping.values).toEqual([ws, "@alice:tmbh.local", "alice"]);
|
||||
mappedCreator = null;
|
||||
const explicit = await service.registerArchiveEntry(ws, entry({ createdBy: bob }));
|
||||
expect(files.find((f) => f.id === explicit.id)?.created_by).toBe(bob);
|
||||
const unknown = await service.registerArchiveEntry(ws, entry({ senderMxid: "@alice:foreign.example" }));
|
||||
expect(files.find((f) => f.id === unknown.id)?.created_by).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads into group/personal folders or roots and attributes the authenticated creator", async () => {
|
||||
const f = folder(); const personal = folder({ group_id: null, owner_id: alice }); folders.push(f, personal);
|
||||
const input = { buffer: Buffer.from("hello"), mimetype: "text/plain", originalName: "hello.txt" };
|
||||
expect(await service.uploadForGroup(ws, group, member, input, { folderId: f.id })).toMatchObject({ groupId: group, folderId: f.id, createdBy: alice });
|
||||
expect(await service.uploadForGroup(ws, null, member, input, { personal: true, folderId: personal.id })).toMatchObject({ groupId: null, ownerId: alice, folderId: personal.id, createdBy: alice });
|
||||
expect(await service.uploadForGroup(ws, null, member, input, { personal: true })).toMatchObject({ groupId: null, ownerId: alice, folderId: null });
|
||||
await expect(service.uploadForGroup(ws, group, member, input, { folderId: personal.id })).rejects.toMatchObject({ status: 400 });
|
||||
expect(storage.putContent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("resolves configured Hub Matrix identities without trusting foreign localparts", async () => {
|
||||
vi.stubEnv("MATRIX_SSO_HOMESERVER_URL", "https://chat.example.org");
|
||||
vi.stubEnv("MATRIX_SSO_LOCALPART_PREFIX", "hub_");
|
||||
mappedCreator = alice;
|
||||
await service.registerArchiveEntry(ws, entry({ senderMxid: "@hub_alice:chat.example.org", originKind: "matrix" }));
|
||||
const own = statements.find((s) => s.sql.startsWith("SELECT u.id"))!;
|
||||
expect(own.values).toEqual([ws, "@hub_alice:chat.example.org", "alice"]);
|
||||
statements = [];
|
||||
await service.registerArchiveEntry(ws, entry({ senderMxid: "@hub_alice:foreign.example", originKind: "matrix" }));
|
||||
const foreign = statements.find((s) => s.sql.startsWith("SELECT u.id"))!;
|
||||
expect(foreign.values).toEqual([ws, "@hub_alice:foreign.example", null]);
|
||||
});
|
||||
|
||||
it("accepts personal direct upload init/complete and rejects cross-scope destinations", async () => {
|
||||
const f = folder({ group_id: null, owner_id: alice }); folders.push(f);
|
||||
storage.directPublicJwk = vi.fn(async () => ({ kty: "EC" as const, crv: "P-256" as const, x: "x", y: "y" }));
|
||||
storage.presignPut = vi.fn(async () => "https://storage.example/put");
|
||||
storage.headObject = vi.fn(async () => ({ exists: true, contentLength: 177 }));
|
||||
const upload = await service.initDirectUpload(ws, null, member, { filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 177, personal: true, folderId: f.id });
|
||||
const result = await service.completeDirectUpload(ws, member, { ...upload, personal: true, folderId: f.id, filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 177, sha256: "a".repeat(64) });
|
||||
expect(result).toMatchObject({ groupId: null, ownerId: alice, folderId: f.id, createdBy: alice, file: { storageKey: upload.objectKey } });
|
||||
await expect(service.initDirectUpload(ws, group, member, { filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 177, folderId: f.id })).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it.each(["matrix", "bridge", "pad-export", "upload"])("allows deleting creator-owned %s rows regardless of sender text", async (origin) => {
|
||||
const source = file({ origin, created_by: alice, sender_mxid: "@someone:else.example" }); files.push(source);
|
||||
await expect(service.deleteFile(ws, String(source.id), member)).resolves.toMatchObject({ status: "revoked" });
|
||||
expect(files).toHaveLength(1); expect(source.storage_key).toBe("sha256/same.mp4");
|
||||
});
|
||||
|
||||
it("gives clear disabled/other-creator errors and exposes settings only to authorized callers", async () => {
|
||||
const source = file(); files.push(source);
|
||||
memberDeleteOwn = false;
|
||||
await expect(service.deleteFile(ws, String(source.id), member)).rejects.toThrow("member file deletion is disabled");
|
||||
expect(await service.getGroupFileSettings(ws, group, member)).toEqual({ memberDeleteOwn: false });
|
||||
await expect(service.setGroupFileSettings(ws, group, member, { memberDeleteOwn: true })).rejects.toMatchObject({ status: 403 });
|
||||
expect(await service.setGroupFileSettings(ws, group, { userId: alice, role: "Admin" }, { memberDeleteOwn: true })).toEqual({ memberDeleteOwn: true });
|
||||
source.created_by = bob;
|
||||
await expect(service.deleteFile(ws, String(source.id), member)).rejects.toThrow("members may only delete files they created");
|
||||
source.created_by = null;
|
||||
await expect(service.deleteFile(ws, String(source.id), member)).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.setGroupFileSettings(ws, group, { userId: alice, role: "Owner" }, { memberDeleteOwn: "false" })).rejects.toMatchObject({ status: 400 });
|
||||
memberOf = [];
|
||||
await expect(service.getGroupFileSettings(ws, group, member)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller contract", () => {
|
||||
it("declares literal folder routes ahead of file-id routes and binds the expected verbs", () => {
|
||||
const prototype = GroupFilesController.prototype;
|
||||
expect(Object.getOwnPropertyNames(prototype).indexOf("listFolders")).toBeLessThan(Object.getOwnPropertyNames(prototype).indexOf("get"));
|
||||
for (const [method, path, verb] of [
|
||||
["createFolder", "folders", RequestMethod.POST], ["listFolders", "folders", RequestMethod.GET],
|
||||
["updateFolder", "folders/:id", RequestMethod.PATCH], ["deleteFolder", "folders/:id", RequestMethod.DELETE],
|
||||
["move", ":id/move", RequestMethod.POST], ["copy", ":id/copy", RequestMethod.POST], ["shareToGroup", ":id/share-to-group", RequestMethod.POST],
|
||||
] as const) {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, prototype[method])).toBe(path);
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, prototype[method])).toBe(verb);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards authenticated scope and optional upload folder from both multipart routes", async () => {
|
||||
const stub = { uploadForGroup: vi.fn(async (..._args: unknown[]) => ({})), initDirectUpload: vi.fn(async () => ({})), completeDirectUpload: vi.fn(async () => ({})) };
|
||||
const controller = new GroupFilesController(stub as unknown as GroupFilesService);
|
||||
const session = { workspaceId: ws, userId: alice, role: "Publisher" };
|
||||
const request = { session } as unknown as Request;
|
||||
const upload = { buffer: Buffer.from("x"), originalname: "x.txt", mimetype: "text/plain" };
|
||||
const folderId = randomUUID();
|
||||
await controller.upload(request, group, upload, { folderId });
|
||||
await controller.uploadPersonal(request, upload, { folderId });
|
||||
expect(stub.uploadForGroup.mock.calls[0]?.slice(0, 3)).toEqual([ws, group, member]);
|
||||
expect(stub.uploadForGroup.mock.calls[0]?.[4]).toEqual({ folderId });
|
||||
expect(stub.uploadForGroup.mock.calls[1]?.[4]).toEqual({ personal: true, folderId });
|
||||
});
|
||||
});
|
||||
|
|
@ -255,4 +255,88 @@ describe.skipIf(appUrl === undefined || ownerUrl === undefined)("group-files met
|
|||
});
|
||||
await expect(svc.getFile(wsA, owner, "Owner", otherTenant)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("persists folder trees, blocks cycles/nonempty deletion and protects personal owners", async () => {
|
||||
const actor = { userId: publisher, role: "Publisher" };
|
||||
const parent = await svc.createFolder(wsA, actor, { groupId: groupA1, name: "Parent" });
|
||||
const child = await svc.createFolder(wsA, actor, { groupId: groupA1, parentId: parent.id, name: "Child" });
|
||||
expect(await svc.listFolders(wsA, actor, { scope: "group", groupId: groupA1 })).toEqual(expect.arrayContaining([parent, child]));
|
||||
await expect(svc.updateFolder(wsA, parent.id, actor, { parentId: child.id })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(svc.deleteFolder(wsA, parent.id, actor)).rejects.toMatchObject({ status: 409 });
|
||||
await svc.updateFolder(wsA, child.id, actor, { parentId: null, name: "Renamed" });
|
||||
await svc.deleteFolder(wsA, parent.id, actor);
|
||||
const personal = await svc.createFolder(wsA, actor, { personal: true, name: "Private" });
|
||||
await expect(svc.updateFolder(wsA, personal.id, { userId: owner, role: "Owner" }, { name: "No" })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(svc.createFolder(wsA, actor, { groupId: groupA2, name: "No" })).rejects.toMatchObject({ status: 403 });
|
||||
await svc.deleteFolder(wsA, personal.id, actor);
|
||||
await svc.deleteFolder(wsA, child.id, actor);
|
||||
});
|
||||
|
||||
async function personalEntry(): Promise<string> {
|
||||
const { id } = await svc.registerArchiveEntry(wsA, {
|
||||
groupId: null, ownerId: publisher, createdBy: publisher, roomId: null, eventId: null,
|
||||
origin: { kind: "upload" }, originKind: "upload", senderMxid: "@publisher:tmbh.local",
|
||||
originalName: "private.txt", safeName: "private.txt", mime: "text/plain", mimeClass: "document", byteSize: 3,
|
||||
sha256: "a".repeat(64), ocisResourceId: "sha256/metadata-only.txt", storageKey: "sha256/metadata-only.txt",
|
||||
ocisResourceVersion: null, createdAt: new Date(),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it("moves one row, copies/shares new rows with identical keys, and leaves the personal original intact", async () => {
|
||||
const actor = { userId: publisher, role: "Publisher" };
|
||||
const id = await personalEntry();
|
||||
const folder = await svc.createFolder(wsA, actor, { personal: true, name: "Personal files" });
|
||||
const original = await svc.getFile(wsA, publisher, "Publisher", id);
|
||||
const copy = await svc.copyFile(wsA, id, actor, { targetFolderId: folder.id });
|
||||
expect(copy.id).not.toBe(id);
|
||||
expect(copy).toMatchObject({ name: "private.txt (copy)", folderId: folder.id, file: original.file, checksum: original.checksum });
|
||||
const shared = await svc.shareToGroup(wsA, id, actor, { groupId: groupA1 });
|
||||
expect(shared).toMatchObject({ groupId: groupA1, ownerId: null, shared: true, origin: { kind: "upload", sharedFrom: "personal" }, file: { storageKey: original.file.storageKey }, checksum: original.checksum });
|
||||
expect(await svc.getFile(wsA, publisher, "Publisher", id)).toEqual(original);
|
||||
await expect(svc.getFile(wsA, owner, "Owner", id)).rejects.toMatchObject({ status: 403 });
|
||||
expect((await svc.moveFile(wsA, id, actor, { folderId: folder.id })).id).toBe(id);
|
||||
const root = await svc.listFiles(wsA, publisher, "Publisher", { scope: "personal" });
|
||||
expect(root.items.some((f) => f.id === id)).toBe(false);
|
||||
const inFolder = await svc.listFiles(wsA, publisher, "Publisher", { scope: "personal", folderId: folder.id });
|
||||
expect(inFolder.items.map((f) => f.id).sort()).toEqual([id, copy.id].sort());
|
||||
await expect(svc.deleteFolder(wsA, folder.id, actor)).rejects.toMatchObject({ status: 409 });
|
||||
const count = await inTenant(wsA, (c) => c.query("SELECT count(*)::int AS n FROM chat_media_archive WHERE storage_key = $1", [original.file.storageKey]));
|
||||
expect(count.rows[0]?.n).toBe(3);
|
||||
});
|
||||
|
||||
it("enforces member creator/settings deletion while Owner/Admin keep soft-revoke access", async () => {
|
||||
const actor = { userId: publisher, role: "Publisher" };
|
||||
const personal = await personalEntry();
|
||||
const shared = await svc.shareToGroup(wsA, personal, actor, { groupId: groupA1 });
|
||||
await svc.setGroupFileSettings(wsA, groupA1, { userId: owner, role: "Owner" }, { memberDeleteOwn: false });
|
||||
await expect(svc.deleteFile(wsA, shared.id, actor)).rejects.toMatchObject({ status: 403 });
|
||||
await svc.setGroupFileSettings(wsA, groupA1, { userId: owner, role: "Admin" }, { memberDeleteOwn: true });
|
||||
await expect(svc.deleteFile(wsA, shared.id, actor)).resolves.toMatchObject({ status: "revoked" });
|
||||
const other = await svc.copyFile(wsA, personal, { userId: publisher, role: "Publisher" }, { targetGroupId: groupA1 });
|
||||
await inTenant(wsA, (c) => c.query("UPDATE chat_media_archive SET created_by = $2 WHERE id = $1", [other.id, owner]));
|
||||
await expect(svc.deleteFile(wsA, other.id, actor)).rejects.toMatchObject({ status: 403 });
|
||||
await svc.setGroupFileSettings(wsA, groupA1, { userId: owner, role: "Owner" }, { memberDeleteOwn: false });
|
||||
await expect(svc.deleteFile(wsA, other.id, { userId: owner, role: "Owner" })).resolves.toMatchObject({ status: "revoked" });
|
||||
const raw = await inTenant(wsA, (c) => c.query("SELECT available, storage_key FROM chat_media_archive WHERE id = $1", [shared.id]));
|
||||
expect(raw.rows[0]).toEqual({ available: false, storage_key: "sha256/metadata-only.txt" });
|
||||
});
|
||||
|
||||
it("applies FORCE tenant RLS to folders and attributes direct database inserts", async () => {
|
||||
const folder = await svc.createFolder(wsB, { userId: owner, role: "Owner" }, { groupId: groupB, name: "Other tenant" });
|
||||
await expect(svc.updateFolder(wsA, folder.id, { userId: owner, role: "Owner" }, { name: "No" })).rejects.toMatchObject({ status: 404 });
|
||||
const withoutTenant = await m.db.pool.query("SELECT id FROM file_folders WHERE id = $1", [folder.id]);
|
||||
expect(withoutTenant.rows).toEqual([]);
|
||||
const id = await personalEntry();
|
||||
const username = await inTenant(wsA, (c) => c.query("SELECT username FROM users WHERE id = $1", [publisher]));
|
||||
const inserted = await inTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO chat_media_archive (workspace_id, group_id, origin, sender_mxid, original_name, safe_name,
|
||||
mime, mime_class, byte_size, sha256, ocis_resource_id, created_at)
|
||||
SELECT workspace_id, $2, origin, $3, original_name, safe_name, mime, mime_class, byte_size, sha256, ocis_resource_id, now()
|
||||
FROM chat_media_archive WHERE id = $1 RETURNING created_by`,
|
||||
[id, groupA1, `@${String(username.rows[0]?.username)}:tmbh.local`],
|
||||
));
|
||||
expect(inserted.rows[0]?.created_by).toBe(publisher);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import type { PoolClient } from "pg";
|
||||
import { GroupFilesService } from "./group-files.service.js";
|
||||
import type { Response } from "express";
|
||||
import { GUARDS_METADATA } from "@nestjs/common/constants.js";
|
||||
import { FilePlaybackController, GroupFilesController } from "./group-files.controller.js";
|
||||
import { RbacGuard } from "../rbac/rbac.guard.js";
|
||||
import { FileRangeNotSatisfiableException } from "./playback-range.js";
|
||||
import { directStorageDisabled, type StorageService } from "../storage/storage.js";
|
||||
import { hashShareToken } from "./share-token.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
/** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */
|
||||
|
|
@ -355,3 +361,214 @@ describe("GroupFilesService invariants", () => {
|
|||
await expect(service.claimPublicDownload("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("range playback capabilities", () => {
|
||||
const payload = Buffer.from("0123456789");
|
||||
let service: GroupFilesService;
|
||||
let storage: StorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
behavior = () => ({ rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 });
|
||||
storage = {
|
||||
...directStorageDisabled(),
|
||||
putContent: vi.fn(), hasContent: vi.fn(), presign: vi.fn(), getObject: vi.fn(), getSealedObject: vi.fn(),
|
||||
readDirectRange: vi.fn(async (_key, start, length) => payload.subarray(start, start + length)),
|
||||
};
|
||||
service = new GroupFilesService({ storage });
|
||||
});
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
async function issue(): Promise<string> {
|
||||
const result = await service.openRangePlayback(row.workspace_id, "user-1", "Publisher", row.id, 2, 4);
|
||||
return result.ticket.token;
|
||||
}
|
||||
|
||||
function responseFixture(): { response: Response; headers: Record<string, string>; chunks: Buffer[]; status: () => number } {
|
||||
const headers: Record<string, string> = {};
|
||||
const chunks: Buffer[] = [];
|
||||
let code = 0;
|
||||
const response = {
|
||||
status(value: number) { code = value; return this; },
|
||||
setHeader(key: string, value: string) { headers[key] = value; return this; },
|
||||
flushHeaders: vi.fn(), write: (bytes: Buffer) => chunks.push(bytes), end: vi.fn(),
|
||||
} as unknown as Response;
|
||||
return { response, headers, chunks, status: () => code };
|
||||
}
|
||||
|
||||
it("issues a 256-bit single-file ticket with exactly a 10-minute TTL without reading payloads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-18T12:00:00Z"));
|
||||
const { ticket } = await service.openRangePlayback(row.workspace_id, "user-1", "Publisher", row.id, 2, 4);
|
||||
expect(ticket).toEqual({ token: expect.stringMatching(/^[a-f0-9]{64}$/), itemId: row.id, expiresAt: "2026-10-18T12:10:00.000Z" });
|
||||
expect(storage.readDirectRange).not.toHaveBeenCalled();
|
||||
// The ticket covers its one file, allowing later seeks beyond the initial range.
|
||||
const slice = await service.readPlaybackTicket(ticket.token, "6", "8");
|
||||
expect(slice.bytes.toString()).toBe("678");
|
||||
expect(slice.item.id).toBe(row.id);
|
||||
expect(storage.readDirectRange).toHaveBeenCalledWith("direct/video.enc", 6, 3);
|
||||
expect(storage.getObject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expires tickets at the 10-minute boundary and rejects unknown tokens before any DB/storage access", async () => {
|
||||
vi.useFakeTimers();
|
||||
const token = await issue();
|
||||
vi.advanceTimersByTime(10 * 60_000 - 1);
|
||||
await expect(service.readPlaybackTicket(token, "0", "1")).resolves.toMatchObject({ start: 0, end: 1 });
|
||||
const count = statements.length;
|
||||
vi.advanceTimersByTime(1);
|
||||
await expect(service.readPlaybackTicket(token, "0", "1")).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.readPlaybackTicket("unknown", "0", "1")).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(statements).toHaveLength(count);
|
||||
expect(storage.readDirectRange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("checks group membership before issuance and again on use, and rejects legacy objects", async () => {
|
||||
behavior = (sql) => sql.includes("FROM group_members")
|
||||
? { rows: [], rowCount: 0 } : { rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 };
|
||||
await expect(issue()).rejects.toBeInstanceOf(ForbiddenException);
|
||||
behavior = () => ({ rows: [{ ...row, storage_key: "sha256/legacy.mp4" }], rowCount: 1 });
|
||||
await expect(issue()).rejects.toBeInstanceOf(BadRequestException);
|
||||
behavior = () => ({ rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 });
|
||||
const token = await issue();
|
||||
behavior = (sql) => sql.includes("FROM group_members")
|
||||
? { rows: [], rowCount: 0 } : { rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc" }], rowCount: 1 };
|
||||
await expect(service.readPlaybackTicket(token, "0", "1")).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(storage.readDirectRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ available: false },
|
||||
{ retention_mode: "until", retention_until: new Date("2020-01-01T00:00:00Z") },
|
||||
])("stops playback of unavailable files: %j", async (patch) => {
|
||||
const token = await issue();
|
||||
behavior = () => ({ rows: [{ ...row, byte_size: "177", storage_key: "direct/video.enc", ...patch }], rowCount: 1 });
|
||||
await expect(service.readPlaybackTicket(token, "0", "1")).rejects.toBeInstanceOf(GoneException);
|
||||
await expect(issue()).rejects.toBeInstanceOf(GoneException);
|
||||
expect(storage.readDirectRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves 206 with plaintext size (sealed metadata overhead removed) and no auth guard", async () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, FilePlaybackController)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, FilePlaybackController.prototype.play)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, GroupFilesController)).toEqual([RbacGuard]);
|
||||
const token = await issue();
|
||||
const out = responseFixture();
|
||||
await new FilePlaybackController(service).play(token, { start: "2", end: "5" }, undefined, out.response);
|
||||
expect(out.status()).toBe(206);
|
||||
expect(out.headers).toEqual({
|
||||
"Cache-Control": "no-store", "Accept-Ranges": "bytes", "Content-Type": "video/mp4",
|
||||
"Content-Range": "bytes 2-5/10", "Content-Length": "4",
|
||||
});
|
||||
expect(Buffer.concat(out.chunks).toString()).toBe("2345");
|
||||
expect(out.response.flushHeaders).toHaveBeenCalled();
|
||||
expect(out.response.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bytes=7-99", "789", 7, 9],
|
||||
["bytes=8-", "89", 8, 9],
|
||||
["bytes=-3", "789", 7, 9],
|
||||
["bytes=-100", "0123456789", 0, 9],
|
||||
[undefined, "0123456789", 0, 9],
|
||||
])("supports browser Range header %s and clips to EOF", async (range, expected, start, end) => {
|
||||
const slice = await service.readPlaybackTicket(await issue(), undefined, undefined, range);
|
||||
expect(slice.bytes.toString()).toBe(expected);
|
||||
expect(slice).toMatchObject({ start, end, total: 10 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ start: "10", end: "20" }, { start: "4", end: "3" }, { start: "-1", end: "4" },
|
||||
{ start: "0.5", end: "4" }, { start: "9007199254740992" }, { start: ["0", "1"] },
|
||||
])("returns 416 and bytes */total for invalid/unsatisfiable query %j", async (query) => {
|
||||
const out = responseFixture();
|
||||
await new FilePlaybackController(service).play(await issue(), query, undefined, out.response);
|
||||
expect(out.status()).toBe(416);
|
||||
expect(out.headers["Content-Range"]).toBe("bytes */10");
|
||||
expect(out.headers["Accept-Ranges"]).toBe("bytes");
|
||||
expect(out.headers["Content-Length"]).toBe("0");
|
||||
expect(out.chunks).toEqual([]);
|
||||
expect(storage.readDirectRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["bytes=10-", "bytes=3-2", "bytes=-0", "bytes=", "bytes=0-1,3-4", "bytes=a-b"])("rejects invalid/unsatisfiable Range %s", async (range) => {
|
||||
await expect(service.readPlaybackTicket(await issue(), undefined, undefined, range)).rejects.toBeInstanceOf(FileRangeNotSatisfiableException);
|
||||
expect(storage.readDirectRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 416 before storage for an unsatisfiable initial range", async () => {
|
||||
await expect(service.openRangePlayback(row.workspace_id, "user-1", "Owner", row.id, 10, 1)).rejects.toMatchObject({ status: 416 });
|
||||
expect(storage.readDirectRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unwraps browser-download CEKs from a header-only fetch", async () => {
|
||||
storage.presign = vi.fn(async () => "https://storage.example/video");
|
||||
storage.openDirectHeaderBlob = vi.fn(async () => Buffer.alloc(135));
|
||||
storage.openDirectHeader = vi.fn(async () => Buffer.alloc(32, 1));
|
||||
const ticket = await service.openDirectDownload(row.workspace_id, "u", "Owner", row.id);
|
||||
expect(ticket.frame).toBe("SPENC2");
|
||||
expect(storage.openDirectHeaderBlob).toHaveBeenCalledWith("direct/video.enc");
|
||||
expect(storage.getSealedObject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("member-delete-own permissions", () => {
|
||||
// Default-true is enforced by migrations 045/046; both explicit setting values
|
||||
// are exercised here for members and the Owner/Admin override.
|
||||
const cases = ["Publisher", "Editor", "Owner", "Admin"].flatMap((role) =>
|
||||
[true, false].flatMap((enabled) => [true, false].map((own) => ({ role, enabled, own,
|
||||
allowed: role === "Owner" || role === "Admin" || (enabled && own) }))));
|
||||
|
||||
it.each(cases)("$role setting=$enabled own=$own => allowed=$allowed", async ({ role, enabled, own, allowed }) => {
|
||||
let revoked = false;
|
||||
behavior = (sql) => {
|
||||
if (sql.includes("SELECT g.member_delete_own")) return { rows: [{ member_delete_own: enabled, username: "alice" }], rowCount: 1 };
|
||||
if (sql.includes("FROM group_members")) return { rows: [{}], rowCount: 1 };
|
||||
if (sql.startsWith("UPDATE chat_media_archive")) revoked = true;
|
||||
return { rows: [{ ...row, origin: "upload", created_by: own ? "alice-id" : "bob-id", sender_mxid: own ? "@alice:tmbh.local" : "@bob:tmbh.local", available: !revoked }], rowCount: 1 };
|
||||
};
|
||||
const service = new GroupFilesService();
|
||||
const result = service.deleteFile(row.workspace_id, row.id, { userId: "alice-id", role });
|
||||
if (allowed) {
|
||||
await expect(result).resolves.toMatchObject({ status: "revoked" });
|
||||
expect(statements.some((q) => q.sql.startsWith("UPDATE group_file_shares SET revoked_at"))).toBe(true);
|
||||
expect(statements.find((q) => q.sql.startsWith("SELECT audit_append"))?.values[3]).toBe("file.deleted");
|
||||
} else {
|
||||
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(statements.some((q) => q.sql.startsWith("UPDATE"))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["matrix", "bridge", "pad-export"])("does not infer ownership of %s attachments from sender_mxid", async (origin) => {
|
||||
behavior = (sql) => {
|
||||
if (sql.includes("SELECT g.member_delete_own")) return { rows: [{ member_delete_own: true, username: "alice" }], rowCount: 1 };
|
||||
return { rows: [{ ...row, origin, sender_mxid: "@alice:tmbh.local" }], rowCount: 1 };
|
||||
};
|
||||
await expect(new GroupFilesService().deleteFile(row.workspace_id, row.id, { userId: "alice-id", role: "Publisher" }))
|
||||
.rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("denies former/non-members even for their own uploads", async () => {
|
||||
behavior = (sql) => sql.includes("FROM group_members") ? { rows: [], rowCount: 0 }
|
||||
: { rows: [{ ...row, origin: "upload", sender_mxid: "@alice:tmbh.local" }], rowCount: 1 };
|
||||
await expect(new GroupFilesService().deleteFile(row.workspace_id, row.id, { userId: "alice-id", role: "Publisher" }))
|
||||
.rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(statements.some((q) => q.sql.startsWith("UPDATE"))).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the setting to the revoke route too", async () => {
|
||||
behavior = (sql) => sql.includes("SELECT g.member_delete_own")
|
||||
? { rows: [{ member_delete_own: false, username: "alice" }], rowCount: 1 }
|
||||
: { rows: [{ ...row, origin: "upload", sender_mxid: "@alice:tmbh.local" }], rowCount: 1 };
|
||||
await expect(new GroupFilesService().markRevoked(row.workspace_id, row.id, { userId: "alice-id", role: "Publisher" }))
|
||||
.rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it.each(["Owner", "Admin"])("preserves legal hold even for %s deletion", async (role) => {
|
||||
behavior = () => ({ rows: [{ ...row, legal_hold: true }], rowCount: 1 });
|
||||
await expect(new GroupFilesService().deleteFile(row.workspace_id, row.id, { userId: "u", role }))
|
||||
.rejects.toThrow("legal hold");
|
||||
expect(statements.some((q) => q.sql.startsWith("UPDATE"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,17 +20,20 @@
|
|||
* (audit_append), the house pattern.
|
||||
*/
|
||||
import { BadRequestException, ConflictException, ForbiddenException, GoneException, Injectable, NotFoundException, Optional, Inject, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import type { PoolClient } from "pg";
|
||||
import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core";
|
||||
import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention } from "@tactical-media-broadcast-hub/core";
|
||||
import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
|
||||
import { withSystem, withTenant } from "../db/client.js";
|
||||
import { isUuid, parseLimit } from "../http/validation.js";
|
||||
import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js";
|
||||
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
|
||||
import { S3Storage, directPlaintextSize, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
|
||||
import type { PayloadDownload } from "./verify-stream.js";
|
||||
import { hashShareToken, mintShareToken } from "./share-token.js";
|
||||
import { playbackRange, requestedPlaybackRange } from "./playback-range.js";
|
||||
import { hubUsernameFor } from "../matrix-sso/mxid-display.js";
|
||||
import type { FileFolder, FileScopeInput, GroupFile } from "./group-files.types.js";
|
||||
import { MIME_CLASSES, ORIGIN_KINDS, parseRetentionInput, type MimeClass, type OriginKind } from "./group-files.validation.js";
|
||||
|
||||
export const GROUP_FILES_DEPENDENCIES: unique symbol = Symbol("GROUP_FILES_DEPENDENCIES");
|
||||
|
|
@ -91,6 +94,30 @@ export interface DirectDownloadTicket {
|
|||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface PlaybackTicket {
|
||||
token: string;
|
||||
itemId: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface PlaybackGrant {
|
||||
ticket: PlaybackTicket;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface PlaybackSlice {
|
||||
item: GroupFile;
|
||||
bytes: Buffer;
|
||||
start: number;
|
||||
end: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const PLAYBACK_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_PLAYBACK_TICKETS = 1000;
|
||||
|
||||
export const DIRECT_MAX_BYTES = 2_147_483_648;
|
||||
const DIRECT_PUT_TTL_SECONDS = 900;
|
||||
const DIRECT_GET_TTL_SECONDS = 600;
|
||||
|
|
@ -106,8 +133,11 @@ const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
|
|||
interface ArchiveRow {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
group_id: string;
|
||||
room_id: string;
|
||||
group_id: string | null;
|
||||
owner_id: string | null;
|
||||
folder_id: string | null;
|
||||
created_by: string | null;
|
||||
room_id: string | null;
|
||||
event_id: string | null;
|
||||
origin: string;
|
||||
sender_mxid: string;
|
||||
|
|
@ -141,6 +171,23 @@ interface ShareRow {
|
|||
revoked_at: Date | null;
|
||||
}
|
||||
|
||||
interface FolderRow {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
group_id: string | null;
|
||||
owner_id: string | null;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface FileScope { groupId: string | null; ownerId: string | null }
|
||||
|
||||
function folderToWire(row: FolderRow): FileFolder {
|
||||
return { id: row.id, workspaceId: row.workspace_id, groupId: row.group_id, ownerId: row.owner_id,
|
||||
parentId: row.parent_id, name: row.name, createdAt: iso(row.created_at) };
|
||||
}
|
||||
|
||||
const ARCHIVE_SELECT = `SELECT a.*, s.access AS share_access, s.expires_at AS share_expires_at, s.revoked_at AS share_revoked_at
|
||||
FROM chat_media_archive a
|
||||
LEFT JOIN LATERAL (
|
||||
|
|
@ -175,7 +222,7 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
|
|||
if (row.origin === "pad-export") {
|
||||
return { kind: "pad-export", padId: String(payload.padId ?? ""), revision: String(payload.revision ?? "") };
|
||||
}
|
||||
return { kind: "upload" };
|
||||
return { kind: "upload", ...(payload.sharedFrom === "personal" ? { sharedFrom: "personal" as const } : {}) };
|
||||
})();
|
||||
const retention: FileRetention =
|
||||
row.retention_mode === "until" && row.retention_until !== null
|
||||
|
|
@ -190,6 +237,10 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
|
|||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
groupId: row.group_id,
|
||||
ownerId: row.owner_id ?? null,
|
||||
folderId: row.folder_id ?? null,
|
||||
createdBy: row.created_by ?? null,
|
||||
shared: payload.shared === true,
|
||||
roomId: row.room_id,
|
||||
senderId: row.sender_mxid,
|
||||
eventId: row.event_id,
|
||||
|
|
@ -220,6 +271,10 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str
|
|||
@Injectable()
|
||||
export class GroupFilesService {
|
||||
private store: StorageService | null = null;
|
||||
// Process-local capabilities: bounded to 1000 entries, expire after 10 minutes,
|
||||
// and disappear on restart. Multi-process deployments need sticky routing or
|
||||
// a shared ticket store. Every use rechecks the issuing user's file ACL.
|
||||
private readonly playbackTickets = new Map<string, PlaybackGrant>();
|
||||
|
||||
constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { storage?: StorageService }) {}
|
||||
|
||||
|
|
@ -237,6 +292,224 @@ export class GroupFilesService {
|
|||
return base === "" ? resourceId : `${base}/f/${encodeURIComponent(resourceId)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Folders and metadata-only file placement
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async createFolder(workspaceId: string, actor: ActorRef, input: FileScopeInput & { name?: unknown; parentId?: unknown }): Promise<FileFolder> {
|
||||
const scope = parseWriteScope(input, actor.userId);
|
||||
const name = folderName(input.name);
|
||||
const parentId = optionalUuid(input.parentId, "parentId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.lockFolderTree(client, workspaceId);
|
||||
await this.requireScopeAccess(client, scope, actor);
|
||||
if (parentId !== null) this.requireSameScope(await this.requireFolder(client, parentId, actor), scopeRow(scope));
|
||||
const inserted = await client.query<FolderRow>(
|
||||
`INSERT INTO file_folders (workspace_id, group_id, owner_id, parent_id, name)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`, [workspaceId, scope.groupId, scope.ownerId, parentId, name],
|
||||
);
|
||||
const folder = inserted.rows[0];
|
||||
if (folder === undefined) throw new Error("folder insert returned no row");
|
||||
await this.audit(client, workspaceId, actor, { action: "file.folder_created", targetId: folder.id, diff: { name, parentId } });
|
||||
return folderToWire(folder);
|
||||
});
|
||||
}
|
||||
|
||||
/** Flat list of every folder in the scope; parentId reconstructs the tree. */
|
||||
async listFolders(workspaceId: string, actor: ActorRef, query: Record<string, unknown>): Promise<FileFolder[]> {
|
||||
const scope = parseListScope(query, actor.userId);
|
||||
if (scope.ownerId === null && scope.groupId === null) throw new BadRequestException("groupId is required for group folders");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.requireScopeAccess(client, scope, actor);
|
||||
const result = await client.query<FolderRow>(
|
||||
`SELECT * FROM file_folders WHERE workspace_id = $1
|
||||
AND group_id IS NOT DISTINCT FROM $2::uuid AND owner_id IS NOT DISTINCT FROM $3::uuid
|
||||
ORDER BY name, id`, [workspaceId, scope.groupId, scope.ownerId],
|
||||
);
|
||||
return result.rows.map(folderToWire);
|
||||
});
|
||||
}
|
||||
|
||||
async updateFolder(workspaceId: string, folderId: string, actor: ActorRef, input: { name?: unknown; parentId?: unknown }): Promise<FileFolder> {
|
||||
const name = input.name === undefined ? undefined : folderName(input.name);
|
||||
if (name === undefined && input.parentId === undefined) throw new BadRequestException("pass name and/or parentId");
|
||||
const parentId = input.parentId === undefined ? undefined : optionalUuid(input.parentId, "parentId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.lockFolderTree(client, workspaceId);
|
||||
const before = await this.requireFolder(client, folderId, actor);
|
||||
if (parentId !== undefined && parentId !== null) {
|
||||
this.requireSameScope(await this.requireFolder(client, parentId, actor), before);
|
||||
const descendants = await client.query(
|
||||
`WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM file_folders WHERE id = $1
|
||||
UNION SELECT f.id FROM file_folders f JOIN descendants d ON f.parent_id = d.id
|
||||
) SELECT id FROM descendants WHERE id = $2`, [folderId, parentId],
|
||||
);
|
||||
if (descendants.rows.length > 0) throw new ConflictException("a folder cannot be moved into itself or a descendant");
|
||||
}
|
||||
const updated = await client.query<FolderRow>(
|
||||
"UPDATE file_folders SET name = $2, parent_id = $3 WHERE id = $1 RETURNING *",
|
||||
[folderId, name ?? before.name, parentId === undefined ? before.parent_id : parentId],
|
||||
);
|
||||
const folder = updated.rows[0];
|
||||
if (folder === undefined) throw new NotFoundException("folder not found");
|
||||
await this.audit(client, workspaceId, actor, { action: "file.folder_updated", targetId: folderId, diff: { name: folder.name, parentId: folder.parent_id } });
|
||||
return folderToWire(folder);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFolder(workspaceId: string, folderId: string, actor: ActorRef): Promise<{ ok: true }> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.lockFolderTree(client, workspaceId);
|
||||
await this.requireFolder(client, folderId, actor, true);
|
||||
const children = await client.query(
|
||||
`SELECT id FROM file_folders WHERE parent_id = $1
|
||||
UNION ALL SELECT id FROM chat_media_archive WHERE folder_id = $1 LIMIT 1`, [folderId],
|
||||
);
|
||||
if (children.rows.length > 0) throw new ConflictException("folder must be empty of files and subfolders before deletion");
|
||||
await client.query("DELETE FROM file_folders WHERE id = $1", [folderId]);
|
||||
await this.audit(client, workspaceId, actor, { action: "file.folder_deleted", targetId: folderId });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
async moveFile(workspaceId: string, itemId: string, actor: ActorRef, input: { folderId?: unknown }): Promise<GroupFile> {
|
||||
if (input.folderId === undefined) throw new BadRequestException("folderId is required (use null for root)");
|
||||
const folderId = optionalUuid(input.folderId, "folderId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = await this.requireRow(client, itemId);
|
||||
await this.requireFileAccess(client, before, actor);
|
||||
this.requireAvailable(before);
|
||||
if (folderId !== null) this.requireSameScope(await this.requireFolder(client, folderId, actor), before);
|
||||
await client.query("UPDATE chat_media_archive SET folder_id = $2 WHERE id = $1", [itemId, folderId]);
|
||||
await this.audit(client, workspaceId, actor, { action: "file.moved", targetId: itemId, diff: { from: before.folder_id, to: folderId } });
|
||||
return rowToGroupFile(await this.requireRow(client, itemId), (key) => this.libraryUrlFor(key));
|
||||
});
|
||||
}
|
||||
|
||||
async copyFile(workspaceId: string, itemId: string, actor: ActorRef, input: { targetFolderId?: unknown; targetGroupId?: unknown }): Promise<GroupFile> {
|
||||
const folderId = optionalUuid(input.targetFolderId, "targetFolderId");
|
||||
const groupId = optionalUuid(input.targetGroupId, "targetGroupId");
|
||||
if ((folderId === null) === (groupId === null)) throw new BadRequestException("provide exactly one of targetFolderId or targetGroupId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = await this.requireRow(client, itemId);
|
||||
await this.requireFileAccess(client, before, actor);
|
||||
this.requireAvailable(before);
|
||||
const scope = folderId === null ? { groupId, ownerId: null } : folderScope(await this.requireFolder(client, folderId, actor));
|
||||
await this.requireScopeAccess(client, scope, actor);
|
||||
return this.duplicateFile(client, workspaceId, before, actor, scope, folderId, false);
|
||||
});
|
||||
}
|
||||
|
||||
async shareToGroup(workspaceId: string, itemId: string, actor: ActorRef, input: { groupId?: unknown }): Promise<GroupFile> {
|
||||
const groupId = optionalUuid(input.groupId);
|
||||
if (groupId === null) throw new BadRequestException("groupId is required");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = await this.requireRow(client, itemId);
|
||||
await this.requireFileAccess(client, before, actor);
|
||||
if (before.group_id !== null) throw new BadRequestException("only personal files can be shared into a group");
|
||||
this.requireAvailable(before);
|
||||
const scope = { groupId, ownerId: null };
|
||||
await this.requireScopeAccess(client, scope, actor);
|
||||
return this.duplicateFile(client, workspaceId, before, actor, scope, null, true);
|
||||
});
|
||||
}
|
||||
|
||||
private async duplicateFile(client: PoolClient, workspaceId: string, before: ArchiveRow, actor: ActorRef, scope: FileScope, folderId: string | null, shared: boolean): Promise<GroupFile> {
|
||||
const name = shared ? before.original_name : `${before.original_name.slice(0, 1017)} (copy)`;
|
||||
const safeName = shared ? before.safe_name : `${before.safe_name.slice(0, 248)} (copy)`;
|
||||
const payload = shared ? { kind: "upload", sharedFrom: "personal", shared: true } : before.matrix_event_json;
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO chat_media_archive
|
||||
(workspace_id, group_id, owner_id, folder_id, created_by, room_id, event_id, origin, sender_mxid,
|
||||
matrix_event_json, original_name, safe_name, mime, mime_class, byte_size, sha256,
|
||||
ocis_resource_id, ocis_resource_version, storage_key, created_at, retention_mode, legal_hold, retention_until)
|
||||
SELECT workspace_id, $2, $3, $4, $5, NULL, NULL, $6, sender_mxid, $7::jsonb, $8, $9,
|
||||
mime, mime_class, byte_size, sha256, ocis_resource_id, ocis_resource_version, storage_key, now(),
|
||||
retention_mode, legal_hold, retention_until
|
||||
FROM chat_media_archive WHERE id = $1 RETURNING id`,
|
||||
[before.id, scope.groupId, scope.ownerId, folderId, actor.userId, shared ? "upload" : before.origin,
|
||||
JSON.stringify(payload), name, safeName],
|
||||
);
|
||||
const id = inserted.rows[0]?.id;
|
||||
if (id === undefined) throw new NotFoundException("source file not found");
|
||||
await this.audit(client, workspaceId, actor, { action: shared ? "file.shared_to_group" : "file.copied", targetId: id, diff: { sourceId: before.id, groupId: scope.groupId, folderId } });
|
||||
return rowToGroupFile(await this.requireRow(client, id), (key) => this.libraryUrlFor(key));
|
||||
}
|
||||
|
||||
async getGroupFileSettings(workspaceId: string, groupId: string, actor: ActorRef): Promise<{ memberDeleteOwn: boolean }> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.requireScopeAccess(client, { groupId, ownerId: null }, actor);
|
||||
const result = await client.query<{ member_delete_own: boolean }>("SELECT member_delete_own FROM groups WHERE id = $1", [groupId]);
|
||||
const setting = result.rows[0];
|
||||
if (setting === undefined) throw new NotFoundException("group not found");
|
||||
return { memberDeleteOwn: setting.member_delete_own };
|
||||
});
|
||||
}
|
||||
|
||||
async setGroupFileSettings(workspaceId: string, groupId: string, actor: ActorRef, input: { memberDeleteOwn?: unknown }): Promise<{ memberDeleteOwn: boolean }> {
|
||||
if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) throw new ForbiddenException("only Owner/Admin can change group file settings");
|
||||
if (typeof input.memberDeleteOwn !== "boolean") throw new BadRequestException("memberDeleteOwn must be a boolean");
|
||||
const memberDeleteOwn = input.memberDeleteOwn;
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
await this.requireScopeAccess(client, { groupId, ownerId: null }, actor);
|
||||
const updated = await client.query("UPDATE groups SET member_delete_own = $2 WHERE id = $1", [groupId, memberDeleteOwn]);
|
||||
if ((updated.rowCount ?? 0) === 0) throw new NotFoundException("group not found");
|
||||
await this.audit(client, workspaceId, actor, { action: "file.group_settings_changed", targetId: groupId, diff: { memberDeleteOwn } });
|
||||
return { memberDeleteOwn };
|
||||
});
|
||||
}
|
||||
|
||||
private async lockFolderTree(client: PoolClient, workspaceId: string): Promise<void> {
|
||||
// Serialize parent edits so two concurrent moves cannot form a cycle.
|
||||
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 46))", [workspaceId]);
|
||||
}
|
||||
|
||||
private async folderRow(client: PoolClient, id: string, deleting = false): Promise<FolderRow> {
|
||||
if (!isUuid(id)) throw new BadRequestException("folderId must be a UUID");
|
||||
// FK writers take KEY SHARE too; DELETE's UPDATE lock waits for them.
|
||||
const result = await client.query<FolderRow>(`SELECT * FROM file_folders WHERE id = $1 ${deleting ? "FOR UPDATE" : "FOR KEY SHARE"}`, [id]);
|
||||
if (result.rows[0] === undefined) throw new NotFoundException("folder not found");
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private async requireFolder(client: PoolClient, id: string, actor: ActorRef, deleting = false): Promise<FolderRow> {
|
||||
const folder = await this.folderRow(client, id, deleting);
|
||||
await this.requireScopeAccess(client, folderScope(folder), actor);
|
||||
return folder;
|
||||
}
|
||||
|
||||
private requireSameScope(a: { group_id: string | null; owner_id: string | null }, b: { group_id: string | null; owner_id: string | null }): void {
|
||||
if (a.group_id !== b.group_id || a.owner_id !== b.owner_id) throw new BadRequestException("destination must belong to the same scope");
|
||||
}
|
||||
|
||||
private async requireScopeAccess(client: PoolClient, scope: FileScope, actor: ActorRef): Promise<void> {
|
||||
if (scope.ownerId !== null) {
|
||||
if (scope.ownerId !== actor.userId) throw new ForbiddenException("personal folders are private to their owner");
|
||||
return;
|
||||
}
|
||||
if (scope.groupId === null || !isUuid(scope.groupId)) throw new BadRequestException("groupId must be a UUID");
|
||||
const group = await client.query("SELECT 1 FROM groups WHERE id = $1", [scope.groupId]);
|
||||
if (group.rows.length === 0) throw new NotFoundException("group not found");
|
||||
await this.requireAccess(client, actor.userId, actor.role, scope.groupId);
|
||||
}
|
||||
|
||||
private async validateUploadScope(workspaceId: string, scope: FileScope, rawFolderId: unknown, actor: ActorRef): Promise<string | null> {
|
||||
const folderId = optionalUuid(rawFolderId, "folderId");
|
||||
await withTenant(workspaceId, async (client) => {
|
||||
await this.requireScopeAccess(client, scope, actor);
|
||||
if (folderId !== null) this.requireSameScope(await this.requireFolder(client, folderId, actor), scopeRow(scope));
|
||||
});
|
||||
return folderId;
|
||||
}
|
||||
|
||||
private requireAvailable(row: ArchiveRow): void {
|
||||
if (!row.available) throw new GoneException("file is revoked");
|
||||
if (row.retention_mode === "until" && row.retention_until !== null && new Date(row.retention_until).getTime() <= Date.now()) {
|
||||
throw new GoneException("file retention has expired");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Read paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -267,7 +540,21 @@ export class GroupFilesService {
|
|||
"a.available = true",
|
||||
"(a.retention_mode <> 'until' OR a.retention_until > now())",
|
||||
];
|
||||
if (!WORKSPACE_WIDE_ROLES.includes(role)) {
|
||||
const scope = parseListScope(query, userId);
|
||||
const folderId = optionalUuid(query.folderId, "folderId");
|
||||
if (folderId !== null) {
|
||||
const folder = await this.requireFolder(client, folderId, { userId, role });
|
||||
if (scope.ownerId !== folder.owner_id || (scope.groupId !== null && scope.groupId !== folder.group_id)) {
|
||||
throw new BadRequestException("folderId does not belong to the requested scope");
|
||||
}
|
||||
}
|
||||
if (scope.ownerId !== null) {
|
||||
values.push(userId);
|
||||
conds.push(`a.group_id IS NULL AND a.owner_id = $${values.length}::uuid`);
|
||||
} else {
|
||||
conds.push("a.group_id IS NOT NULL");
|
||||
}
|
||||
if (scope.ownerId === null && !WORKSPACE_WIDE_ROLES.includes(role)) {
|
||||
values.push(userId);
|
||||
conds.push(`a.group_id IN (SELECT group_id FROM group_members WHERE user_id = $${values.length})`);
|
||||
}
|
||||
|
|
@ -275,6 +562,12 @@ export class GroupFilesService {
|
|||
values.push(filters.groupId);
|
||||
conds.push(`a.group_id = $${values.length}::uuid`);
|
||||
}
|
||||
if (folderId === null) {
|
||||
conds.push("a.folder_id IS NULL");
|
||||
} else {
|
||||
values.push(folderId);
|
||||
conds.push(`a.folder_id = $${values.length}::uuid`);
|
||||
}
|
||||
if (filters.senderMxid !== null) {
|
||||
values.push(filters.senderMxid);
|
||||
conds.push(`a.sender_mxid = $${values.length}`);
|
||||
|
|
@ -330,7 +623,7 @@ export class GroupFilesService {
|
|||
async getFile(workspaceId: string, userId: string, role: string, itemId: string): Promise<GroupFile> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const row = await this.requireRow(client, itemId);
|
||||
await this.requireAccess(client, userId, role, row.group_id);
|
||||
await this.requireFileAccess(client, row, { userId, role });
|
||||
if (!row.available) throw new GoneException("file is revoked");
|
||||
return rowToGroupFile(row, (resourceId) => this.libraryUrlFor(resourceId));
|
||||
});
|
||||
|
|
@ -353,17 +646,71 @@ export class GroupFilesService {
|
|||
* be sliced, so the whole-file route serves them.
|
||||
*/
|
||||
async readRange(workspaceId: string, userId: string, role: string, itemId: string, offset: number, length: number): Promise<{ bytes: Buffer; mime: string; total: number | null }> {
|
||||
const item = await this.requireDirectPlaybackItem(workspaceId, userId, role, itemId);
|
||||
const bounds = playbackRange(item.sizeBytes, offset, offset + length - 1);
|
||||
const bytes = await this.readPlaybackSlice(item, bounds.start, bounds.end);
|
||||
return { bytes, mime: item.mimeType, total: item.sizeBytes };
|
||||
}
|
||||
|
||||
/** Mint a file-scoped capability after the same ACL/availability checks as download. */
|
||||
async openRangePlayback(
|
||||
workspaceId: string, userId: string, role: string, itemId: string, start: number, length: number,
|
||||
): Promise<{ ticket: PlaybackTicket }> {
|
||||
const item = await this.requireDirectPlaybackItem(workspaceId, userId, role, itemId);
|
||||
playbackRange(item.sizeBytes, start, start + length - 1);
|
||||
this.prunePlaybackTickets();
|
||||
if (this.playbackTickets.size >= MAX_PLAYBACK_TICKETS) {
|
||||
throw new ServiceUnavailableException("playback ticket capacity reached");
|
||||
}
|
||||
const ticket: PlaybackTicket = {
|
||||
token: randomBytes(32).toString("hex"), itemId,
|
||||
expiresAt: new Date(Date.now() + PLAYBACK_TTL_MS).toISOString(),
|
||||
};
|
||||
this.playbackTickets.set(ticket.token, { ticket, workspaceId, userId, role });
|
||||
return { ticket: { ...ticket } };
|
||||
}
|
||||
|
||||
/** The capability is the auth; callers cannot select another file or tenant. */
|
||||
async readPlaybackTicket(token: string, start?: unknown, end?: unknown, range?: string): Promise<PlaybackSlice> {
|
||||
this.prunePlaybackTickets();
|
||||
const grant = this.playbackTickets.get(token);
|
||||
if (grant === undefined) throw new NotFoundException("playback ticket is invalid or expired");
|
||||
const item = await this.requireDirectPlaybackItem(grant.workspaceId, grant.userId, grant.role, grant.ticket.itemId);
|
||||
const bounds = requestedPlaybackRange(item.sizeBytes, start, end, range);
|
||||
const bytes = await this.readPlaybackSlice(item, bounds.start, bounds.end);
|
||||
return { item, bytes, ...bounds, total: item.sizeBytes };
|
||||
}
|
||||
|
||||
private prunePlaybackTickets(): void {
|
||||
const now = Date.now();
|
||||
for (const [token, grant] of this.playbackTickets) {
|
||||
if (Date.parse(grant.ticket.expiresAt) <= now) this.playbackTickets.delete(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireDirectPlaybackItem(workspaceId: string, userId: string, role: string, itemId: string): Promise<GroupFile> {
|
||||
const item = await this.getFile(workspaceId, userId, role, itemId);
|
||||
if (item.status !== "available") throw new GoneException(`file is ${item.status}`);
|
||||
const storageKey = item.file.storageKey;
|
||||
if (storageKey === undefined || !storageKey.startsWith("direct/")) {
|
||||
if (!item.file.storageKey?.startsWith(DIRECT_KEY_PREFIX)) {
|
||||
throw new BadRequestException("range reads are only available for direct objects");
|
||||
}
|
||||
const storage = this.storage() as StorageService & { readDirectRange(key: string, start: number, length: number): Promise<Buffer> };
|
||||
const bytes = await storage.readDirectRange(storageKey, offset, length).catch(() => {
|
||||
// /direct/complete stores the HEAD-verified sealed frame size. Its fixed
|
||||
// 135-byte header and 32-byte segment overhead must not count as media.
|
||||
try {
|
||||
return { ...item, sizeBytes: directPlaintextSize(item.sizeBytes) };
|
||||
} catch {
|
||||
throw new ServiceUnavailableException("file size metadata is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private async readPlaybackSlice(item: GroupFile, start: number, end: number): Promise<Buffer> {
|
||||
try {
|
||||
const bytes = await this.storage().readDirectRange(item.file.storageKey!, start, end - start + 1);
|
||||
if (bytes.length !== end - start + 1) throw new Error("incomplete plaintext range");
|
||||
return bytes;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException("file storage is unavailable");
|
||||
});
|
||||
return { bytes, mime: item.mimeType, total: item.sizeBytes };
|
||||
}
|
||||
}
|
||||
|
||||
private async openPayload(storageKey: string | null | undefined, mime: string): Promise<PayloadDownload> {
|
||||
|
|
@ -382,6 +729,7 @@ export class GroupFilesService {
|
|||
const retention = parseRetentionInput(retentionInput);
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = await this.requireRow(client, itemId);
|
||||
await this.requireFileAccess(client, before, actor);
|
||||
await client.query(
|
||||
`UPDATE chat_media_archive
|
||||
SET retention_mode = $2, legal_hold = $3, retention_until = $4
|
||||
|
|
@ -404,13 +752,24 @@ export class GroupFilesService {
|
|||
|
||||
/** Redaction/membership-removal path: revoke links, stop serving. No deletion. */
|
||||
async markRevoked(workspaceId: string, itemId: string, actor: ActorRef): Promise<GroupFile> {
|
||||
return this.revokeFile(workspaceId, itemId, actor, false);
|
||||
}
|
||||
|
||||
/** API deletion stops serving and revokes shares; payload retention remains separate. */
|
||||
async deleteFile(workspaceId: string, itemId: string, actor: ActorRef): Promise<GroupFile> {
|
||||
return this.revokeFile(workspaceId, itemId, actor, true);
|
||||
}
|
||||
|
||||
private async revokeFile(workspaceId: string, itemId: string, actor: ActorRef, deleting: boolean): Promise<GroupFile> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = await this.requireRow(client, itemId);
|
||||
await this.requireDeleteAccess(client, before, actor);
|
||||
if (deleting && before.legal_hold) throw new ForbiddenException("file is under legal hold");
|
||||
if (!before.available) throw new ConflictException("file is already revoked");
|
||||
await client.query("UPDATE chat_media_archive SET available = false WHERE id = $1", [itemId]);
|
||||
await client.query("UPDATE group_file_shares SET revoked_at = now() WHERE item_id = $1 AND revoked_at IS NULL", [itemId]);
|
||||
await this.audit(client, workspaceId, actor, {
|
||||
action: "file.revoked",
|
||||
action: deleting ? "file.deleted" : "file.revoked",
|
||||
targetId: itemId,
|
||||
diff: { name: before.safe_name, roomId: before.room_id, eventId: before.event_id },
|
||||
});
|
||||
|
|
@ -440,6 +799,8 @@ export class GroupFilesService {
|
|||
const expiresAt = new Date(Date.now() + hours * 3_600_000);
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const row = await this.requireRow(client, itemId);
|
||||
await this.requireFileAccess(client, row, actor);
|
||||
this.requireAvailable(row);
|
||||
const inserted = await client.query<ShareRow>(
|
||||
`INSERT INTO group_file_shares (workspace_id, item_id, access, token_hash, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, workspace_id, item_id, access, expires_at, revoked_at`,
|
||||
|
|
@ -467,6 +828,9 @@ export class GroupFilesService {
|
|||
async revokeShare(workspaceId: string, shareId: string, actor: ActorRef): Promise<void> {
|
||||
await withTenant(workspaceId, async (client) => {
|
||||
if (!isUuid(shareId)) throw new BadRequestException("shareId must be a UUID");
|
||||
const found = await client.query<{ item_id: string }>("SELECT item_id FROM group_file_shares WHERE id = $1 AND workspace_id = $2", [shareId, workspaceId]);
|
||||
if (found.rows[0] === undefined) throw new NotFoundException(`share ${shareId} not found`);
|
||||
await this.requireFileAccess(client, await this.requireRow(client, found.rows[0].item_id), actor);
|
||||
const updated = await client.query(
|
||||
"UPDATE group_file_shares SET revoked_at = now() WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL RETURNING item_id",
|
||||
[shareId, workspaceId],
|
||||
|
|
@ -535,20 +899,37 @@ export class GroupFilesService {
|
|||
? null
|
||||
: attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId);
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const ownerId = entry.ownerId ?? null;
|
||||
if ((entry.groupId === null) === (ownerId === null)) throw new BadRequestException("file must have exactly one group or personal owner");
|
||||
if (entry.folderId != null) {
|
||||
const folder = await this.folderRow(client, entry.folderId, true);
|
||||
this.requireSameScope(folder, { group_id: entry.groupId, owner_id: ownerId });
|
||||
}
|
||||
const senderUsername = /^@([^:]+):tmbh\.local$/.exec(entry.senderMxid)?.[1] ?? hubUsernameFor(entry.senderMxid);
|
||||
let creatorId = entry.createdBy ?? null;
|
||||
if (creatorId === null) {
|
||||
const creator = await client.query<{ id: string }>(
|
||||
`SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id
|
||||
WHERE m.workspace_id = $1 AND (u.external_id = $2 OR u.username = $3)
|
||||
ORDER BY (u.external_id = $2) DESC, u.id LIMIT 1`,
|
||||
[workspaceId, entry.senderMxid, senderUsername],
|
||||
);
|
||||
creatorId = creator.rows[0]?.id ?? null;
|
||||
}
|
||||
let inserted: { rows: Array<{ id: string }> };
|
||||
try {
|
||||
inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO chat_media_archive
|
||||
(workspace_id, group_id, room_id, event_id, origin, sender_mxid, matrix_event_json,
|
||||
original_name, safe_name, mime, mime_class, byte_size, sha256,
|
||||
ocis_resource_id, ocis_resource_version, created_at, storage_key)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
ocis_resource_id, ocis_resource_version, created_at, storage_key, folder_id, created_by, owner_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||
RETURNING id`,
|
||||
[
|
||||
workspaceId, entry.groupId, entry.roomId, entry.eventId, entry.originKind, entry.senderMxid,
|
||||
JSON.stringify(entry.origin), entry.originalName, entry.safeName, entry.mime, entry.mimeClass,
|
||||
entry.byteSize, entry.sha256, entry.ocisResourceId, entry.ocisResourceVersion, entry.createdAt,
|
||||
entry.storageKey ?? null,
|
||||
entry.storageKey ?? null, entry.folderId ?? null, creatorId, ownerId,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
|
|
@ -593,17 +974,17 @@ export class GroupFilesService {
|
|||
*/
|
||||
async initDirectUpload(
|
||||
workspaceId: string,
|
||||
groupId: string,
|
||||
groupId: string | null,
|
||||
actor: ActorRef,
|
||||
input: { filename: string; mimeType: string; sizeBytes: number },
|
||||
input: { filename: string; mimeType: string; sizeBytes: number; personal?: unknown; folderId?: unknown },
|
||||
): Promise<DirectUploadInit> {
|
||||
if (!isUuid(groupId)) throw new BadRequestException("groupId must be a UUID");
|
||||
const scope = parseWriteScope({ groupId, personal: input.personal }, actor.userId);
|
||||
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1 || input.sizeBytes > DIRECT_MAX_BYTES) {
|
||||
throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`);
|
||||
}
|
||||
validateDirectFilename(input.filename);
|
||||
validateDirectMime(input.mimeType);
|
||||
await this.requireGroupMembership(workspaceId, groupId, actor);
|
||||
await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
|
||||
if ((await this.storage().directPublicJwk()) === null) {
|
||||
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
|
||||
}
|
||||
|
|
@ -625,10 +1006,10 @@ export class GroupFilesService {
|
|||
async completeDirectUpload(
|
||||
workspaceId: string,
|
||||
actor: ActorRef,
|
||||
input: { uploadId: string; objectKey: string; groupId: string; filename: string; mimeType: string; sizeBytes: number; sha256: string },
|
||||
input: { uploadId: string; objectKey: string; groupId?: string | null; personal?: unknown; folderId?: unknown; filename: string; mimeType: string; sizeBytes: number; sha256: string },
|
||||
): Promise<GroupFile> {
|
||||
if (!isUuid(input.uploadId)) throw new BadRequestException("uploadId must be a UUID");
|
||||
if (!isUuid(input.groupId)) throw new BadRequestException("groupId must be a UUID");
|
||||
const scope = parseWriteScope(input, actor.userId);
|
||||
if (input.objectKey !== `${DIRECT_KEY_PREFIX}${input.uploadId}${DIRECT_OBJECT_SUFFIX}`) {
|
||||
throw new BadRequestException("objectKey must be direct/<uploadId>.enc");
|
||||
}
|
||||
|
|
@ -638,7 +1019,7 @@ export class GroupFilesService {
|
|||
}
|
||||
const safe = validateDirectFilename(input.filename);
|
||||
const mimeType = validateDirectMime(input.mimeType);
|
||||
await this.requireGroupMembership(workspaceId, input.groupId, actor);
|
||||
const folderId = await this.validateUploadScope(workspaceId, scope, input.folderId, actor);
|
||||
|
||||
const head = await this.storage().headObject(input.objectKey).catch(() => {
|
||||
throw new ServiceUnavailableException("file storage is unavailable");
|
||||
|
|
@ -650,7 +1031,10 @@ export class GroupFilesService {
|
|||
const mimeClass = mimeClassForDeclared(mimeType);
|
||||
const username = await this.usernameFor(workspaceId, actor.userId);
|
||||
const { id } = await this.registerArchiveEntry(workspaceId, {
|
||||
groupId: input.groupId,
|
||||
groupId: scope.groupId,
|
||||
ownerId: scope.ownerId,
|
||||
folderId,
|
||||
createdBy: actor.userId,
|
||||
roomId: null,
|
||||
eventId: null,
|
||||
origin: { kind: "upload" },
|
||||
|
|
@ -685,7 +1069,7 @@ export class GroupFilesService {
|
|||
const storage = this.storage();
|
||||
const [url, frame] = await Promise.all([
|
||||
storage.presign(storageKey, DIRECT_GET_TTL_SECONDS),
|
||||
storage.getSealedObject(storageKey).then((stored) => storage.openDirectHeader(stored)),
|
||||
storage.openDirectHeaderBlob(storageKey).then((header) => storage.openDirectHeader(header)),
|
||||
]).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); });
|
||||
return { url, cekB64: frame.toString("base64"), frame: "SPENC2", sizeBytes: item.sizeBytes };
|
||||
}
|
||||
|
|
@ -717,6 +1101,29 @@ export class GroupFilesService {
|
|||
if ((result.rowCount ?? 0) === 0) throw new ForbiddenException("file belongs to a group you are not a member of");
|
||||
}
|
||||
|
||||
/** Personal ownership is private even from workspace Owner/Admin. */
|
||||
private async requireFileAccess(client: PoolClient, row: ArchiveRow, actor: ActorRef): Promise<void> {
|
||||
if (row.group_id === null) {
|
||||
if (row.owner_id !== actor.userId) throw new ForbiddenException("personal files are private to their owner");
|
||||
return;
|
||||
}
|
||||
await this.requireAccess(client, actor.userId, actor.role, row.group_id);
|
||||
}
|
||||
|
||||
private async requireDeleteAccess(client: PoolClient, row: ArchiveRow, actor: ActorRef): Promise<void> {
|
||||
await this.requireFileAccess(client, row, actor);
|
||||
if (row.group_id === null || WORKSPACE_WIDE_ROLES.includes(actor.role)) return;
|
||||
const result = await client.query<{ member_delete_own: boolean }>(
|
||||
"SELECT g.member_delete_own FROM groups g WHERE g.id = $1 FOR SHARE", [row.group_id],
|
||||
);
|
||||
if (result.rows[0]?.member_delete_own !== true) {
|
||||
throw new ForbiddenException("member file deletion is disabled for this group");
|
||||
}
|
||||
if (row.created_by !== actor.userId) {
|
||||
throw new ForbiddenException("members may only delete files they created");
|
||||
}
|
||||
}
|
||||
|
||||
private async audit(
|
||||
client: PoolClient,
|
||||
workspaceId: string,
|
||||
|
|
@ -736,23 +1143,20 @@ export class GroupFilesService {
|
|||
|
||||
/**
|
||||
* Direct web upload: ACL (active member of the group, or Owner/Admin),
|
||||
* stream the buffer to oCIS (magic-byte sniff decides mime/class, the
|
||||
* store the buffer in R2 (magic-byte sniff decides mime/class, the
|
||||
* stored name is library-safe), index the entry, return the wire shape.
|
||||
* Attribution uses a synthetic MXID — the Hub session carries no chat
|
||||
* identity (docs/occupymedia-storage.md, direct-upload origin).
|
||||
*/
|
||||
async uploadForGroup(
|
||||
workspaceId: string,
|
||||
groupId: string,
|
||||
groupId: string | null,
|
||||
actor: { userId: string; role: string },
|
||||
file: { buffer: Buffer; mimetype: string; originalName: string },
|
||||
options: { folderId?: unknown; personal?: unknown } = {},
|
||||
): Promise<GroupFile> {
|
||||
if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) {
|
||||
const membership = await withTenant(workspaceId, (client) =>
|
||||
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", [groupId, actor.userId]),
|
||||
);
|
||||
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
|
||||
}
|
||||
const scope = parseWriteScope({ groupId, personal: options.personal }, actor.userId);
|
||||
const folderId = await this.validateUploadScope(workspaceId, scope, options.folderId, actor);
|
||||
const sniff = sniffMime(file.buffer.subarray(0, SNIFF_BYTES));
|
||||
const safe = safeFileName(file.originalName, sniff.extension);
|
||||
// Object storage is the only payload path (user directive 2026-10-06):
|
||||
|
|
@ -763,7 +1167,10 @@ export class GroupFilesService {
|
|||
});
|
||||
const username = await this.usernameFor(workspaceId, actor.userId);
|
||||
const { id } = await this.registerArchiveEntry(workspaceId, {
|
||||
groupId,
|
||||
groupId: scope.groupId,
|
||||
ownerId: scope.ownerId,
|
||||
folderId,
|
||||
createdBy: actor.userId,
|
||||
roomId: null,
|
||||
eventId: null,
|
||||
origin: { kind: "upload" },
|
||||
|
|
@ -783,15 +1190,6 @@ export class GroupFilesService {
|
|||
return this.getFile(workspaceId, actor.userId, actor.role, id);
|
||||
}
|
||||
|
||||
/** Owner/Admin pass; everyone else must be an active member of the group. */
|
||||
private async requireGroupMembership(workspaceId: string, groupId: string, actor: ActorRef): Promise<void> {
|
||||
if (WORKSPACE_WIDE_ROLES.includes(actor.role)) return;
|
||||
const membership = await withTenant(workspaceId, (client) =>
|
||||
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", [groupId, actor.userId]),
|
||||
);
|
||||
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
|
||||
}
|
||||
|
||||
private async usernameFor(workspaceId: string, userId: string): Promise<string> {
|
||||
const row = await withTenant(workspaceId, (client) =>
|
||||
client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]),
|
||||
|
|
@ -806,7 +1204,10 @@ export interface ActorRef {
|
|||
}
|
||||
|
||||
export interface ArchiveEntryInput {
|
||||
groupId: string;
|
||||
groupId: string | null;
|
||||
ownerId?: string | null;
|
||||
folderId?: string | null;
|
||||
createdBy?: string | null;
|
||||
/** Matrix room when mirrored from a room; null for direct web uploads. */
|
||||
roomId: string | null;
|
||||
eventId: string | null;
|
||||
|
|
@ -882,12 +1283,48 @@ function mimeClassForDeclared(mimeType: string): MimeClass {
|
|||
return "other";
|
||||
}
|
||||
|
||||
function optionalUuid(value: unknown): string | null {
|
||||
function optionalUuid(value: unknown, field = "groupId"): string | null {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException("groupId must be a UUID");
|
||||
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException(`${field} must be a UUID`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function scopeRow(scope: FileScope): { group_id: string | null; owner_id: string | null } {
|
||||
return { group_id: scope.groupId, owner_id: scope.ownerId };
|
||||
}
|
||||
|
||||
function folderScope(row: FolderRow): FileScope {
|
||||
return { groupId: row.group_id, ownerId: row.owner_id };
|
||||
}
|
||||
|
||||
function parseWriteScope(input: FileScopeInput, userId: string): FileScope {
|
||||
if (input.personal !== undefined && typeof input.personal !== "boolean") throw new BadRequestException("personal must be a boolean");
|
||||
const groupId = optionalUuid(input.groupId);
|
||||
if (input.personal === true) {
|
||||
if (groupId !== null) throw new BadRequestException("personal and groupId cannot be combined");
|
||||
return { groupId: null, ownerId: userId };
|
||||
}
|
||||
if (groupId === null) throw new BadRequestException("provide groupId or personal: true");
|
||||
return { groupId, ownerId: null };
|
||||
}
|
||||
|
||||
function parseListScope(query: Record<string, unknown>, userId: string): FileScope {
|
||||
if (query.scope !== undefined && query.scope !== "group" && query.scope !== "personal") throw new BadRequestException("scope must be group or personal");
|
||||
const groupId = optionalUuid(query.groupId);
|
||||
if (query.scope === "personal") {
|
||||
if (groupId !== null) throw new BadRequestException("personal scope cannot include groupId");
|
||||
return { groupId: null, ownerId: userId };
|
||||
}
|
||||
return { groupId, ownerId: null };
|
||||
}
|
||||
|
||||
function folderName(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "" || value.trim().length > 255 || /[\u0000-\u001f/\\]/.test(value)) {
|
||||
throw new BadRequestException("name must be 1-255 characters without slashes or control characters");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
const ISO_BOUND = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
||||
|
||||
function parseBound(value: unknown, field: string): string | null {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
28
apps/api/src/group-files/group-files.types.ts
Normal file
28
apps/api/src/group-files/group-files.types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { GroupFile as CoreGroupFile } from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
/** Files wire shape, including personal roots and folder placement. */
|
||||
export interface GroupFile extends Omit<CoreGroupFile, "groupId" | "file" | "origin"> {
|
||||
groupId: string | null;
|
||||
ownerId: string | null;
|
||||
folderId: string | null;
|
||||
createdBy: string | null;
|
||||
shared: boolean;
|
||||
origin: Exclude<CoreGroupFile["origin"], { kind: "upload" }> | { kind: "upload"; sharedFrom?: "personal" };
|
||||
file: Omit<CoreGroupFile["file"], "spaceId"> & { spaceId: string | null };
|
||||
}
|
||||
|
||||
export interface FileFolder {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
groupId: string | null;
|
||||
ownerId: string | null;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FileScopeInput {
|
||||
groupId?: unknown;
|
||||
personal?: unknown;
|
||||
folderId?: unknown;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
37
apps/api/src/group-files/playback-range.ts
Normal file
37
apps/api/src/group-files/playback-range.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||
|
||||
/** Carries the plaintext size for the controller’s unsatisfied Content-Range header. */
|
||||
export class FileRangeNotSatisfiableException extends HttpException {
|
||||
constructor(readonly total: number) {
|
||||
super("requested range is not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Inclusive HTTP byte bounds, clipped to the plaintext EOF. */
|
||||
export function playbackRange(total: number, start: number, end: number): { start: number; end: number } {
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= total || end < start) {
|
||||
throw new FileRangeNotSatisfiableException(total);
|
||||
}
|
||||
return { start, end: Math.min(end, total - 1) };
|
||||
}
|
||||
|
||||
/** Query bounds take precedence; also accept the single Range browsers send. */
|
||||
export function requestedPlaybackRange(total: number, start: unknown, end: unknown, range?: string): { start: number; end: number } {
|
||||
const fail = (): never => { throw new FileRangeNotSatisfiableException(total); };
|
||||
const integer = (value: unknown): number => {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) return fail();
|
||||
return Number(value);
|
||||
};
|
||||
if (start !== undefined || end !== undefined) {
|
||||
return playbackRange(total, start === undefined ? 0 : integer(start), end === undefined ? total - 1 : integer(end));
|
||||
}
|
||||
if (range === undefined) return playbackRange(total, 0, total - 1);
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||
if (match === null || (match[1] === "" && match[2] === "")) return fail();
|
||||
if (match[1] === "") {
|
||||
const suffix = integer(match[2]);
|
||||
if (suffix === 0) return fail();
|
||||
return playbackRange(total, Math.max(0, total - suffix), total - 1);
|
||||
}
|
||||
return playbackRange(total, integer(match[1]), match[2] === "" ? total - 1 : integer(match[2]));
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
|||
app.enableCors({
|
||||
origin: corsOrigins(),
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allowedHeaders: ["Authorization", "Content-Type"],
|
||||
});
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface RefreshIssueInput {
|
|||
export interface OidcRefreshStore {
|
||||
issue(input: RefreshIssueInput): Promise<void>;
|
||||
lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
||||
consume(record: RefreshTokenRecord): Promise<void>;
|
||||
consume(record: RefreshTokenRecord): Promise<boolean>;
|
||||
/** Reuse detection: delete the whole chain; returns rows removed. */
|
||||
revokeFamily(workspaceId: string, familyId: string): Promise<number>;
|
||||
/** The minting Hub session must still be live (not revoked, not expired). */
|
||||
|
|
@ -90,10 +90,12 @@ export class PgRefreshStore implements OidcRefreshStore {
|
|||
return row === undefined ? null : fromRow(row);
|
||||
}
|
||||
|
||||
async consume(record: RefreshTokenRecord): Promise<void> {
|
||||
await withTenant(record.workspaceId, (client) =>
|
||||
/** Single-use consumption: true when THIS call won the rotation race. */
|
||||
async consume(record: RefreshTokenRecord): Promise<boolean> {
|
||||
const result = await withTenant(record.workspaceId, (client) =>
|
||||
client.query("UPDATE oidc_refresh_tokens SET consumed_at = now() WHERE id = $1 AND consumed_at IS NULL", [record.id]),
|
||||
);
|
||||
return (result.rowCount ?? 0) === 1;
|
||||
}
|
||||
|
||||
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
|
||||
|
|
@ -145,8 +147,11 @@ export class MemoryRefreshStore implements OidcRefreshStore {
|
|||
return this.rows.get(tokenHash) ?? null;
|
||||
}
|
||||
|
||||
async consume(record: RefreshTokenRecord): Promise<void> {
|
||||
for (const row of this.rows.values()) if (row.id === record.id) row.consumedAt = new Date();
|
||||
async consume(record: RefreshTokenRecord): Promise<boolean> {
|
||||
const row = this.rows.get(record.id);
|
||||
if (row === undefined || row.consumedAt !== null) return false;
|
||||
row.consumedAt = new Date();
|
||||
return true;
|
||||
}
|
||||
|
||||
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
||||
|
||||
const s3Calls = vi.hoisted(() => ({ sent: [] as string[] }));
|
||||
const s3Calls = vi.hoisted(() => ({
|
||||
sent: [] as string[],
|
||||
ranges: [] as string[],
|
||||
sealed: null as Buffer | null,
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => {
|
||||
class FakeCommand {
|
||||
|
|
@ -27,13 +31,20 @@ vi.mock("@aws-sdk/client-s3", () => {
|
|||
s3Calls.sent.push(cmd.marker);
|
||||
if (cmd.marker === "Create") throw new S3ServiceException("Access Denied", 403, "AccessDenied");
|
||||
if (cmd.marker === "Head") throw new S3ServiceException("Not Found", 404, "NotFound");
|
||||
if (cmd.marker === "Get" && s3Calls.sealed !== null) {
|
||||
const range = (cmd.input as { Range: string }).Range;
|
||||
s3Calls.ranges.push(range);
|
||||
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
||||
const body = s3Calls.sealed.subarray(Number(match[1]), Number(match[2]) + 1);
|
||||
return { Body: { transformToByteArray: async () => body } };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { S3Storage, extensionFor, keyForHash, open, seal, sha256 } from "./storage.js";
|
||||
import { DIRECT_SEGMENT_BYTES, S3Storage, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
|
||||
|
||||
const TEST_KEK = createHash("sha256").update("test-kek").digest();
|
||||
|
||||
|
|
@ -116,3 +127,76 @@ describe("S3Storage with object-scoped credentials", () => {
|
|||
expect(s3Calls.sent).toEqual(["Create", "Head", "Put"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("SPENC2 range reads", () => {
|
||||
beforeEach(() => {
|
||||
s3Calls.sent.length = 0;
|
||||
s3Calls.ranges.length = 0;
|
||||
s3Calls.sealed = null;
|
||||
});
|
||||
|
||||
async function fixture(plaintext: Buffer): Promise<S3Storage> {
|
||||
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const storage = new S3Storage({ mediaKek: TEST_KEK, directPrivateKey: privateKey });
|
||||
const jwk = (await storage.directPublicJwk())!;
|
||||
s3Calls.sealed = sealForDirect(plaintext, jwk);
|
||||
expect(directPlaintextSize(s3Calls.sealed.length)).toBe(plaintext.length);
|
||||
return storage;
|
||||
}
|
||||
|
||||
it("recovers plaintext size at full-segment boundaries", () => {
|
||||
for (const size of [1, DIRECT_SEGMENT_BYTES - 1, DIRECT_SEGMENT_BYTES, DIRECT_SEGMENT_BYTES + 1]) {
|
||||
const sealedSize = 135 + size + Math.ceil(size / DIRECT_SEGMENT_BYTES) * 32;
|
||||
expect(directPlaintextSize(sealedSize)).toBe(size);
|
||||
}
|
||||
expect(() => directPlaintextSize(167)).toThrow("invalid SPENC2 sealed size");
|
||||
expect(() => directPlaintextSize(135 + DIRECT_SEGMENT_BYTES + 33)).toThrow("invalid SPENC2 sealed size");
|
||||
});
|
||||
|
||||
it("slices mid-segment bounds across three segments without fetching earlier segments", async () => {
|
||||
const plaintext = randomBytes(4 * DIRECT_SEGMENT_BYTES);
|
||||
const storage = await fixture(plaintext);
|
||||
const start = DIRECT_SEGMENT_BYTES + 321;
|
||||
const end = 3 * DIRECT_SEGMENT_BYTES + 789;
|
||||
const bytes = await storage.readDirectRange("direct/clip.enc", start, end - start);
|
||||
expect(bytes.equals(plaintext.subarray(start, end))).toBe(true);
|
||||
const stride = DIRECT_SEGMENT_BYTES + 32;
|
||||
expect(s3Calls.ranges).toEqual(["bytes=0-134", `bytes=${135 + stride}-${135 + 4 * stride - 1}`]);
|
||||
expect(s3Calls.sent).toEqual(["Get", "Get"]);
|
||||
});
|
||||
|
||||
it("clips the sealed fetch at EOF and slices a final partial segment", async () => {
|
||||
const plaintext = randomBytes(2 * DIRECT_SEGMENT_BYTES + 1234);
|
||||
const storage = await fixture(plaintext);
|
||||
const start = DIRECT_SEGMENT_BYTES + 17;
|
||||
expect((await storage.readDirectRange("direct/clip.enc", start, plaintext.length - start))
|
||||
.equals(plaintext.subarray(start))).toBe(true);
|
||||
expect(s3Calls.ranges).toEqual(["bytes=0-134",
|
||||
`bytes=${135 + DIRECT_SEGMENT_BYTES + 32}-${135 + 3 * (DIRECT_SEGMENT_BYTES + 32) - 1}`]);
|
||||
});
|
||||
|
||||
it("validates the magic on every read before fetching any segments", async () => {
|
||||
const storage = await fixture(Buffer.from("video payload"));
|
||||
await storage.readDirectRange("direct/clip.enc", 0, 3);
|
||||
s3Calls.sealed![0] = 0;
|
||||
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow("magic mismatch");
|
||||
expect(s3Calls.ranges).toEqual(["bytes=0-134", `bytes=135-${135 + DIRECT_SEGMENT_BYTES + 32 - 1}`, "bytes=0-134"]);
|
||||
});
|
||||
|
||||
it("refuses tampered ciphertext and truncated segments", async () => {
|
||||
const storage = await fixture(Buffer.from("authenticated payload"));
|
||||
const last = s3Calls.sealed!.length - 1;
|
||||
s3Calls.sealed![last] = (s3Calls.sealed![last] ?? 0) ^ 1;
|
||||
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow();
|
||||
s3Calls.sealed = s3Calls.sealed!.subarray(0, 140);
|
||||
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow("truncated");
|
||||
});
|
||||
|
||||
it("rejects ranges beyond the segment count or final plaintext length", async () => {
|
||||
const storage = await fixture(Buffer.from("short"));
|
||||
await expect(storage.readDirectRange("direct/clip.enc", DIRECT_SEGMENT_BYTES, 1)).rejects.toThrow(RangeError);
|
||||
expect(s3Calls.ranges).toEqual(["bytes=0-134"]);
|
||||
await expect(storage.readDirectRange("direct/clip.enc", 4, 5)).rejects.toThrow("object length");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface StorageService {
|
|||
headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }>;
|
||||
/** Direct-to-R2: unwrap the frame's CEK (header only) for /files/:id/direct-download. */
|
||||
openDirectHeader(stored: Buffer): Promise<Buffer>;
|
||||
/** Direct-to-R2: fetch only the validated 135-byte SPENC2 header. */
|
||||
openDirectHeaderBlob(key: string): Promise<Buffer>;
|
||||
/** Direct-to-R2: raw still-sealed object bytes — the CEK header lives only in the sealed form. */
|
||||
getSealedObject(key: string): Promise<Buffer>;
|
||||
/** Direct-to-R2: decrypt an arbitrary PLAINTEXT byte range (segment-aligned fetches). */
|
||||
|
|
@ -57,7 +59,7 @@ export interface StorageService {
|
|||
/** The direct-only members of StorageService, as one spreadable group. */
|
||||
export type DirectStorageMembers = Pick<
|
||||
StorageService,
|
||||
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "getSealedObject" | "readDirectRange"
|
||||
"directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
@ -74,6 +76,7 @@ export function directStorageDisabled(): DirectStorageMembers {
|
|||
presignPut: unavailable,
|
||||
headObject: unavailable,
|
||||
openDirectHeader: unavailable,
|
||||
openDirectHeaderBlob: unavailable,
|
||||
getSealedObject: unavailable,
|
||||
readDirectRange: unavailable,
|
||||
};
|
||||
|
|
@ -166,6 +169,16 @@ const DIRECT_WRAP_INFO = Buffer.from("tmbh-direct-cek-wrap", "ascii");
|
|||
/** Segment plaintext ceiling. Mirrors the web side, which seals 10 MiB chunks. */
|
||||
export const DIRECT_SEGMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Direct upload metadata records sealed bytes; HTTP ranges address plaintext. */
|
||||
export function directPlaintextSize(sealedBytes: number): number {
|
||||
const count = Math.ceil((sealedBytes - SEGMENTS_OFFSET) / (DIRECT_SEGMENT_BYTES + 32));
|
||||
const plaintextBytes = sealedBytes - SEGMENTS_OFFSET - count * 32;
|
||||
if (!Number.isSafeInteger(sealedBytes) || count < 1 || plaintextBytes <= (count - 1) * DIRECT_SEGMENT_BYTES) {
|
||||
throw new Error("storage: invalid SPENC2 sealed size");
|
||||
}
|
||||
return plaintextBytes;
|
||||
}
|
||||
|
||||
/** Server direct keypair: P-256 private key from DIRECT_EC_PRIVATE_KEY (base64 DER). */
|
||||
export interface DirectKeyPair {
|
||||
privateKey: ReturnType<typeof createPrivateKey>;
|
||||
|
|
@ -239,6 +252,9 @@ function parseDirectHeader(stored: Buffer): DirectHeader {
|
|||
if (stored.length < SEGMENTS_OFFSET) {
|
||||
throw new Error("storage: SPENC2 frame is truncated");
|
||||
}
|
||||
if (!stored.subarray(0, DIRECT_FRAME_MAGIC.length).equals(DIRECT_FRAME_MAGIC)) {
|
||||
throw new Error("storage: not a SPENC2 frame (magic mismatch)");
|
||||
}
|
||||
const ephemeralPoint = stored.subarray(EPHEMERAL_PUBLIC_OFFSET, WRAP_NONCE_OFFSET);
|
||||
if (ephemeralPoint[0] !== 0x04) throw new Error("storage: SPENC2 ephemeral key is not an uncompressed point");
|
||||
const wrapNonce = stored.subarray(WRAP_NONCE_OFFSET, WRAPPED_CEK_OFFSET);
|
||||
|
|
@ -408,6 +424,7 @@ export class S3Storage implements StorageService {
|
|||
this.client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region ?? "us-east-1",
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
credentials:
|
||||
config.accessKeyId !== undefined && config.secretAccessKey !== undefined
|
||||
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
||||
|
|
@ -521,6 +538,21 @@ export class S3Storage implements StorageService {
|
|||
return unwrapDirectCek(stored, this.directKey.privateKey);
|
||||
}
|
||||
|
||||
async openDirectHeaderBlob(key: string): Promise<Buffer> {
|
||||
const header = await this.getSealedRange(key, 0, SEGMENTS_OFFSET);
|
||||
parseDirectHeader(header);
|
||||
return header;
|
||||
}
|
||||
|
||||
/** S3 uses an inclusive end; callers supply an exclusive end. */
|
||||
private async getSealedRange(key: string, start: number, end: number): Promise<Buffer> {
|
||||
const res = await this.client.send(new GetObjectCommand({
|
||||
Bucket: this.bucket, Key: key, Range: `bytes=${start}-${end - 1}`,
|
||||
}));
|
||||
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
|
||||
return Buffer.from(await res.Body.transformToByteArray());
|
||||
}
|
||||
|
||||
async getSealedObject(key: string): Promise<Buffer> {
|
||||
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
|
||||
|
|
@ -535,39 +567,38 @@ export class S3Storage implements StorageService {
|
|||
*/
|
||||
async readDirectRange(key: string, start: number, length: number): Promise<Buffer> {
|
||||
if (this.directKey === null) throw missingDirectKey();
|
||||
if (length <= 0) return Buffer.alloc(0);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(length) || length < 0
|
||||
|| !Number.isSafeInteger(start + length)) {
|
||||
throw new RangeError("storage: invalid plaintext range");
|
||||
}
|
||||
const header = await this.openDirectHeaderBlob(key);
|
||||
const { segmentCount } = parseDirectHeader(header);
|
||||
const cek = unwrapDirectCek(header, this.directKey.privateKey);
|
||||
if (length === 0) return Buffer.alloc(0);
|
||||
const first = Math.floor(start / DIRECT_SEGMENT_BYTES);
|
||||
const last = Math.floor((start + length - 1) / DIRECT_SEGMENT_BYTES);
|
||||
// ONE ranged GET covering the header (the CEK wraps there) + every segment
|
||||
// through `last`. Segments before `first` are walked but skipped, so the
|
||||
// sealed offsets stay deterministic; all non-final segments carry a full
|
||||
// 10 MiB plaintext, which makes the sealed end computable up front.
|
||||
const segSealedStart = (i: number): number => SEGMENTS_OFFSET + i * (4 + 12 + DIRECT_SEGMENT_BYTES + 16);
|
||||
const fetchEnd = segSealedStart(last) + 4 + 12 + DIRECT_SEGMENT_BYTES + 16;
|
||||
const res = await this.client.send(new GetObjectCommand({
|
||||
Bucket: this.bucket, Key: key, Range: `bytes=0-${fetchEnd - 1}`,
|
||||
}));
|
||||
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
|
||||
const stored = Buffer.from(await res.Body.transformToByteArray());
|
||||
const cek = unwrapDirectCek(stored, this.directKey.privateKey);
|
||||
if (last >= segmentCount) throw new RangeError("storage: plaintext range exceeds segment count");
|
||||
const sealedStride = 4 + 12 + DIRECT_SEGMENT_BYTES + 16;
|
||||
// Fetch only covering segments. S3 clips the final partial segment to EOF.
|
||||
const stored = await this.getSealedRange(key,
|
||||
SEGMENTS_OFFSET + first * sealedStride, SEGMENTS_OFFSET + (last + 1) * sealedStride);
|
||||
const parts: Buffer[] = [];
|
||||
let filled = 0;
|
||||
let offset = SEGMENTS_OFFSET;
|
||||
const segmentCount = stored.readUInt32BE(SEGMENT_COUNT_OFFSET);
|
||||
for (let i = 0; i < segmentCount && filled < length; i += 1) {
|
||||
let offset = 0;
|
||||
for (let i = first; i <= last; i += 1) {
|
||||
if (offset + 4 + 12 > stored.length) throw new Error("storage: SPENC2 segment table is truncated");
|
||||
const ctLen = stored.readUInt32BE(offset); offset += 4;
|
||||
if (ctLen < 16 || offset + 12 + ctLen > stored.length) break;
|
||||
if (ctLen < 17 || ctLen > DIRECT_SEGMENT_BYTES + 16 || offset + 12 + ctLen > stored.length
|
||||
|| (i < segmentCount - 1 && ctLen !== DIRECT_SEGMENT_BYTES + 16)) {
|
||||
throw new Error("storage: SPENC2 segment length is out of range");
|
||||
}
|
||||
const iv = stored.subarray(offset, offset + 12); offset += 12;
|
||||
const ciphertext = stored.subarray(offset, offset + ctLen); offset += ctLen;
|
||||
const segStart = i * DIRECT_SEGMENT_BYTES;
|
||||
const segEnd = segStart + ctLen - 16;
|
||||
if (segEnd <= start) continue;
|
||||
if (segStart >= start + length) break;
|
||||
const plain = aesGcmOpen(cek, iv, ciphertext);
|
||||
const from = Math.max(0, start - segStart);
|
||||
const to = Math.min(plain.length, start + length - segStart);
|
||||
parts.push(plain.subarray(from, to));
|
||||
filled += to - from;
|
||||
const segStart = i * DIRECT_SEGMENT_BYTES;
|
||||
parts.push(plain.subarray(Math.max(0, start - segStart), Math.min(plain.length, start + length - segStart)));
|
||||
}
|
||||
if (parts.reduce((sum, part) => sum + part.length, 0) !== length) {
|
||||
throw new RangeError("storage: plaintext range exceeds object length");
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -632,7 +632,7 @@ function validateUpload(storageKey: string, mimeType: string, sizeBytes: number)
|
|||
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1) {
|
||||
throw new BadRequestException("sizeBytes must be a positive safe integer");
|
||||
}
|
||||
if (sizeBytes > MAX_VIDEO_UPLOAD_BYTES) throw new BadRequestException("video exceeds the 2 GiB upload limit");
|
||||
if (sizeBytes > MAX_CHUNKED_UPLOAD_BYTES) throw new BadRequestException("video exceeds the chunked upload limit");
|
||||
}
|
||||
|
||||
function requiredRow(row: unknown, description: string): Record<string, unknown> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue