import { Readable } from "node:stream"; import { randomUUID } from "node:crypto"; import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import type { IWorkspaceFileStorage, WorkspaceFileMetadata } from "@codemation/core"; import { WorkspaceFileNotFoundError } from "@codemation/core"; import type { WorkspaceFileStorageConfig } from "./WorkspaceFileStorageConfig"; export class S3WorkspaceFileStorage implements IWorkspaceFileStorage { private readonly client: S3Client; private readonly bucket: string; private readonly workspaceId: string; constructor(config: WorkspaceFileStorageConfig) { this.bucket = config.bucket; this.workspaceId = config.workspaceId; this.client = new S3Client({ endpoint: config.endpoint, region: config.region, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, }); } async listFiles(filenameFilter?: string): Promise> { const prefix = `${this.workspaceId}/files/`; const keys = await this.listKeysByPrefix(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.workspaceId}/files/${fileId}`; return this.getMetadataByKey(key); } async getStream(key: string): Promise> { this.validateKey(key); let response; try { response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); } catch (err) { if (this.isNotFound(err)) { throw new WorkspaceFileNotFoundError(key); } throw err; } if (!response.Body) { throw new WorkspaceFileNotFoundError(key); } const nodeReadable = Readable.from(response.Body as AsyncIterable); return Readable.toWeb(nodeReadable) as ReadableStream; } async writeFile(filename: string, body: Uint8Array, contentType: string): Promise { const fileId = randomUUID(); const key = `${this.workspaceId}/files/${fileId}`; this.validateKey(key); await this.client.send( new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: contentType, ContentLength: body.byteLength, Metadata: { filename }, }), ); return { key, fileId, filename, contentType, size: body.byteLength, lastModified: new Date(), }; } private async getMetadataByKey(key: string): Promise { this.validateKey(key); let response; try { response = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); } catch (err) { if (this.isNotFound(err)) { throw new WorkspaceFileNotFoundError(key); } throw err; } const fileId = key.split("/").pop() ?? key; return { key, fileId, filename: response.Metadata?.["filename"] ?? "", contentType: response.ContentType ?? "application/octet-stream", size: response.ContentLength ?? 0, lastModified: response.LastModified ?? new Date(), }; } private async listKeysByPrefix(prefix: string): Promise { const keys: string[] = []; let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: continuationToken, }), ); for (const obj of response.Contents ?? []) { if (obj.Key) { keys.push(obj.Key); } } continuationToken = response.NextContinuationToken; } while (continuationToken); return keys; } private validateKey(key: string): void { if (key.includes("..")) { throw new Error(`Invalid workspace file storage key: ${key}`); } } private isNotFound(err: unknown): boolean { if (typeof err !== "object" || err === null) return false; const anyErr = err as Record; const statusCode = anyErr["$metadata"] != null ? (anyErr["$metadata"] as Record)["httpStatusCode"] : undefined; return statusCode === 404 || anyErr["name"] === "NotFound" || anyErr["name"] === "NoSuchKey"; } }