import { Router, type Router as ExpressRouter } from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { projectNameSchema, relPathSchema, resolveFile } from "../paths.js"; export const filesRouter: ExpressRouter = Router(); const projectParam = z.object({ project: projectNameSchema }); const fileQuery = z.object({ path: relPathSchema }); const writeBody = z.object({ content: z.string() }); const renameBody = z.object({ from: relPathSchema, to: relPathSchema }); const createFileBody = z.object({ path: relPathSchema, content: z.string().optional().default(""), }); const createFolderBody = z.object({ path: relPathSchema }); filesRouter.get("/:project/file", async (req, res) => { const p = projectParam.safeParse(req.params); const q = fileQuery.safeParse(req.query); if (!p.success || !q.success) { res.status(400).json({ error: [...(p.error?.issues ?? []), ...(q.error?.issues ?? [])], }); return; } try { const { abs } = resolveFile(p.data.project, q.data.path); const content = await fs.readFile(abs, "utf8"); const stat = await fs.stat(abs); res.json({ path: q.data.path, content, mtime: stat.mtimeMs }); } catch (err) { res.status(404).json({ error: (err as Error).message }); } }); filesRouter.put("/:project/file", async (req, res) => { const p = projectParam.safeParse(req.params); const q = fileQuery.safeParse(req.query); const b = writeBody.safeParse(req.body); if (!p.success || !q.success || !b.success) { res.status(400).json({ error: [ ...(p.error?.issues ?? []), ...(q.error?.issues ?? []), ...(b.error?.issues ?? []), ], }); return; } try { const { abs } = resolveFile(p.data.project, q.data.path); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, b.data.content, "utf8"); const stat = await fs.stat(abs); res.json({ path: q.data.path, mtime: stat.mtimeMs }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); // Create a new file (optional content, defaults to empty) filesRouter.post("/:project/file", async (req, res) => { const p = projectParam.safeParse(req.params); const b = createFileBody.safeParse(req.body); if (!p.success || !b.success) { res.status(400).json({ error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])], }); return; } try { const { abs } = resolveFile(p.data.project, b.data.path); await fs.mkdir(path.dirname(abs), { recursive: true }); const fd = await fs.open(abs, "wx").catch((e) => { if (e.code === "EEXIST") { const wrapped = new Error("file already exists") as NodeJS.ErrnoException; wrapped.code = "EEXIST"; throw wrapped; } throw e; }); if (b.data.content) await fd.writeFile(b.data.content, "utf8"); await fd.close(); res.status(201).json({ path: b.data.path, ok: true }); } catch (err) { const code = (err as NodeJS.ErrnoException).code; res.status(code === "EEXIST" || code === "ENOENT" ? 409 : 500).json({ ok: false, error: (err as Error).message, }); } }); // Delete a file filesRouter.delete("/:project/file", async (req, res) => { const p = projectParam.safeParse(req.params); const q = fileQuery.safeParse(req.query); if (!p.success || !q.success) { res.status(400).json({ error: [...(p.error?.issues ?? []), ...(q.error?.issues ?? [])], }); return; } try { const { abs } = resolveFile(p.data.project, q.data.path); await fs.unlink(abs); res.json({ ok: true }); } catch (err) { res.status(404).json({ ok: false, error: (err as Error).message }); } }); // Rename / move a file filesRouter.patch("/:project/file", async (req, res) => { const p = projectParam.safeParse(req.params); const b = renameBody.safeParse(req.body); if (!p.success || !b.success) { res.status(400).json({ error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])], }); return; } try { const { abs: fromAbs } = resolveFile(p.data.project, b.data.from); const { abs: toAbs } = resolveFile(p.data.project, b.data.to); await fs.mkdir(path.dirname(toAbs), { recursive: true }); // Atomically guard against overwriting an existing file. On POSIX // link() fails if the destination exists, and rename() overwrites // the destination only after we have created a hard link, so there // is no window for another process to create the target in between. const tempAbs = `${toAbs}.tmp-${Date.now()}`; try { await fs.link(fromAbs, tempAbs); } catch (err) { const code = (err as NodeJS.ErrnoException).code; res .status(code === "EEXIST" ? 409 : 500) .json({ ok: false, error: code === "EEXIST" ? "destination already exists" : (err as Error).message }); return; } try { await fs.rename(tempAbs, toAbs); } catch (err) { await fs.unlink(tempAbs).catch(() => {}); throw err; } await fs.unlink(fromAbs).catch(() => {}); res.json({ ok: true, from: b.data.from, to: b.data.to }); } catch (err) { res.status(500).json({ ok: false, error: (err as Error).message }); } }); // Create a folder filesRouter.post("/:project/folder", async (req, res) => { const p = projectParam.safeParse(req.params); const b = createFolderBody.safeParse(req.body); if (!p.success || !b.success) { res.status(400).json({ error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])], }); return; } try { const dirPath = resolveFile(p.data.project, b.data.path).abs; await fs.mkdir(dirPath, { recursive: true }); res.status(201).json({ path: b.data.path, ok: true }); } catch (err) { res.status(500).json({ ok: false, error: (err as Error).message }); } });