evOo-local/apps/web/src/lib/artifact-upload.ts
cynarAI 797906dde2
Allow repository ZIP uploads up to 5 GiB (#194)
* Allow ZIP uploads up to 5 GiB and stream them onto Docker computers

Repository archives could not be picked at all: application/zip was not an
allowed attachment type. ZIPs are now allowed with their own 5 GiB cap,
upload as a stream, and reach the bot's Docker computer through a new
streamed supervisor write that commits only after the body ended cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use a disallowed type for the invalid-attachment check now that ZIP is allowed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 12:35:32 +02:00

69 lines
2.4 KiB
TypeScript

import { t } from "@lingui/core/macro";
import {
ArtifactSchema,
ATTACHMENT_MAX_BYTES,
artifactMaxBytes,
attachmentKindOf,
} from "@rakazo/contracts";
import type { ArtifactTarget } from "./artifact-open";
import { rpc, withSpaceHeaders } from "./rpc";
type UploadedArtifact = ReturnType<typeof ArtifactSchema.parse>;
/** Largest file of this type the composer accepts. */
export function attachmentMaxBytes(mimeType: string): number {
return artifactMaxBytes(mimeType);
}
/** Media and anything above the inline RPC cap upload as a raw stream instead of base64. */
export function usesStreamingUpload(size: number, mimeType: string): boolean {
const kind = attachmentKindOf(mimeType);
return size > ATTACHMENT_MAX_BYTES || kind === "video" || kind === "audio";
}
export async function uploadAttachment(
target: ArtifactTarget,
file: File,
mimeType: string,
): Promise<UploadedArtifact> {
if (usesStreamingUpload(file.size, mimeType)) {
return uploadArtifactFile(target, file, file.name, mimeType);
}
const contentBase64 = await readFileAsBase64(file);
return rpc.artifacts.create({ ...target, name: file.name, mimeType, contentBase64 });
}
/** Uploads a file as a raw streamed body to the artifact route. */
export async function uploadArtifactFile(
target: ArtifactTarget,
file: Blob,
name: string,
mimeType: string,
): Promise<UploadedArtifact> {
const query = new URLSearchParams({ ...target, name });
const response = await fetch(`/api/artifacts?${query}`, {
method: "POST",
headers: withSpaceHeaders({ "content-type": mimeType }),
credentials: "include",
body: file,
});
const body: unknown = await response.json().catch(() => null);
const artifact = ArtifactSchema.safeParse(body);
if (response.ok && artifact.success) return artifact.data;
const error = (body as { error?: unknown } | null)?.error;
throw new Error(typeof error === "string" ? error : t`Could not upload ${name}. Try again.`);
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === "string" ? reader.result : "";
const base64 = result.includes(",") ? (result.split(",")[1] ?? "") : result;
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}