* 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>
114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { open } from "node:fs/promises";
|
|
import type { AdapterContext, ArtifactStore, ArtifactStreamPut } from "@rakazo/adapter-kit";
|
|
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
|
import { artifactMaxBytes } from "@rakazo/contracts";
|
|
import { validateAttachmentMimeType } from "@rakazo/core";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
|
|
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
|
|
export type StoreArtifactOwner = {
|
|
spaceId: string;
|
|
userId: string;
|
|
botId: string | null;
|
|
groupId: string | null;
|
|
runId: string | null;
|
|
};
|
|
|
|
export type StoreArtifactMetadata = {
|
|
name: string;
|
|
mimeType: string;
|
|
width?: number;
|
|
height?: number;
|
|
durationMs?: number;
|
|
parentArtifactId?: string;
|
|
};
|
|
|
|
type StoreArtifactDeps = {
|
|
prisma: Pick<PrismaClient, "artifact">;
|
|
artifacts: ArtifactStore;
|
|
};
|
|
|
|
/**
|
|
* Streams bytes into the artifact store and records the artifact row; the stored blob is removed
|
|
* again when the row cannot be written. Rejects disallowed MIME types and anything above the
|
|
* per-kind cap (or the smaller `maxBytes`) without buffering the whole file.
|
|
*/
|
|
export async function storeArtifactStream(
|
|
deps: StoreArtifactDeps,
|
|
input: StoreArtifactOwner &
|
|
StoreArtifactMetadata & {
|
|
stream: ArtifactStreamPut["stream"];
|
|
maxBytes?: number;
|
|
signal?: AbortSignal;
|
|
operationId?: string;
|
|
},
|
|
) {
|
|
validateAttachmentMimeType(input.mimeType);
|
|
const maxBytes = Math.min(
|
|
input.maxBytes ?? Number.POSITIVE_INFINITY,
|
|
artifactMaxBytes(input.mimeType),
|
|
);
|
|
const operationId =
|
|
input.operationId ?? `artifact-store:${input.runId ?? input.botId ?? input.userId}`;
|
|
const context: AdapterContext = {
|
|
operationId,
|
|
traceId: operationId,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
...(input.botId ? { botId: input.botId } : {}),
|
|
...(input.runId ? { runId: input.runId } : {}),
|
|
signal: input.signal ?? new AbortController().signal,
|
|
};
|
|
const stored = await deps.artifacts.putStream(
|
|
{ name: input.name, mimeType: input.mimeType, stream: input.stream, maxBytes },
|
|
context,
|
|
);
|
|
try {
|
|
return await deps.prisma.artifact.create({
|
|
data: {
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
botId: input.botId,
|
|
groupId: input.groupId,
|
|
runId: input.runId,
|
|
name: input.name,
|
|
mimeType: input.mimeType,
|
|
size: stored.size,
|
|
hash: stored.sha256,
|
|
storageKey: stored.key,
|
|
width: input.width,
|
|
height: input.height,
|
|
durationMs: input.durationMs,
|
|
parentArtifactId: input.parentArtifactId,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
await deps.artifacts.remove(stored.key, context).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Stores a file a job produced on local disk (e.g. an ffmpeg output) as an artifact. */
|
|
export async function storeArtifactFromFile(
|
|
deps: StoreArtifactDeps,
|
|
input: StoreArtifactOwner & StoreArtifactMetadata & { path: string; signal?: AbortSignal },
|
|
) {
|
|
validateAttachmentMimeType(input.mimeType);
|
|
const maxBytes = artifactMaxBytes(input.mimeType);
|
|
const handle = await open(input.path, constants.O_RDONLY | O_NOFOLLOW);
|
|
try {
|
|
const info = await handle.stat();
|
|
if (!info.isFile()) throw new Error(`Not a file: ${input.name}`);
|
|
if (info.size > maxBytes) throw new ArtifactTooLargeError(maxBytes);
|
|
return await storeArtifactStream(deps, {
|
|
...input,
|
|
stream: handle.createReadStream({ autoClose: false }),
|
|
maxBytes,
|
|
});
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|