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;

View file

@ -1,43 +1 @@
export type MimeClass = "image" | "video" | "audio" | "document" | "archive" | "other";
export interface SniffedMime {
mime: string;
mimeClass: MimeClass;
extension: string | null;
}
/** Bytes needed to recognise every supported signature. */
export const SNIFF_BYTES = 16;
const UNKNOWN: SniffedMime = { mime: "application/octet-stream", mimeClass: "other", extension: null };
function startsWith(head: Uint8Array, signature: readonly number[], offset = 0): boolean {
if (head.length < offset + signature.length) return false;
return signature.every((byte, i) => head[offset + i] === byte);
}
function ascii(text: string): number[] {
return [...text].map((c) => c.charCodeAt(0));
}
/**
* MIME is decided by magic bytes only; the sender's declared `info.mimetype`
* is never trusted. Unrecognised payloads are stored as octet-stream/other.
*/
export function sniffMime(head: Uint8Array): SniffedMime {
if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return { mime: "image/png", mimeClass: "image", extension: "png" };
}
if (startsWith(head, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", mimeClass: "image", extension: "jpg" };
if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a"))) {
return { mime: "image/gif", mimeClass: "image", extension: "gif" };
}
if (startsWith(head, ascii("%PDF-"))) return { mime: "application/pdf", mimeClass: "document", extension: "pdf" };
if (startsWith(head, [0x50, 0x4b, 0x03, 0x04])) {
return { mime: "application/zip", mimeClass: "archive", extension: "zip" };
}
if (startsWith(head, ascii("ftyp"), 4)) return { mime: "video/mp4", mimeClass: "video", extension: "mp4" };
if (startsWith(head, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "video/webm", mimeClass: "video", extension: "webm" };
if (startsWith(head, ascii("OggS"))) return { mime: "audio/ogg", mimeClass: "audio", extension: "ogg" };
return UNKNOWN;
}
export { SNIFF_BYTES, sniffMime, type MimeClass, type SniffedMime } from "@tactical-media-broadcast-hub/core";

View file

@ -1,44 +1 @@
export const MAX_SAFE_NAME_BYTES = 120;
const FALLBACK = "attachment";
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
function truncateUtf8(text: string, maxBytes: number): string {
let out = "";
for (const ch of text) {
if (Buffer.byteLength(out + ch, "utf8") > maxBytes) break;
out += ch;
}
return out;
}
/**
* Library-safe single path segment: no directories, control or reserved
* characters, leading dots, or unbounded length. Extension is preserved when
* truncating; a sniffed extension is appended when the name has none.
*/
export function safeFileName(raw: string, sniffedExtension: string | null = null): string {
const basename = raw.normalize("NFKC").split(/[/\\]/).pop() ?? "";
let name = basename
.replace(/[\p{Cc}\p{Cf}]/gu, "")
.replace(/[<>:"|?*#%{}^~[\]`]/g, "_")
.replace(/\s+/g, " ")
.replace(/_+/g, "_")
.trim()
.replace(/^[.\s_-]+/, "")
.replace(/[.\s]+$/, "");
if (name === "") name = FALLBACK;
if (WINDOWS_RESERVED.test(name)) name = `_${name}`;
const dot = name.lastIndexOf(".");
let stem = dot > 0 ? name.slice(0, dot) : name;
let ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
if (ext === "" && sniffedExtension !== null) ext = sniffedExtension;
if (Buffer.byteLength(ext, "utf8") > 16) {
stem = name;
ext = sniffedExtension ?? "";
}
const suffix = ext === "" ? "" : `.${ext}`;
stem = truncateUtf8(stem, MAX_SAFE_NAME_BYTES - Buffer.byteLength(suffix, "utf8")).trim();
if (stem === "") stem = FALLBACK;
return `${stem}${suffix}`;
}
export { MAX_SAFE_NAME_BYTES, safeFileName } from "@tactical-media-broadcast-hub/core";

View file

@ -3,13 +3,13 @@
import { Badge, Button, Card } from "../ui/primitives";
import { formatBytes } from "../video/format";
import { MimeIcon } from "./mime-icon";
import { mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels";
import { LEGAL_HOLD_LABEL, mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels";
import { useFilesLang } from "./i18n";
import type { GroupFileItem } from "./types";
import type { GroupFile } from "./types";
/** One file row: identity + metadata on the left, actions on the right. */
export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDownload, onShare, onRevoke, onRetention }: {
item: GroupFileItem;
item: GroupFile;
live: boolean;
canRetention: boolean;
busy: boolean;
@ -20,28 +20,35 @@ export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDown
onRetention: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const disabled = busy || !live || !item.available;
const disabled = busy || !live || item.status !== "available";
const retentionLabel =
item.retentionMode === "until" && item.retentionUntil !== null
? `${t(RETENTION_LABEL.until)} ${new Date(item.retentionUntil).toLocaleDateString()}`
: t(RETENTION_LABEL[item.retentionMode]);
item.retention.mode === "until" && item.retention.expiresAt !== null
? `${t(RETENTION_LABEL.until)} ${new Date(item.retention.expiresAt).toLocaleDateString()}`
: t(RETENTION_LABEL[item.retention.mode]);
return (
<Card className="flex flex-wrap items-center gap-3 p-3">
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium" title={item.originalName}>{item.safeName}</span>
<Badge tone={ORIGIN_TONE[item.origin]}>{t(ORIGIN_LABEL[item.origin])}</Badge>
<Badge tone={RETENTION_TONE[item.retentionMode]}>{retentionLabel}</Badge>
{!item.available && <Badge tone="danger">{t({ en: "Unavailable", es: "No disponible" })}</Badge>}
<span className="truncate text-sm font-medium" title={item.name}>{item.name}</span>
<Badge tone={ORIGIN_TONE[item.origin.kind]}>{t(ORIGIN_LABEL[item.origin.kind])}</Badge>
<Badge tone={RETENTION_TONE[item.retention.mode]}>{retentionLabel}</Badge>
{item.retention.legalHold && <Badge tone="danger">{t(LEGAL_HOLD_LABEL)}</Badge>}
{item.status !== "available" && (
<Badge tone="danger">
{item.status === "revoked"
? t({ en: "Revoked", es: "Revocado" })
: t({ en: "Expired", es: "Caducado" })}
</Badge>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-text-faint">
<span>{formatBytes(item.byteSize)}</span>
<span>{formatBytes(item.sizeBytes)}</span>
<span aria-hidden="true">·</span>
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mime}`}>{t(mimeClassLabel(item.mimeClass))}</span>
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mimeType}`}>{t(mimeClassLabel(item.mimeClass))}</span>
<span aria-hidden="true">·</span>
<span className="max-w-[16rem] truncate" title={item.senderMxid}>{item.senderMxid}</span>
<span className="max-w-[16rem] truncate" title={item.senderId}>{item.senderId}</span>
<span aria-hidden="true">·</span>
<time dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>
</div>

View file

@ -1,18 +1,22 @@
import { API_URL, ApiError } from "../../lib/api-client";
import { getSessionToken } from "../../lib/session-token";
function authHeaders(): Record<string, string> {
const token = getSessionToken();
return token === null ? {} : { Authorization: `Bearer ${token}` };
}
/**
* Binary download for GET /files/items/:itemId/download. This cannot use
* `apiFetch` (it parses JSON) or a bare anchor (the endpoint needs the
* Authorization header, which anchors cannot send), so it replays the client's
* auth rules: same base URL, Bearer token, cookie credentials — then streams
* the body into a Blob and clicks a temporary anchor.
* Binary download for GET /files/:id/download. This cannot use `apiFetch`
* (it parses JSON) or a bare anchor (the endpoint needs the Authorization
* header, which anchors cannot send), so it replays the client's auth rules:
* same base URL, Bearer token, cookie credentials — then streams the body
* into a Blob and clicks a temporary anchor.
*/
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
const token = getSessionToken();
const response = await fetch(`${API_URL}/files/items/${encodeURIComponent(itemId)}/download`, {
const response = await fetch(`${API_URL}/files/${encodeURIComponent(itemId)}/download`, {
credentials: "include",
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
headers: authHeaders(),
});
if (!response.ok) {
if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null);
@ -29,12 +33,7 @@ export async function downloadGroupFile(itemId: string, fileName: string): Promi
URL.revokeObjectURL(url);
}
/**
* Derive the share id from a public tokenUrl of the form
* `/files/shares/:token/download`, used for DELETE /files/shares/:shareId when
* the POST response omits an explicit shareId.
*/
export function shareIdFromUrl(tokenUrl: string): string | null {
const match = /\/files\/shares\/([^/?#]+)/.exec(tokenUrl);
return match?.[1] !== undefined && match[1] !== "" ? match[1] : null;
/** Public share-claim URL for a minted capability token (GET /public/files/s/:token). */
export function publicClaimUrl(token: string): string {
return `${API_URL}/public/files/s/${encodeURIComponent(token)}`;
}

View file

@ -1,5 +1,5 @@
import type { LocalizedText } from "./i18n";
import type { FileOrigin, MimeClass, RetentionMode } from "./types";
import type { FileOrigin, FileRetention, MimeClass } from "./types";
/** Bilingual display labels for the group-files enums (values are fixed by the API). */
@ -22,21 +22,22 @@ export const MIME_CLASS_LABEL: Record<MimeClass, LocalizedText> = {
video: { en: "Video", es: "Vídeo" },
audio: { en: "Audio", es: "Audio" },
document: { en: "Document", es: "Documento" },
archive: { en: "Archive", es: "Archivo comprimido" },
other: { en: "File", es: "Archivo" },
};
export const RETENTION_LABEL: Record<RetentionMode, LocalizedText> = {
export const RETENTION_LABEL: Record<FileRetention["mode"], LocalizedText> = {
keep: { en: "Keep", es: "Conservar" },
until: { en: "Until", es: "Hasta" },
legalHold: { en: "Legal hold", es: "Retención legal" },
};
export const RETENTION_TONE: Record<RetentionMode, "neutral" | "accent" | "live" | "warn" | "danger"> = {
export const RETENTION_TONE: Record<FileRetention["mode"], "neutral" | "accent" | "danger"> = {
keep: "neutral",
until: "accent",
legalHold: "danger",
};
export const LEGAL_HOLD_LABEL: LocalizedText = { en: "Legal hold", es: "Retención legal" };
/** Human label for a MIME class, tolerating an unknown value from the server. */
export function mimeClassLabel(mimeClass: MimeClass): LocalizedText {
return MIME_CLASS_LABEL[mimeClass] ?? MIME_CLASS_LABEL.other;

View file

@ -3,28 +3,30 @@
import { useState } from "react";
import { Button, Input } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import { RETENTION_LABEL } from "./labels";
import { LEGAL_HOLD_LABEL, RETENTION_LABEL } from "./labels";
import { useFilesLang } from "./i18n";
import type { GroupFileItem, RetentionInput, RetentionMode } from "./types";
import type { GroupFile, RetentionInput } from "./types";
/**
* Admin-only retention change: POST /files/items/:itemId/retention
* {mode, until?}. Retention is separate from share expiry — legalHold blocks
* deletion, `until` requires a deadline, `keep` is the no-purge default.
* Admin-only retention change: POST /files/:id/retention
* {mode: keep|until, legalHold?, until?}. Retention is separate from share
* expiry — legal hold blocks deletion outright (orthogonal flag), `until`
* requires a deadline, `keep` is the no-purge default.
*/
export function RetentionDialog({ item, onClose, onSaved }: {
item: GroupFileItem;
item: GroupFile;
onClose: () => void;
onSaved: (itemId: string, input: RetentionInput) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [mode, setMode] = useState<RetentionMode>(item.retentionMode);
const [until, setUntil] = useState(item.retentionUntil?.slice(0, 16) ?? "");
const [mode, setMode] = useState<"keep" | "until">(item.retention.mode);
const [legalHold, setLegalHold] = useState(item.retention.legalHold);
const [until, setUntil] = useState(item.retention.expiresAt?.slice(0, 16) ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const modes: RetentionMode[] = ["keep", "until", "legalHold"];
const modes: Array<"keep" | "until"> = ["keep", "until"];
async function save(): Promise<void> {
let iso: string | undefined;
@ -43,8 +45,8 @@ export function RetentionDialog({ item, onClose, onSaved }: {
setBusy(true);
setError(null);
try {
const body: RetentionInput = mode === "until" ? { mode, until: iso } : { mode };
await api.post(`/files/items/${item.id}/retention`, body);
const body: RetentionInput = { mode, legalHold, ...(mode === "until" && iso !== undefined ? { until: iso } : {}) };
await api.post(`/files/${item.id}/retention`, body);
onSaved(item.id, body);
onClose();
} catch (err) {
@ -64,7 +66,7 @@ export function RetentionDialog({ item, onClose, onSaved }: {
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">{t({ en: "Set retention", es: "Definir retención" })}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
<p className="mt-1 truncate text-xs text-text-dim" title={item.name}>{item.name}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Retention is independent of share expiry. Legal hold blocks deletion until an admin releases it.",
@ -92,6 +94,15 @@ export function RetentionDialog({ item, onClose, onSaved }: {
<Input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} className="mt-1" />
</label>
)}
<label className="mt-3 flex min-h-9 items-center gap-2 text-xs text-text-dim">
<input
type="checkbox"
checked={legalHold}
onChange={(event) => setLegalHold(event.target.checked)}
className="h-4 w-4 rounded border-border accent-accent"
/>
{t({ ...LEGAL_HOLD_LABEL, en: `${LEGAL_HOLD_LABEL.en} (blocks deletion)`, es: `${LEGAL_HOLD_LABEL.es} (bloquea el borrado)` })}
</label>
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>

View file

@ -3,36 +3,41 @@
import { useMemo, useState } from "react";
import { Button, Input } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import { shareIdFromUrl } from "./files-client";
import { publicClaimUrl } from "./files-client";
import { useFilesLang } from "./i18n";
import type { GroupFileItem, ShareResponse } from "./types";
import type { GroupFile, ShareCreated } from "./types";
/**
* Mint a public share link for one file: POST /files/items/:itemId/share
* {expiresAt}. The token is returned once — after this dialog closes it can
* never be recovered, so the result view says so in plain language and offers
* a copy button before the user leaves.
* Mint a public share link for one file: POST /files/:id/shares
* {access: "public", expiresInHours} → ShareCreated. The capability token is
* returned once — after this dialog closes it can never be recovered, so the
* result view says so in plain language and offers a copy button before the
* user leaves. The shareable URL is the public claim endpoint; the library
* link in the response stays members-only.
*/
type ExpiryKey = "1h" | "24h" | "7d" | "custom";
const EXPIRY_MS: Record<Exclude<ExpiryKey, "custom">, number> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
const EXPIRY_HOURS: Record<Exclude<ExpiryKey, "custom">, number> = {
"1h": 1,
"24h": 24,
"7d": 168,
};
/** The api caps share TTL at 720 hours (30 days). */
const MAX_SHARE_HOURS = 720;
export function ShareDialog({ item, onClose, onCreated }: {
item: GroupFileItem;
item: GroupFile;
onClose: () => void;
onCreated: (itemId: string, share: { shareId: string | null; tokenUrl: string }) => void;
onCreated: (itemId: string, share: ShareCreated) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [expiry, setExpiry] = useState<ExpiryKey>("24h");
const [customAt, setCustomAt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tokenUrl, setTokenUrl] = useState<string | null>(null);
const [share, setShare] = useState<ShareCreated | null>(null);
const [copied, setCopied] = useState(false);
const options: Array<{ key: ExpiryKey; label: string }> = [
@ -47,27 +52,30 @@ export function ShareDialog({ item, onClose, onCreated }: {
return now.toISOString().slice(0, 16);
}, []);
function expiresAtIso(): string | null {
function expiresInHours(): number | null {
if (expiry === "custom") {
if (customAt === "") return null;
const parsed = new Date(customAt);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
if (!Number.isFinite(parsed.getTime())) return null;
const hours = Math.ceil((parsed.getTime() - Date.now()) / 3_600_000);
if (hours < 1) return null;
return Math.min(hours, MAX_SHARE_HOURS);
}
return new Date(Date.now() + EXPIRY_MS[expiry]).toISOString();
return EXPIRY_HOURS[expiry];
}
async function create(): Promise<void> {
const expiresAt = expiresAtIso();
if (expiresAt === null) {
setError(t({ en: "Pick a future expiry date.", es: "Elige una fecha de caducidad futura." }));
const hours = expiresInHours();
if (hours === null) {
setError(t({ en: "Pick a future expiry date (up to 30 days).", es: "Elige una fecha de caducidad futura (hasta 30 días)." }));
return;
}
setBusy(true);
setError(null);
try {
const response = await api.post<ShareResponse>(`/files/items/${item.id}/share`, { expiresAt });
setTokenUrl(response.tokenUrl);
onCreated(item.id, { shareId: response.shareId ?? shareIdFromUrl(response.tokenUrl), tokenUrl: response.tokenUrl });
const created = await api.post<ShareCreated>(`/files/${item.id}/shares`, { access: "public", expiresInHours: hours });
setShare(created);
onCreated(item.id, created);
} catch (err) {
setError(errorMessage(err));
} finally {
@ -76,9 +84,9 @@ export function ShareDialog({ item, onClose, onCreated }: {
}
async function copy(): Promise<void> {
if (tokenUrl === null) return;
if (share === null) return;
try {
await navigator.clipboard.writeText(tokenUrl);
await navigator.clipboard.writeText(publicClaimUrl(share.token));
setCopied(true);
} catch {
setError(t({ en: "Could not copy — select the link and copy it manually.", es: "No se pudo copiar — selecciona el enlace y cópialo a mano." }));
@ -95,9 +103,9 @@ export function ShareDialog({ item, onClose, onCreated }: {
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">{t({ en: "Share file", es: "Compartir archivo" })}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
<p className="mt-1 truncate text-xs text-text-dim" title={item.name}>{item.name}</p>
{tokenUrl === null ? (
{share === null ? (
<>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
@ -142,7 +150,16 @@ export function ShareDialog({ item, onClose, onCreated }: {
es: "Copia este enlace ahora — se muestra solo una vez y no se puede recuperar tras cerrar este cuadro.",
})}
</p>
<Input readOnly value={tokenUrl} aria-label={t({ en: "Share link", es: "Enlace para compartir" })} onFocus={(e) => e.target.select()} className="mt-2 font-mono text-2xs" />
<Input
readOnly
value={publicClaimUrl(share.token)}
aria-label={t({ en: "Share link", es: "Enlace para compartir" })}
onFocus={(e) => e.target.select()}
className="mt-2 font-mono text-2xs"
/>
<p className="mt-1 text-2xs text-text-faint">
{t({ en: "Expires", es: "Caduca" })}: {new Date(share.expiresAt).toLocaleString()}
</p>
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Close", es: "Cerrar" })}</Button>

View file

@ -1,64 +1,68 @@
/**
* Wire shapes for the group-files HTTP surface (apps/api/src/group-files).
* Kept local to the Files feature so it does not collide with the shared
* api-types.ts other builders may be editing in parallel. Hand-synced with the
* controller: GET /files/groups/:groupId/items returns the flat item below.
* api-types.ts other builders may be editing in parallel. Hand-synced with
* the controller + the core GroupFile contract.
*/
export type FileOrigin = "matrix" | "bridge" | "upload" | "pad-export";
/**
* Coarse MIME class. NOTE: the API's DB CHECK (migration 029) and
* group-files.validation.ts MIME_CLASSES both use
* "image" | "video" | "audio" | "document" | "other" — the task brief said
* image/video/audio/file/doc; this follows the backend's real enum.
*/
export type MimeClass = "image" | "video" | "audio" | "document" | "other";
export type MimeClass = "image" | "video" | "audio" | "document" | "archive" | "other";
export type RetentionMode = "keep" | "until" | "legalHold";
export type FileStatus = "available" | "revoked" | "expired";
/** One row of the group file library — the served item shape. */
export interface GroupFileItem {
/** Retention is separate from share expiry: legalHold blocks deletion, `until` sets a deadline. */
export interface FileRetention {
mode: "keep" | "until";
legalHold: boolean;
expiresAt: string | null;
}
/** One row of the group file library — the served item shape (core GroupFile). */
export interface GroupFile {
id: string;
workspaceId: string;
groupId: string;
origin: FileOrigin;
senderMxid: string;
originalName: string;
safeName: string;
mime: string;
mimeClass: MimeClass;
byteSize: number;
sha256: string;
roomId: string | null;
senderId: string;
eventId: string | null;
origin: { kind: FileOrigin };
name: string;
mimeType: string;
sizeBytes: number;
checksum: { value: string };
createdAt: string;
available: boolean;
retentionMode: RetentionMode;
retentionUntil: string | null;
indexedAt: string;
file: { resourceId: string };
shareLink: { url: string; access: "members" | "public"; expiresAt: string | null };
retention: FileRetention;
status: FileStatus;
}
export interface GroupFilePage {
items: GroupFileItem[];
items: GroupFile[];
nextCursor: string | null;
}
/** POST /files/:id/shares response. `token` is the public capability — shown once. */
export interface ShareCreated {
id: string;
itemId: string;
access: "members" | "public";
url: string;
expiresAt: string;
token: string;
}
/** POST /files/:id/retention body. */
export interface RetentionInput {
mode: "keep" | "until";
legalHold?: boolean;
until?: string;
}
/** GET /groups/mine (same shape the More page already consumes). */
export interface GroupSummary {
id: string;
name: string;
}
/**
* POST /files/items/:itemId/share response. The task surface documents only
* `tokenUrl`; the API may also return a `shareId` used by
* DELETE /files/shares/:shareId. When absent the client derives the id from
* the tokenUrl's path segment so revoke still works.
*/
export interface ShareResponse {
tokenUrl: string;
shareId?: string;
}
/** POST /files/items/:itemId/retention body. */
export interface RetentionInput {
mode: RetentionMode;
until?: string;
}

View file

@ -6,7 +6,7 @@ import { Loading } from "../brand/loading";
import { apiUploadFile, errorMessage } from "../../lib/api-client";
import { formatBytes } from "../video/format";
import { useFilesLang } from "./i18n";
import type { GroupFileItem } from "./types";
import type { GroupFile } from "./types";
/**
* Drag-drop + picker for POST /files/groups/:groupId/upload. The API accepts a
@ -19,7 +19,7 @@ const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
export function UploadDropzone({ groupId, live, onUploaded }: {
groupId: string;
live: boolean;
onUploaded: (item: GroupFileItem) => void;
onUploaded: (item: GroupFile) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const inputRef = useRef<HTMLInputElement>(null);
@ -52,7 +52,7 @@ export function UploadDropzone({ groupId, live, onUploaded }: {
setProgress(0);
setUploading(`${file.name} (${formatBytes(file.size)})`);
try {
const created = await apiUploadFile<GroupFileItem>(
const created = await apiUploadFile<GroupFile>(
`/files/groups/${encodeURIComponent(groupId)}/upload`,
file,
setProgress,