759 lines
27 KiB
JavaScript
759 lines
27 KiB
JavaScript
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;
|
|
const DEFAULT_REPOSITORY_ROOTS = [
|
|
"/data/tools/svn_server",
|
|
"/var/svn",
|
|
"/srv/svn",
|
|
"/var/lib/svn",
|
|
"/opt/svn",
|
|
"/home/svn",
|
|
];
|
|
|
|
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 splitRootList(value) {
|
|
if (Array.isArray(value)) return value;
|
|
return String(value || "")
|
|
.split(/[;\n,]+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function readTextIfExists(filePath) {
|
|
try {
|
|
return (await fsp.readFile(filePath, "utf8")).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
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 repositoryRootCandidates(options) {
|
|
const roots = [...splitRootList(options.svnRepositoryRoots), ...DEFAULT_REPOSITORY_ROOTS]
|
|
.map((item) => path.resolve(item))
|
|
.filter((item, index, list) => list.indexOf(item) === index);
|
|
return roots;
|
|
}
|
|
|
|
async function isSvnRepositoryDir(dirPath) {
|
|
try {
|
|
const [formatStat, dbStat] = await Promise.all([
|
|
fsp.stat(path.join(dirPath, "format")),
|
|
fsp.stat(path.join(dirPath, "db")),
|
|
]);
|
|
return formatStat.isFile() && dbStat.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function discoverRepoDirs(rootPath, maxDepth = 2) {
|
|
const found = [];
|
|
|
|
async function walk(dirPath, depth) {
|
|
if (found.length >= 200) return;
|
|
if (await isSvnRepositoryDir(dirPath)) {
|
|
found.push(dirPath);
|
|
return;
|
|
}
|
|
if (depth <= 0) return;
|
|
let entries = [];
|
|
try {
|
|
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
await walk(path.join(dirPath, entry.name), depth - 1);
|
|
}
|
|
}
|
|
|
|
await walk(rootPath, maxDepth);
|
|
return found;
|
|
}
|
|
|
|
function requestSvnBaseUrl(options, req) {
|
|
const configured = String(options.svnPublicBaseUrl || "").trim().replace(/\/+$/g, "");
|
|
if (configured) return configured;
|
|
const host = req?.hostname || "localhost";
|
|
return `svn://${host}`;
|
|
}
|
|
|
|
function publicUrlForRepo(options, req, rootPath, repoPath) {
|
|
const baseUrl = requestSvnBaseUrl(options, req);
|
|
const relativePath = path.relative(rootPath, repoPath).split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
|
return relativePath ? `${baseUrl}/${relativePath}` : baseUrl;
|
|
}
|
|
|
|
async function discoveredRepoInfo(options, req, rootPath, repoPath) {
|
|
const relativePath = path.relative(rootPath, repoPath).split(path.sep).join("/");
|
|
const name = relativePath || path.basename(repoPath);
|
|
const current = await readTextIfExists(path.join(repoPath, "db", "current"));
|
|
const revision = current.split(/\s+/)[0] || "";
|
|
return {
|
|
id: slugify(name),
|
|
name,
|
|
url: publicUrlForRepo(options, req, rootPath, repoPath),
|
|
rootPath,
|
|
repositoryPath: repoPath,
|
|
relativePath,
|
|
revision,
|
|
uuid: await readTextIfExists(path.join(repoPath, "db", "uuid")),
|
|
fsType: await readTextIfExists(path.join(repoPath, "db", "fs-type")),
|
|
format: await readTextIfExists(path.join(repoPath, "format")),
|
|
};
|
|
}
|
|
|
|
async function discoverServerRepositories(options, req) {
|
|
const roots = repositoryRootCandidates(options);
|
|
const repositories = [];
|
|
const rootStatus = [];
|
|
|
|
for (const rootPath of roots) {
|
|
let exists = false;
|
|
try {
|
|
exists = (await fsp.stat(rootPath)).isDirectory();
|
|
} catch {
|
|
exists = false;
|
|
}
|
|
if (!exists) {
|
|
rootStatus.push({ rootPath, exists: false, repositoryCount: 0 });
|
|
continue;
|
|
}
|
|
|
|
const repoDirs = await discoverRepoDirs(rootPath);
|
|
rootStatus.push({ rootPath, exists: true, repositoryCount: repoDirs.length });
|
|
for (const repoPath of repoDirs) {
|
|
repositories.push(await discoveredRepoInfo(options, req, rootPath, repoPath));
|
|
}
|
|
}
|
|
|
|
return {
|
|
repositoryRoots: rootStatus,
|
|
publicBaseUrl: requestSvnBaseUrl(options, req),
|
|
repositories,
|
|
};
|
|
}
|
|
|
|
function markConfigured(discoveredRepos, configuredRepos) {
|
|
return discoveredRepos.map((repo) => {
|
|
const configured = configuredRepos.find((item) => (
|
|
item.serverRepositoryPath === repo.repositoryPath
|
|
|| item.url === repo.url
|
|
|| item.id === repo.id
|
|
|| item.name === repo.name
|
|
));
|
|
return {
|
|
...repo,
|
|
configured: Boolean(configured),
|
|
configuredId: configured?.id || "",
|
|
};
|
|
});
|
|
}
|
|
|
|
function repoFromDiscovered(discovered, existing = {}) {
|
|
return normalizeRepoPatch({
|
|
...existing,
|
|
name: discovered.name,
|
|
url: discovered.url,
|
|
checkoutPath: existing.checkoutPath || discovered.id,
|
|
source: "server",
|
|
serverRepositoryRoot: discovered.rootPath,
|
|
serverRepositoryPath: discovered.repositoryPath,
|
|
serverRelativePath: discovered.relativePath,
|
|
serverRevision: discovered.revision,
|
|
serverUuid: discovered.uuid,
|
|
description: existing.description || `从服务器 SVN 服务接入:${discovered.repositoryPath}`,
|
|
}, existing);
|
|
}
|
|
|
|
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),
|
|
source: limitText(body.source || existing.source, 40),
|
|
serverRepositoryRoot: limitText(body.serverRepositoryRoot || existing.serverRepositoryRoot, 1000),
|
|
serverRepositoryPath: limitText(body.serverRepositoryPath || existing.serverRepositoryPath, 1000),
|
|
serverRelativePath: limitText(body.serverRelativePath || existing.serverRelativePath, 1000),
|
|
serverRevision: limitText(body.serverRevision || existing.serverRevision, 80),
|
|
serverUuid: limitText(body.serverUuid || existing.serverUuid, 120),
|
|
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", body.target ? resolveAnyTarget(options, repo, target) : repo.url], { cwd: options.svnRoot });
|
|
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", body.target ? resolveAnyTarget(options, repo, target) : repo.url, "-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, { cwd: isRemoteTarget(args[1]) ? options.svnRoot : undefined });
|
|
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":
|
|
args = ["list", body.target ? resolveAnyTarget(options, repo, target) : repo.url];
|
|
result = await runRepoSvn(options, repo, args, { cwd: isRemoteTarget(args[1]) ? options.svnRoot : undefined });
|
|
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 || body.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, { cwd: isRemoteTarget(source) ? options.svnRoot : undefined, 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,
|
|
repositoryRoots: repositoryRootCandidates(options),
|
|
publicBaseUrl: requestSvnBaseUrl(options, req),
|
|
stderr: result.stderr,
|
|
});
|
|
});
|
|
|
|
app.get("/api/admin/svn/server-repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const store = await readSvnStore(options);
|
|
const discovered = await discoverServerRepositories(options, req);
|
|
res.json({
|
|
...discovered,
|
|
svnRoot: options.svnRoot,
|
|
repositories: markConfigured(discovered.repositories, store.repositories),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post("/api/admin/svn/server-repositories/sync", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const store = await readSvnStore(options);
|
|
const discovered = await discoverServerRepositories(options, req);
|
|
let created = 0;
|
|
let updated = 0;
|
|
|
|
for (const repo of discovered.repositories) {
|
|
const index = store.repositories.findIndex((item) => (
|
|
item.serverRepositoryPath === repo.repositoryPath
|
|
|| item.url === repo.url
|
|
|| item.id === repo.id
|
|
|| item.name === repo.name
|
|
));
|
|
if (index === -1) {
|
|
let nextRepo = repoFromDiscovered(repo);
|
|
let id = nextRepo.id;
|
|
while (store.repositories.some((item) => item.id === id)) {
|
|
id = `${nextRepo.id}-${Math.random().toString(16).slice(2, 8)}`;
|
|
}
|
|
nextRepo = { ...nextRepo, id };
|
|
getRepoPath(options, nextRepo);
|
|
store.repositories.push(nextRepo);
|
|
created += 1;
|
|
} else {
|
|
store.repositories[index] = repoFromDiscovered(repo, store.repositories[index]);
|
|
updated += 1;
|
|
}
|
|
}
|
|
|
|
await writeSvnStore(options, store);
|
|
res.json({
|
|
created,
|
|
updated,
|
|
repositories: store.repositories.map(publicRepo),
|
|
discovered: markConfigured(discovered.repositories, store.repositories),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|