import { randomUUID } from "node:crypto"; import { inject, injectable } from "@codemation/core"; import type { IWorkspaceFileStorage, WorkspaceFileMetadata } from "@codemation/core"; import { WorkspaceFileNotFoundError } from "@codemation/core"; import { PairingConfigToken } from "../../pairing/PairingConfigToken"; import type { PairingConfig } from "../../pairing/pairing.types"; import { BlobSignClient } from "./BlobSignClient"; @injectable() export class PresignedS3WorkspaceFileStorage implements IWorkspaceFileStorage { constructor( @inject(BlobSignClient) private readonly blobSignClient: BlobSignClient, @inject(PairingConfigToken) private readonly pairingConfig: PairingConfig, ) {} async listFiles(filenameFilter?: string): Promise> { const prefix = `${this.pairingConfig.workspaceId}/files/`; const keys = await this.blobSignClient.listKeys(prefix); const metas = await Promise.all(keys.map((k) => this.getMetadataByKey(k))); const sorted = [...metas].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); if (filenameFilter) { const lower = filenameFilter.toLowerCase(); return sorted.filter((m) => m.filename.toLowerCase().includes(lower)); } return sorted; } async getFileByName(filename: string): Promise { const all = await this.listFiles(); const matches = all.filter((m) => m.filename === filename); if (matches.length === 0) { throw new WorkspaceFileNotFoundError(`filename:${filename}`); } return matches[0]!; } async getFileById(fileId: string): Promise { const key = `${this.pairingConfig.workspaceId}/files/${fileId}`; return this.getMetadataByKey(key); } async getStream(key: string): Promise> { this.validateKey(key); const [signed] = await this.blobSignClient.getPresignedUrls("GET", [key]); if (!signed) { throw new WorkspaceFileNotFoundError(key); } const response = await fetch(signed.url, { method: "GET" }); if (response.status === 404) { throw new WorkspaceFileNotFoundError(key); } if (!response.ok) { throw new Error(`PresignedS3WorkspaceFileStorage GET failed: ${response.status} for key ${key}`); } if (!response.body) { throw new WorkspaceFileNotFoundError(key); } return response.body as ReadableStream; } async writeFile(filename: string, body: Uint8Array, contentType: string): Promise { const fileId = randomUUID(); const key = `${this.pairingConfig.workspaceId}/files/${fileId}`; this.validateKey(key); const [signed] = await this.blobSignClient.getPresignedUrls("PUT", [key]); if (!signed) { throw new Error(`PresignedS3WorkspaceFileStorage: no URL returned for PUT ${key}`); } const response = await fetch(signed.url, { method: "PUT", body: body as unknown as BodyInit, headers: { "Content-Length": String(body.byteLength), "Content-Type": contentType }, }); if (!response.ok) { throw new Error(`PresignedS3WorkspaceFileStorage PUT failed: ${response.status} for key ${key}`); } return { key, fileId, filename, contentType, size: body.byteLength, lastModified: new Date(), }; } private async getMetadataByKey(key: string): Promise { this.validateKey(key); const [signed] = await this.blobSignClient.getPresignedUrls("HEAD", [key]); if (!signed) { throw new WorkspaceFileNotFoundError(key); } const response = await fetch(signed.url, { method: "HEAD" }); if (response.status === 404) { throw new WorkspaceFileNotFoundError(key); } if (!response.ok) { throw new Error(`PresignedS3WorkspaceFileStorage HEAD failed: ${response.status} for key ${key}`); } const fileId = key.split("/").pop() ?? key; return { key, fileId, filename: response.headers.get("x-amz-meta-filename") ?? "", contentType: response.headers.get("content-type") ?? "application/octet-stream", size: Number(response.headers.get("content-length") ?? "0"), lastModified: new Date(response.headers.get("last-modified") ?? Date.now()), }; } private validateKey(key: string): void { if (key.includes("..")) { throw new Error(`Invalid workspace file storage key: ${key}`); } } }