import { spawn } from "node:child_process"; import fsp from "node:fs/promises"; import path from "node:path"; const DEFAULT_TIMEOUT_MS = 120000; const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; const REMOTE_TARGET_PATTERN = /^(https?|svn|file):\/\//i; function slugify(value) { const slug = String(value || "") .normalize("NFKD") .replace(/[^\w\s.-]/g, "") .trim() .replace(/[\s_.]+/g, "-") .replace(/-+/g, "-") .toLowerCase() .slice(0, 64); return slug || `svn-${Date.now()}`; } function isPathInside(parent, child) { const relative = path.relative(parent, child); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function parseBoolean(value, fallback = false) { if (value === undefined || value === null || value === "") return fallback; return ["1", "true", "on", "yes"].includes(String(value).toLowerCase()); } function parseIntValue(value, fallback, min, max) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function limitText(value, maxLength = 4000) { return String(value || "").trim().slice(0, maxLength); } function isRemoteTarget(value) { return REMOTE_TARGET_PATTERN.test(String(value || "").trim()); } function decodeOutput(buffer) { return buffer.toString("utf8"); } function appendOutput(chunks, chunk, counter) { if (counter.size >= MAX_OUTPUT_BYTES) return counter; const remaining = MAX_OUTPUT_BYTES - counter.size; chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk); counter.size += Math.min(chunk.length, remaining); return counter; } function commandPreview(args) { return ["svn", ...args].map((part) => (/\s/.test(part) ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" "); } async function runSvn(args, options = {}) { const cwd = options.cwd || process.cwd(); const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS; const startedAt = Date.now(); return await new Promise((resolve) => { const stdout = []; const stderr = []; const stdoutCounter = { size: 0 }; const stderrCounter = { size: 0 }; let timedOut = false; const child = spawn("svn", args, { cwd, windowsHide: true, env: { ...process.env, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, }); const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeoutMs); child.stdout.on("data", (chunk) => appendOutput(stdout, chunk, stdoutCounter)); child.stderr.on("data", (chunk) => appendOutput(stderr, chunk, stderrCounter)); child.on("error", (error) => { clearTimeout(timer); resolve({ ok: false, code: error.code === "ENOENT" ? 127 : 1, command: commandPreview(args), cwd, stdout: "", stderr: error.code === "ENOENT" ? "svn command not found. Please install Subversion CLI on the server." : error.message, durationMs: Date.now() - startedAt, timedOut, }); }); child.on("close", (code) => { clearTimeout(timer); resolve({ ok: code === 0 && !timedOut, code: timedOut ? 124 : code, command: commandPreview(args), cwd, stdout: decodeOutput(Buffer.concat(stdout)), stderr: timedOut ? `Command timed out after ${timeoutMs}ms` : decodeOutput(Buffer.concat(stderr)), durationMs: Date.now() - startedAt, timedOut, }); }); }); } function addAuthArgs(args, repo) { const next = [...args, "--non-interactive"]; if (repo.trustServerCert !== false) { next.push("--trust-server-cert"); } if (repo.username) { next.push("--username", repo.username); } if (repo.password) { next.push("--password", repo.password); } if (repo.noAuthCache !== false) { next.push("--no-auth-cache"); } return next; } async function readSvnStore(options) { const raw = await fsp.readFile(options.svnDataFile, "utf8"); const parsed = JSON.parse(raw || "{}"); return { repositories: Array.isArray(parsed.repositories) ? parsed.repositories : [] }; } async function writeSvnStore(options, store) { const tmpFile = `${options.svnDataFile}.${process.pid}.tmp`; await fsp.writeFile(tmpFile, `${JSON.stringify({ repositories: store.repositories }, null, 2)}\n`, "utf8"); await fsp.rename(tmpFile, options.svnDataFile); } export async function ensureSvnStorage(options) { await Promise.all([ fsp.mkdir(options.svnRoot, { recursive: true }), fsp.mkdir(path.join(options.svnRoot, "exports"), { recursive: true }), ]); try { await fsp.access(options.svnDataFile); } catch { await writeSvnStore(options, { repositories: [] }); } } function publicRepo(repo) { const copy = { ...repo }; delete copy.password; copy.hasPassword = Boolean(repo.password); copy.localPath = repo.checkoutPath; return copy; } function getRepoPath(options, repo) { const checkoutPath = String(repo.checkoutPath || repo.id || "").trim(); const localPath = path.resolve(options.svnRoot, checkoutPath); if (!isPathInside(options.svnRoot, localPath)) { const error = new Error("Repository working copy path is outside SVN root"); error.status = 400; throw error; } return localPath; } function resolveLocalTarget(options, repo, target = "") { const repoPath = getRepoPath(options, repo); const localTarget = path.resolve(repoPath, String(target || ".")); if (!isPathInside(repoPath, localTarget)) { const error = new Error("SVN target path is outside repository working copy"); error.status = 400; throw error; } return localTarget; } function resolveAnyTarget(options, repo, target = "") { const value = String(target || "").trim(); if (isRemoteTarget(value)) return value; return resolveLocalTarget(options, repo, value || "."); } function resolveExportPath(options, value) { const exportRoot = path.join(options.svnRoot, "exports"); const exportPath = path.resolve(exportRoot, String(value || `export-${Date.now()}`)); if (!isPathInside(exportRoot, exportPath)) { const error = new Error("Export destination is outside SVN export root"); error.status = 400; throw error; } return exportPath; } function normalizeRepoPatch(body, existing = {}) { const name = limitText(body.name || existing.name, 120); const url = limitText(body.url || existing.url, 1000); const id = existing.id || slugify(name || url); const checkoutPath = limitText(body.checkoutPath || existing.checkoutPath || id, 200); const username = limitText(body.username ?? existing.username, 200); const rawPassword = body.password; const password = rawPassword === undefined || rawPassword === "" ? existing.password || "" : String(rawPassword); const trunkPath = limitText(body.trunkPath || existing.trunkPath || "trunk", 200); const branchesPath = limitText(body.branchesPath || existing.branchesPath || "branches", 200); const tagsPath = limitText(body.tagsPath || existing.tagsPath || "tags", 200); return { ...existing, id, name, url, checkoutPath, username, password, trunkPath, branchesPath, tagsPath, trustServerCert: parseBoolean(body.trustServerCert, existing.trustServerCert !== false), noAuthCache: parseBoolean(body.noAuthCache, existing.noAuthCache !== false), description: limitText(body.description || existing.description, 500), updatedAt: new Date().toISOString(), createdAt: existing.createdAt || new Date().toISOString(), }; } function requireValue(value, label) { const text = limitText(value, 4000); if (!text) { const error = new Error(`${label} is required`); error.status = 400; throw error; } return text; } function withDepth(args, body) { const depth = String(body.depth || "").trim(); if (depth && ["empty", "files", "immediates", "infinity"].includes(depth)) { args.push("--depth", depth); } return args; } async function runRepoSvn(options, repo, args, runOptions = {}) { const fullArgs = addAuthArgs(args, repo); const result = await runSvn(fullArgs, { cwd: runOptions.cwd || getRepoPath(options, repo), timeoutMs: runOptions.timeoutMs, }); return result; } async function handleSvnAction(options, repo, action, body = {}) { const repoPath = getRepoPath(options, repo); const message = limitText(body.message, 4000); const target = body.target || "."; let args; let result; switch (action) { case "checkout": await fsp.mkdir(path.dirname(repoPath), { recursive: true }); args = ["checkout", repo.url, repoPath]; result = await runRepoSvn(options, repo, withDepth(args, body), { cwd: options.svnRoot, timeoutMs: 10 * 60 * 1000 }); break; case "info": result = await runRepoSvn(options, repo, ["info", resolveAnyTarget(options, repo, target)]); break; case "status": args = ["status", resolveLocalTarget(options, repo, target)]; if (parseBoolean(body.showUpdates, false)) args.push("--show-updates"); result = await runRepoSvn(options, repo, args); break; case "update": args = ["update", resolveLocalTarget(options, repo, target)]; if (body.revision) args.push("-r", limitText(body.revision, 80)); result = await runRepoSvn(options, repo, withDepth(args, body), { timeoutMs: 10 * 60 * 1000 }); break; case "commit": args = ["commit", resolveLocalTarget(options, repo, target), "-m", requireValue(message, "Commit message")]; result = await runRepoSvn(options, repo, args, { timeoutMs: 10 * 60 * 1000 }); break; case "log": args = ["log", resolveAnyTarget(options, repo, target), "-l", String(parseIntValue(body.limit, 30, 1, 300))]; if (body.revision) args.push("-r", limitText(body.revision, 80)); if (parseBoolean(body.verbose, false)) args.push("-v"); result = await runRepoSvn(options, repo, args); break; case "diff": args = ["diff", resolveLocalTarget(options, repo, target)]; if (body.revision) args.push("-r", limitText(body.revision, 80)); result = await runRepoSvn(options, repo, args); break; case "cleanup": result = await runRepoSvn(options, repo, ["cleanup", resolveLocalTarget(options, repo, target)]); break; case "revert": args = ["revert", resolveLocalTarget(options, repo, target)]; if (parseBoolean(body.recursive, true)) args.push("--depth", "infinity"); result = await runRepoSvn(options, repo, args); break; case "resolve": result = await runRepoSvn(options, repo, ["resolve", "--accept", limitText(body.accept || "working", 80), resolveLocalTarget(options, repo, target)]); break; case "add": result = await runRepoSvn(options, repo, withDepth(["add", resolveLocalTarget(options, repo, target)], body)); break; case "delete": args = ["delete", resolveAnyTarget(options, repo, requireValue(body.target || target, "Target"))]; if (message) args.push("-m", message); result = await runRepoSvn(options, repo, args); break; case "mkdir": args = ["mkdir", resolveAnyTarget(options, repo, requireValue(body.target || target, "Target"))]; if (message || isRemoteTarget(body.target || target)) args.push("-m", message || `Create ${body.target || target}`); result = await runRepoSvn(options, repo, args); break; case "copy": case "move": args = [action, resolveAnyTarget(options, repo, requireValue(body.source, "Source")), resolveAnyTarget(options, repo, requireValue(body.destination, "Destination"))]; if (message || isRemoteTarget(body.source) || isRemoteTarget(body.destination)) args.push("-m", message || `${action} ${body.source} to ${body.destination}`); result = await runRepoSvn(options, repo, args); break; case "branch": { const branchName = requireValue(body.name, "Branch name").replace(/^\/+|\/+$/g, ""); const source = body.source || `${repo.url.replace(/\/+$/g, "")}/${repo.trunkPath || "trunk"}`; const destination = `${repo.url.replace(/\/+$/g, "")}/${repo.branchesPath || "branches"}/${branchName}`; result = await runRepoSvn(options, repo, ["copy", source, destination, "-m", message || `Create branch ${branchName}`]); break; } case "tag": { const tagName = requireValue(body.name, "Tag name").replace(/^\/+|\/+$/g, ""); const source = body.source || `${repo.url.replace(/\/+$/g, "")}/${repo.trunkPath || "trunk"}`; const destination = `${repo.url.replace(/\/+$/g, "")}/${repo.tagsPath || "tags"}/${tagName}`; result = await runRepoSvn(options, repo, ["copy", source, destination, "-m", message || `Create tag ${tagName}`]); break; } case "switch": result = await runRepoSvn(options, repo, ["switch", requireValue(body.url, "Switch URL"), resolveLocalTarget(options, repo, target)], { timeoutMs: 10 * 60 * 1000 }); break; case "relocate": args = ["relocate"]; if (body.from) args.push(limitText(body.from, 1000)); args.push(requireValue(body.to || body.url, "Relocate URL"), resolveLocalTarget(options, repo, target)); result = await runRepoSvn(options, repo, args); break; case "merge": args = ["merge", requireValue(body.source, "Merge source"), resolveLocalTarget(options, repo, target)]; if (body.revision) args.splice(1, 0, "-r", limitText(body.revision, 80)); if (parseBoolean(body.dryRun, false)) args.push("--dry-run"); result = await runRepoSvn(options, repo, args, { timeoutMs: 10 * 60 * 1000 }); break; case "lock": args = ["lock", resolveLocalTarget(options, repo, target)]; if (message) args.push("-m", message); result = await runRepoSvn(options, repo, args); break; case "unlock": args = ["unlock", resolveLocalTarget(options, repo, target)]; if (parseBoolean(body.force, false)) args.push("--force"); result = await runRepoSvn(options, repo, args); break; case "list": result = await runRepoSvn(options, repo, ["list", resolveAnyTarget(options, repo, target || repo.url)]); break; case "blame": result = await runRepoSvn(options, repo, ["blame", resolveLocalTarget(options, repo, requireValue(target, "Target"))]); break; case "propget": result = await runRepoSvn(options, repo, ["propget", requireValue(body.property, "Property"), resolveLocalTarget(options, repo, target)]); break; case "propset": result = await runRepoSvn(options, repo, ["propset", requireValue(body.property, "Property"), String(body.value ?? ""), resolveLocalTarget(options, repo, target)]); break; case "ignore": { const directory = resolveLocalTarget(options, repo, target); const pattern = requireValue(body.pattern, "Ignore pattern"); const current = await runRepoSvn(options, repo, ["propget", "svn:ignore", directory]); const values = new Set(current.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)); values.add(pattern); result = await runRepoSvn(options, repo, ["propset", "svn:ignore", [...values].join("\n"), directory]); result.stdout = `${current.stdout ? `Existing ignore:\n${current.stdout}\n` : ""}${result.stdout}`; break; } case "export": { const source = resolveAnyTarget(options, repo, body.source || target || repo.url); const destination = resolveExportPath(options, body.destination || `${repo.id}-${Date.now()}`); args = ["export", source, destination]; if (parseBoolean(body.force, true)) args.push("--force"); result = await runRepoSvn(options, repo, args, { timeoutMs: 10 * 60 * 1000 }); break; } default: { const error = new Error(`Unsupported SVN action: ${action}`); error.status = 400; throw error; } } repo.lastAction = { action, ok: result.ok, code: result.code, command: result.command.replace(/--password\s+\S+/g, "--password ******"), at: new Date().toISOString(), durationMs: result.durationMs, }; return result; } async function sendSvnResult(options, store, repo, action, body, res) { const result = await handleSvnAction(options, repo, action, body); repo.updatedAt = new Date().toISOString(); await writeSvnStore(options, store); res.status(result.ok ? 200 : 409).json({ ok: result.ok, action, repo: publicRepo(repo), result: { ...result, command: result.command.replace(/--password\s+\S+/g, "--password ******"), }, }); } export function registerSvnRoutes(app, options) { app.get("/api/admin/svn/health", options.requirePlayer, options.requireAdmin, async (req, res) => { const result = await runSvn(["--version", "--quiet"], { cwd: options.svnRoot, timeoutMs: 10000 }); res.status(result.ok ? 200 : 503).json({ ok: result.ok, version: result.stdout.trim(), svnRoot: options.svnRoot, stderr: result.stderr, }); }); app.get("/api/admin/svn/repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => { try { const store = await readSvnStore(options); res.json({ svnRoot: options.svnRoot, repositories: store.repositories.map(publicRepo) }); } catch (error) { next(error); } }); app.post("/api/admin/svn/repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => { try { const store = await readSvnStore(options); const repo = normalizeRepoPatch(req.body); if (!repo.name || !repo.url) { res.status(400).json({ error: "Repository name and URL are required" }); return; } let id = repo.id; while (store.repositories.some((item) => item.id === id)) { id = `${repo.id}-${Math.random().toString(16).slice(2, 8)}`; } repo.id = id; getRepoPath(options, repo); store.repositories.push(repo); await writeSvnStore(options, store); res.status(201).json({ repository: publicRepo(repo) }); } catch (error) { next(error); } }); app.put("/api/admin/svn/repositories/:id", options.requirePlayer, options.requireAdmin, async (req, res, next) => { try { const store = await readSvnStore(options); const index = store.repositories.findIndex((item) => item.id === req.params.id); if (index === -1) { res.status(404).json({ error: "SVN repository not found" }); return; } const repo = normalizeRepoPatch(req.body, store.repositories[index]); if (!repo.name || !repo.url) { res.status(400).json({ error: "Repository name and URL are required" }); return; } getRepoPath(options, repo); store.repositories[index] = repo; await writeSvnStore(options, store); res.json({ repository: publicRepo(repo) }); } catch (error) { next(error); } }); app.delete("/api/admin/svn/repositories/:id", options.requirePlayer, options.requireAdmin, async (req, res, next) => { try { const store = await readSvnStore(options); const initialLength = store.repositories.length; store.repositories = store.repositories.filter((item) => item.id !== req.params.id); if (store.repositories.length === initialLength) { res.status(404).json({ error: "SVN repository not found" }); return; } await writeSvnStore(options, store); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/admin/svn/repositories/:id/actions/:action", options.requirePlayer, options.requireAdmin, async (req, res, next) => { try { const store = await readSvnStore(options); const repo = store.repositories.find((item) => item.id === req.params.id); if (!repo) { res.status(404).json({ error: "SVN repository not found" }); return; } await sendSvnResult(options, store, repo, req.params.action, req.body || {}, res); } catch (error) { next(error); } }); }