// @ts-nocheck // Project files registry. Each project is a folder under // /.od/projects//. The frontend's project list // (localStorage) carries metadata; this module is the single owner of the // on-disk content (HTML artifacts, sketches, uploaded images, pasted text). // // All paths flowing in from HTTP handlers are validated against the project // directory to prevent path traversal — see resolveSafe(). import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { inferLegacyManifest, parsePersistedManifest, validateArtifactManifestInput, } from './artifact-manifest.js'; const FORBIDDEN_SEGMENT = /^$|^\.\.?$/; export function projectDir(projectsRoot, projectId) { if (!isSafeId(projectId)) throw new Error('invalid project id'); return path.join(projectsRoot, projectId); } export async function ensureProject(projectsRoot, projectId) { const dir = projectDir(projectsRoot, projectId); await mkdir(dir, { recursive: true }); return dir; } export async function listFiles(projectsRoot, projectId) { const dir = projectDir(projectsRoot, projectId); const out = []; await collectFiles(dir, '', out); // Newest first — matches the visual order users expect after generating. out.sort((a, b) => b.mtime - a.mtime); return out; } async function collectFiles(dir, relDir, out) { let entries = []; try { entries = await readdir(dir, { withFileTypes: true }); } catch (err) { if (err && err.code === 'ENOENT') return; throw err; } for (const e of entries) { if (e.name.startsWith('.')) continue; const rel = relDir ? `${relDir}/${e.name}` : e.name; const full = path.join(dir, e.name); if (e.isDirectory()) { await collectFiles(full, rel, out); continue; } if (!e.isFile()) continue; if (e.name.endsWith('.artifact.json')) continue; const st = await stat(full); const manifest = await readManifestForPath(dir, rel); out.push({ name: rel, path: rel, type: 'file', size: st.size, mtime: st.mtimeMs, kind: kindFor(rel), mime: mimeFor(rel), artifactKind: manifest?.kind, artifactManifest: manifest, }); } } export async function readProjectFile(projectsRoot, projectId, name) { const dir = projectDir(projectsRoot, projectId); const file = resolveSafe(dir, name); const buf = await readFile(file); const st = await stat(file); const rel = toProjectPath(path.relative(dir, file)); const manifest = await readManifestForPath(dir, rel); return { buffer: buf, name: rel, path: rel, size: st.size, mtime: st.mtimeMs, mime: mimeFor(rel), kind: kindFor(rel), artifactKind: manifest?.kind, artifactManifest: manifest, }; } export async function writeProjectFile( projectsRoot, projectId, name, body, { overwrite = true, artifactManifest = null } = {}, ) { const dir = await ensureProject(projectsRoot, projectId); const safeName = sanitizePath(name); const target = resolveSafe(dir, safeName); if (!overwrite) { try { await stat(target); throw new Error('file already exists'); } catch (err) { if (!err || err.code !== 'ENOENT') throw err; } } await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, body); if (artifactManifest && typeof artifactManifest === 'object') { const manifestFileName = artifactManifestNameFor(safeName); const manifestTarget = resolveSafe(dir, manifestFileName); const validated = validateArtifactManifestInput(artifactManifest, safeName); if (validated.ok && validated.value) { const nextManifest = validated.value; await writeFile(manifestTarget, JSON.stringify(nextManifest, null, 2)); } } const st = await stat(target); const persistedManifest = await readManifestForPath(dir, safeName); return { name: safeName, path: safeName, size: st.size, mtime: st.mtimeMs, kind: kindFor(safeName), mime: mimeFor(safeName), artifactKind: persistedManifest?.kind, artifactManifest: persistedManifest, }; } function artifactManifestNameFor(name) { return `${name}.artifact.json`; } async function readManifestForPath(projectDirPath, relPath) { const manifestPath = path.join(projectDirPath, artifactManifestNameFor(relPath)); try { const raw = await readFile(manifestPath, 'utf8'); const parsed = parseManifest(raw); if (parsed) return parsed; } catch (err) { if (!err || err.code !== 'ENOENT') { // ignore malformed/invalid manifests and fallback to inference } } return inferLegacyManifest(relPath); } function parseManifest(raw) { return parsePersistedManifest(raw, ''); } export async function deleteProjectFile(projectsRoot, projectId, name) { const dir = projectDir(projectsRoot, projectId); const file = resolveSafe(dir, name); await unlink(file); } export async function removeProjectDir(projectsRoot, projectId) { const dir = projectDir(projectsRoot, projectId); await rm(dir, { recursive: true, force: true }); } function resolveSafe(dir, name) { const safePath = validateProjectPath(name); const target = path.resolve(dir, safePath); if (!target.startsWith(dir + path.sep) && target !== dir) { throw new Error('path escapes project dir'); } return target; } export function sanitizePath(raw) { const normalized = validateProjectPath(raw); return normalized.split('/').map(sanitizeName).join('/'); } export function validateProjectPath(raw) { if (typeof raw !== 'string' || !raw.trim()) { throw new Error('invalid file name'); } if (raw.includes('\0') || /^[A-Za-z]:/.test(raw) || raw.startsWith('/')) { throw new Error('invalid file name'); } const normalized = raw.replace(/\\/g, '/'); const parts = normalized.split('/').filter(Boolean); if (parts.length === 0 || parts.some((p) => FORBIDDEN_SEGMENT.test(p))) { throw new Error('invalid file name'); } return parts.join('/'); } // Replace anything outside [A-Za-z0-9._-] with underscore. Spaces collapse // to dashes (matches the kebab-case style used by the agent's slugs). export function sanitizeName(raw) { const cleaned = String(raw ?? '') .replace(/[\\/]/g, '_') .replace(/\s+/g, '-') .replace(/[^A-Za-z0-9._-]/g, '_') .replace(/^\.+/, '_') .trim(); return cleaned || `file-${Date.now()}`; } function toProjectPath(raw) { return raw.split(path.sep).join('/'); } function isSafeId(id) { return typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id); } const EXT_MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.cjs': 'text/javascript; charset=utf-8', '.ts': 'text/typescript; charset=utf-8', '.tsx': 'text/typescript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.pdf': 'application/pdf', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', }; export function mimeFor(name) { const ext = path.extname(name).toLowerCase(); return EXT_MIME[ext] || 'application/octet-stream'; } // Coarse kind buckets the frontend uses to pick a viewer. export function kindFor(name) { // Editable sketches use a compound extension so they slot into the // "sketch" bucket while still being valid JSON on disk. if (name.endsWith('.sketch.json')) return 'sketch'; const ext = path.extname(name).toLowerCase(); if (ext === '.html' || ext === '.htm') return 'html'; if (ext === '.svg') return 'sketch'; if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].includes(ext)) { if (name.startsWith('sketch-')) return 'sketch'; return 'image'; } if (['.md', '.txt'].includes(ext)) return 'text'; if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css'].includes(ext)) { return 'code'; } if (ext === '.pdf') return 'pdf'; if (ext === '.docx') return 'document'; if (ext === '.pptx') return 'presentation'; if (ext === '.xlsx') return 'spreadsheet'; return 'binary'; }