Connect server SVN repositories
This commit is contained in:
@@ -5,6 +5,14 @@ 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 || "")
|
||||
@@ -34,6 +42,14 @@ function parseIntValue(value, fallback, min, max) {
|
||||
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);
|
||||
}
|
||||
@@ -114,6 +130,14 @@ async function runSvn(args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -163,6 +187,146 @@ function publicRepo(repo) {
|
||||
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);
|
||||
@@ -228,6 +392,12 @@ function normalizeRepoPatch(body, existing = {}) {
|
||||
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(),
|
||||
};
|
||||
@@ -440,10 +610,68 @@ export function registerSvnRoutes(app, options) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user