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

@ -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,