// P4 Files — client-side chunked upload sequence (init -> chunk -> complete) // extracted from the files upload route so other flows (e.g. the AK-P16d // signature wizard) can upload a file and get its file id back. Pure fetch // client, no React; errors carry the server's i18n error key. import { csrfRequestHeaders } from "~/utils/csrf"; export type UploadFileClientResult = { id: string; displayName: string }; export type ChatAttachmentScanStatus = "pending" | "clean" | "infected" | "skipped"; export class UploadFileClientError extends Error { readonly errorKey: string; constructor(errorKey: string) { super(errorKey); this.name = "UploadFileClientError"; this.errorKey = errorKey; } } /** * MIME types a browser commonly leaves empty on `File.type`, by extension. * * Markdown is the one that bites: macOS has no system type for `.md`, so Chrome * hands over an empty string, the upload declares `application/octet-stream` and * the store rejects it as a blocked type — for a file it does allow. Only types * the file store accepts are mapped; anything else keeps the octet-stream * fallback and gets the store's plain "type not allowed" answer. */ const MIME_BY_EXTENSION: Readonly> = { md: "text/markdown", markdown: "text/markdown", txt: "text/plain", csv: "text/csv", pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", heic: "image/heic", heif: "image/heif", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", }; /** The type to declare for a file: what the browser says, else its extension. */ export function resolveUploadMimeType(file: { name: string; type: string }): string { if (file.type) return file.type; const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; return MIME_BY_EXTENSION[extension] ?? "application/octet-stream"; } export async function uploadFileInChunks(input: { tenantSlug: string; file: File; onProgress?: (percent: number) => void; onProcessing?: () => void; signal?: AbortSignal; }): Promise { const { tenantSlug, file } = input; const initRes = await fetch(`/${tenantSlug}/files/upload/init`, { method: "POST", headers: csrfRequestHeaders({ "content-type": "application/json" }), body: JSON.stringify({ originalFilename: file.name, mimeType: resolveUploadMimeType(file), totalSizeBytes: file.size }), signal: input.signal, }); if (!initRes.ok) { const body = (await initRes.json().catch(() => ({}))) as { error?: string }; throw new UploadFileClientError(body.error ?? "files.error.invalidUploadRequest"); } // The server's chunk size is authoritative for slicing so client and server // always agree on chunk boundaries. const { uploadId, chunkSize, totalChunks } = (await initRes.json()) as { uploadId: string; chunkSize: number; totalChunks: number }; for (let index = 0; index < totalChunks; index += 1) { const slice = file.slice(index * chunkSize, (index + 1) * chunkSize); const chunkRes = await fetch(`/${tenantSlug}/files/upload/${uploadId}/chunk/${index}`, { method: "POST", headers: csrfRequestHeaders(), body: slice, signal: input.signal, }); if (!chunkRes.ok) { const body = (await chunkRes.json().catch(() => ({}))) as { error?: string }; throw new UploadFileClientError(body.error ?? "files.error.uploadIncomplete"); } input.onProgress?.(Math.round(((index + 1) / totalChunks) * 100)); } input.onProcessing?.(); const completeRes = await fetch(`/${tenantSlug}/files/upload/${uploadId}/complete`, { method: "POST", headers: csrfRequestHeaders({ "content-type": "application/json" }), body: JSON.stringify({}), signal: input.signal, }); if (!completeRes.ok) { const body = (await completeRes.json().catch(() => ({}))) as { error?: string }; throw new UploadFileClientError(body.error ?? "files.error.uploadIncomplete"); } return (await completeRes.json()) as UploadFileClientResult; } export async function waitForChatAttachmentScan(input: { tenantSlug: string; fileId: string; signal?: AbortSignal; fetchImpl?: typeof fetch; pollIntervalMs?: number; maxAttempts?: number; }): Promise { const fetchImpl = input.fetchImpl ?? fetch; const maxAttempts = input.maxAttempts ?? 80; const pollIntervalMs = input.pollIntervalMs ?? 750; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { assertNotAborted(input.signal); const response = await fetchImpl(`/${input.tenantSlug}/chat/attachments/${encodeURIComponent(input.fileId)}/status`, { headers: { accept: "application/json" }, signal: input.signal, }); if (!response.ok) { throw new UploadFileClientError("chat.attachments.scanStatusFailed"); } const body = (await response.json()) as { scanStatus?: ChatAttachmentScanStatus }; if (body.scanStatus === "clean") return; if (body.scanStatus === "infected" || body.scanStatus === "skipped") { throw new UploadFileClientError("chat.attachments.blocked"); } if (body.scanStatus !== "pending") { throw new UploadFileClientError("chat.attachments.scanStatusFailed"); } if (attempt + 1 < maxAttempts) await abortableDelay(pollIntervalMs, input.signal); } throw new UploadFileClientError("chat.attachments.scanPendingRetry"); } function assertNotAborted(signal?: AbortSignal): void { if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError"); } function abortableDelay(delayMs: number, signal?: AbortSignal): Promise { if (delayMs <= 0) return Promise.resolve(); return new Promise((resolve, reject) => { const cleanup = () => signal?.removeEventListener("abort", onAbort); const timeout = setTimeout(() => { cleanup(); resolve(); }, delayMs); const onAbort = () => { clearTimeout(timeout); cleanup(); reject(new DOMException("The operation was aborted", "AbortError")); }; signal?.addEventListener("abort", onAbort, { once: true }); }); }