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:
parent
688a2ce996
commit
e7e703c105
21 changed files with 473 additions and 227 deletions
|
|
@ -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}$'),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue