evOo-local/packages/adapters/src/artifact-files.ts
Claude 62e9bc67f1
feat(media): stream artifacts over HTTP with Range support
Videos and audio can be hundreds of megabytes. Artifacts only travelled as
base64 inside RPC calls, capped at 10 MiB and held whole in memory, so media
could neither be uploaded, played with seeking nor produced by media jobs.

- ArtifactStore gains stat, createReadStream (inclusive byte ranges),
  putStream (size cap enforced while writing, real SHA-256) and an optional
  localPath so ffmpeg can read stored files in place. LocalArtifactStore
  implements them with the same safety as before (O_EXCL, O_NOFOLLOW, 0600)
  and now validates space ids and keys before building paths.
- storeArtifactStream / storeArtifactFromFile store a stream or a job's
  output file as an artifact row, with width, height, duration and parent,
  and remove the blob again if the row cannot be written.
- GET /api/artifacts/:id serves an artifact with Range/206/416, ETag,
  inline disposition for media and attachment otherwise (safe RFC 6266
  filenames), nosniff and same-origin CORP. Authorization matches
  artifacts.get: the owner's artifact of an active bot thread or group,
  with membership of the artifact's space (media elements cannot send the
  space header; an explicit header must name the same space).
- POST /api/artifacts takes a raw streamed body with the target and name in
  the query, authorized like artifacts.create, with the MIME allowlist and
  the per-kind caps checked from Content-Length and again while writing.
- The API CSP allows media from the app origin and blob URLs.
- Web: artifactUrl(artifactId) and uploadArtifactFile for the UI.

Tests cover the streaming store, range parsing, filename sanitizing, the
file helper, and a Postgres suite for both routes: ranges, caching, other
users, shared-space members, other spaces, archived threads, signed-out
requests and upload limits including chunked bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:31:39 +00:00

117 lines
3.7 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 { ARTIFACT_MAX_BYTES_BY_KIND, attachmentKindOf } 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;
};
/** Largest artifact of this MIME type that may be streamed or generated. */
export function artifactMaxBytes(mimeType: string): number {
return ARTIFACT_MAX_BYTES_BY_KIND[attachmentKindOf(mimeType)];
}
/**
* 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;
},
) {
validateAttachmentMimeType(input.mimeType);
const maxBytes = Math.min(
input.maxBytes ?? Number.POSITIVE_INFINITY,
artifactMaxBytes(input.mimeType),
);
const 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();
}
}