import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { realpath, stat, readdir, access } from 'node:fs/promises'; import { basename, resolve, sep } from 'node:path'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; import type { Broker } from '../services/broker.js'; import type { Project, AvailableProject } from '../types/api.js'; const AddProjectBody = z.object({ path: z.string().min(1), name: z.string().min(1).optional(), }); async function isDir(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); } catch { return false; } } async function resolveProjectPath( raw: string, whitelist: string ): Promise<{ real: string; name: string } | { error: string }> { if (!raw.startsWith('/')) return { error: 'path must be absolute' }; let real: string; try { real = await realpath(raw); } catch { return { error: 'path does not exist' }; } const whitelistReal = await realpath(whitelist); if (real !== whitelistReal && !real.startsWith(whitelistReal + sep)) { return { error: `path must be under ${whitelist}` }; } if (!(await isDir(real))) return { error: 'path is not a directory' }; return { real, name: basename(real) }; } export function registerProjectRoutes( app: FastifyInstance, sql: Sql, config: Config, broker: Broker ): void { app.get('/api/projects', async () => { const rows = await sql` SELECT id, name, path, added_at, last_session_id FROM projects ORDER BY added_at DESC `; return rows; }); app.post('/api/projects', async (req, reply) => { const parsed = AddProjectBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const resolved = await resolveProjectPath(parsed.data.path, config.PROJECT_ROOT_WHITELIST); if ('error' in resolved) { reply.code(400); return { error: resolved.error }; } const name = parsed.data.name?.trim() || resolved.name; try { const [row] = await sql` INSERT INTO projects (name, path) VALUES (${name}, ${resolved.real}) RETURNING id, name, path, added_at, last_session_id `; broker.publishUser(req.user!, { type: 'project_created', project: row as unknown as Project }); reply.code(201); return row; } catch (err) { if (err instanceof Error && err.message.includes('duplicate key')) { reply.code(409); return { error: 'project already exists' }; } throw err; } }); app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { const id = req.params.id; const result = await sql`DELETE FROM projects WHERE id = ${id}`; if (result.count === 0) { reply.code(404); return { error: 'not found' }; } broker.publishUser(req.user!, { type: 'project_deleted', project_id: id }); reply.code(204); return null; }); app.get('/api/projects/available', async () => { const whitelist = await realpath(config.PROJECT_ROOT_WHITELIST); let entries: string[]; try { entries = await readdir(whitelist); } catch { return [] as AvailableProject[]; } const existing = await sql<{ path: string }[]>`SELECT path FROM projects`; const existingSet = new Set(existing.map((r) => r.path)); const out: AvailableProject[] = []; for (const entry of entries) { const full = resolve(whitelist, entry); let real: string; try { real = await realpath(full); } catch { continue; } if (real !== whitelist && !real.startsWith(whitelist + sep)) continue; if (existingSet.has(real)) continue; if (!(await isDir(real))) continue; try { await access(resolve(real, '.git')); } catch { continue; } out.push({ path: real, name: basename(real) }); } out.sort((a, b) => a.name.localeCompare(b.name)); return out; }); }