156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
// 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<Record<string, string>> = {
|
|
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<UploadFileClientResult> {
|
|
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<void> {
|
|
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<void> {
|
|
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 });
|
|
});
|
|
}
|