Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/files/uploadFileClient.ts
T

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 });
});
}