SPB Git

Repository Public

index.ts 9.0 KB 163 lines typescript

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
Files main
Menu

Theme