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
238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { type FileHandle, lstat, mkdir, open, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
type AdapterContext,
|
|
type ArtifactByteRange,
|
|
type ArtifactPut,
|
|
type ArtifactStore,
|
|
type ArtifactStreamPut,
|
|
ArtifactTooLargeError,
|
|
type NotificationMessage,
|
|
type NotificationProvider,
|
|
type StoredArtifactStream,
|
|
} from "@rakazo/adapter-kit";
|
|
|
|
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
const READ_CHUNK_BYTES = 256 * 1024;
|
|
/** Space ids and artifact keys are generated ids; anything else could address another path. */
|
|
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
|
|
export class LocalArtifactStore implements ArtifactStore {
|
|
constructor(private readonly root: string) {}
|
|
|
|
describe() {
|
|
return {
|
|
id: "local-artifacts",
|
|
contractVersion: "1",
|
|
adapterVersion: "0.1.0",
|
|
capabilities: { stream: true },
|
|
};
|
|
}
|
|
|
|
private spaceDir(context: AdapterContext) {
|
|
if (!SAFE_SEGMENT.test(context.spaceId)) throw new Error("Invalid artifact space");
|
|
return path.join(this.root, "artifacts", context.spaceId);
|
|
}
|
|
|
|
private file(key: string, context: AdapterContext) {
|
|
if (!SAFE_SEGMENT.test(key)) throw new Error("Invalid artifact key");
|
|
return path.join(this.spaceDir(context), key);
|
|
}
|
|
|
|
/** Creates a new owner-only file under a fresh key; never follows or replaces an existing path. */
|
|
private async create(context: AdapterContext) {
|
|
const dir = this.spaceDir(context);
|
|
await mkdir(dir, { recursive: true });
|
|
const key = randomUUID();
|
|
const file = path.join(dir, key);
|
|
const handle = await open(
|
|
file,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW,
|
|
0o600,
|
|
);
|
|
return { key, file, handle };
|
|
}
|
|
|
|
private openForRead(key: string, context: AdapterContext) {
|
|
return open(this.file(key, context), constants.O_RDONLY | O_NOFOLLOW);
|
|
}
|
|
|
|
async put(artifact: ArtifactPut, context: AdapterContext) {
|
|
const { key, handle } = await this.create(context);
|
|
try {
|
|
await handle.writeFile(artifact.bytes);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
return { id: key, hash: String(artifact.bytes.byteLength) };
|
|
}
|
|
|
|
async putStream(
|
|
artifact: ArtifactStreamPut,
|
|
context: AdapterContext,
|
|
): Promise<StoredArtifactStream> {
|
|
if (!Number.isSafeInteger(artifact.maxBytes) || artifact.maxBytes < 0) {
|
|
throw new Error("maxBytes must be a non-negative integer");
|
|
}
|
|
const { key, file, handle } = await this.create(context);
|
|
const hash = createHash("sha256");
|
|
let size = 0;
|
|
try {
|
|
try {
|
|
for await (const chunk of byteChunks(artifact.stream)) {
|
|
context.signal.throwIfAborted();
|
|
size += chunk.byteLength;
|
|
if (size > artifact.maxBytes) throw new ArtifactTooLargeError(artifact.maxBytes);
|
|
hash.update(chunk);
|
|
await writeFully(handle, chunk);
|
|
}
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} catch (error) {
|
|
await rm(file, { force: true }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
return { key, size, sha256: hash.digest("hex") };
|
|
}
|
|
|
|
async get(id: string, context: AdapterContext) {
|
|
const handle = await this.openForRead(id, context);
|
|
try {
|
|
return new Uint8Array(await handle.readFile());
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async stat(key: string, context: AdapterContext) {
|
|
const handle = await this.openForRead(key, context);
|
|
try {
|
|
const info = await handle.stat();
|
|
if (!info.isFile()) throw new Error("Artifact is not a file");
|
|
return { size: info.size };
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async createReadStream(key: string, context: AdapterContext, range?: ArtifactByteRange) {
|
|
const handle = await this.openForRead(key, context);
|
|
try {
|
|
const info = await handle.stat();
|
|
if (!info.isFile()) throw new Error("Artifact is not a file");
|
|
const start = range?.start ?? 0;
|
|
const end = range?.end ?? info.size - 1;
|
|
if (
|
|
range &&
|
|
(!Number.isSafeInteger(start) ||
|
|
!Number.isSafeInteger(end) ||
|
|
start < 0 ||
|
|
end < start ||
|
|
end >= info.size)
|
|
) {
|
|
throw new RangeError("Range does not fit the artifact");
|
|
}
|
|
return fileByteStream(handle, start, end);
|
|
} catch (error) {
|
|
await handle.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async localPath(key: string, context: AdapterContext) {
|
|
const file = this.file(key, context);
|
|
const info = await lstat(file);
|
|
if (!info.isFile()) throw new Error("Artifact is not a file");
|
|
return file;
|
|
}
|
|
|
|
async remove(id: string, context: AdapterContext) {
|
|
await rm(this.file(id, context), { force: true });
|
|
}
|
|
}
|
|
|
|
async function* byteChunks(
|
|
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
): AsyncIterable<Uint8Array> {
|
|
if (!("getReader" in source)) {
|
|
yield* source;
|
|
return;
|
|
}
|
|
const reader = source.getReader();
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return;
|
|
yield value;
|
|
}
|
|
} finally {
|
|
// Stop the producer (e.g. an upload body) when the store gives up early.
|
|
await reader.cancel().catch(() => undefined);
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
async function writeFully(handle: FileHandle, chunk: Uint8Array) {
|
|
let offset = 0;
|
|
while (offset < chunk.byteLength) {
|
|
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
|
|
offset += bytesWritten;
|
|
}
|
|
}
|
|
|
|
/** Streams [start, end] of an open file and closes it when done, failed or cancelled. */
|
|
function fileByteStream(handle: FileHandle, start: number, end: number) {
|
|
let position = start;
|
|
let closed = false;
|
|
const close = async () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
await handle.close().catch(() => undefined);
|
|
};
|
|
return new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
try {
|
|
const length = Math.min(end - position + 1, READ_CHUNK_BYTES);
|
|
if (length <= 0) {
|
|
await close();
|
|
controller.close();
|
|
return;
|
|
}
|
|
const buffer = new Uint8Array(length);
|
|
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
if (bytesRead === 0) throw new Error("Artifact ended before the requested range");
|
|
position += bytesRead;
|
|
controller.enqueue(bytesRead === length ? buffer : buffer.subarray(0, bytesRead));
|
|
if (position > end) {
|
|
await close();
|
|
controller.close();
|
|
}
|
|
} catch (error) {
|
|
await close();
|
|
controller.error(error);
|
|
}
|
|
},
|
|
cancel: close,
|
|
});
|
|
}
|
|
|
|
export class CapturingNotificationProvider implements NotificationProvider {
|
|
readonly sent: NotificationMessage[] = [];
|
|
|
|
describe() {
|
|
return {
|
|
id: "capturing",
|
|
contractVersion: "1",
|
|
adapterVersion: "0.1.0",
|
|
capabilities: { push: false, email: false },
|
|
};
|
|
}
|
|
|
|
async send(message: NotificationMessage, _context: AdapterContext): Promise<void> {
|
|
this.sent.push(message);
|
|
}
|
|
}
|