|
|
|
@@ -0,0 +1,262 @@
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { createWriteStream } from "node:fs";
|
|
|
|
|
import { chmod, lstat, mkdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
|
|
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
|
|
import { Transform, type TransformCallback } from "node:stream";
|
|
|
|
|
import { pipeline } from "node:stream/promises";
|
|
|
|
|
import { ImageAttachmentSchema, type ImageAttachment } from "@agent-studio/shared";
|
|
|
|
|
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
|
|
|
import sharp from "sharp";
|
|
|
|
|
import type { EventStore } from "../events/event-store.js";
|
|
|
|
|
import { HarnessError } from "../errors/harness-error.js";
|
|
|
|
|
import type { AttachmentStore, ImageUpload } from "./attachment-store.js";
|
|
|
|
|
import { assertImageType, extensionForMediaType, type SupportedImageMediaType } from "./image-signatures.js";
|
|
|
|
|
|
|
|
|
|
type StoredAttachment = { metadata: ImageAttachment; filePath: string };
|
|
|
|
|
|
|
|
|
|
export type FileAttachmentStoreOptions = {
|
|
|
|
|
sessionsRoot: string;
|
|
|
|
|
eventStore?: EventStore;
|
|
|
|
|
maxBytes?: number;
|
|
|
|
|
maxWidth?: number;
|
|
|
|
|
maxHeight?: number;
|
|
|
|
|
maxPixels?: number;
|
|
|
|
|
maxAttachmentsPerMessage?: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class FileAttachmentStore implements AttachmentStore {
|
|
|
|
|
readonly #sessionsRoot: string;
|
|
|
|
|
readonly #eventStore: EventStore | undefined;
|
|
|
|
|
readonly #maxBytes: number;
|
|
|
|
|
readonly #maxWidth: number;
|
|
|
|
|
readonly #maxHeight: number;
|
|
|
|
|
readonly #maxPixels: number;
|
|
|
|
|
readonly #maxAttachmentsPerMessage: number;
|
|
|
|
|
readonly #bySession = new Map<string, Map<string, StoredAttachment>>();
|
|
|
|
|
|
|
|
|
|
public constructor(options: FileAttachmentStoreOptions) {
|
|
|
|
|
this.#sessionsRoot = resolve(options.sessionsRoot);
|
|
|
|
|
this.#eventStore = options.eventStore;
|
|
|
|
|
this.#maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
|
|
|
|
|
this.#maxWidth = options.maxWidth ?? 8_192;
|
|
|
|
|
this.#maxHeight = options.maxHeight ?? 8_192;
|
|
|
|
|
this.#maxPixels = options.maxPixels ?? 25_000_000;
|
|
|
|
|
this.#maxAttachmentsPerMessage = options.maxAttachmentsPerMessage ?? 4;
|
|
|
|
|
for (const [name, value] of Object.entries({
|
|
|
|
|
maxBytes: this.#maxBytes,
|
|
|
|
|
maxWidth: this.#maxWidth,
|
|
|
|
|
maxHeight: this.#maxHeight,
|
|
|
|
|
maxPixels: this.#maxPixels,
|
|
|
|
|
maxAttachmentsPerMessage: this.#maxAttachmentsPerMessage,
|
|
|
|
|
})) assertPositiveInteger(value, name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async add(sessionId: string, upload: ImageUpload): Promise<ImageAttachment> {
|
|
|
|
|
let uploadPath: string | undefined;
|
|
|
|
|
let normalizedPath: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
const safeName = validateUploadMetadata(sessionId, upload);
|
|
|
|
|
const attachmentDirectory = await this.#attachmentDirectory(sessionId);
|
|
|
|
|
uploadPath = join(attachmentDirectory, `${randomUUID()}.upload`);
|
|
|
|
|
normalizedPath = join(attachmentDirectory, `${randomUUID()}.normalized`);
|
|
|
|
|
await pipeline(
|
|
|
|
|
upload.stream,
|
|
|
|
|
new ByteLimitTransform(this.#maxBytes),
|
|
|
|
|
createWriteStream(uploadPath, { flags: "wx", mode: 0o600 }),
|
|
|
|
|
);
|
|
|
|
|
const headerAndBody = await readFile(uploadPath);
|
|
|
|
|
const mediaType = assertImageType(safeName, upload.mediaType, headerAndBody.subarray(0, 12));
|
|
|
|
|
const sourceMetadata = await sharp(uploadPath, { failOn: "error", limitInputPixels: false }).metadata();
|
|
|
|
|
assertDimensions(sourceMetadata.width, sourceMetadata.height, this.#maxWidth, this.#maxHeight, this.#maxPixels);
|
|
|
|
|
await normalizeImage(uploadPath, normalizedPath, mediaType, this.#maxPixels);
|
|
|
|
|
const imageMetadata = await sharp(normalizedPath, { failOn: "error", limitInputPixels: this.#maxPixels }).metadata();
|
|
|
|
|
const width = imageMetadata.width;
|
|
|
|
|
const height = imageMetadata.height;
|
|
|
|
|
assertDimensions(width, height, this.#maxWidth, this.#maxHeight, this.#maxPixels);
|
|
|
|
|
const normalizedStat = await stat(normalizedPath);
|
|
|
|
|
if (normalizedStat.size > this.#maxBytes) {
|
|
|
|
|
throw new HarnessError("IMAGE_TOO_LARGE", "Normalized image exceeds the configured byte limit");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const attachmentId = `att_${randomUUID()}`;
|
|
|
|
|
const finalPath = join(attachmentDirectory, `${attachmentId}${extensionForMediaType(mediaType)}`);
|
|
|
|
|
await rename(normalizedPath, finalPath);
|
|
|
|
|
normalizedPath = undefined;
|
|
|
|
|
const metadata = ImageAttachmentSchema.parse({
|
|
|
|
|
attachmentId,
|
|
|
|
|
sessionId,
|
|
|
|
|
name: safeName,
|
|
|
|
|
mediaType,
|
|
|
|
|
byteSize: normalizedStat.size,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
status: "ready",
|
|
|
|
|
});
|
|
|
|
|
this.#sessionIndex(sessionId).set(attachmentId, { metadata, filePath: finalPath });
|
|
|
|
|
this.#eventStore?.append(sessionId, {
|
|
|
|
|
type: "attachment.uploaded",
|
|
|
|
|
payload: {
|
|
|
|
|
attachmentId,
|
|
|
|
|
name: safeName,
|
|
|
|
|
mediaType,
|
|
|
|
|
byteSize: normalizedStat.size,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return { ...metadata };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const safeName = typeof upload.name === "string" && upload.name.trim() !== ""
|
|
|
|
|
? upload.name.trim().slice(0, 255)
|
|
|
|
|
: "unnamed-image";
|
|
|
|
|
this.#eventStore?.append(sessionId, {
|
|
|
|
|
type: "attachment.rejected",
|
|
|
|
|
payload: { name: safeName, reason: safeErrorCode(error) },
|
|
|
|
|
});
|
|
|
|
|
if (error instanceof HarnessError) throw error;
|
|
|
|
|
throw new HarnessError("IMAGE_INVALID", "Image could not be decoded");
|
|
|
|
|
} finally {
|
|
|
|
|
if (uploadPath !== undefined) await rm(uploadPath, { force: true }).catch(() => undefined);
|
|
|
|
|
if (normalizedPath !== undefined) await rm(normalizedPath, { force: true }).catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public get(sessionId: string, attachmentId: string): ImageAttachment | undefined {
|
|
|
|
|
const stored = this.#bySession.get(sessionId)?.get(attachmentId);
|
|
|
|
|
return stored === undefined ? undefined : { ...stored.metadata };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async openNormalized(sessionId: string, attachmentId: string): Promise<Buffer> {
|
|
|
|
|
const stored = this.#stored(sessionId, attachmentId);
|
|
|
|
|
return readFile(stored.filePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async remove(sessionId: string, attachmentId: string): Promise<boolean> {
|
|
|
|
|
const session = this.#bySession.get(sessionId);
|
|
|
|
|
const stored = session?.get(attachmentId);
|
|
|
|
|
if (stored === undefined) return false;
|
|
|
|
|
session!.delete(attachmentId);
|
|
|
|
|
await rm(stored.filePath, { force: true });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async removeAll(sessionId: string): Promise<void> {
|
|
|
|
|
const session = this.#bySession.get(sessionId);
|
|
|
|
|
if (session === undefined) return;
|
|
|
|
|
this.#bySession.delete(sessionId);
|
|
|
|
|
await Promise.all([...session.values()].map(({ filePath }) => rm(filePath, { force: true })));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async toPiImages(sessionId: string, attachmentIds: string[]): Promise<ImageContent[]> {
|
|
|
|
|
if (attachmentIds.length > this.#maxAttachmentsPerMessage) {
|
|
|
|
|
throw new HarnessError("TOO_MANY_ATTACHMENTS", "Message contains too many attachments");
|
|
|
|
|
}
|
|
|
|
|
return Promise.all(attachmentIds.map(async (attachmentId) => {
|
|
|
|
|
const stored = this.#stored(sessionId, attachmentId);
|
|
|
|
|
const bytes = await readFile(stored.filePath);
|
|
|
|
|
return { type: "image", data: bytes.toString("base64"), mimeType: stored.metadata.mediaType };
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async #attachmentDirectory(sessionId: string): Promise<string> {
|
|
|
|
|
if (!/^ses_[a-zA-Z0-9_-]+$/.test(sessionId)) {
|
|
|
|
|
throw new HarnessError("INVALID_SESSION_ID", "Session ID is invalid");
|
|
|
|
|
}
|
|
|
|
|
const canonicalRoot = await realpath(this.#sessionsRoot);
|
|
|
|
|
const sessionDirectory = join(canonicalRoot, sessionId);
|
|
|
|
|
const sessionMetadata = await lstat(sessionDirectory).catch(() => undefined);
|
|
|
|
|
if (sessionMetadata === undefined || !sessionMetadata.isDirectory() || sessionMetadata.isSymbolicLink()) {
|
|
|
|
|
throw new HarnessError("SESSION_NOT_FOUND", "Session workspace does not exist");
|
|
|
|
|
}
|
|
|
|
|
assertContained(canonicalRoot, await realpath(sessionDirectory));
|
|
|
|
|
const directory = join(sessionDirectory, "attachments");
|
|
|
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
|
|
|
const canonicalDirectory = await realpath(directory);
|
|
|
|
|
assertContained(sessionDirectory, canonicalDirectory);
|
|
|
|
|
return canonicalDirectory;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#stored(sessionId: string, attachmentId: string): StoredAttachment {
|
|
|
|
|
const stored = this.#bySession.get(sessionId)?.get(attachmentId);
|
|
|
|
|
if (stored === undefined) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment was not found");
|
|
|
|
|
return stored;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#sessionIndex(sessionId: string): Map<string, StoredAttachment> {
|
|
|
|
|
const current = this.#bySession.get(sessionId);
|
|
|
|
|
if (current !== undefined) return current;
|
|
|
|
|
const created = new Map<string, StoredAttachment>();
|
|
|
|
|
this.#bySession.set(sessionId, created);
|
|
|
|
|
return created;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class ByteLimitTransform extends Transform {
|
|
|
|
|
#bytes = 0;
|
|
|
|
|
|
|
|
|
|
public constructor(private readonly maxBytes: number) {
|
|
|
|
|
super();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
|
|
|
|
|
this.#bytes += chunk.byteLength;
|
|
|
|
|
if (this.#bytes > this.maxBytes) {
|
|
|
|
|
callback(new HarnessError("IMAGE_TOO_LARGE", "Image exceeds the configured byte limit"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
callback(null, chunk);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function normalizeImage(
|
|
|
|
|
inputPath: string,
|
|
|
|
|
outputPath: string,
|
|
|
|
|
mediaType: SupportedImageMediaType,
|
|
|
|
|
maxPixels: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
let pipeline = sharp(inputPath, { failOn: "error", limitInputPixels: maxPixels }).rotate();
|
|
|
|
|
if (mediaType === "image/jpeg") pipeline = pipeline.jpeg({ quality: 90 });
|
|
|
|
|
if (mediaType === "image/png") pipeline = pipeline.png();
|
|
|
|
|
if (mediaType === "image/webp") pipeline = pipeline.webp({ quality: 90 });
|
|
|
|
|
await pipeline.toFile(outputPath);
|
|
|
|
|
await chmod(outputPath, 0o600);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateUploadMetadata(sessionId: string, upload: ImageUpload): string {
|
|
|
|
|
if (!/^ses_[a-zA-Z0-9_-]+$/.test(sessionId)) {
|
|
|
|
|
throw new HarnessError("INVALID_SESSION_ID", "Session ID is invalid");
|
|
|
|
|
}
|
|
|
|
|
if (typeof upload.name !== "string" || upload.name.trim().length === 0 || upload.name.trim().length > 255) {
|
|
|
|
|
throw new HarnessError("INVALID_ATTACHMENT_NAME", "Attachment name is invalid");
|
|
|
|
|
}
|
|
|
|
|
return upload.name.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeErrorCode(error: unknown): string {
|
|
|
|
|
return error instanceof HarnessError ? error.code : "IMAGE_INVALID";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertContained(root: string, candidate: string): void {
|
|
|
|
|
const fromRoot = relative(root, candidate);
|
|
|
|
|
if (fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot))) return;
|
|
|
|
|
throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Attachment path is outside the Session workspace");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertPositiveInteger(value: number, name: string): void {
|
|
|
|
|
if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertDimensions(
|
|
|
|
|
width: number | undefined,
|
|
|
|
|
height: number | undefined,
|
|
|
|
|
maxWidth: number,
|
|
|
|
|
maxHeight: number,
|
|
|
|
|
maxPixels: number,
|
|
|
|
|
): asserts width is number {
|
|
|
|
|
if (width === undefined || height === undefined) {
|
|
|
|
|
throw new HarnessError("IMAGE_INVALID", "Image dimensions could not be determined");
|
|
|
|
|
}
|
|
|
|
|
if (width > maxWidth || height > maxHeight || width * height > maxPixels) {
|
|
|
|
|
throw new HarnessError("IMAGE_DIMENSIONS_EXCEEDED", "Image dimensions exceed the configured limit");
|
|
|
|
|
}
|
|
|
|
|
}
|