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.
40 lines
1.7 KiB
TypeScript
40 lines
1.7 KiB
TypeScript
import { API_URL, ApiError } from "../../lib/api-client";
|
|
import { getSessionToken } from "../../lib/session-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.
|
|
*/
|
|
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
|
|
const token = getSessionToken();
|
|
const response = await fetch(`${API_URL}/files/items/${encodeURIComponent(itemId)}/download`, {
|
|
credentials: "include",
|
|
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!response.ok) {
|
|
if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null);
|
|
throw new ApiError(response.status, `Download failed (HTTP ${response.status})`, null);
|
|
}
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = fileName;
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
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;
|
|
}
|