initial
This commit is contained in:
130
apps/server/src/routes/projects.ts
Normal file
130
apps/server/src/routes/projects.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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 { 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<boolean> {
|
||||
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
|
||||
): void {
|
||||
app.get('/api/projects', async () => {
|
||||
const rows = await sql<Project[]>`
|
||||
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<Project[]>`
|
||||
INSERT INTO projects (name, path)
|
||||
VALUES (${name}, ${resolved.real})
|
||||
RETURNING id, name, path, added_at, last_session_id
|
||||
`;
|
||||
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' };
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user