import { randomUUID } from 'crypto'; import { mkdir, writeFile, readFile } from 'fs/promises'; import { watch } from 'fs'; import { basename, join } from 'path'; import { parse as parseToml } from 'smol-toml'; import { isSafeRouteName, isRouteConfig, type RouteConfig, type StoredRequest } from '../types/index.js'; import { Either, type IEither } from '@emprespresso/pengueno'; type IncomingUpload = { fieldName: string; filename: string; contentType: string; size: number; data: Uint8Array; }; function sanitizeFilename(filename: string): string { const base = basename(filename); const safe = base.replace(/[^a-zA-Z0-9._-]/g, '_'); return safe.length > 0 ? safe.slice(0, 200) : 'upload.bin'; } export class Storage { private routes: Map = new Map(); private configPath: string; constructor( private readonly dataDir: string = './data', configPath: string = './routes.toml', ) { this.configPath = configPath; } async init(): Promise> { try { await mkdir(this.dataDir, { recursive: true }); await this.loadRoutes(); this.watchConfig(); return Either.right(undefined); } catch (err) { return Either.left(err instanceof Error ? err : new Error(String(err))); } } private async loadRoutes(): Promise { try { const data = await readFile(this.configPath, 'utf-8'); const parsed = parseToml(data); if (!parsed || typeof parsed !== 'object' || !('route' in parsed)) { console.error('Invalid routes.toml: missing [[route]] sections'); process.exit(1); } const routes = parsed.route; if (!Array.isArray(routes)) { console.error('Invalid routes.toml: "route" must be an array of tables'); process.exit(1); } const newRoutes = new Map(); for (const route of routes) { if (!isRouteConfig(route)) { console.error('Invalid route configuration:', route); process.exit(1); } if (newRoutes.has(route.name)) { console.error(`Duplicate route name: ${route.name}`); process.exit(1); } newRoutes.set(route.name, route); // Ensure route directory exists const routeDir = join(this.dataDir, route.name); await mkdir(routeDir, { recursive: true }); } this.routes = newRoutes; console.log(`Loaded ${this.routes.size} route(s) from ${this.configPath}`); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { console.log(`No ${this.configPath} found, starting with empty routes`); return; } console.error(`Failed to load routes from ${this.configPath}:`, err); process.exit(1); } } private watchConfig(): void { const watcher = watch(this.configPath, async (eventType) => { if (eventType === 'change') { console.log(`${this.configPath} changed, reloading...`); await this.loadRoutes(); } }); watcher.on('error', (err) => { console.error(`Error watching ${this.configPath}:`, err); }); } getRoute(name: string): RouteConfig | undefined { if (!isSafeRouteName(name)) return undefined; return this.routes.get(name); } listRoutes(): RouteConfig[] { return Array.from(this.routes.values()); } async storeRequest( routeName: string, method: string, headers: Record, body: unknown, uploads?: IncomingUpload[], ): Promise> { if (!isSafeRouteName(routeName)) { return Either.left(new Error('Invalid route name')); } const timestamp = Date.now(); const uuid = randomUUID(); const baseName = `${timestamp}_${uuid}`; const routeDir = join(this.dataDir, routeName); try { await mkdir(routeDir, { recursive: true }); const requestDir = join(routeDir, baseName); await mkdir(requestDir, { recursive: true }); const files: StoredRequest['files'] = uploads?.length ? await (async () => { const filesDir = join(requestDir, 'files'); await mkdir(filesDir, { recursive: true }); const storedFiles: NonNullable = []; for (let i = 0; i < uploads.length; i++) { const upload = uploads[i]; const safeOriginal = sanitizeFilename(upload.filename); const savedName = `${i}_${safeOriginal}`; const diskPath = join(filesDir, savedName); await writeFile(diskPath, Buffer.from(upload.data)); storedFiles.push({ fieldName: upload.fieldName, originalFilename: upload.filename, filename: savedName, contentType: upload.contentType, size: upload.size, path: join('files', savedName), }); } return storedFiles; })() : undefined; const stored: StoredRequest = { timestamp, uuid, routeName, method, headers, body, files, }; await writeFile(join(requestDir, 'request.json'), JSON.stringify(stored, null, 2)); return Either.right(stored); } catch (err) { return Either.left(err instanceof Error ? err : new Error(String(err))); } } }