import { createReadStream } from "node:fs"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { Readable } from "node:stream"; import { randomUUID } from "node:crypto"; import type { IWorkspaceFileStorage, WorkspaceFileMetadata } from "@codemation/core"; import { WorkspaceFileNotFoundError } from "@codemation/core"; import type { WorkspaceFileStorageConfig } from "./WorkspaceFileStorageConfig"; export class LocalFilesystemWorkspaceFileStorage implements IWorkspaceFileStorage { private readonly baseDir: string; private readonly workspaceId: string; constructor(config: WorkspaceFileStorageConfig) { this.baseDir = config.baseDir; this.workspaceId = config.workspaceId; } async listFiles(filenameFilter?: string): Promise> { const prefix = `${this.workspaceId}/files/`; const keys: string[] = []; await this.collectKeysWithPrefix(prefix, this.baseDir, keys); 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> { const filePath = this.resolvePath(key); await this.assertExists(filePath, key); return Readable.toWeb(createReadStream(filePath)) as ReadableStream; } async writeFile(filename: string, body: Uint8Array, contentType: string): Promise { const fileId = randomUUID(); const key = `${this.workspaceId}/files/${fileId}`; const filePath = this.resolvePath(key); await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, body); await writeFile(`${filePath}.meta.json`, JSON.stringify({ contentType, size: body.byteLength, filename }), "utf8"); const s = await stat(filePath); return { key, fileId, filename, contentType, size: body.byteLength, lastModified: s.mtime, }; } private async getMetadataByKey(key: string): Promise { const filePath = this.resolvePath(key); await this.assertExists(filePath, key); const s = await stat(filePath); const meta = await this.readSidecar(filePath); const fileId = key.split("/").pop() ?? key; return { key, fileId, filename: meta.filename ?? "", contentType: meta.contentType, size: meta.size, lastModified: s.mtime, }; } private async readSidecar(filePath: string): Promise<{ contentType: string; size: number; filename?: string }> { try { const raw = await readFile(`${filePath}.meta.json`, "utf8"); return JSON.parse(raw) as { contentType: string; size: number; filename?: string }; } catch { return { contentType: "application/octet-stream", size: 0 }; } } private async collectKeysWithPrefix(prefix: string, dir: string, results: string[]): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const entryPath = join(dir, entry.name); const relKey = relative(resolve(this.baseDir), entryPath).replace(/\\/g, "/"); if (entry.isDirectory()) { await this.collectKeysWithPrefix(prefix, entryPath, results); } else if (relKey.startsWith(prefix) && !relKey.endsWith(".meta.json")) { results.push(relKey); } } } private resolvePath(key: string): string { if (key.includes("..")) { throw new Error(`Invalid workspace file storage key: ${key}`); } const absoluteBase = resolve(this.baseDir); const target = resolve(join(absoluteBase, key)); if (!target.startsWith(`${absoluteBase}${sep}`) && target !== absoluteBase) { throw new Error(`Invalid workspace file storage key: ${key}`); } return target; } private async assertExists(filePath: string, key: string): Promise { try { await stat(filePath); } catch { throw new WorkspaceFileNotFoundError(key); } } }