Add group cloud: files index api, web UI, archive sync bot

The Telegram-like group file contract (packages/core shared-files.ts) becomes
a working subsystem, built in three parallel tracks:

API (NestJS): migration 029 group_files (chat_media_archive metadata-only
index with partial replay-unique + RLS, group_file_shares with sha256 token
persistence, archive_sync_state/outbox durable-claim seam incl. a unique
claim_key index), group-files module (list/search with cursor pagination,
direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL +
retention rechecks, share links with expiry, retention modes incl.
legalHold) — RBAC group-membership authz + audit chain events.

Web: Files section per group — group picker, drag-drop upload (20 MiB cap),
search + MIME/origin filters, cursor pagination, download via authenticated
blob fetch, share dialog with expiry + shown-once token, admin retention
dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav
wiring; MIME classes aligned to the DB CHECK (document/other).

Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring
attachments from explicitly-mapped unencrypted archive rooms — raw /sync
with durable since-cursor, outbox-claimed ingestion (hash-verified stream,
no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV
upload, indexed + replied with link, redaction→unavailable; health endpoint;
Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
This commit is contained in:
glass beadian 2026-10-11 08:21:11 +00:00
parent eb1b60f67d
commit d58db63fe6
58 changed files with 5543 additions and 4 deletions

View file

@ -28,6 +28,7 @@ import { LiveChatModule } from "./live-chat/live-chat.module";
import { LiveModule } from "./live/live.module.js";
import { ChatgovModule } from "./chatgov/chatgov.module.js";
import { IntakeModule } from "./intake/intake.module.js";
import { GroupFilesModule } from "./group-files/group-files.module.js";
import { SettingsModule } from "./settings/settings.module.js";
import { SettingsController } from "./settings/settings.controller.js";
import { SettingsService } from "./settings/settings.service.js";
@ -37,7 +38,7 @@ import { AnalyticsController } from "./analytics/analytics.controller";
import { SnapshotService } from "./analytics/snapshot.service";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, SettingsModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule],
controllers: [
DemoController,
PublishingController,

View file

@ -0,0 +1,44 @@
/**
* (createdAt,id) keyset cursor for the group-files index, scoped to
* workspace/group in the query itself (docs/occupymedia-storage.md
* §"Metadata index and hub-archive seam": "Use a (createdAt,id) cursor scoped
* to workspace/group").
*
* House pattern (video.service.ts / intake-search.ts): base64url JSON, the
* cursor carries ONLY the sort key — the workspace/group scope never travels
* in the cursor, it is re-bound server-side on every request so a cursor from
* another tenant/group is just an empty page.
*/
import { BadRequestException } from "@nestjs/common";
import { isUuid } from "../http/validation.js";
export interface FileListCursor {
createdAt: string;
id: string;
}
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
export function encodeFileListCursor(cursor: FileListCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
export function decodeFileListCursor(cursor: string): FileListCursor {
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown;
} catch {
throw new BadRequestException("cursor is invalid");
}
if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
throw new BadRequestException("cursor is invalid");
}
const row = decoded as Record<string, unknown>;
const createdAt = row.createdAt;
const id = row.id;
if (typeof createdAt !== "string" || !ISO_TIMESTAMP.test(createdAt) || !Number.isFinite(Date.parse(createdAt))) {
throw new BadRequestException("cursor is invalid");
}
if (typeof id !== "string" || !isUuid(id)) throw new BadRequestException("cursor is invalid");
return { createdAt, id };
}

View file

@ -0,0 +1,148 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
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 { verifyWhileStreaming } from "./ocis-webdav.js";
/**
* Group files API — the Telegram-like group file surface.
*
* GET /files list/search, keyset (groupId, senderMxid,
* mimeClass, name, from, to, cursor, limit)
* GET /files/:id one GroupFile
* GET /files/:id/download stream payload (sha256 re-verified)
* POST /files/:id/retention set retention (keep/until/legalHold)
* POST /files/:id/revoke revoke links + stop serving
* POST /files/:id/shares mint a share link (token shown ONCE)
* DELETE /files/shares/:shareId revoke a share
*
* Authenticated routes gate on media.view (read) / media.manage (mutations) —
* the same media permission family the video library uses. ACLs are
* re-resolved per request in the service (current group membership), never
* carried by the token.
*
* The download route streams: the API proxies the payload with the digest
* verified chunk-by-chunk instead of redirecting or presigning, per the
* "never presigned object URLs" contract.
*/
@Controller("files")
@UseGuards(RbacGuard)
export class GroupFilesController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get()
@RequirePermission("media.view")
async list(@Req() request: Request & AuthenticatedRequest, @Query() query: Record<string, unknown>): Promise<FilePage> {
const session = requireSession(request);
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
}
@Get(":id")
@RequirePermission("media.view")
async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.getFile(session.workspaceId, session.userId, session.role, id);
}
@Get(":id/download")
@RequirePermission("media.view")
async download(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Res() response: Response,
): Promise<void> {
const session = requireSession(request);
const handle = await this.files.openDownload(session.workspaceId, session.userId, session.role, id);
response.status(200);
response.setHeader("Content-Type", handle.item.mimeType);
response.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(handle.item.name)}"`);
response.setHeader("Cache-Control", "no-store");
if (handle.download.sizeBytes !== null) response.setHeader("Content-Length", String(handle.download.sizeBytes));
response.flushHeaders();
await verifyWhileStreaming(handle.download, handle.item.checksum.value, (chunk) => {
response.write(chunk);
});
response.end();
}
@Post(":id/retention")
@RequirePermission("media.manage")
async setRetention(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<GroupFile> {
const session = requireSession(request);
return this.files.setRetention(session.workspaceId, id, requireObject(body, "body"), {
userId: session.userId,
role: session.role,
});
}
@Post(":id/revoke")
@HttpCode(200)
@RequirePermission("media.manage")
async revoke(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {
const session = requireSession(request);
return this.files.markRevoked(session.workspaceId, id, { userId: session.userId, role: session.role });
}
@Post(":id/shares")
@RequirePermission("media.manage")
async createShare(
@Req() request: Request & AuthenticatedRequest,
@Param("id", ParseUUIDPipe) id: string,
@Body() body: unknown,
): Promise<ReturnType<GroupFilesService["createShare"]>> {
const session = requireSession(request);
const dto = requireObject(body, "body");
if (dto.access !== undefined && dto.access !== "members" && dto.access !== "public") {
throw new BadRequestException("access must be 'members' or 'public'");
}
const expiresInHours = requirePositiveInt(dto.expiresInHours, "expiresInHours", 720);
return this.files.createShare(
session.workspaceId,
id,
{ access: dto.access, expiresInHours },
{ userId: session.userId, role: session.role },
);
}
/** Declared before :id routes so the literal path wins route matching. */
@Delete("shares/:shareId")
@RequirePermission("media.manage")
async revokeShare(
@Req() request: Request & AuthenticatedRequest,
@Param("shareId", ParseUUIDPipe) shareId: string,
): Promise<{ ok: true }> {
const session = requireSession(request);
await this.files.revokeShare(session.workspaceId, shareId, { userId: session.userId, role: session.role });
return { ok: true };
}
}
/**
* Public share-claim surface: NO RbacGuard (the claimer holds only the
* token). Token lives in the path — same shape as the invite claim routes.
*/
@Controller("public/files")
export class PublicFileShareController {
constructor(@Inject(GroupFilesService) private readonly files: GroupFilesService) {}
@Get("s/:token")
async claim(@Param("token") token: string, @Res() response: Response): Promise<void> {
response.setHeader("Cache-Control", "no-store");
const handle = await this.files.claimPublicDownload(requireString(token, "token"));
response.status(200);
response.setHeader("Content-Type", handle.item.mimeType);
response.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(handle.item.name)}"`);
if (handle.download.sizeBytes !== null) response.setHeader("Content-Length", String(handle.download.sizeBytes));
response.flushHeaders();
await verifyWhileStreaming(handle.download, handle.item.checksum.value, (chunk) => {
response.write(chunk);
});
response.end();
}
}

View file

@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { GroupFilesController, PublicFileShareController } from "./group-files.controller.js";
import { GroupFilesService } from "./group-files.service.js";
/**
* Group files module — the metadata index + share/link lifecycle over
* chat_media_archive (029) and the oCIS WebDAV port (occ-files deploy).
*
* NOT registered in AppModule by this slice: per the deployment contract
* (infra/occupymedia/files/README.md) the oCIS service is scaffolded
* separately and admission checks are mandatory before this surface accepts
* user payloads. Wiring is one line in apps/api/src/app.module.ts:
*
* imports: [..., GroupFilesModule]
*
* until that gate is passed — the ChatgovModule precedent.
*
* The oCIS client is built lazily from env on first use, so importing the
* module never requires FILES_OCID_* to be present; routes fail 503 when the
* deployment is absent instead of breaking startup.
*/
@Module({
controllers: [GroupFilesController, PublicFileShareController],
providers: [GroupFilesService],
exports: [GroupFilesService],
})
export class GroupFilesModule {}

View file

@ -0,0 +1,304 @@
import type { PoolClient } from "pg";
import { GroupFilesService } from "./group-files.service.js";
import { hashShareToken } from "./share-token.js";
import { 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. */
function hex64(seed: string): string {
let out = "";
while (out.length < 64) out += Buffer.from(seed).toString("hex");
return out.slice(0, 64);
}
// ---------------------------------------------------------------------------
// Fake db surface: route by SQL shape, capture every statement.
// ---------------------------------------------------------------------------
type QueryResult = { rows: Record<string, unknown>[]; rowCount: number };
type Behavior = (sql: string, values: unknown[]) => QueryResult | Promise<QueryResult>;
let behavior: Behavior = () => ({ rows: [], rowCount: 0 });
const statements: Array<{ sql: string; values: unknown[] }> = [];
vi.mock("../db/client.js", () => ({
withTenant: async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) =>
fn({
query: async (sql: string, values: unknown[] = []) => {
statements.push({ sql, values });
return behavior(sql, values);
},
} as unknown as PoolClient),
withSystem: async (fn: (client: PoolClient) => Promise<unknown>) =>
fn({
query: async (sql: string, values: unknown[] = []) => {
statements.push({ sql, values });
return behavior(sql, values);
},
} as unknown as PoolClient),
}));
const row = {
id: "11111111-1111-4111-8111-111111111111",
workspace_id: "22222222-2222-4222-8222-222222222222",
group_id: "33333333-3333-4333-8333-333333333333",
room_id: "!r:example.org",
event_id: "$e",
origin: "matrix",
sender_mxid: "@s:example.org",
matrix_event_json: { mxcUri: "mxc://example.org/x" },
original_name: "clip.mp4",
safe_name: "clip.mp4",
mime: "video/mp4",
mime_class: "video",
byte_size: "10",
sha256: hex64("z"),
ocis_resource_id: "res-1",
ocis_resource_version: "v1",
created_at: new Date("2026-01-02T03:04:05Z"),
indexed_at: new Date("2026-01-02T03:04:05Z"),
available: true,
retention_mode: "keep",
legal_hold: false,
retention_until: null,
share_access: null,
share_expires_at: null,
share_revoked_at: null,
};
/** sha256 hex of a string, computed with the real crypto module. */
function digestOf(input: string): string {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { createHash } = require("node:crypto") as typeof import("node:crypto");
return createHash("sha256").update(input, "utf8").digest("hex");
}
function envReady(): void {
process.env.FILES_OCID_WEBDAV_URL = "https://files.example.org";
process.env.FILES_OCID_USER = "svc";
process.env.FILES_OCID_PASSWORD = "pw";
process.env.FILES_PUBLIC_BASE = "https://files.example.org";
}
beforeEach(() => {
statements.length = 0;
behavior = () => ({ rows: [], rowCount: 0 });
envReady();
});
describe("GroupFilesService invariants", () => {
it("scopes listings to group membership for non-Owner/Admin, binding the caller id positionally", async () => {
behavior = () => ({ rows: [row], rowCount: 1 });
const service = new GroupFilesService();
const page = await service.listFiles(row.workspace_id, "user-1", "Publisher", {});
const list = statements.find((q) => q.sql.includes("FROM chat_media_archive"))!;
expect(list.sql).toContain("group_members");
expect(list.values[1]).toBe("user-1"); // caller id is a bound parameter…
expect(list.sql).not.toContain("user-1"); // …never SQL text
expect(list.sql).not.toContain("user-1");
// Owner/Admin skip the membership subquery but keep $2 bound
await service.listFiles(row.workspace_id, "user-1", "Owner", {});
const ownerList = statements.filter((q) => q.sql.includes("FROM chat_media_archive")).at(-1)!;
expect(ownerList.sql).not.toContain("group_members");
expect(ownerList.values[1]).toBe("user-1");
expect(page.items[0]?.status).toBe("available");
expect(page.items[0]?.shareLink.url).toBe("https://files.example.org/f/res-1");
});
it("filters facets positionally and paginates with a (createdAt,id) keyset cursor", async () => {
behavior = () => ({ rows: [row], rowCount: 1 });
const service = new GroupFilesService();
const cursor = Buffer.from(JSON.stringify({ createdAt: "2026-01-01T00:00:00Z", id: row.id })).toString("base64url");
await service.listFiles(row.workspace_id, "u", "Owner", {
groupId: row.group_id,
senderMxid: "@s:example.org",
mimeClass: "video",
name: "clip",
from: "2026-01-01",
to: "2026-02-01",
cursor,
limit: "10",
});
const q = statements.find((s) => s.sql.includes("FROM chat_media_archive"))!;
expect(q.values).toEqual([row.workspace_id, "u", row.group_id, "@s:example.org", "video", "%clip%", "2026-01-01", "2026-02-01", "2026-01-01T00:00:00Z", row.id, 11]);
expect(q.sql).toContain("(a.created_at, a.id) <");
expect(q.sql).toContain("ORDER BY a.created_at DESC, a.id DESC");
// bad facets are rejected before SQL
await expect(service.listFiles(row.workspace_id, "u", "Owner", { mimeClass: "zip" })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.listFiles(row.workspace_id, "u", "Owner", { from: "nope" })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.listFiles(row.workspace_id, "u", "Owner", { from: "2026-03-01", to: "2026-01-01" })).rejects.toBeInstanceOf(BadRequestException);
});
it("emits no presigned URL anywhere in a served payload", async () => {
behavior = () => ({ rows: [row], rowCount: 1 });
const service = new GroupFilesService();
const page = await service.listFiles(row.workspace_id, "u", "Owner", {});
expect(JSON.stringify(page)).not.toContain("X-Amz-Signature");
expect(JSON.stringify(page)).not.toContain("presign");
expect(page.items[0]?.shareLink.url).toBe("https://files.example.org/f/res-1");
});
it("keeps retention independent from share expiry, and legalHold is an orthogonal flag", async () => {
behavior = () => ({
rows: [{ ...row, share_access: "public", share_expires_at: new Date("2020-01-01T00:00:00Z") }],
rowCount: 1,
});
const service = new GroupFilesService();
const item = await service.getFile(row.workspace_id, "u", "Owner", row.id);
// share link long gone; the FILE itself is still fully available
expect(item.status).toBe("available");
expect(item.shareLink.expiresAt).toBe("2020-01-01T00:00:00.000Z");
expect(item.retention.mode).toBe("keep");
// legal hold survives alongside the mode (FileRetention contract)
behavior = () => ({ rows: [{ ...row, legal_hold: true }], rowCount: 1 });
const held = await service.getFile(row.workspace_id, "u", "Owner", row.id);
expect(held.retention).toEqual({ mode: "keep", legalHold: true });
expect(held.status).toBe("available");
});
it("reconstructs all four origin kinds from matrix_event_json", async () => {
const cases = [
["matrix", { mxcUri: "mxc://e/x" }, { kind: "matrix", mxcUri: "mxc://e/x" }],
["bridge", { mxcUri: "mxc://e/x", providerId: "telegram", remoteEventId: "9" }, { kind: "bridge", mxcUri: "mxc://e/x", providerId: "telegram", remoteEventId: "9" }],
["upload", null, { kind: "upload" }],
["pad-export", { padId: "p", revision: "3" }, { kind: "pad-export", padId: "p", revision: "3" }],
] as const;
for (const [origin, json, expected] of cases) {
behavior = () => ({ rows: [{ ...row, origin, matrix_event_json: json }], rowCount: 1 });
const service = new GroupFilesService();
const item = await service.getFile(row.workspace_id, "u", "Owner", row.id);
expect(item.origin).toEqual(expected);
}
});
it("returns the share token exactly once and keeps it out of audit rows", async () => {
let auditedDiff: unknown = null;
behavior = (sql) => {
if (sql.startsWith("SELECT audit_append")) return { rows: [{ id: "audit-1" }], rowCount: 1 };
if (sql.startsWith("INSERT INTO group_file_shares")) {
return {
rows: [{ id: "55555555-5555-4555-8555-555555555555", access: "public", expires_at: new Date(Date.now() + 3_600_000), revoked_at: null }],
rowCount: 1,
};
}
return { rows: [row], rowCount: 1 };
};
const service = new GroupFilesService();
const share = await service.createShare(row.workspace_id, row.id, { access: "public", expiresInHours: 2 }, { userId: "u", role: "Owner" });
expect(share.token).toBeDefined();
expect(hashShareToken(share.token)).toMatch(/^[0-9a-f]{64}$/);
expect(share.url).toContain("/files/s/");
const auditCall = statements.find((q) => q.sql.startsWith("SELECT audit_append"));
expect(auditCall).toBeDefined();
auditedDiff = auditCall?.values[6];
expect(JSON.stringify(auditedDiff)).not.toContain(share.token);
expect(JSON.stringify(auditCall?.values)).not.toContain(share.token);
// shape errors before any write
await expect(service.createShare(row.workspace_id, row.id, { access: "secret" }, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.createShare(row.workspace_id, row.id, { expiresInHours: 0 }, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(BadRequestException);
});
it("revoking stops serving immediately: item + every live share flipped", async () => {
let flipped = false;
behavior = (sql) => {
if (sql.startsWith("UPDATE chat_media_archive SET available = false")) flipped = true;
return { rows: [flipped ? { ...row, available: false } : row], rowCount: 1 };
};
const service = new GroupFilesService();
const revoked = await service.markRevoked(row.workspace_id, row.id, { userId: "u", role: "Owner" });
expect(revoked.status).toBe("revoked");
const shareRevoke = statements.find((q) => q.sql.startsWith("UPDATE group_file_shares SET revoked_at"));
expect(shareRevoke).toBeDefined();
expect(shareRevoke?.values[0]).toBe(row.id);
// already-revoked is a conflict, not a silent no-op
behavior = () => ({ rows: [{ ...row, available: false }], rowCount: 1 });
await expect(service.markRevoked(row.workspace_id, row.id, { userId: "u", role: "Owner" })).rejects.toBeInstanceOf(ConflictException);
});
it("replay of an attachment event is a conflict (partial unique (ws,room,event))", async () => {
behavior = (sql) => {
if (sql.startsWith("INSERT INTO chat_media_archive")) {
throw Object.assign(new Error("dup"), { code: "23505" });
}
return { rows: [], rowCount: 0 };
};
const service = new GroupFilesService();
await expect(
service.registerArchiveEntry(row.workspace_id, {
groupId: row.group_id,
roomId: "!r:example.org",
eventId: "$e",
origin: {},
originKind: "matrix",
senderMxid: "@s:example.org",
originalName: "a.mp4",
safeName: "a.mp4",
mime: "video/mp4",
mimeClass: "video",
byteSize: 10,
sha256: hex64("z"),
ocisResourceVersion: null,
ocisResourceId: "res",
createdAt: new Date(),
}),
).rejects.toBeInstanceOf(ConflictException);
});
it("openDownload refuses revoked/expired items with Gone, missing rows with NotFound", async () => {
behavior = () => ({ rows: [{ ...row, available: false }], rowCount: 1 });
const service = new GroupFilesService();
await expect(service.openDownload(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(GoneException);
behavior = () => ({ rows: [{ ...row, retention_mode: "until", retention_until: new Date("2020-01-01T00:00:00Z") }], rowCount: 1 });
await expect(service.openDownload(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(GoneException);
behavior = () => ({ rows: [], rowCount: 0 });
await expect(service.getFile(row.workspace_id, "u", "Owner", row.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getFile(row.workspace_id, "u", "Owner", "not-a-uuid")).rejects.toBeInstanceOf(BadRequestException);
});
it("membership gate denies non-members (Forbidden)", async () => {
behavior = (sql) => {
if (sql.includes("FROM group_members")) return { rows: [], rowCount: 0 };
if (sql.includes("FROM chat_media_archive")) return { rows: [row], rowCount: 1 };
return { rows: [], rowCount: 0 };
};
const service = new GroupFilesService();
await expect(service.getFile(row.workspace_id, "u", "Publisher", row.id)).rejects.toBeInstanceOf(ForbiddenException);
});
it("public claim resolves via the SECURITY DEFINER lookup, then rechecks in-tenant", async () => {
behavior = (sql) => {
if (sql.includes("share_for_public_claim")) {
return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "public", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 };
}
return { rows: [{ ...row, sha256: digestOf("payload-bytes") }], rowCount: 1 };
};
const payload = Buffer.from("payload-bytes");
const { createHash } = await import("node:crypto");
const digest = createHash("sha256").update(payload).digest("hex");
const { Readable } = await import("node:stream");
const fetcher = (async () =>
new Response(Readable.toWeb(Readable.from([payload])) as unknown as ReadableStream, {
status: 200,
headers: { "content-type": "application/octet-stream", "content-length": String(payload.byteLength) },
})) as unknown as typeof fetch;
const service = new GroupFilesService({ fetcher });
const handle = await service.claimPublicDownload("some-token");
expect(handle.item.id).toBe(row.id);
expect(handle.item.checksum.value).toBe(digest);
expect(handle.download.sizeBytes).toBe(payload.byteLength);
const claim = statements.find((q) => q.sql.includes("share_for_public_claim"));
expect(claim).toBeDefined();
// members-only tokens are rejected on the public surface
behavior = (sql) => {
if (sql.includes("share_for_public_claim")) {
return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "members", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 };
}
return { rows: [row], rowCount: 1 };
};
await expect(service.claimPublicDownload("some-token")).rejects.toBeInstanceOf(ForbiddenException);
// unknown token: 404, no tenant entered
behavior = () => ({ rows: [], rowCount: 0 });
await expect(service.claimPublicDownload("nope")).rejects.toBeInstanceOf(NotFoundException);
});
});

View file

@ -0,0 +1,573 @@
/**
* 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 } from "node:crypto";
import type { PoolClient } from "pg";
import { attachmentArchiveKey, 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 { ocisConfigFromEnv, OcisWebdavClient, type FetchLike, type OcisDownload } from "./ocis-webdav.js";
import { hashShareToken, mintShareToken } from "./share-token.js";
import { MIME_CLASSES, parseRetentionInput, type MimeClass } 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: OcisDownload;
}
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;
name: string | null;
from: string | null;
to: string | null;
}
export interface FilePage {
items: GroupFile[];
nextCursor: string | null;
}
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;
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,
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.ocis_resource_id,
version: row.ocis_resource_version ?? row.ocis_resource_id,
},
shareLink: {
url: libraryUrl(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 client: OcisWebdavClient | null = null;
constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { fetcher?: FetchLike }) {}
/** Lazily bound: the oCIS env may be absent in dev/test contexts. */
private webdav(): OcisWebdavClient {
if (this.client === null) {
const fetcher: FetchLike = this.deps?.fetcher ?? ((url, init) => fetch(url, init));
this.client = new OcisWebdavClient(ocisConfigFromEnv(), fetcher);
}
return this.client;
}
// -------------------------------------------------------------------------
// 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) => {
// values[1] is always the caller's id: the membership subquery needs it
// for non-Owner/Admin callers; Owner/Admin leave $2 bound but unused so
// every placeholder stays positional and no SQL text carries a value.
const values: unknown[] = [workspaceId, userId];
const conds: string[] = ["a.available = true", "(a.retention_mode <> 'until' OR a.retention_until > now())"];
if (!WORKSPACE_WIDE_ROLES.includes(role)) {
conds.push("a.group_id IN (SELECT group_id FROM group_members WHERE user_id = $2 AND status = 'active')");
}
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.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.webdav().libraryUrl(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,
};
});
}
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);
return rowToGroupFile(row, (resourceId) => this.webdav().libraryUrl(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.webdav().download(item.file.resourceId).catch(() => {
throw new ServiceUnavailableException("file storage is unavailable");
});
return { item, download };
}
// -------------------------------------------------------------------------
// 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.webdav().libraryUrl(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.webdav().libraryUrl(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.webdav().libraryUrl(resourceId));
const download = await this.webdav().download(item.file.resourceId).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 ? 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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
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,
],
);
} 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 };
});
}
// -------------------------------------------------------------------------
// 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 AND status = 'active'",
[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,
]);
}
}
export interface ActorRef {
userId: string;
role: string;
}
export interface ArchiveEntryInput {
groupId: string;
roomId: string;
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;
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 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, name, from, to };
}
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;
}

View file

@ -0,0 +1,242 @@
import { describe, expect, it } from "vitest";
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
import { Readable } from "node:stream";
import {
decodeFileListCursor,
encodeFileListCursor,
} from "./file-cursor.js";
import {
parseGroupFile,
parseMatrixAttachmentCandidate,
parseRetention,
parseRetentionInput,
parseSharedFileOrigin,
requireMxid,
requireMxcUri,
requireRoomId,
requireSha256,
} from "./group-files.validation.js";
import { OcisVerificationError, ocisConfigFromEnv, OcisWebdavClient, verifyWhileStreaming } from "./ocis-webdav.js";
import { hashShareToken, isPlausibleShareToken, mintShareToken, sameToken } from "./share-token.js";
/** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */
function hex64(seed: string): string {
let out = "";
while (out.length < 64) out += Buffer.from(seed).toString("hex");
return out.slice(0, 64);
}
describe("group-files runtime validation (shared contracts)", () => {
it("accepts the four SharedFileOrigin variants and rejects anything else", () => {
expect(parseSharedFileOrigin({ kind: "matrix", mxcUri: "mxc://example.org/abc" })).toEqual({
kind: "matrix",
mxcUri: "mxc://example.org/abc",
});
expect(
parseSharedFileOrigin({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "telegram" }),
).toEqual({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "telegram" });
expect(parseSharedFileOrigin({ kind: "bridge", mxcUri: "mxc://example.org/abc", providerId: "t", remoteEventId: "42" })).toEqual({
kind: "bridge",
mxcUri: "mxc://example.org/abc",
providerId: "t",
remoteEventId: "42",
});
expect(parseSharedFileOrigin({ kind: "upload" })).toEqual({ kind: "upload" });
expect(parseSharedFileOrigin({ kind: "pad-export", padId: "p", revision: "7" })).toEqual({
kind: "pad-export",
padId: "p",
revision: "7",
});
for (const bad of [null, {}, { kind: "nope" }, { kind: "matrix" }, { kind: "matrix", mxcUri: "https://evil/x" }]) {
expect(() => parseSharedFileOrigin(bad)).toThrow(BadRequestException);
}
});
it("validates MXIDs, room ids, mxc URIs and sha256 shape", () => {
expect(requireMxid("@alice:example.org")).toBe("@alice:example.org");
expect(() => requireMxid("Alice (display)")).toThrow(BadRequestException);
expect(() => requireMxid("#alias:example.org")).toThrow(BadRequestException);
expect(requireRoomId("!archive:example.org")).toBe("!archive:example.org");
expect(() => requireRoomId("#alias:example.org")).toThrow(BadRequestException); // alias, never an identity
expect(() => requireRoomId("")).toThrow(BadRequestException);
expect(requireMxcUri("mxc://example.org/AbC123")).toBe("mxc://example.org/AbC123");
expect(() => requireMxcUri("mxc://example.org/")).toThrow(BadRequestException);
expect(requireSha256(hex64("a"))).toBe(hex64("a"));
expect(() => requireSha256("nope")).toThrow(BadRequestException);
// hex digits include letters: an uppercase digest must be rejected
const upperHex = "A".repeat(64);
expect(/[A-F]/.test(upperHex)).toBe(true);
expect(() => requireSha256(upperHex)).toThrow(BadRequestException);
expect(() => requireSha256("0".repeat(63))).toThrow(BadRequestException); // wrong length
});
it("keeps retention separate from link expiry and enforces shape coupling", () => {
expect(parseRetention({ mode: "keep", legalHold: false })).toEqual({ mode: "keep", legalHold: false });
// legalHold is a FLAG alongside the mode, not a third mode (FileRetention contract)
expect(parseRetention({ mode: "keep", legalHold: true })).toEqual({ mode: "keep", legalHold: true });
expect(parseRetention({ mode: "until", legalHold: true, expiresAt: "2030-01-01T00:00:00Z" })).toEqual({
mode: "until",
legalHold: true,
expiresAt: "2030-01-01T00:00:00Z",
});
expect(() => parseRetention({ mode: "burn", legalHold: false })).toThrow(BadRequestException);
expect(() => parseRetention({ mode: "until", legalHold: false })).toThrow(BadRequestException);
expect(() => parseRetention({ mode: "keep", legalHold: false, expiresAt: "2030-01-01T00:00:00Z" })).toThrow(BadRequestException);
expect(() => parseRetention({ mode: "until", legalHold: false, expiresAt: "next tuesday" })).toThrow(BadRequestException);
// input variant
expect(parseRetentionInput({ mode: "until", until: "2030-01-01T00:00:00Z" })).toEqual({
mode: "until",
legalHold: false,
expiresAt: "2030-01-01T00:00:00Z",
});
expect(() => parseRetentionInput({ mode: "until" })).toThrow(BadRequestException);
expect(() => parseRetentionInput({ mode: "keep", until: "2030-01-01T00:00:00Z" })).toThrow(BadRequestException);
expect(parseRetentionInput({ mode: "keep", legalHold: true })).toEqual({ mode: "keep", legalHold: true });
expect(() => parseRetentionInput({ mode: "legalHold" })).toThrow(BadRequestException); // not a mode
});
it("fails closed on Matrix attachment candidates with unknown/bad state", () => {
const good = {
roomId: "!a:example.org",
eventId: "$1",
senderId: "@s:example.org",
eventType: "m.room.message",
msgtype: "m.image",
roomEncryption: "unencrypted",
encryptedFile: false,
redacted: false,
mxcUri: "mxc://example.org/x",
};
expect(parseMatrixAttachmentCandidate(good).roomEncryption).toBe("unencrypted");
// shape-level failures
for (const patch of [
{ roomId: "#alias:example.org" },
{ senderId: "Bob" },
{ mxcUri: "https://attacker/x" },
{ encryptedFile: "yes" },
]) {
expect(() => parseMatrixAttachmentCandidate({ ...good, ...patch })).toThrow(BadRequestException);
}
// state-level eligibility is the CORE helper's call, not the parser's:
// parse accepts encrypted/unknown/redacted states; canArchiveAttachment
// is what fails closed on them (core test shared-files.test.ts).
const parsed = parseMatrixAttachmentCandidate({ ...good, roomEncryption: "unknown" });
expect(parsed.roomEncryption).toBe("unknown");
});
it("round-trips a full GroupFile wire shape", () => {
const value = {
id: "b3a1c2d4-1111-4222-8333-444455556666",
workspaceId: "c3a1c2d4-1111-4222-8333-444455556666",
groupId: "d3a1c2d4-1111-4222-8333-444455556666",
roomId: "!r:example.org",
senderId: "@s:example.org",
eventId: "$evt",
origin: { kind: "matrix", mxcUri: "mxc://example.org/x" },
name: "clip.mp4",
mimeType: "video/mp4",
sizeBytes: 12,
checksum: { algorithm: "sha256", value: hex64("z") },
createdAt: "2026-01-02T03:04:05Z",
indexedAt: "2026-01-02T03:04:05Z",
file: { spaceId: "sp", resourceId: "res", version: "v1" },
shareLink: { url: "https://files.example.org/f/res", access: "members", expiresAt: null },
retention: { mode: "keep", legalHold: false },
status: "available",
};
expect(parseGroupFile(value)).toEqual(value);
expect(() => parseGroupFile({ ...value, sizeBytes: -1 })).toThrow(BadRequestException);
expect(() => parseGroupFile({ ...value, senderId: "Bob" })).toThrow(BadRequestException);
expect(() => parseGroupFile({ ...value, checksum: { algorithm: "sha256", value: "zz" } })).toThrow(BadRequestException);
});
});
describe("file-cursor", () => {
it("encodes/decodes (createdAt,id) and rejects malformed scopes", () => {
const cursor = { createdAt: "2026-01-02T03:04:05Z", id: "b3a1c2d4-1111-4222-8333-444455556666" };
expect(decodeFileListCursor(encodeFileListCursor(cursor))).toEqual(cursor);
for (const bad of ["", "!!!", Buffer.from(JSON.stringify({ createdAt: "nope", id: "x" })).toString("base64url")]) {
expect(() => decodeFileListCursor(bad)).toThrow(BadRequestException);
}
// a cursor from another tenant carries no scope: it is just an empty page,
// never a leak — the scope is re-bound server-side on every request.
expect(decodeFileListCursor(encodeFileListCursor({ createdAt: "2026-01-02T03:04:05Z", id: "00000000-0000-4000-8000-000000000000" })).id).toBe(
"00000000-0000-4000-8000-000000000000",
);
});
});
describe("share tokens", () => {
it("mints 256-bit capabilities and stores only sha256(token)", () => {
const token = mintShareToken();
expect(token.length).toBeGreaterThanOrEqual(40);
expect(isPlausibleShareToken(token)).toBe(true);
expect(isPlausibleShareToken("short")).toBe(false);
expect(isPlausibleShareToken("not base64url!!")).toBe(false);
const hash = hashShareToken(token);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hashShareToken(token)).toBe(hash); // deterministic
expect(hashShareToken(mintShareToken())).not.toBe(hash); // unique per mint
expect(sameToken(token, token)).toBe(true);
expect(sameToken(token, mintShareToken())).toBe(false);
// the plaintext can never be derived from the hash
expect(hash).not.toContain(token);
});
});
describe("ocis-webdav config + streaming verification", () => {
it("reads env lazily and throws 503 when unconfigured", () => {
expect(() => ocisConfigFromEnv({})).toThrow(ServiceUnavailableException);
expect(() => ocisConfigFromEnv({ FILES_OCID_WEBDAV_URL: "https://files.example.org" })).toThrow(ServiceUnavailableException);
const config = ocisConfigFromEnv({
FILES_OCID_WEBDAV_URL: "https://files.example.org/",
FILES_OCID_USER: "svc",
FILES_OCID_PASSWORD: "pw",
FILES_PUBLIC_BASE: "https://files.example.org/",
});
expect(config.webdavUrl).toBe("https://files.example.org");
expect(config.publicBase).toBe("https://files.example.org");
});
it("uploads hash the actual streamed bytes and surface oc-fileid/oc-etag", async () => {
const bytes = Buffer.from("group file payload");
const fetcher = (async () =>
new Response(null, {
status: 201,
headers: { "oc-fileid": "res-1", "oc-etag": "v-9" },
})) as unknown as typeof fetch;
const client = new OcisWebdavClient(
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
fetcher,
);
const result = await client.upload("group-1", "clip.mp4", Readable.from([bytes]), "video/mp4");
const { createHash } = await import("node:crypto");
expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex"));
expect(result.sizeBytes).toBe(bytes.byteLength);
expect(result.resourceId).toBe("res-1");
expect(result.version).toBe("v-9");
expect(client.uploadUrl("g", "n.mp4")).toContain("/remote.php/dav/files/svc/hub-groups/g/n.mp4");
expect(client.libraryUrl("res-1")).toBe("https://files.example.org/f/res-1");
});
it("verifyWhileStreaming passes on match and aborts on tamper", async () => {
const { createHash } = await import("node:crypto");
const payload = Buffer.from("0123456789");
const good = createHash("sha256").update(payload).digest("hex");
const sinkChunks: Buffer[] = [];
const out = await verifyWhileStreaming(
{ stream: Readable.from([payload.subarray(0, 4), payload.subarray(4)]), sizeBytes: payload.byteLength, contentType: "application/octet-stream" },
good,
(chunk) => sinkChunks.push(chunk),
);
expect(out.sizeBytes).toBe(10);
expect(Buffer.concat(sinkChunks).toString()).toBe("0123456789");
await expect(
verifyWhileStreaming(
{ stream: Readable.from([Buffer.from("tampered")]), sizeBytes: 8, contentType: null },
good,
() => undefined,
),
).rejects.toBeInstanceOf(OcisVerificationError);
});
});

View file

@ -0,0 +1,241 @@
/**
* Runtime validation for the shared group-files contracts
* (packages/core/src/shared-files.ts — imported as @tactical-media-broadcast-hub/core).
*
* The shared file is TYPES ONLY; the task contract says "future HTTP/DB
* adapters must validate these shapes at runtime". This module is that
* adapter boundary: every value entering or leaving the DB/HTTP seam passes
* through one of these guards, so a stray row can never render an
* invalid GroupFile.
*
* Style follows the api's existing house convention: `unknown` in, typed
* value out, BadRequestException on bad shape (http/validation.ts), never
* `any`, and DB CHECK constraints (029) are coarse backstops only.
*/
import { BadRequestException } from "@nestjs/common";
import type {
FileRetention,
GroupFile,
GroupFileMapping,
MatrixAttachmentCandidate,
SharedFileOrigin,
} from "@tactical-media-broadcast-hub/core";
// FileRetention (packages/core/src/shared-files.ts): mode is keep|until and
// legalHold is an orthogonal boolean flag — NOT a third mode.
export const RETENTION_MODES = ["keep", "until"] as const;
export type RetentionMode = (typeof RETENTION_MODES)[number];
export const ORIGIN_KINDS = ["matrix", "bridge", "upload", "pad-export"] as const;
export type OriginKind = (typeof ORIGIN_KINDS)[number];
export const MIME_CLASSES = ["image", "video", "audio", "document", "other"] as const;
export type MimeClass = (typeof MIME_CLASSES)[number];
export const FILE_STATUSES = ["available", "revoked", "expired"] as const;
export type FileStatus = (typeof FILE_STATUSES)[number];
const SHA256_HEX = /^[0-9a-f]{64}$/;
const MXID = /^@[^@\s]+:[^@\s]+$/;
const MXC_URI = /^mxc:\/\/[^/\s]+\/[^/\s?#]+$/;
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireField(container: Record<string, unknown>, key: string): unknown {
if (!(key in container)) throw new BadRequestException(`${key} is required`);
return container[key];
}
function stringField(container: Record<string, unknown>, key: string, field = key): string {
const value = requireField(container, key);
if (typeof value !== "string" || value === "") throw new BadRequestException(`${field} must be a non-empty string`);
return value;
}
function isoField(container: Record<string, unknown>, key: string, field = key): string {
const value = stringField(container, key, field);
if (!ISO_TIMESTAMP.test(value)) throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`);
return value;
}
function enumField<T extends string>(container: Record<string, unknown>, key: string, allowed: readonly T[], field = key): T {
const value = requireField(container, key);
if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
throw new BadRequestException(`${field} must be one of: ${allowed.join(", ")}`);
}
return value as T;
}
function integerField(container: Record<string, unknown>, key: string, field = key): number {
const value = requireField(container, key);
const n = typeof value === "number" ? value : NaN;
if (!Number.isSafeInteger(n) || n < 0) throw new BadRequestException(`${field} must be a non-negative safe integer`);
return n;
}
/** sha256 lowercase hex, per the contract ("verified payload bytes ... hex"). */
export function isSha256Hex(value: unknown): value is string {
return typeof value === "string" && SHA256_HEX.test(value);
}
export function requireSha256(value: unknown, field = "sha256"): string {
if (!isSha256Hex(value)) throw new BadRequestException(`${field} must be lowercase sha256 hex (64 chars)`);
return value;
}
export function requireMxid(value: unknown, field = "senderMxid"): string {
if (typeof value !== "string" || !MXID.test(value)) throw new BadRequestException(`${field} must be a Matrix user id (@user:host)`);
return value;
}
export function requireMxcUri(value: unknown, field = "mxcUri"): string {
if (typeof value !== "string" || !MXC_URI.test(value)) throw new BadRequestException(`${field} must be an mxc:// URI`);
return value;
}
export function requireRoomId(value: unknown, field = "roomId"): string {
if (typeof value !== "string" || !/^![^:\s]+:[^:\s]+$/.test(value)) {
throw new BadRequestException(`${field} must be an immutable Matrix room id (!localpart:host) — never an alias`);
}
return value;
}
/** Retention is decoupled from link expiry; legalHold blocks deletion. */
export function parseRetention(value: unknown, field = "retention"): FileRetention {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const mode = enumField(value, "mode", RETENTION_MODES, `${field}.mode`);
const legalHold = requireField(value, "legalHold");
if (typeof legalHold !== "boolean") throw new BadRequestException(`${field}.legalHold must be a boolean`);
if (mode === "until") return { mode, legalHold, expiresAt: isoField(value, "expiresAt", `${field}.expiresAt`) };
if (value.expiresAt !== undefined && value.expiresAt !== null) {
throw new BadRequestException(`${field}.expiresAt must not be set when mode is "keep"`);
}
return { mode, legalHold };
}
/** POST /files/:id/retention body {mode, until?, legalHold?} → FileRetention. */
export function parseRetentionInput(value: unknown): FileRetention {
if (!isRecord(value)) throw new BadRequestException("body must be an object");
const mode = enumField(value, "mode", RETENTION_MODES, "mode");
const legalHold = value.legalHold === undefined ? false : value.legalHold;
if (typeof legalHold !== "boolean") throw new BadRequestException("legalHold must be a boolean");
const rawUntil = value.until;
if (mode === "until") {
if (rawUntil === undefined || rawUntil === null) throw new BadRequestException("until is required when mode is 'until'");
return { mode, legalHold, expiresAt: requireIsoTimestamp(rawUntil, "until") };
}
if (rawUntil !== undefined && rawUntil !== null) {
throw new BadRequestException("until must not be set when mode is 'keep'");
}
return { mode, legalHold };
}
export function requireIsoTimestamp(value: unknown, field: string): string {
if (typeof value !== "string" || !ISO_TIMESTAMP.test(value) || !Number.isFinite(Date.parse(value))) {
throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`);
}
return value;
}
/** The four SharedFileOrigin variants, discriminated by `kind`. */
export function parseSharedFileOrigin(value: unknown, field = "origin"): SharedFileOrigin {
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`) };
if (kind === "bridge") {
return {
kind,
mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`),
providerId: stringField(value, "providerId", `${field}.providerId`),
...(value.remoteEventId === undefined || value.remoteEventId === null ? {} : { remoteEventId: stringField(value, "remoteEventId", `${field}.remoteEventId`) }),
};
}
if (kind === "upload") return { kind };
return { kind: "pad-export", padId: stringField(value, "padId", `${field}.padId`), revision: stringField(value, "revision", `${field}.revision`) };
}
/** Matrix event eligibility input for the hub-archive seam. Unknown state fails closed (core helper). */
export function parseMatrixAttachmentCandidate(value: unknown, field = "event"): MatrixAttachmentCandidate {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const encryption = enumField(value, "roomEncryption", ["unencrypted", "encrypted", "unknown"] as const, `${field}.roomEncryption`);
const encryptedFile = requireField(value, "encryptedFile");
const redacted = requireField(value, "redacted");
if (typeof encryptedFile !== "boolean") throw new BadRequestException(`${field}.encryptedFile must be a boolean`);
if (typeof redacted !== "boolean") throw new BadRequestException(`${field}.redacted must be a boolean`);
return {
roomId: requireRoomId(value.roomId, `${field}.roomId`),
eventId: stringField(value, "eventId", `${field}.eventId`),
senderId: requireMxid(value.senderId, `${field}.senderId`),
eventType: stringField(value, "eventType", `${field}.eventType`),
msgtype: stringField(value, "msgtype", `${field}.msgtype`),
roomEncryption: encryption,
encryptedFile,
redacted,
mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`),
};
}
export function parseGroupFileMapping(value: unknown, field = "mapping"): GroupFileMapping {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const policy = enumField(value, "archivePolicy", ["disabled", "unencrypted-archive"] as const, `${field}.archivePolicy`);
return {
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
groupId: stringField(value, "groupId", `${field}.groupId`),
roomId: requireRoomId(value.roomId, `${field}.roomId`),
spaceId: stringField(value, "spaceId", `${field}.spaceId`),
folderId: stringField(value, "folderId", `${field}.folderId`),
archivePolicy: policy,
retention: parseRetention(value.retention, `${field}.retention`),
};
}
/** The served GroupFile wire shape, guarded on the way out of the DB. */
export function parseGroupFile(value: unknown, field = "item"): GroupFile {
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
const origin = parseSharedFileOrigin(value.origin, `${field}.origin`);
const eventId = value.eventId;
if (eventId !== undefined && eventId !== null && typeof eventId !== "string") {
throw new BadRequestException(`${field}.eventId must be a string or null`);
}
const file = value.file;
if (!isRecord(file)) throw new BadRequestException(`${field}.file must be an object`);
const shareLink = value.shareLink;
if (!isRecord(shareLink)) throw new BadRequestException(`${field}.shareLink must be an object`);
const access = enumField(shareLink, "access", ["members", "public"] as const, `${field}.shareLink.access`);
const shareExpires = shareLink.expiresAt;
if (shareExpires !== undefined && shareExpires !== null && (typeof shareExpires !== "string" || !ISO_TIMESTAMP.test(shareExpires))) {
throw new BadRequestException(`${field}.shareLink.expiresAt must be a UTC ISO-8601 timestamp or null`);
}
const checksum = value.checksum;
if (!isRecord(checksum)) throw new BadRequestException(`${field}.checksum must be an object`);
return {
id: stringField(value, "id", `${field}.id`),
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
groupId: stringField(value, "groupId", `${field}.groupId`),
roomId: requireRoomId(value.roomId, `${field}.roomId`),
senderId: requireMxid(value.senderId, `${field}.senderId`),
eventId: eventId === undefined || eventId === null ? null : eventId,
origin,
name: stringField(value, "name", `${field}.name`),
mimeType: stringField(value, "mimeType", `${field}.mimeType`),
sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`),
checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) },
createdAt: isoField(value, "createdAt", `${field}.createdAt`),
indexedAt: isoField(value, "indexedAt", `${field}.indexedAt`),
file: {
spaceId: stringField(file, "spaceId", `${field}.file.spaceId`),
resourceId: stringField(file, "resourceId", `${field}.file.resourceId`),
version: stringField(file, "version", `${field}.file.version`),
},
shareLink: {
url: stringField(shareLink, "url", `${field}.shareLink.url`),
access,
expiresAt: shareExpires === undefined || shareExpires === null ? null : shareExpires,
},
retention: parseRetention(value.retention, `${field}.retention`),
status: enumField(value, "status", FILE_STATUSES, `${field}.status`),
};
}

View file

@ -0,0 +1,254 @@
/**
* oCIS WebDAV port — the ONLY payload I/O path for group files.
*
* The API streams bytes through itself on both upload and download: it NEVER
* mints or persists a presigned object URL (docs/occupymedia-storage.md
* "Persist library links/share IDs and expiry, not presigned object URLs").
* Downloads re-verify the SHA-256 while streaming so storage-level tampering
* is detected instead of silently relayed.
*
* ENV CONTRACT (lazy getters — read per call so tests/parallel deployments
* can reconfigure without a rebuild; nothing is read at import time):
*
* FILES_OCID_WEBDAV_URL oCIS WebDAV root, e.g. https://files.example.org
* FILES_OCID_USER service account username (the scoped oCIS identity)
* FILES_OCID_PASSWORD service account password
* FILES_PUBLIC_BASE public base of the file library, used to build the
* persistent (authenticated) library link returned in
* GroupFile.shareLink.url — a stable resource URL,
* NEVER a presigned object URL.
*
* The oCIS deployment lands in parallel; when the env is unset the port
* reports unconfigured and routes fail 503 rather than silently no-op.
*
* WebDAV shape: PUT returns the stable resource id (oc-fileid) and ETag
* (oc-etag / etag) this index needs; plain PUT/GET carry the bytes. The
* transport is injectable (OCIS_FETCH token) so tests stub it without HTTP.
*/
import { ServiceUnavailableException } from "@nestjs/common";
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
export interface OcisEnvConfig {
webdavUrl: string;
user: string;
password: string;
publicBase: string;
}
export function ocisConfigFromEnv(env: Readonly<Record<string, string | undefined>> = process.env): OcisEnvConfig {
const webdavUrl = env.FILES_OCID_WEBDAV_URL;
const user = env.FILES_OCID_USER;
const password = env.FILES_OCID_PASSWORD;
if (webdavUrl === undefined || webdavUrl === "") throw new ServiceUnavailableException("FILES_OCID_WEBDAV_URL is not configured");
if (user === undefined || user === "") throw new ServiceUnavailableException("FILES_OCID_USER is not configured");
if (password === undefined || password === "") throw new ServiceUnavailableException("FILES_OCID_PASSWORD is not configured");
return {
webdavUrl: webdavUrl.replace(/\/+$/, ""),
user,
password,
publicBase: (env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, ""),
};
}
/** Result of a committed upload: stable identity + digest over bytes sent. */
export interface OcisUploadResult {
resourceId: string;
version: string | null;
sha256: string;
sizeBytes: number;
}
/** An opened download: verifyWhileStreaming checks the digest before relay. */
export interface OcisDownload {
stream: Readable;
sizeBytes: number | null;
contentType: string | null;
}
export class OcisVerificationError extends Error {
constructor(expected: string, actual: string) {
super(`oCIS payload verification failed: expected sha256 ${expected}, got ${actual}`);
this.name = "OcisVerificationError";
}
}
/** Minimal fetch surface so tests can stub transport without HTTP. */
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
export const OCIS_FETCH: unique symbol = Symbol("OCIS_FETCH");
/** Bytes allowed in flight to the transport before the pump pauses. */
export const UPLOAD_BUFFER_CAP = 8 * 1024 * 1024;
/** Node's undici fetch needs this hint when a stream body is supplied. */
type FetchInitWithDuplex = RequestInit & { duplex?: "half" };
/**
* WebDAV client. `fetcher` defaults to the global fetch.
*/
export class OcisWebdavClient {
constructor(
private readonly config: OcisEnvConfig,
private readonly fetcher: FetchLike = (url, init) => fetch(url, init),
) {}
/** Group folder path under the service identity's WebDAV root. */
uploadUrl(groupId: string, safeName: string): string {
return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-groups/${encodeURIComponent(groupId)}/${encodeURIComponent(safeName)}`;
}
downloadUrl(resourceId: string): string {
return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-resources/${encodeURIComponent(resourceId)}`;
}
private authHeaders(): Record<string, string> {
const basic = Buffer.from(`${this.config.user}:${this.config.password}`, "utf8").toString("base64");
return { authorization: `Basic ${basic}` };
}
/**
* Stream-to-storage PUT, hashing while streaming (contract: "Hash actual
* bytes while streaming"). The digest covers the exact bytes handed to the
* store — never a claimed Content-Length.
*
* Backpressure: the pump pauses when more than UPLOAD_BUFFER_CAP bytes are
* queued for the transport, so RAM stays bounded for large payloads. The
* pump is driven by our own loop, so the digest completes even if the
* transport returns without draining a small body (a 2xx store that never
* reads its request body is broken and will surface as an integrity error).
*/
async upload(groupId: string, safeName: string, body: Readable, contentType: string): Promise<OcisUploadResult> {
const hash = createHash("sha256");
let sizeBytes = 0;
let queuedBytes = 0;
const queue: Buffer[] = [];
let sourceEnded = false;
let sourceError: Error | undefined;
let wakeTransport: (() => void) | undefined;
let wakePump: (() => void) | undefined;
const requestStream = new ReadableStream<Buffer>({
async pull(controller): Promise<void> {
if (queue.length > 0) {
const next = queue.shift()!;
queuedBytes -= next.byteLength;
controller.enqueue(next);
wakePump?.();
return;
}
if (sourceEnded) {
if (sourceError !== undefined) controller.error(sourceError);
else controller.close();
return;
}
await new Promise<void>((resolve) => {
wakeTransport = resolve;
});
wakeTransport = undefined;
if (queue.length > 0) {
controller.enqueue(queue.shift()!);
wakePump?.();
} else if (sourceError !== undefined) {
controller.error(sourceError);
} else if (sourceEnded) {
controller.close();
}
},
cancel(): void {
// The transport hung up (e.g. it rejected early): keep draining the
// source so the digest/size accounting still completes.
wakeTransport?.();
},
});
const pumped = (async (): Promise<void> => {
for await (const chunk of body) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array | string);
hash.update(buffer);
sizeBytes += buffer.byteLength;
queuedBytes += buffer.byteLength;
queue.push(buffer);
wakeTransport?.();
while (queuedBytes > UPLOAD_BUFFER_CAP) {
await new Promise<void>((resolve) => {
wakePump = resolve;
});
wakePump = undefined;
}
}
sourceEnded = true;
wakeTransport?.();
})().catch((err: unknown) => {
sourceError = err instanceof Error ? err : new Error(String(err));
wakeTransport?.();
wakePump?.();
});
const response = await this.fetcher(this.uploadUrl(groupId, safeName), {
method: "PUT",
headers: { ...this.authHeaders(), "content-type": contentType },
body: requestStream as unknown as ReadableStream,
duplex: "half",
} satisfies FetchInitWithDuplex);
if (!response.ok) {
requestStream.cancel().catch(() => undefined);
await pumped;
throw new Error(`oCIS upload failed with ${response.status}`);
}
await pumped;
const etag = response.headers.get("oc-etag") ?? response.headers.get("etag");
const resourceId = response.headers.get("oc-fileid");
return {
resourceId: resourceId ?? safeName,
version: etag,
sha256: hash.digest("hex"),
sizeBytes,
};
}
/** Open a GET. The digest is re-checked by verifyWhileStreaming downstream. */
async download(resourceId: string): Promise<OcisDownload> {
const response = await this.fetcher(this.downloadUrl(resourceId), { method: "GET", headers: this.authHeaders() });
if (!response.ok || response.body === null) throw new Error(`oCIS download failed with ${response.status}`);
const length = response.headers.get("content-length");
return {
stream: response.body as unknown as Readable,
sizeBytes: length === null ? null : Number(length),
contentType: response.headers.get("content-type"),
};
}
/** Stable resource link for GroupFile.shareLink.url — never presigned. */
libraryUrl(resourceId: string): string {
return this.config.publicBase === "" ? resourceId : `${this.config.publicBase}/f/${encodeURIComponent(resourceId)}`;
}
}
/**
* Pipe a download forward, verifying sha256 chunk-by-chunk. Contract:
* "SHA-256 verified while streaming" — a mismatch aborts the relay rather
* than handing over tampered bytes (the sink sees them, but the caller
* treats the error as fatal and closes the response).
*/
export async function verifyWhileStreaming(
download: OcisDownload,
expectedSha256: string,
sink: (chunk: Buffer) => void,
): Promise<{ sizeBytes: number }> {
const hash = createHash("sha256");
let sizeBytes = 0;
for await (const chunk of download.stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
hash.update(buffer);
sizeBytes += buffer.byteLength;
sink(buffer);
}
const actual = hash.digest("hex");
if (actual !== expectedSha256) throw new OcisVerificationError(expectedSha256, actual);
return { sizeBytes };
}
export function isOcisVerificationError(err: unknown): err is OcisVerificationError {
return err instanceof OcisVerificationError;
}

View file

@ -0,0 +1,40 @@
/**
* Public share tokens for group files.
*
* Contract (docs/occupymedia-storage.md): "Persist library links/share IDs
* and expiry, not presigned object URLs, WOPI tokens or passwords." The DB
* stores sha256(token) ONLY (029 group_file_shares.token_hash); the
* plaintext token is returned exactly once at creation and can never be
* recovered from the database.
*
* 256 bits of entropy (32 random bytes, base64url — same shape as invite
* links), hashed with plain sha256. Deliberately NOT a salted password hash:
* the token is a high-entropy capability, not a low-entropy secret, so an
* offline dictionary attack against token_hash is infeasible and a plain
* digest keeps the public lookup O(1) on a unique index.
*/
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export const SHARE_TOKEN_BYTES = 32;
/** Plaintext capability, base64url. Returned ONCE; never persisted. */
export function mintShareToken(): string {
return randomBytes(SHARE_TOKEN_BYTES).toString("base64url");
}
/** The ONLY value ever stored: sha256 of the plaintext, lowercase hex. */
export function hashShareToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
/** Constant-time compare for lookups done outside the unique-index path. */
export function sameToken(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
return left.length === right.length && timingSafeEqual(left, right);
}
/** Shape guard before hashing: reject absurd values before touching the DB. */
export function isPlausibleShareToken(value: unknown): value is string {
return typeof value === "string" && value.length >= 20 && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
}