For scale: payload bytes go browser → R2 → browser; the api handles keys, authz and metadata only, so a cheap box can serve thousands of users. - SPENC2 frame (browser-sealed, P-256 ECDH → HKDF wrap of a per-file CEK, 10 MiG AES-GCM segments): storage.ts gains the reader (magic-branched getObject), presignPut/headObject, openDirectHeader; sealForDirect is the reference sealer for cross-implementation tests - Files API: GET /files/direct/config, POST /files/direct/init (presigned PUT), POST /files/direct/complete (HEAD-gated commit), POST /files/:id/direct-download (presigned GET + CEK ticket) - web: lib/direct-storage (WebCrypto seal/open), lib/files-direct (flows), dropzone rewritten with a multi-file sequential queue (per-file status, direct-first with server fallback), file download decrypts in-browser for direct objects (falls back to streaming) - video dropzone wired to the chunked ingest API (>10 MiB → init/chunk/ complete), fixing the Cloudflare ~100 MB body cap for large videos - DIRECT_EC_PRIVATE_KEY (P-256, PKCS8 b64) wired through compose; cross-impl roundtrip tests prove web-seal ↔ server-open byte compatibility
879 lines
38 KiB
TypeScript
879 lines
38 KiB
TypeScript
/**
|
|
* Group files service — the metadata index + share/link lifecycle over
|
|
* chat_media_archive (029) and the oCIS WebDAV port.
|
|
*
|
|
* Invariants this service owns (docs/occupymedia-storage.md):
|
|
* * List/search return ONLY available, unexpired rows visible through
|
|
* current group/file ACLs — expired/retention-past/revoked rows never
|
|
* surface, and non-Owner/Admin callers only see their groups' files.
|
|
* * Attribution by sender MXID; origin is one of the four SharedFileOrigin
|
|
* kinds, reconstructed from origin + matrix_event_json.
|
|
* * Retention is SEPARATE from link expiry: an expired share never deletes
|
|
* or hides a file; 'legalHold' blocks deletion outright.
|
|
* * Share tokens: plaintext returned ONCE, sha256(token) persisted, public
|
|
* access requires an explicit grant + bounded expiry, revoke stops
|
|
* serving immediately.
|
|
* * Downloads stream through the API with the SHA-256 re-verified; no
|
|
* presigned object URL is ever minted or stored.
|
|
*
|
|
* Every mutation lands its audit row on the SAME tenant transaction
|
|
* (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 { 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 { 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 type { PayloadDownload } from "./verify-stream.js";
|
|
import { hashShareToken, mintShareToken } from "./share-token.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");
|
|
|
|
/** Wraps the raw payload handle the controller streams out. */
|
|
export interface FileDownloadHandle {
|
|
item: GroupFile;
|
|
download: PayloadDownload;
|
|
}
|
|
|
|
export interface ShareCreated {
|
|
id: string;
|
|
itemId: string;
|
|
access: "members" | "public";
|
|
url: string;
|
|
expiresAt: string;
|
|
revokedAt: string | null;
|
|
/** Plaintext capability — returned ONCE here, never persisted anywhere. */
|
|
token: string;
|
|
}
|
|
|
|
export interface FileListFilters {
|
|
groupId: string | null;
|
|
senderMxid: string | null;
|
|
mimeClass: MimeClass | null;
|
|
/** SharedFileOrigin facet: matrix | bridge | upload | pad-export. */
|
|
origin: OriginKind | null;
|
|
name: string | null;
|
|
from: string | null;
|
|
to: string | null;
|
|
}
|
|
|
|
export interface FilePage {
|
|
items: GroupFile[];
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
/** GET /files/direct/config — what the browser needs before it can seal. */
|
|
export interface DirectUploadConfig {
|
|
enabled: boolean;
|
|
frameVersion: 2;
|
|
publicKeyJwk: { kty: "EC"; crv: "P-256"; x: string; y: string } | null;
|
|
maxBytes: number;
|
|
}
|
|
|
|
/** POST /files/direct/init response. Nothing is persisted until complete. */
|
|
export interface DirectUploadInit {
|
|
uploadId: string;
|
|
objectKey: string;
|
|
putUrl: string;
|
|
}
|
|
|
|
/** POST /files/:id/direct-download response: URL + the unwrapped CEK. */
|
|
export interface DirectDownloadTicket {
|
|
url: string;
|
|
cekB64: string;
|
|
frame: "SPENC2";
|
|
sizeBytes: number;
|
|
}
|
|
|
|
export const DIRECT_MAX_BYTES = 2_147_483_648;
|
|
const DIRECT_PUT_TTL_SECONDS = 900;
|
|
const DIRECT_GET_TTL_SECONDS = 600;
|
|
const DIRECT_KEY_PREFIX = "direct/";
|
|
const DIRECT_OBJECT_SUFFIX = ".enc";
|
|
|
|
/** MIME allowlist for direct uploads: prefix match on the declared type. */
|
|
const DIRECT_MIME_PREFIXES = ["image/", "video/", "audio/", "application/", "text/"] as const;
|
|
|
|
const MAX_SHARE_TTL_HOURS = 720;
|
|
const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"];
|
|
|
|
interface ArchiveRow {
|
|
id: string;
|
|
workspace_id: string;
|
|
group_id: string;
|
|
room_id: string;
|
|
event_id: string | null;
|
|
origin: string;
|
|
sender_mxid: string;
|
|
matrix_event_json: Record<string, unknown> | null;
|
|
original_name: string;
|
|
safe_name: string;
|
|
mime: string;
|
|
mime_class: string;
|
|
byte_size: string | number;
|
|
sha256: string;
|
|
ocis_resource_id: string;
|
|
ocis_resource_version: string | null;
|
|
storage_key: string | null;
|
|
created_at: Date;
|
|
indexed_at: Date;
|
|
available: boolean;
|
|
retention_mode: string;
|
|
legal_hold: boolean;
|
|
retention_until: Date | null;
|
|
share_access: string | null;
|
|
share_expires_at: Date | null;
|
|
share_revoked_at: Date | null;
|
|
}
|
|
|
|
interface ShareRow {
|
|
id: string;
|
|
workspace_id: string;
|
|
item_id: string;
|
|
access: string;
|
|
expires_at: Date;
|
|
revoked_at: Date | null;
|
|
}
|
|
|
|
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 (
|
|
SELECT access, expires_at, revoked_at FROM group_file_shares
|
|
WHERE item_id = a.id AND revoked_at IS NULL AND expires_at > now()
|
|
ORDER BY created_at DESC LIMIT 1
|
|
) s ON true`;
|
|
|
|
function iso(value: Date): string {
|
|
return new Date(value).toISOString();
|
|
}
|
|
|
|
function safeNumber(value: string | number): number {
|
|
const n = typeof value === "number" ? value : Number(value);
|
|
if (!Number.isSafeInteger(n)) throw new Error(`unsafe numeric column: ${String(value)}`);
|
|
return n;
|
|
}
|
|
|
|
/** Row → GroupFile, reconstructing origin + retention + share link. */
|
|
function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => string): GroupFile {
|
|
const payload = row.matrix_event_json ?? {};
|
|
const origin: GroupFile["origin"] = (() => {
|
|
if (row.origin === "matrix") return { kind: "matrix", mxcUri: String(payload.mxcUri ?? "") };
|
|
if (row.origin === "bridge") {
|
|
return {
|
|
kind: "bridge",
|
|
mxcUri: String(payload.mxcUri ?? ""),
|
|
providerId: String(payload.providerId ?? ""),
|
|
...(payload.remoteEventId === undefined ? {} : { remoteEventId: String(payload.remoteEventId) }),
|
|
};
|
|
}
|
|
if (row.origin === "pad-export") {
|
|
return { kind: "pad-export", padId: String(payload.padId ?? ""), revision: String(payload.revision ?? "") };
|
|
}
|
|
return { kind: "upload" };
|
|
})();
|
|
const retention: FileRetention =
|
|
row.retention_mode === "until" && row.retention_until !== null
|
|
? { mode: "until", legalHold: row.legal_hold, expiresAt: iso(row.retention_until) }
|
|
: { mode: "keep", legalHold: row.legal_hold };
|
|
const status: GroupFile["status"] = !row.available
|
|
? "revoked"
|
|
: retention.mode === "until" && new Date(retention.expiresAt).getTime() <= Date.now()
|
|
? "expired"
|
|
: "available";
|
|
return {
|
|
id: row.id,
|
|
workspaceId: row.workspace_id,
|
|
groupId: row.group_id,
|
|
roomId: row.room_id,
|
|
senderId: row.sender_mxid,
|
|
eventId: row.event_id,
|
|
origin,
|
|
name: row.original_name,
|
|
mimeType: row.mime,
|
|
mimeClass: row.mime_class as MimeClass,
|
|
sizeBytes: safeNumber(row.byte_size),
|
|
checksum: { algorithm: "sha256", value: row.sha256 },
|
|
createdAt: iso(row.created_at),
|
|
indexedAt: iso(row.indexed_at),
|
|
file: {
|
|
spaceId: row.group_id,
|
|
resourceId: row.storage_key ?? row.ocis_resource_id,
|
|
version: row.ocis_resource_version ?? row.ocis_resource_id,
|
|
...(row.storage_key === null ? {} : { storageKey: row.storage_key }),
|
|
},
|
|
shareLink: {
|
|
url: libraryUrl(row.storage_key ?? row.ocis_resource_id),
|
|
access: row.share_access === "public" ? "public" : "members",
|
|
expiresAt: row.share_expires_at === null ? null : iso(row.share_expires_at),
|
|
},
|
|
retention,
|
|
status,
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class GroupFilesService {
|
|
private store: StorageService | null = null;
|
|
|
|
constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { storage?: StorageService }) {}
|
|
|
|
/** R2 object storage — the only payload store (041; oCIS retired). */
|
|
private storage(): StorageService {
|
|
if (this.store === null) {
|
|
this.store = this.deps?.storage ?? new S3Storage(s3ConfigFromEnv());
|
|
}
|
|
return this.store;
|
|
}
|
|
|
|
/** Authenticated library link; never a persisted presigned R2 URL. */
|
|
private libraryUrlFor(resourceId: string): string {
|
|
const base = (process.env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, "");
|
|
return base === "" ? resourceId : `${base}/f/${encodeURIComponent(resourceId)}`;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Read paths
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Keyset page over the visible slice: available, unexpired, and — for
|
|
* non-Owner/Admin callers — restricted to the caller's groups (the ACL
|
|
* rule "List/search return only available, unexpired rows visible through
|
|
* current group/file ACLs").
|
|
*/
|
|
async listFiles(
|
|
workspaceId: string,
|
|
userId: string,
|
|
role: string,
|
|
query: Record<string, unknown>,
|
|
): Promise<FilePage> {
|
|
const filters = parseFilters(query);
|
|
const limit = parseLimit(query.limit, 50, 100);
|
|
const cursor = typeof query.cursor === "string" && query.cursor !== "" ? decodeFileListCursor(query.cursor) : undefined;
|
|
return withTenant(workspaceId, async (client) => {
|
|
// $1 is the explicit workspace scope; RLS (app.workspace_id) enforces the
|
|
// same tenant inside withTenant, so the two agree. Every value is bound
|
|
// positionally — no SQL text ever carries a value, and no parameter is
|
|
// left unused (Postgres cannot type an unused placeholder).
|
|
const values: unknown[] = [workspaceId];
|
|
const conds: string[] = [
|
|
"a.workspace_id = $1::uuid",
|
|
"a.available = true",
|
|
"(a.retention_mode <> 'until' OR a.retention_until > now())",
|
|
];
|
|
if (!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})`);
|
|
}
|
|
if (filters.groupId !== null) {
|
|
values.push(filters.groupId);
|
|
conds.push(`a.group_id = $${values.length}::uuid`);
|
|
}
|
|
if (filters.senderMxid !== null) {
|
|
values.push(filters.senderMxid);
|
|
conds.push(`a.sender_mxid = $${values.length}`);
|
|
}
|
|
if (filters.mimeClass !== null) {
|
|
values.push(filters.mimeClass);
|
|
conds.push(`a.mime_class = $${values.length}`);
|
|
}
|
|
if (filters.origin !== null) {
|
|
values.push(filters.origin);
|
|
conds.push(`a.origin = $${values.length}`);
|
|
}
|
|
if (filters.name !== null) {
|
|
values.push(`%${filters.name}%`);
|
|
conds.push(`a.safe_name ILIKE $${values.length}`);
|
|
}
|
|
if (filters.from !== null) {
|
|
values.push(filters.from);
|
|
conds.push(`a.created_at >= $${values.length}::timestamptz`);
|
|
}
|
|
if (filters.to !== null) {
|
|
values.push(filters.to);
|
|
conds.push(`a.created_at < $${values.length}::timestamptz`);
|
|
}
|
|
if (cursor !== undefined) {
|
|
values.push(cursor.createdAt, cursor.id);
|
|
conds.push(`(a.created_at, a.id) < ($${values.length - 1}::timestamptz, $${values.length}::uuid)`);
|
|
}
|
|
values.push(limit + 1);
|
|
const sql = `${ARCHIVE_SELECT}
|
|
WHERE ${conds.join(" AND ")}
|
|
ORDER BY a.created_at DESC, a.id DESC
|
|
LIMIT $${values.length}`;
|
|
const result = await client.query<ArchiveRow>(sql, values);
|
|
const page = result.rows.slice(0, limit);
|
|
const last = page.at(-1);
|
|
const lib = (resourceId: string): string => this.libraryUrlFor(resourceId);
|
|
return {
|
|
items: page.map((row) => rowToGroupFile(row, lib)),
|
|
nextCursor: result.rows.length > limit && last !== undefined
|
|
? encodeFileListCursor({ createdAt: iso(last.created_at), id: last.id })
|
|
: null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Single archived-media read. Unavailable rows never surface: the ACL
|
|
* recheck runs first (so a non-member learns nothing about the row), then a
|
|
* redacted (available = false) row answers Gone — the same exclusion the
|
|
* list applies ("only available rows visible through current ACLs").
|
|
*/
|
|
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);
|
|
if (!row.available) throw new GoneException("file is revoked");
|
|
return rowToGroupFile(row, (resourceId) => this.libraryUrlFor(resourceId));
|
|
});
|
|
}
|
|
|
|
/** Open the payload for streaming; membership/ACL + retention rechecked here. */
|
|
async openDownload(workspaceId: string, userId: string, role: string, itemId: string): Promise<FileDownloadHandle> {
|
|
const item = await this.getFile(workspaceId, userId, role, itemId);
|
|
if (item.status !== "available") throw new GoneException(`file is ${item.status}`);
|
|
const download = await this.openPayload(item.file.storageKey, item.mimeType).catch(() => {
|
|
throw new ServiceUnavailableException("file storage is unavailable");
|
|
});
|
|
return { item, download };
|
|
}
|
|
|
|
private async openPayload(storageKey: string | null | undefined, mime: string): Promise<PayloadDownload> {
|
|
if (storageKey === null || storageKey === undefined) {
|
|
throw new ServiceUnavailableException("file was stored on retired storage and is no longer served");
|
|
}
|
|
const bytes = await this.storage().getObject(storageKey);
|
|
return { stream: Readable.from(bytes), sizeBytes: bytes.byteLength, contentType: mime };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Retention / availability
|
|
// -------------------------------------------------------------------------
|
|
|
|
async setRetention(workspaceId: string, itemId: string, retentionInput: unknown, actor: ActorRef): Promise<GroupFile> {
|
|
const retention = parseRetentionInput(retentionInput);
|
|
return withTenant(workspaceId, async (client) => {
|
|
const before = await this.requireRow(client, itemId);
|
|
await client.query(
|
|
`UPDATE chat_media_archive
|
|
SET retention_mode = $2, legal_hold = $3, retention_until = $4
|
|
WHERE id = $1`,
|
|
[itemId, retention.mode, retention.legalHold, retention.mode === "until" ? new Date(retention.expiresAt) : null],
|
|
);
|
|
await this.audit(client, workspaceId, actor, {
|
|
action: "file.retention_changed",
|
|
targetId: itemId,
|
|
diff: {
|
|
from: { mode: before.retention_mode, legalHold: before.legal_hold, until: before.retention_until === null ? null : iso(before.retention_until) },
|
|
to: { mode: retention.mode, legalHold: retention.legalHold, until: retention.mode === "until" ? retention.expiresAt : null },
|
|
name: before.safe_name,
|
|
},
|
|
});
|
|
const after = await this.requireRow(client, itemId);
|
|
return rowToGroupFile(after, (resourceId) => this.libraryUrlFor(resourceId));
|
|
});
|
|
}
|
|
|
|
/** Redaction/membership-removal path: revoke links, stop serving. No deletion. */
|
|
async markRevoked(workspaceId: string, itemId: string, actor: ActorRef): Promise<GroupFile> {
|
|
return withTenant(workspaceId, async (client) => {
|
|
const before = await this.requireRow(client, itemId);
|
|
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",
|
|
targetId: itemId,
|
|
diff: { name: before.safe_name, roomId: before.room_id, eventId: before.event_id },
|
|
});
|
|
const after = await this.requireRow(client, itemId);
|
|
return rowToGroupFile(after, (resourceId) => this.libraryUrlFor(resourceId));
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Shares / public links
|
|
// -------------------------------------------------------------------------
|
|
|
|
async createShare(
|
|
workspaceId: string,
|
|
itemId: string,
|
|
input: { access?: unknown; expiresInHours?: unknown },
|
|
actor: ActorRef,
|
|
): Promise<ShareCreated> {
|
|
const access = input.access === undefined ? "members" : input.access;
|
|
if (access !== "members" && access !== "public") throw new BadRequestException("access must be 'members' or 'public'");
|
|
const hours = typeof input.expiresInHours === "number" ? input.expiresInHours : NaN;
|
|
if (!Number.isInteger(hours) || hours < 1 || hours > MAX_SHARE_TTL_HOURS) {
|
|
throw new BadRequestException(`expiresInHours must be an integer between 1 and ${MAX_SHARE_TTL_HOURS}`);
|
|
}
|
|
const token = mintShareToken();
|
|
const tokenHash = hashShareToken(token);
|
|
const expiresAt = new Date(Date.now() + hours * 3_600_000);
|
|
return withTenant(workspaceId, async (client) => {
|
|
const row = await this.requireRow(client, itemId);
|
|
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`,
|
|
[workspaceId, itemId, access, tokenHash, actor.userId, expiresAt],
|
|
);
|
|
const share = inserted.rows[0];
|
|
if (share === undefined) throw new Error("share insert returned no row");
|
|
await this.audit(client, workspaceId, actor, {
|
|
action: "file.share_created",
|
|
targetId: share.id,
|
|
diff: { itemId, access, expiresAt: iso(expiresAt), name: row.safe_name }, // never the token
|
|
});
|
|
return {
|
|
id: share.id,
|
|
itemId,
|
|
access,
|
|
url: `${this.publicWebBase()}/files/s/${token}`,
|
|
expiresAt: iso(expiresAt),
|
|
revokedAt: null,
|
|
token,
|
|
};
|
|
});
|
|
}
|
|
|
|
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 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],
|
|
);
|
|
if ((updated.rowCount ?? 0) === 0) {
|
|
const existing = await client.query("SELECT item_id FROM group_file_shares WHERE id = $1 AND workspace_id = $2", [shareId, workspaceId]);
|
|
if (existing.rows[0] === undefined) throw new NotFoundException(`share ${shareId} not found`);
|
|
throw new ConflictException("share is already revoked");
|
|
}
|
|
await this.audit(client, workspaceId, actor, {
|
|
action: "file.share_revoked",
|
|
targetId: shareId,
|
|
diff: { itemId: String(updated.rows[0]?.item_id) },
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Public claim path (no session): resolve the token through the narrow
|
|
* SECURITY DEFINER lookup — no tenant context exists yet, exactly like the
|
|
* invite-link claim — then enter the row's tenant to recheck availability
|
|
* and retention before opening the payload. Serving stops immediately on
|
|
* revoke/expiry (Gone).
|
|
*/
|
|
async claimPublicDownload(token: string): Promise<FileDownloadHandle> {
|
|
if (token === "") throw new NotFoundException("share token is invalid");
|
|
const tokenHash = createHash("sha256").update(token, "utf8").digest("hex");
|
|
const share = await withSystem(async (client) => {
|
|
const result = await client.query<ShareRow>(
|
|
"SELECT id, workspace_id, item_id, access, expires_at, revoked_at FROM share_for_public_claim($1)",
|
|
[tokenHash],
|
|
);
|
|
return result.rows[0];
|
|
});
|
|
if (share === undefined) throw new NotFoundException("share token is invalid or has expired");
|
|
if (share.access !== "public") throw new ForbiddenException("share token is not a public link");
|
|
return withTenant(share.workspace_id, async (client) => {
|
|
const row = await this.requireRow(client, share.item_id);
|
|
if (!row.available) throw new GoneException("file is revoked");
|
|
if (row.retention_mode === "until" && row.retention_until !== null && row.retention_until.getTime() <= Date.now()) {
|
|
throw new GoneException("file retention has expired");
|
|
}
|
|
const item = rowToGroupFile(row, (resourceId) => this.libraryUrlFor(resourceId));
|
|
const download = await this.openPayload(row.storage_key, item.mimeType).catch(() => {
|
|
throw new ServiceUnavailableException("file storage is unavailable");
|
|
});
|
|
return { item, download };
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Ingest seam (hub-archive adapter / direct upload), same idempotency rule
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Register a committed library reference. Replay-safe: the partial unique
|
|
* (workspace,room,event) makes a second attempt for the same attachment
|
|
* event a conflict, and the claim key is attachmentArchiveKey().
|
|
*/
|
|
async registerArchiveEntry(
|
|
workspaceId: string,
|
|
entry: ArchiveEntryInput,
|
|
): Promise<{ id: string; claimKey: string | null }> {
|
|
const claimKey =
|
|
entry.eventId === null || entry.roomId === null
|
|
? null
|
|
: attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId);
|
|
return withTenant(workspaceId, async (client) => {
|
|
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)
|
|
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,
|
|
],
|
|
);
|
|
} catch (err) {
|
|
if (typeof err === "object" && err !== null && (err as { code?: string }).code === "23505") {
|
|
throw new ConflictException("attachment event is already archived");
|
|
}
|
|
throw err;
|
|
}
|
|
const id = inserted.rows[0]?.id;
|
|
if (id === undefined) throw new Error("archive insert returned no row");
|
|
return { id, claimKey };
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Direct-to-R2 (browser-sealed SPENC2): keys/authz/metadata stay here,
|
|
// payload bytes go browser → R2 → browser
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Whether the deployment can unwrap browser-sealed frames. The public key is
|
|
* DERIVED from the private key, so a browser can only ever wrap a CEK this
|
|
* process can open — it never sends a key of its own.
|
|
*/
|
|
async directUploadConfig(): Promise<DirectUploadConfig> {
|
|
const storage = this.storage();
|
|
const publicKeyJwk = typeof storage.directPublicJwk === "function" ? await storage.directPublicJwk() : null;
|
|
return {
|
|
enabled: publicKeyJwk !== null,
|
|
frameVersion: 2,
|
|
publicKeyJwk,
|
|
maxBytes: DIRECT_MAX_BYTES,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mint the presigned PUT for a direct upload. Group membership is checked
|
|
* exactly like uploadForGroup; the size is bounded up front so an oversized
|
|
* pick fails before any bytes are sealed. Nothing is persisted — the row
|
|
* only appears on complete, so an abandoned PUT leaves an orphan object
|
|
* (no row), never a dangling row without an object.
|
|
*/
|
|
async initDirectUpload(
|
|
workspaceId: string,
|
|
groupId: string,
|
|
actor: ActorRef,
|
|
input: { filename: string; mimeType: string; sizeBytes: number },
|
|
): Promise<DirectUploadInit> {
|
|
if (!isUuid(groupId)) throw new BadRequestException("groupId must be a UUID");
|
|
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);
|
|
if ((await this.storage().directPublicJwk()) === null) {
|
|
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
|
|
}
|
|
const uploadId = randomUUID();
|
|
const objectKey = `${DIRECT_KEY_PREFIX}${uploadId}${DIRECT_OBJECT_SUFFIX}`;
|
|
const putUrl = await this.storage()
|
|
.presignPut(objectKey, input.mimeType, DIRECT_PUT_TTL_SECONDS)
|
|
.catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); });
|
|
return { uploadId, objectKey, putUrl };
|
|
}
|
|
|
|
/**
|
|
* Commit a completed direct upload. The object is HEAD-checked (exists, exact
|
|
* declared size) before the row is written, so metadata can never point at a
|
|
* missing or partial PUT. sha256 is client-attested — the payload is opaque
|
|
* to this process by design; the browser computed it over the plaintext it
|
|
* sealed. Filename runs through the same safeFileName the streamed path uses.
|
|
*/
|
|
async completeDirectUpload(
|
|
workspaceId: string,
|
|
actor: ActorRef,
|
|
input: { uploadId: string; objectKey: string; groupId: string; 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");
|
|
if (input.objectKey !== `${DIRECT_KEY_PREFIX}${input.uploadId}${DIRECT_OBJECT_SUFFIX}`) {
|
|
throw new BadRequestException("objectKey must be direct/<uploadId>.enc");
|
|
}
|
|
if (!/^[0-9a-f]{64}$/.test(input.sha256)) throw new BadRequestException("sha256 must be lowercase hex (64 chars)");
|
|
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}`);
|
|
}
|
|
const safe = validateDirectFilename(input.filename);
|
|
const mimeType = validateDirectMime(input.mimeType);
|
|
await this.requireGroupMembership(workspaceId, input.groupId, actor);
|
|
|
|
const head = await this.storage().headObject(input.objectKey).catch(() => {
|
|
throw new ServiceUnavailableException("file storage is unavailable");
|
|
});
|
|
if (!head.exists || head.contentLength !== input.sizeBytes) {
|
|
throw new BadRequestException("uploaded object is missing or its size does not match sizeBytes");
|
|
}
|
|
|
|
const mimeClass = mimeClassForDeclared(mimeType);
|
|
const username = await this.usernameFor(workspaceId, actor.userId);
|
|
const { id } = await this.registerArchiveEntry(workspaceId, {
|
|
groupId: input.groupId,
|
|
roomId: null,
|
|
eventId: null,
|
|
origin: { kind: "upload" },
|
|
originKind: "upload",
|
|
senderMxid: `@${username}:tmbh.local`,
|
|
originalName: input.filename,
|
|
safeName: safe,
|
|
mime: mimeType,
|
|
mimeClass,
|
|
byteSize: input.sizeBytes,
|
|
sha256: input.sha256,
|
|
ocisResourceId: input.objectKey,
|
|
ocisResourceVersion: null,
|
|
storageKey: input.objectKey,
|
|
createdAt: new Date(),
|
|
});
|
|
return this.getFile(workspaceId, actor.userId, actor.role, id);
|
|
}
|
|
|
|
/**
|
|
* Ticket for a browser-side download: presigned GET (600 s) plus the
|
|
* unwrapped CEK. Availability/ACL checks are getFile's (the same ones the
|
|
* streaming route runs); only direct/ objects can take this path.
|
|
*/
|
|
async openDirectDownload(workspaceId: string, userId: string, role: string, itemId: string): Promise<DirectDownloadTicket> {
|
|
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_KEY_PREFIX)) {
|
|
throw new BadRequestException("not a direct object");
|
|
}
|
|
const storage = this.storage();
|
|
const [url, frame] = await Promise.all([
|
|
storage.presign(storageKey, DIRECT_GET_TTL_SECONDS),
|
|
storage.getObject(storageKey).then((stored) => storage.openDirectHeader(stored)),
|
|
]).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); });
|
|
return { url, cekB64: frame.toString("base64"), frame: "SPENC2", sizeBytes: item.sizeBytes };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// internals
|
|
// -------------------------------------------------------------------------
|
|
|
|
private publicWebBase(): string {
|
|
const base = process.env.FILES_PUBLIC_BASE ?? "";
|
|
return base === "" ? "" : base.replace(/\/+$/, "");
|
|
}
|
|
|
|
private async requireRow(client: PoolClient, itemId: string): Promise<ArchiveRow> {
|
|
if (!isUuid(itemId)) throw new BadRequestException("itemId must be a UUID");
|
|
const result = await client.query<ArchiveRow>(`${ARCHIVE_SELECT} WHERE a.id = $1`, [itemId]);
|
|
const row = result.rows[0];
|
|
if (row === undefined) throw new NotFoundException(`file ${itemId} not found`);
|
|
return row;
|
|
}
|
|
|
|
/** Default links require current group membership; Owner/Admin pass. */
|
|
private async requireAccess(client: PoolClient, userId: string, role: string, groupId: string): Promise<void> {
|
|
if (WORKSPACE_WIDE_ROLES.includes(role)) return;
|
|
const result = await client.query(
|
|
"SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2",
|
|
[groupId, userId],
|
|
);
|
|
if ((result.rowCount ?? 0) === 0) throw new ForbiddenException("file belongs to a group you are not a member of");
|
|
}
|
|
|
|
private async audit(
|
|
client: PoolClient,
|
|
workspaceId: string,
|
|
actor: ActorRef,
|
|
event: Omit<AuditEvent, "actorUserId" | "actorRole" | "targetType">,
|
|
): Promise<void> {
|
|
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
|
|
workspaceId,
|
|
actor.userId,
|
|
actor.role,
|
|
event.action,
|
|
"group_file",
|
|
event.targetId,
|
|
event.diff ?? null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Direct web upload: ACL (active member of the group, or Owner/Admin),
|
|
* stream the buffer to oCIS (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,
|
|
actor: { userId: string; role: string },
|
|
file: { buffer: Buffer; mimetype: string; originalName: string },
|
|
): 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 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):
|
|
// SPENC1-sealed, content-addressed, deduplicated. ocis_resource_id keeps
|
|
// carrying the resource handle (the R2 key) for legacy readers.
|
|
const stored = await this.storage().putContent(file.buffer, sniff.mime).catch(() => {
|
|
throw new ServiceUnavailableException("file storage is unavailable");
|
|
});
|
|
const username = await this.usernameFor(workspaceId, actor.userId);
|
|
const { id } = await this.registerArchiveEntry(workspaceId, {
|
|
groupId,
|
|
roomId: null,
|
|
eventId: null,
|
|
origin: { kind: "upload" },
|
|
originKind: "upload",
|
|
senderMxid: `@${username}:tmbh.local`,
|
|
originalName: file.originalName,
|
|
safeName: safe,
|
|
mime: sniff.mime,
|
|
mimeClass: sniff.mimeClass,
|
|
byteSize: stored.sizeBytes,
|
|
sha256: stored.hash,
|
|
ocisResourceId: stored.key,
|
|
ocisResourceVersion: null,
|
|
storageKey: stored.key,
|
|
createdAt: new Date(),
|
|
});
|
|
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]),
|
|
);
|
|
return row.rows[0]?.username ?? "unknown";
|
|
}
|
|
}
|
|
|
|
export interface ActorRef {
|
|
userId: string;
|
|
role: string;
|
|
}
|
|
|
|
export interface ArchiveEntryInput {
|
|
groupId: string;
|
|
/** Matrix room when mirrored from a room; null for direct web uploads. */
|
|
roomId: string | null;
|
|
eventId: string | null;
|
|
origin: Record<string, unknown>;
|
|
originKind: string;
|
|
senderMxid: string;
|
|
originalName: string;
|
|
safeName: string;
|
|
mime: string;
|
|
mimeClass: MimeClass;
|
|
byteSize: number;
|
|
sha256: string;
|
|
ocisResourceId: string;
|
|
ocisResourceVersion: string | null;
|
|
storageKey?: string | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
/** Kept for the audit import contract; appendTenantAudit is used by callers that prefer a fresh tx. */
|
|
export { appendTenantAudit };
|
|
|
|
function parseFilters(query: Record<string, unknown>): FileListFilters {
|
|
const groupId = optionalUuid(query.groupId);
|
|
const senderMxid = typeof query.senderMxid === "string" && query.senderMxid.trim() !== "" ? query.senderMxid.trim() : null;
|
|
if (senderMxid !== null && senderMxid.length > 320) throw new BadRequestException("senderMxid is too long");
|
|
const rawClass = typeof query.mimeClass === "string" && query.mimeClass !== "" ? query.mimeClass : null;
|
|
if (rawClass !== null && !(MIME_CLASSES as readonly string[]).includes(rawClass)) {
|
|
throw new BadRequestException(`mimeClass must be one of: ${MIME_CLASSES.join(", ")}`);
|
|
}
|
|
const rawOrigin = typeof query.origin === "string" && query.origin !== "" ? query.origin : null;
|
|
if (rawOrigin !== null && !(ORIGIN_KINDS as readonly string[]).includes(rawOrigin)) {
|
|
throw new BadRequestException(`origin must be one of: ${ORIGIN_KINDS.join(", ")}`);
|
|
}
|
|
const name = typeof query.name === "string" && query.name.trim() !== "" ? query.name.trim().slice(0, 255) : null;
|
|
const from = parseBound(query.from, "from");
|
|
const to = parseBound(query.to, "to");
|
|
if (from !== null && to !== null && Date.parse(from) > Date.parse(to)) throw new BadRequestException("from must not be after to");
|
|
return { groupId, senderMxid, mimeClass: rawClass as MimeClass | null, origin: rawOrigin as OriginKind | null, name, from, to };
|
|
}
|
|
|
|
/**
|
|
* Filename gate for direct uploads: no client bytes are sniffed (the payload
|
|
* is opaque), so the name itself must pass the library-safe rules and carry a
|
|
* plausible extension for the declared type.
|
|
*/
|
|
function validateDirectFilename(filename: string): string {
|
|
if (typeof filename !== "string" || filename.trim() === "" || filename.length > 255) {
|
|
throw new BadRequestException("filename must be 1-255 characters");
|
|
}
|
|
const safe = safeFileName(filename);
|
|
if (safe === "" || safe === "attachment") {
|
|
throw new BadRequestException("filename must contain a usable name");
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
/** Declared MIME must sit under one of the allowed prefixes (no sniffing). */
|
|
function validateDirectMime(mimeType: string): string {
|
|
if (typeof mimeType !== "string" || mimeType.trim() === "" || mimeType.length > 255) {
|
|
throw new BadRequestException("mimeType must be 1-255 characters");
|
|
}
|
|
if (DIRECT_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix))) return mimeType;
|
|
throw new BadRequestException(`mimeType must start with one of: ${DIRECT_MIME_PREFIXES.join(" ")}`);
|
|
}
|
|
|
|
/** Coarse class from the declared MIME — the sniff path's equivalent. */
|
|
function mimeClassForDeclared(mimeType: string): MimeClass {
|
|
if (mimeType.startsWith("image/")) return "image";
|
|
if (mimeType.startsWith("video/")) return "video";
|
|
if (mimeType.startsWith("audio/")) return "audio";
|
|
if (mimeType === "application/pdf" || mimeType.startsWith("text/")) return "document";
|
|
if (mimeType === "application/zip" || mimeType === "application/x-zip-compressed") return "archive";
|
|
return "other";
|
|
}
|
|
|
|
function optionalUuid(value: unknown): string | null {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
if (typeof value !== "string" || !isUuid(value)) throw new BadRequestException("groupId must be a UUID");
|
|
return value;
|
|
}
|
|
|
|
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 {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
if (typeof value !== "string" || value.length > 32 || !ISO_BOUND.test(value) || !Number.isFinite(Date.parse(value))) {
|
|
throw new BadRequestException(`${field} must be an ISO-8601 date or timestamp`);
|
|
}
|
|
return value;
|
|
}
|