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'; import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js'; import { listDir, viewFile } from '../services/file_ops.js'; import { getProjectFiles } from '../services/file_index.js'; import { bootstrapProject, BootstrapNameError, BootstrapCollisionError, BootstrapPathError, } from '../services/project_bootstrap.js'; const AddProjectBody = z.object({ path: z.string().min(1), name: z.string().min(1).optional(), }); const PatchProjectBody = z.object({ name: z.string().min(1).max(200), }); const CreateProjectBody = z.object({ name: z.string().min(1).max(64), commit_message: z.string().min(1).max(200).optional(), visibility: z.enum(['private', 'public']).optional(), create_gitea_remote: z.boolean().optional(), }); async function isDir(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); } catch { return false; } } export 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<{ Querystring: { status?: string } }>('/api/projects', async (req) => { const status = req.query.status === 'archived' ? 'archived' : 'open'; const rows = await sql` SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE status = ${status} ORDER BY added_at DESC `; return rows; }); app.post('/api/projects/create', async (req, reply) => { const parsed = CreateProjectBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const visibility = parsed.data.visibility ?? 'private'; const createRemote = parsed.data.create_gitea_remote ?? true; const commitMessage = parsed.data.commit_message ?? 'Initial commit'; let bootstrap; try { bootstrap = await bootstrapProject(config, app.log, { name: parsed.data.name, commitMessage, visibility, createGiteaRemote: createRemote, }); } catch (err) { if (err instanceof BootstrapNameError) { reply.code(400); return { error: `invalid project name: ${err.message}` }; } if (err instanceof BootstrapCollisionError) { reply.code(409); return { error: err.message }; } if (err instanceof BootstrapPathError) { reply.code(400); return { error: err.message }; } app.log.error({ err }, 'bootstrap failed'); reply.code(500); return { error: err instanceof Error ? err.message : 'bootstrap failed' }; } // Insert into projects table only after bootstrap succeeded. try { const [row] = await sql` INSERT INTO projects (name, path, gitea_remote) VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url}) RETURNING id, name, path, added_at, last_session_id, status, gitea_remote `; broker.publishUser('default', { type: 'project_created', project: row as unknown as Project }); reply.code(201); return { project: row, bootstrap: { folder_created: bootstrap.folder_created, git_initialized: bootstrap.git_initialized, first_commit: bootstrap.first_commit, gitea_remote_created: bootstrap.gitea_remote_created, gitea_pushed: bootstrap.gitea_pushed, warnings: bootstrap.warnings, }, }; } catch (err) { app.log.error({ err, folder: bootstrap.folder_real_path }, 'project insert failed after bootstrap'); reply.code(500); return { error: 'project created on disk but DB insert failed', folder: bootstrap.folder_real_path, }; } }); 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; // Pre-check the current row (if any) so we can distinguish three cases: // - no row → INSERT fresh, 201, project_created // - row archived → ON CONFLICT UPDATE flips to 'open', 200, project_unarchived // - row already open → 409 (true duplicate) const existing = await sql<{ status: string }[]>` SELECT status FROM projects WHERE path = ${resolved.real} `; if (existing.length > 0 && existing[0]!.status === 'open') { reply.code(409); return { error: 'project already exists' }; } const [row] = await sql` INSERT INTO projects (name, path) VALUES (${name}, ${resolved.real}) ON CONFLICT (path) DO UPDATE SET status = 'open' RETURNING id, name, path, added_at, last_session_id, status, gitea_remote `; if (existing.length === 0) { broker.publishUser('default', { type: 'project_created', project: row as unknown as Project }); reply.code(201); } else { // existing.status was 'archived' — row has been restored. broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project }); reply.code(200); } return row; }); app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { const parsed = PatchProjectBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const rows = await sql` UPDATE projects SET name = ${parsed.data.name} WHERE id = ${req.params.id} RETURNING id, name, path, added_at, last_session_id, status, gitea_remote `; if (rows.length === 0) { reply.code(404); return { error: 'not found' }; } const project = rows[0]!; broker.publishUser('default', { type: 'project_updated', project_id: project.id, name: project.name, }); return project; }); app.post<{ Params: { id: string } }>('/api/projects/:id/archive', async (req, reply) => { const result = await sql` UPDATE projects SET status = 'archived' WHERE id = ${req.params.id} AND status = 'open' `; if (result.count === 0) { reply.code(404); return { error: 'not found or already archived' }; } broker.publishUser('default', { type: 'project_archived', project_id: req.params.id }); reply.code(204); return null; }); app.post<{ Params: { id: string } }>('/api/projects/:id/unarchive', async (req, reply) => { const rows = await sql` UPDATE projects SET status = 'open' WHERE id = ${req.params.id} AND status = 'archived' RETURNING id, name, path, added_at, last_session_id, status, gitea_remote `; if (rows.length === 0) { reply.code(404); return { error: 'not found or not archived' }; } const project = rows[0]!; broker.publishUser('default', { type: 'project_unarchived', project }); return project; }); 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('default', { 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[]; } // Only exclude paths registered with status='open'. Archived projects' // folders should reappear as available so re-add via the picker restores // the existing row (see POST /api/projects ON CONFLICT below). const existing = await sql<{ path: string }[]>` SELECT path FROM projects WHERE status = 'open' `; 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; }); // GET /api/projects/:id/list_dir?path= app.get<{ Params: { id: string }; Querystring: { path?: string } }>( '/api/projects/:id/list_dir', async (req, reply) => { const { id } = req.params; const relPath = req.query.path ?? '.'; const rows = await sql` SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE id = ${id} `; if (rows.length === 0) { reply.code(404); return { error: 'not found' }; } const project = rows[0]!; let projectRoot: string; try { projectRoot = await resolveProjectRoot(project.path); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: err.message }; } throw err; } try { const result = await listDir(projectRoot, relPath); return result; } catch (err) { if (err instanceof PathScopeError) { reply.code(400); return { error: err.message }; } throw err; } } ); // GET /api/projects/:id/view_file?path= app.get<{ Params: { id: string }; Querystring: { path?: string } }>( '/api/projects/:id/view_file', async (req, reply) => { const { id } = req.params; const relPath = req.query.path; if (!relPath) { reply.code(400); return { error: 'path is required' }; } const rows = await sql` SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE id = ${id} `; if (rows.length === 0) { reply.code(404); return { error: 'not found' }; } const project = rows[0]!; let projectRoot: string; try { projectRoot = await resolveProjectRoot(project.path); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: err.message }; } throw err; } try { const result = await viewFile(projectRoot, relPath); return result; } catch (err) { if (err instanceof PathScopeError) { reply.code(400); return { error: err.message }; } // File not found (pathGuard throws PathScopeError for non-existent paths) if (err instanceof Error && err.message.includes('does not exist')) { reply.code(404); return { error: err.message }; } throw err; } } ); // GET /api/projects/:id/files app.get<{ Params: { id: string } }>( '/api/projects/:id/files', async (req, reply) => { const { id } = req.params; const rows = await sql` SELECT id, name, path, added_at, last_session_id, status, gitea_remote FROM projects WHERE id = ${id} `; if (rows.length === 0) { reply.code(404); return { error: 'not found' }; } const project = rows[0]!; let projectRoot: string; try { projectRoot = await resolveProjectRoot(project.path); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: err.message }; } throw err; } const files = await getProjectFiles(id, projectRoot); return { files }; } ); }