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:
glass beadian 2026-10-11 08:21:11 +00:00
parent eb1b60f67d
commit d58db63fe6
58 changed files with 5543 additions and 4 deletions

View file

@ -0,0 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { FilesLangProvider } from "../../../components/files/i18n";
/**
* Files shell. Like the wiki, the language lives in localStorage per browser
* (lang-context explains why it is not an account setting) and is provided here
* so every Files screen and sub-component reads it through one context.
*/
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
return <FilesLangProvider>{children}</FilesLangProvider>;
}

View file

@ -0,0 +1,383 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Input } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
import { Loading } from "../../../components/brand/loading";
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { FileRow } from "../../../components/files/file-row";
import { UploadDropzone } from "../../../components/files/upload-dropzone";
import { ShareDialog } from "../../../components/files/share-dialog";
import { RetentionDialog } from "../../../components/files/retention-dialog";
import { downloadGroupFile } from "../../../components/files/files-client";
import { useFilesLang } from "../../../components/files/i18n";
import { MIME_CLASS_LABEL, ORIGIN_LABEL } from "../../../components/files/labels";
import type {
FileOrigin,
GroupFileItem,
GroupFilePage,
GroupSummary,
MimeClass,
RetentionInput,
} from "../../../components/files/types";
/**
* Group file library — the per-group "group cloud". Live mode pages through
* GET /files/groups/:groupId/items by cursor (video library's load-more
* pattern) with a debounced server-side `q` and mimeClass/origin filters.
* Offline the screen renders sample data read-only per useApiResource rules.
*/
const PAGE_LIMIT = 50;
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
const SAMPLE_FILES: GroupFileItem[] = [
{
id: "sample-1", groupId: "sample-group", origin: "matrix", senderMxid: "@ada:collective.example",
originalName: "press-kit.zip", safeName: "press-kit.zip", mime: "application/zip", mimeClass: "other",
byteSize: 4_812_441, sha256: "a".repeat(64), createdAt: "2026-10-08T09:12:00.000Z", available: true,
retentionMode: "keep", retentionUntil: null,
},
{
id: "sample-2", groupId: "sample-group", origin: "upload", senderMxid: "@you:collective.example",
originalName: "launch-banner.png", safeName: "launch-banner.png", mime: "image/png", mimeClass: "image",
byteSize: 348_112, sha256: "b".repeat(64), createdAt: "2026-10-08T11:40:00.000Z", available: true,
retentionMode: "until", retentionUntil: "2026-11-30T00:00:00.000Z",
},
{
id: "sample-3", groupId: "sample-group", origin: "bridge", senderMxid: "@bridge:collective.example",
originalName: "statement-final.pdf", safeName: "statement-final.pdf", mime: "application/pdf", mimeClass: "document",
byteSize: 1_204_998, sha256: "c".repeat(64), createdAt: "2026-10-07T18:05:00.000Z", available: true,
retentionMode: "legalHold", retentionUntil: null,
},
];
const identityGroups = (groups: GroupSummary[]): GroupSummary[] => groups;
export default function FilesPage(): React.ReactElement {
const { t, lang, setLang } = useFilesLang();
const { status: authStatus, can, session } = useAuth();
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const [pickedGroup, setPickedGroup] = useState<string | null>(null);
const [items, setItems] = useState<GroupFileItem[]>([]);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [nonce, setNonce] = useState(0);
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [origin, setOrigin] = useState<"" | FileOrigin>("");
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [shareFor, setShareFor] = useState<GroupFileItem | null>(null);
const [retentionFor, setRetentionFor] = useState<GroupFileItem | null>(null);
const [shares, setShares] = useState<Record<string, { shareId: string | null; tokenUrl: string }>>({});
const groupId = pickedGroup ?? groups.data[0]?.id ?? "";
const live = source === "live";
const isAdmin = can("workspace.members.manage") || session?.role === "Admin" || session?.role === "Owner";
// Debounce the search box; only the settled value drives the server query.
useEffect(() => {
const timer = window.setTimeout(() => setQ(qInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [qInput]);
useEffect(() => {
if (authStatus === "loading") return;
if (authStatus !== "authenticated") {
setItems(SAMPLE_FILES);
setNextCursor(null);
setSource("sample");
setError(null);
return;
}
if (groupId === "") {
setItems([]);
setNextCursor(null);
setSource("live");
return;
}
const controller = new AbortController();
setLoading(true);
api
.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, { q, mimeClass, origin, limit: PAGE_LIMIT }, controller.signal)
.then((page) => {
setItems(page.items);
setNextCursor(page.nextCursor);
setSource("live");
setError(null);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setItems(SAMPLE_FILES);
setNextCursor(null);
setSource("sample");
return;
}
setSource("live");
setError(errorMessage(err));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [authStatus, groupId, q, mimeClass, origin, nonce]);
async function loadMore(): Promise<void> {
if (nextCursor === null || groupId === "") return;
setLoadingMore(true);
try {
const page = await api.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, {
q, mimeClass, origin, limit: PAGE_LIMIT, cursor: nextCursor,
});
setItems((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...page.items.filter((item) => !seen.has(item.id))];
});
setNextCursor(page.nextCursor);
} catch (err) {
setActionError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
const handleUploaded = useCallback((item: GroupFileItem): void => {
setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]);
setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
}, [t]);
async function handleDownload(item: GroupFileItem): Promise<void> {
setActionError(null);
try {
await downloadGroupFile(item.id, item.safeName);
} catch (err) {
setActionError(errorMessage(err));
}
}
async function revokeShare(item: GroupFileItem): Promise<void> {
const entry = shares[item.id];
if (entry === undefined || entry.shareId === null || entry.shareId === "") {
setActionError(t({ en: "No share id to revoke — reopen the share dialog.", es: "No hay id de enlace que revocar — vuelve a abrir el cuadro de compartir." }));
return;
}
const previous = shares;
setBusyId(item.id);
setActionError(null);
// Optimistic: drop the link immediately, restore if the API rejects.
setShares((current) => {
const next = { ...current };
delete next[item.id];
return next;
});
try {
await api.del(`/files/shares/${encodeURIComponent(entry.shareId)}`);
setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." }));
} catch (err) {
setShares(previous);
setActionError(errorMessage(err));
} finally {
setBusyId(null);
}
}
function handleRetentionSaved(itemId: string, input: RetentionInput): void {
setItems((current) =>
current.map((item) =>
item.id === itemId ? { ...item, retentionMode: input.mode, retentionUntil: input.until ?? null } : item,
),
);
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
}
const groupChosen = groupId !== "";
const showList = source !== "loading";
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-5 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">{t({ en: "Files", es: "Archivos" })}</h1>
<OfflineBadge source={source} />
{groups.data.length > 0 && (
<label className="flex items-center gap-2 text-xs text-text-dim">
<span className="sr-only">{t({ en: "Group", es: "Grupo" })}</span>
<select
aria-label={t({ en: "Group", es: "Grupo" })}
value={groupId}
onChange={(event) => setPickedGroup(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
{groups.data.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</label>
)}
<div className="ml-auto flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
{(["en", "es"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
</div>
<p className="mb-4 text-sm text-text-dim">
{t({
en: "Files shared with this group, from Matrix rooms, bridges, direct uploads and pad exports. Download and share links stream the verified bytes back through the hub — nothing is stored on a third party.",
es: "Archivos compartidos con este grupo, desde salas Matrix, puentes, subidas directas y exportaciones de pad. La descarga y los enlaces de compartir sirven los bytes verificados a través del hub — nada queda en terceros.",
})}
</p>
{groups.error !== null && <p className="mb-3 text-sm text-danger">{groups.error}</p>}
{error !== null && <p className="mb-3 text-sm text-danger">{error}</p>}
{actionError !== null && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
{notice !== null && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
<UploadDropzone groupId={groupId} live={live} onUploaded={handleUploaded} />
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
type="search"
value={qInput}
onChange={(event) => setQInput(event.target.value)}
placeholder={t({ en: "Search files by name…", es: "Buscar archivos por nombre…" })}
aria-label={t({ en: "Search files", es: "Buscar archivos" })}
className="max-w-xs"
/>
<select
aria-label={t({ en: "MIME class", es: "Clase MIME" })}
value={mimeClass}
onChange={(event) => setMimeClass(event.target.value as "" | MimeClass)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All types", es: "Todos los tipos" })}</option>
{(Object.keys(MIME_CLASS_LABEL) as MimeClass[]).map((value) => (
<option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option>
))}
</select>
<select
aria-label={t({ en: "Origin", es: "Origen" })}
value={origin}
onChange={(event) => setOrigin(event.target.value as "" | FileOrigin)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All origins", es: "Todos los orígenes" })}</option>
{(Object.keys(ORIGIN_LABEL) as FileOrigin[]).map((value) => (
<option key={value} value={value}>{t(ORIGIN_LABEL[value])}</option>
))}
</select>
{live && (
<Button size="sm" variant="ghost" onClick={() => setNonce((n) => n + 1)} disabled={loading}>
{t({ en: "Refresh", es: "Actualizar" })}
</Button>
)}
</div>
{groups.data.length === 0 && source !== "sample" && (
<EmptyState
title={t({ en: "No groups yet", es: "Aún no hay grupos" })}
hint={t({ en: "Files live inside a group. Join or create one, then come back.", es: "Los archivos viven dentro de un grupo. Únete o crea uno y vuelve." })}
/>
)}
{groups.data.length > 0 && !groupChosen && source !== "sample" && (
<EmptyState
title={t({ en: "Choose a group", es: "Elige un grupo" })}
hint={t({ en: "Pick a group above to see its shared files.", es: "Elige un grupo arriba para ver sus archivos compartidos." })}
/>
)}
{source === "loading" && (
<div className="flex items-center gap-2 text-xs text-text-faint">
<Loading size={16} label={t({ en: "Loading files", es: "Cargando archivos" })} />
{t({ en: "Loading files…", es: "Cargando archivos…" })}
</div>
)}
{showList && loading && items.length > 0 && (
<div className="mb-3 flex items-center gap-2 text-2xs text-text-faint">
<Loading size={12} label={t({ en: "Refreshing", es: "Actualizando" })} />
{t({ en: "Refreshing…", es: "Actualizando…" })}
</div>
)}
{showList && !loading && (groupChosen || source === "sample") && items.length === 0 && (
<EmptyState
title={t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
hint={
q !== "" || mimeClass !== "" || origin !== ""
? t({ en: "No file matches these filters. Clear the search or filters.", es: "Ningún archivo coincide con estos filtros. Limpia la búsqueda o los filtros." })
: t({ en: "Drop a file above, or wait for a room attachment to arrive.", es: "Suelta un archivo arriba, o espera a que llegue un adjunto de la sala." })
}
/>
)}
{showList && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<FileRow
key={item.id}
item={item}
live={live}
canRetention={isAdmin}
busy={busyId === item.id}
hasActiveShare={shares[item.id] !== undefined}
onDownload={() => void handleDownload(item)}
onShare={() => { setActionError(null); setShareFor(item); }}
onRevoke={() => void revokeShare(item)}
onRetention={() => { setActionError(null); setRetentionFor(item); }}
/>
))}
</div>
)}
{live && nextCursor !== null && (
<div className="mt-4 flex justify-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? t({ en: "Loading…", es: "Cargando…" }) : t({ en: "Load more", es: "Cargar más" })}
</Button>
</div>
)}
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
{t({
en: "Retention (keep / until / legal hold) is separate from link expiry: revoking or expiring a share never deletes the file, and legal hold blocks deletion until an admin releases it.",
es: "La retención (conservar / hasta / retención legal) es independiente de la caducidad del enlace: revocar o dejar caducar un enlace nunca borra el archivo, y la retención legal bloquea el borrado hasta que un admin la libere.",
})}
</p>
{shareFor !== null && (
<ShareDialog
item={shareFor}
onClose={() => setShareFor(null)}
onCreated={(itemId, share) => setShares((current) => ({ ...current, [itemId]: share }))}
/>
)}
{retentionFor !== null && (
<RetentionDialog item={retentionFor} onClose={() => setRetentionFor(null)} onSaved={handleRetentionSaved} />
)}
</div>
);
}

View file

@ -15,7 +15,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();

View file

@ -63,6 +63,7 @@ export default function MorePage(): React.ReactElement {
<Row href="/calendar" title="Calendar" />
<Row href="/analytics" title="Analytics" />
<Row href="/video" title="Video library" />
<Row href="/files" title="Files" detail="Group file library" />
<Row href="/onboarding" title="Onboarding" />
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
</div>