Integrate group files: nullable room for uploads, archive class, claim idempotency, upload route

Wires the three parallel group-cloud tracks to one surface and fixes the
seams between them:

- chat_media_archive.room_id becomes nullable (direct web uploads have no
  Matrix room yet — the contract's null-event pattern extends to the room);
  core GroupFile.roomId and ArchiveEntryInput.roomId follow.
- mime_class gains 'archive' (zip) across the 029 CHECK, validation
  MIME_CLASSES and the bot classifier — one vocabulary everywhere.
- registerArchiveEntry claim keys are null-safe for room-less uploads.
- New: POST /files/groups/:groupId/upload — FileInterceptor (20 MiB) streams
  through magic-byte sniffing, library-safe naming, oCIS upload and the
  metadata index, behind group-membership ACL (Owner/Admin bypass).
- safeFileName/sniffMime promoted to packages/core (file-names.ts,
  file-mime.ts) so the bot and the api share one implementation; the bot's
  local modules become re-export shims; @types/node added to core.

Verified: api typecheck clean, group-files 22/22, bot 74/74, web typecheck
clean; 029 re-applied clean on a fresh scratch DB (room_id nullable,
archive class, claim_key unique all confirmed in the catalog).
This commit is contained in:
glass beadian 2026-10-11 10:09:24 +00:00
parent 688a2ce996
commit e7e703c105
21 changed files with 473 additions and 227 deletions

View file

@ -44,8 +44,9 @@ CREATE TABLE chat_media_archive (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Immutable Matrix room ID, never an alias. Tenant-pinned below.
room_id text NOT NULL CHECK (btrim(room_id) <> '' AND room_id ~ '^!'),
-- Matrix room ID when the file arrived through a room; NULL for direct
-- uploads/exports that have not been announced into one yet. Tenant-pinned.
room_id text CHECK (btrim(room_id) = '' OR room_id ~ '^!'),
-- NULL for direct uploads/pad exports until a room announcement supplies one.
event_id text CHECK (event_id IS NULL OR btrim(event_id) <> ''),
origin text NOT NULL CHECK (origin IN ('matrix','bridge','upload','pad-export')),
@ -58,8 +59,8 @@ CREATE TABLE chat_media_archive (
-- Normalized safe display filename (app-side sanitizeName).
safe_name text NOT NULL CHECK (btrim(safe_name) <> '' AND char_length(safe_name) <= 255),
mime text NOT NULL CHECK (mime <> ''),
-- Coarse class derived from mime (image/video/audio/document/other).
mime_class text NOT NULL CHECK (mime_class IN ('image','video','audio','document','other')),
-- Coarse class derived from mime (image/video/audio/document/archive/other).
mime_class text NOT NULL CHECK (mime_class IN ('image','video','audio','document','archive','other')),
byte_size bigint NOT NULL CHECK (byte_size >= 0),
-- SHA-256 of verified payload bytes, lowercase hex. Never an S3 ETag.
sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),

View file

@ -1,4 +1,5 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, Inject, Param, ParseUUIDPipe, Post, Query, Req, Res, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js";
@ -6,11 +7,20 @@ import { GroupFilesService, type FilePage } from "./group-files.service.js";
import type { GroupFile } from "@tactical-media-broadcast-hub/core";
import { verifyWhileStreaming } from "./ocis-webdav.js";
const MAX_FILE_UPLOAD_BYTES = 20 * 1024 * 1024;
interface FileUpload {
buffer: Buffer;
mimetype: string;
originalname: string;
}
/**
* Group files API — the Telegram-like group file surface.
*
* GET /files list/search, keyset (groupId, senderMxid,
* mimeClass, name, from, to, cursor, limit)
* POST /files/groups/:groupId/upload direct web upload (≤20 MiB, multipart)
* GET /files/:id one GroupFile
* GET /files/:id/download stream payload (sha256 re-verified)
* POST /files/:id/retention set retention (keep/until/legalHold)
@ -39,6 +49,26 @@ export class GroupFilesController {
return this.files.listFiles(session.workspaceId, session.userId, session.role, query);
}
@Post("groups/:groupId/upload")
@RequirePermission("media.view")
@UseInterceptors(FileInterceptor("file", {
limits: { fileSize: MAX_FILE_UPLOAD_BYTES, files: 1, fields: 3, fieldSize: 1024, parts: 4 },
}))
async upload(
@Req() request: Request & AuthenticatedRequest,
@Param("groupId", ParseUUIDPipe) groupId: string,
@UploadedFile() file?: FileUpload,
): Promise<GroupFile> {
const session = requireSession(request);
if (file === undefined) throw new BadRequestException("a file is required (field: file)");
return this.files.uploadForGroup(
session.workspaceId,
groupId,
{ userId: session.userId, role: session.role },
{ buffer: file.buffer, mimetype: file.mimetype, originalName: file.originalname },
);
}
@Get(":id")
@RequirePermission("media.view")
async get(@Req() request: Request & AuthenticatedRequest, @Param("id", ParseUUIDPipe) id: string): Promise<GroupFile> {

View file

@ -21,8 +21,9 @@
*/
import { BadRequestException, ConflictException, ForbiddenException, GoneException, Injectable, NotFoundException, Optional, Inject, ServiceUnavailableException } from "@nestjs/common";
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import type { PoolClient } from "pg";
import { attachmentArchiveKey, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core";
import { attachmentArchiveKey, safeFileName, SNIFF_BYTES, sniffMime, type FileRetention, type GroupFile } from "@tactical-media-broadcast-hub/core";
import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
import { withSystem, withTenant } from "../db/client.js";
import { isUuid, parseLimit } from "../http/validation.js";
@ -441,7 +442,10 @@ export class GroupFilesService {
workspaceId: string,
entry: ArchiveEntryInput,
): Promise<{ id: string; claimKey: string | null }> {
const claimKey = entry.eventId === null ? null : attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId);
const claimKey =
entry.eventId === null || entry.roomId === null
? null
: attachmentArchiveKey(workspaceId, entry.roomId, entry.eventId);
return withTenant(workspaceId, async (client) => {
let inserted: { rows: Array<{ id: string }> };
try {
@ -513,6 +517,56 @@ export class GroupFilesService {
event.diff ?? null,
]);
}
/**
* Direct web upload: ACL (active member of the group, or Owner/Admin),
* stream the buffer to oCIS (magic-byte sniff decides mime/class, the
* stored name is library-safe), index the entry, return the wire shape.
* Attribution uses a synthetic MXID — the Hub session carries no chat
* identity (docs/occupymedia-storage.md, direct-upload origin).
*/
async uploadForGroup(
workspaceId: string,
groupId: string,
actor: { userId: string; role: string },
file: { buffer: Buffer; mimetype: string; originalName: string },
): Promise<GroupFile> {
if (!WORKSPACE_WIDE_ROLES.includes(actor.role)) {
const membership = await withTenant(workspaceId, (client) =>
client.query("SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 AND status = 'active' LIMIT 1", [groupId, actor.userId]),
);
if (membership.rows.length === 0) throw new ForbiddenException("not a member of this group");
}
const sniff = sniffMime(file.buffer.subarray(0, SNIFF_BYTES));
const safe = safeFileName(file.originalName, sniff.extension);
const uploaded = await this.webdav().upload(groupId, safe, Readable.from(file.buffer), sniff.mime);
const username = await this.usernameFor(workspaceId, actor.userId);
const { id } = await this.registerArchiveEntry(workspaceId, {
groupId,
roomId: null,
eventId: null,
origin: { kind: "upload" },
originKind: "upload",
senderMxid: `@${username}:tmbh.local`,
originalName: file.originalName,
safeName: safe,
mime: sniff.mime,
mimeClass: sniff.mimeClass,
byteSize: uploaded.sizeBytes,
sha256: uploaded.sha256,
ocisResourceId: uploaded.resourceId,
ocisResourceVersion: uploaded.version,
createdAt: new Date(),
});
return this.getFile(workspaceId, actor.userId, actor.role, id);
}
private async usernameFor(workspaceId: string, userId: string): Promise<string> {
const row = await withTenant(workspaceId, (client) =>
client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]),
);
return row.rows[0]?.username ?? "unknown";
}
}
export interface ActorRef {
@ -522,7 +576,8 @@ export interface ActorRef {
export interface ArchiveEntryInput {
groupId: string;
roomId: string;
/** Matrix room when mirrored from a room; null for direct web uploads. */
roomId: string | null;
eventId: string | null;
origin: Record<string, unknown>;
originKind: string;

View file

@ -200,11 +200,14 @@ describe("ocis-webdav config + streaming verification", () => {
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, {
const calls: string[] = [];
const fetcher = (async (url: string, init: RequestInit) => {
calls.push(`${init.method} ${url}`);
return new Response(null, {
status: 201,
headers: { "oc-fileid": "res-1", "oc-etag": "v-9" },
})) as unknown as typeof fetch;
headers: init.method === "MKCOL" ? new 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,
@ -215,10 +218,24 @@ describe("ocis-webdav config + streaming verification", () => {
expect(result.sizeBytes).toBe(bytes.byteLength);
expect(result.resourceId).toBe("res-1");
expect(result.version).toBe("v-9");
expect(calls[0]).toBe("MKCOL https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1");
expect(calls[1]).toBe("PUT https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1/clip.mp4");
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("fails the upload when the group folder cannot be created", async () => {
const fetcher = (async (url: string, init: RequestInit) =>
new Response(null, { status: init.method === "MKCOL" ? 500 : 201 })) as unknown as typeof fetch;
const client = new OcisWebdavClient(
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
fetcher,
);
await expect(
client.upload("group-1", "clip.mp4", Readable.from(["x"]), "text/plain"),
).rejects.toThrow("oCIS folder creation failed with 500");
});
it("verifyWhileStreaming passes on match and aborts on tamper", async () => {
const { createHash } = await import("node:crypto");
const payload = Buffer.from("0123456789");

View file

@ -13,11 +13,13 @@
* `any`, and DB CHECK constraints (029) are coarse backstops only.
*/
import { BadRequestException } from "@nestjs/common";
import { MIME_CLASSES } from "@tactical-media-broadcast-hub/core";
import type {
FileRetention,
GroupFile,
GroupFileMapping,
MatrixAttachmentCandidate,
MimeClass,
SharedFileOrigin,
} from "@tactical-media-broadcast-hub/core";
@ -29,8 +31,7 @@ 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 { MIME_CLASSES, type MimeClass };
export const FILE_STATUSES = ["available", "revoked", "expired"] as const;
export type FileStatus = (typeof FILE_STATUSES)[number];

View file

@ -102,6 +102,14 @@ export class OcisWebdavClient {
return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-resources/${encodeURIComponent(resourceId)}`;
}
/** MKCOL the group folder; 405 (already exists) is fine. */
private async ensureGroupFolder(groupId: string): Promise<void> {
const url = `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-groups/${encodeURIComponent(groupId)}`;
const response = await this.fetcher(url, { method: "MKCOL", headers: this.authHeaders() });
if (response.ok || response.status === 405) return;
throw new Error(`oCIS folder creation failed with ${response.status}`);
}
private authHeaders(): Record<string, string> {
const basic = Buffer.from(`${this.config.user}:${this.config.password}`, "utf8").toString("base64");
return { authorization: `Basic ${basic}` };
@ -119,6 +127,7 @@ export class OcisWebdavClient {
* 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> {
await this.ensureGroupFolder(groupId);
const hash = createHash("sha256");
let sizeBytes = 0;
let queuedBytes = 0;