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:
parent
eb1b60f67d
commit
d58db63fe6
58 changed files with 5543 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
44
apps/api/src/group-files/file-cursor.ts
Normal file
44
apps/api/src/group-files/file-cursor.ts
Normal 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 };
|
||||
}
|
||||
148
apps/api/src/group-files/group-files.controller.ts
Normal file
148
apps/api/src/group-files/group-files.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
27
apps/api/src/group-files/group-files.module.ts
Normal file
27
apps/api/src/group-files/group-files.module.ts
Normal 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 {}
|
||||
304
apps/api/src/group-files/group-files.service.test.ts
Normal file
304
apps/api/src/group-files/group-files.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
573
apps/api/src/group-files/group-files.service.ts
Normal file
573
apps/api/src/group-files/group-files.service.ts
Normal 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;
|
||||
}
|
||||
242
apps/api/src/group-files/group-files.test.ts
Normal file
242
apps/api/src/group-files/group-files.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
241
apps/api/src/group-files/group-files.validation.ts
Normal file
241
apps/api/src/group-files/group-files.validation.ts
Normal 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`),
|
||||
};
|
||||
}
|
||||
254
apps/api/src/group-files/ocis-webdav.ts
Normal file
254
apps/api/src/group-files/ocis-webdav.ts
Normal 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;
|
||||
}
|
||||
40
apps/api/src/group-files/share-token.ts
Normal file
40
apps/api/src/group-files/share-token.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue