Repository Public
1/**2 * Project files: thin, authenticated mapping onto the runtime with `root` = the project's3 * absolute directory. Uploads and downloads are streamed end to end (never buffered here).4 * Audit rows carry paths only — never contents.5 */6import type { FastifyInstance, FastifyRequest } from 'fastify';7import { Transform, type Readable } from 'node:stream';8import { z } from 'zod';9import { FileContent, FileEntry, FsOp, GitStatus, Id, SearchHit, WriteFile } from '@cr/shared';10import { HttpError, authOf, parse, type Ctx } from '../../http.js';11import { runtimeToHttp, validateRel } from '../../paths.js';12import { projectDir } from '../sessions/index.js';1314const IdParams = z.object({ id: Id });15const PathQuery = z.object({ path: z.string().max(1024).default('') });16const flag = z.enum(['0', '1', 'true', 'false']).optional();17const isOn = (v: string | undefined): boolean => v === '1' || v === 'true';18const UploadQuery = PathQuery.extend({ overwrite: flag });19const DiffQuery = PathQuery.extend({ staged: flag });20const GitDiff = z.object({ diff: z.string(), truncated: z.boolean() });21const SearchQuery = z.object({22 q: z.string().min(1).max(200),23 mode: z.enum(['name', 'content']).default('name'),24 limit: z.coerce.number().int().min(1).max(500).default(100),25 /** Optional sub-directory to search in (project-relative). */26 path: z.string().max(1024).default(''),27});2829/** Counts streamed bytes and aborts past `limit` (chunked uploads carry no Content-Length). */30function limitStream(limit: number): Transform {31 let seen = 0;32 return new Transform({33 transform(chunk: Buffer, _enc, cb) {34 seen += chunk.length;35 if (seen > limit) cb(new HttpError(413, 'payload_too_large', 'upload exceeds the size limit'));36 else cb(null, chunk);37 },38 });39}4041/** RFC 6266 filename with an ASCII fallback; quotes and control characters cannot break the header. */42export function contentDisposition(name: string): string {43 const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_') || 'download';44 return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;45}4647export default async function filesPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {48 const { rt, audit } = ctx;49 const rootOf = async (req: FastifyRequest, userId: string): Promise<{ root: string; id: string }> => {50 const { id } = parse(IdParams, req.params);51 return { root: (await projectDir(ctx, userId, id)).abs, id };52 };5354 app.get('/projects/:id/files', { config: { doc: { summary: 'List a directory', query: PathQuery, response: z.array(FileEntry), responseName: 'FileEntryList' } } }, async (req) => {55 const { root } = await rootOf(req, authOf(req).userId);56 const rel = validateRel(parse(PathQuery, req.query).path);57 return rt.json('POST', '/fs/list', { root, rel }).catch(runtimeToHttp);58 });5960 app.get('/projects/:id/file', { config: { doc: { summary: 'Read a text file (≤ 2 MiB)', query: PathQuery, response: FileContent, responseName: 'FileContent' } } }, async (req) => {61 const { root } = await rootOf(req, authOf(req).userId);62 const rel = validateRel(parse(PathQuery, req.query).path, { allowEmpty: false });63 return rt.json('POST', '/fs/read', { root, rel }).catch(runtimeToHttp);64 });6566 app.put('/projects/:id/file', { config: { doc: { summary: 'Write a text file (409 on sha mismatch)', body: WriteFile, bodyName: 'WriteFile', response: FileContent, responseName: 'FileContent' } } }, async (req) => {67 const { root, id } = await rootOf(req, authOf(req).userId);68 const body = parse(WriteFile, req.body);69 const rel = validateRel(body.path, { allowEmpty: false });70 try {71 const out = await rt.json<Omit<FileContent, 'content'>>('POST', '/fs/write', { root, rel, content: body.content, baseSha256: body.baseSha256, create: body.create });72 await audit.req(req, 'fs.write', { target: id, detail: { path: rel } });73 // The runtime answers with the metadata only; the browser DTO is a full `FileContent`.74 return { ...out, content: body.content };75 } catch (err) {76 await audit.req(req, 'fs.write', { target: id, ok: false, detail: { path: rel } });77 return runtimeToHttp(err);78 }79 });8081 app.post('/projects/:id/fs', { config: { doc: { summary: 'mkdir / rename / delete', body: FsOp, bodyName: 'FsOp' } } }, async (req) => {82 const { root, id } = await rootOf(req, authOf(req).userId);83 const body = parse(FsOp, req.body);84 const op =85 body.op === 'rename'86 ? { op: body.op, from: validateRel(body.from, { allowEmpty: false }), to: validateRel(body.to, { allowEmpty: false }) }87 : { ...body, path: validateRel(body.path, { allowEmpty: false }) };88 const detail = op.op === 'rename' ? { op: op.op, from: op.from, to: op.to } : { op: op.op, path: op.path };89 try {90 const out = await rt.json<unknown>('POST', '/fs/op', { root, ...op });91 await audit.req(req, 'fs.op', { target: id, detail });92 return out ?? { ok: true };93 } catch (err) {94 await audit.req(req, 'fs.op', { target: id, ok: false, detail });95 return runtimeToHttp(err);96 }97 });9899 app.get('/projects/:id/download', { config: { doc: { summary: 'Download a file (attachment, streamed)', query: PathQuery } } }, async (req, reply) => {100 const { root, id } = await rootOf(req, authOf(req).userId);101 const rel = validateRel(parse(PathQuery, req.query).path, { allowEmpty: false });102 const res = await rt.raw('GET', '/fs/download', { query: { root, rel } }).catch(runtimeToHttp);103 await audit.req(req, 'fs.download', { target: id, detail: { path: rel } });104 const len = res.headers['content-length'];105 if (typeof len === 'string') void reply.header('Content-Length', len);106 return reply107 .header('Content-Type', 'application/octet-stream')108 .header('Content-Disposition', contentDisposition(rel.split('/').pop() ?? 'download'))109 .header('X-Content-Type-Options', 'nosniff')110 .send(res.body);111 });112113 app.get('/projects/:id/search', { config: { doc: { summary: 'Search file names or contents', query: SearchQuery, response: z.array(SearchHit), responseName: 'SearchHitList' } } }, async (req) => {114 const { root } = await rootOf(req, authOf(req).userId);115 const q = parse(SearchQuery, req.query);116 return rt.json('POST', '/fs/search', { root, rel: validateRel(q.path), query: q.q, mode: q.mode, limit: q.limit }).catch(runtimeToHttp);117 });118119 app.get('/projects/:id/git/status', { config: { doc: { summary: 'git status', response: GitStatus, responseName: 'GitStatus' } } }, async (req) => {120 const { root } = await rootOf(req, authOf(req).userId);121 return rt.json('POST', '/git/status', { root }).catch(runtimeToHttp);122 });123124 app.get('/projects/:id/git/diff', { config: { doc: { summary: 'git diff (optionally for one path; staged=1 for the index)', query: DiffQuery, response: GitDiff, responseName: 'GitDiff' } } }, async (req) => {125 const { root } = await rootOf(req, authOf(req).userId);126 const q = parse(DiffQuery, req.query);127 const rel = validateRel(q.path);128 const out = await rt.json<{ diff?: unknown; truncated?: unknown } | null>('POST', '/git/diff', { root, ...(rel ? { rel } : {}), staged: isOn(q.staged) }).catch(runtimeToHttp);129 return { diff: typeof out?.diff === 'string' ? out.diff : '', truncated: out?.truncated === true };130 });131132 // Upload lives in its own encapsulated scope so the raw body stream reaches the handler untouched.133 await app.register(async (scope) => {134 scope.removeAllContentTypeParsers();135 scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));136 scope.post('/projects/:id/upload', { bodyLimit: ctx.config.uploadLimitBytes, config: { doc: { summary: 'Upload a file (raw body stream, ≤ 200 MiB; 409 `exists` unless overwrite=1)', query: UploadQuery } } }, async (req, reply) => {137 const { root, id } = await rootOf(req, authOf(req).userId);138 const q = parse(UploadQuery, req.query);139 const rel = validateRel(q.path, { allowEmpty: false });140 const headers: Record<string, string> = { 'content-type': 'application/octet-stream' };141 const len = req.headers['content-length'];142 if (typeof len === 'string' && /^\d+$/.test(len)) headers['content-length'] = len;143 const limiter = limitStream(ctx.config.uploadLimitBytes);144 let tooLarge: HttpError | null = null;145 limiter.once('error', (err) => {146 if (err instanceof HttpError) tooLarge = err;147 });148 const body = (req.body as Readable).pipe(limiter);149 try {150 const res = await rt.raw('POST', '/fs/upload', { query: { root, rel, overwrite: isOn(q.overwrite) ? '1' : undefined }, body, headers, timeoutMs: 10 * 60_000 });151 const text = await res.body.text();152 await audit.req(req, 'fs.upload', { target: id, detail: { path: rel } });153 void reply.code(201).header('Content-Type', 'application/json; charset=utf-8');154 return text || '{"ok":true}';155 } catch (err) {156 await audit.req(req, 'fs.upload', { target: id, ok: false, detail: { path: rel } });157 if (tooLarge) throw tooLarge;158 return runtimeToHttp(err);159 }160 });161 });162}163