Connect server SVN repositories

This commit is contained in:
lwt
2026-06-18 14:35:58 +08:00
parent 72153127b6
commit 7650fd5bfb
6 changed files with 831 additions and 210 deletions
+6 -1
View File
@@ -15,6 +15,8 @@ const storageRoot = process.env.SUMI_STORAGE_DIR ? path.resolve(process.env.SUMI
const dataDir = path.resolve(process.env.SUMI_DATA_DIR || (storageRoot ? path.join(storageRoot, "data") : path.join(__dirname, "data")));
const uploadDir = path.resolve(process.env.SUMI_UPLOAD_DIR || (storageRoot ? path.join(storageRoot, "uploads") : path.join(__dirname, "uploads")));
const svnRoot = path.resolve(process.env.SUMI_SVN_DIR || (storageRoot ? path.join(storageRoot, "svn") : path.join(__dirname, "svn")));
const svnRepositoryRoots = process.env.SUMI_SVN_REPO_ROOTS || process.env.SUMI_SVN_REPOSITORY_ROOTS || "";
const svnPublicBaseUrl = process.env.SUMI_SVN_PUBLIC_BASE_URL || process.env.SUMI_SVN_BASE_URL || "";
const packageDir = path.join(uploadDir, "packages");
const iconDir = path.join(uploadDir, "icons");
const manifestDir = path.join(uploadDir, "manifests");
@@ -1150,7 +1152,7 @@ app.delete("/api/admin/software/:id", requirePlayer, requireAdmin, async (req, r
}
});
registerSvnRoutes(app, { svnRoot, svnDataFile, requirePlayer, requireAdmin });
registerSvnRoutes(app, { svnRoot, svnDataFile, svnRepositoryRoots, svnPublicBaseUrl, requirePlayer, requireAdmin });
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
@@ -1178,6 +1180,9 @@ app.listen(port, () => {
console.log(`data directory: ${dataDir}`);
console.log(`upload directory: ${uploadDir}`);
console.log(`svn directory: ${svnRoot}`);
if (svnRepositoryRoots) {
console.log(`svn repository roots: ${svnRepositoryRoots}`);
}
if (!process.env.ADMIN_TOKEN) {
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
}
+228
View File
@@ -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);