diff --git a/DEPLOY.md b/DEPLOY.md index 5bd6bb0..debea95 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -66,6 +66,8 @@ export SUMI_STORAGE_DIR="/var/lib/sumi-work" export SUMI_DATA_DIR="/var/lib/sumi-work/data" export SUMI_UPLOAD_DIR="/var/lib/sumi-work/uploads" export SUMI_SVN_DIR="/var/lib/sumi-work/svn" +export SUMI_SVN_REPO_ROOTS="/data/tools/svn_server" +export SUMI_SVN_PUBLIC_BASE_URL="svn://服务器IP" export MAX_UPLOAD_BYTES=1073741824 export PLAYER_SESSION_TTL_MS=604800000 ``` @@ -80,6 +82,8 @@ export PLAYER_SESSION_TTL_MS=604800000 - `SUMI_DATA_DIR`:软件列表和玩家账号数据目录;设置后会覆盖 `SUMI_STORAGE_DIR/data` - `SUMI_UPLOAD_DIR`:上传图标、安装包和 version.json 目录;设置后会覆盖 `SUMI_STORAGE_DIR/uploads` - `SUMI_SVN_DIR`:SVN 工作副本和导出目录;设置后会覆盖 `SUMI_STORAGE_DIR/svn` +- `SUMI_SVN_REPO_ROOTS`:服务器已安装 SVN 服务的仓库根目录,多个目录用英文分号或逗号分隔,例如 `/data/tools/svn_server` +- `SUMI_SVN_PUBLIC_BASE_URL`:旧 SVN 服务对外访问地址,例如 `svn://43.136.76.224`;管理后台会用它生成旧仓库 URL - `MAX_UPLOAD_BYTES`:最大上传文件大小,默认 1GB - `PLAYER_SESSION_TTL_MS`:玩家登录有效期,默认 7 天 @@ -455,6 +459,8 @@ SVN 管理不可用: - 检查服务器是否安装 `svn`:`svn --version --quiet` - 检查 `SUMI_SVN_DIR` 或 `/var/lib/sumi-work/svn/` 是否可写 +- 如果要接入服务器已有仓库,确认 `SUMI_SVN_REPO_ROOTS` 指向 `svnserve -r` 的根目录 +- 确认 `SUMI_SVN_PUBLIC_BASE_URL` 是玩家/管理员能访问到的 SVN 地址 - 检查仓库 URL、账号、密码和证书信任设置 - 写操作需要玩家登录并提供正确的 `ADMIN_TOKEN` diff --git a/admin/svn.html b/admin/svn.html index 976976d..bf72944 100644 --- a/admin/svn.html +++ b/admin/svn.html @@ -6,13 +6,13 @@ sumi.work - SVN 管理 - +
- - + + SVN 管理
-
-
-
+
+
+
+

SVN Repositories

SVN 控制台

-

集中管理 SVN 仓库、工作副本、提交、更新、分支标签、合并、锁和属性。

+

接入服务器现有 SVN 服务和旧仓库,在一个清晰的仓库视图里完成更新、提交、分支、标签、合并和维护操作。

-
-
- - +
+
+ + +
+
+ + + +
+

-

-
+
-
-

新增仓库

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+
+
+
+

Repository

+

选择仓库

+

请选择一个 SVN 仓库。

-
- - +
+
-
- - -
-

- -
-
- -
-
-
-

工作副本

-

请选择一个 SVN 仓库。

-
+ +
+
+ SVN URL + -- +
+
+ 工作副本 + -- +
+
+ 服务端版本 + -- +
+
+ 来源 + -- +
+
+ +
+ + +
-
-
- - - - -
+
+ + + + +
-
-
- - - - - - - - - - +
+
+ + + + + + + + +
-
-
-
- - - - - - +
+
+ + + + + +
-
-
-
- - - - - - +
+
+ + + + + + +
-
-
-
- - +
+
+ + +
-
-
-
-
- - + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - +
+ + + + +
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - - - -
- + -
-
- 等待操作 - +
+
+ 等待操作 + +
+
- -
-

-      
+

+        
+
- + diff --git a/assets/css/software.css b/assets/css/software.css index 1782ff1..a92217d 100644 --- a/assets/css/software.css +++ b/assets/css/software.css @@ -915,6 +915,46 @@ main, gap: 18px; } +.svn-github-shell { + width: min(1280px, calc(100% - 32px)); +} + +.svn-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 520px); + gap: 24px; + align-items: center; +} + +.svn-hero-copy h1 { + margin-bottom: 10px; + color: #0f172a; + font-size: clamp(34px, 4vw, 48px); + line-height: 1.08; + letter-spacing: 0; +} + +.svn-hero-controls { + display: grid; + gap: 12px; +} + +.svn-toolbar-actions, +.svn-header-actions, +.svn-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.svn-github-layout { + display: grid; + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + .svn-console { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); @@ -923,14 +963,44 @@ main, .svn-sidebar, .svn-form-panel, -.svn-workbench { +.svn-workbench, +.svn-repository-view { min-width: 0; } +.svn-sidebar { + position: sticky; + top: 106px; + display: grid; + max-height: calc(100svh - 128px); + overflow: auto; + gap: 16px; +} + +.svn-sidebar-title { + margin-bottom: 0; +} + .section-title-row.compact { margin-bottom: 14px; } +.svn-search input { + width: 100%; + min-height: 42px; + padding: 0 12px; + color: var(--text); + background: #f6f8fb; + border: 1px solid var(--line); + border-radius: 6px; + outline: 0; +} + +.svn-search input:focus { + border-color: rgba(32, 73, 238, 0.28); + box-shadow: 0 0 0 3px rgba(32, 73, 238, 0.08); +} + .svn-repo-list { display: grid; gap: 8px; @@ -939,9 +1009,9 @@ main, .svn-repo-item { display: grid; width: 100%; - min-height: 86px; + min-height: 92px; gap: 5px; - padding: 12px; + padding: 14px; color: inherit; text-align: left; background: #fbfcff; @@ -972,6 +1042,152 @@ main, white-space: nowrap; } +.svn-server-box, +.svn-config-details { + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.svn-server-box h3 { + margin: 0; + color: #111827; + font-size: 16px; +} + +.svn-server-list { + display: grid; + gap: 8px; +} + +.svn-server-repo { + display: grid; + width: 100%; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + color: inherit; + text-align: left; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.svn-server-repo:hover, +.svn-server-repo:focus-visible { + border-color: rgba(32, 73, 238, 0.24); + outline: 0; +} + +.svn-server-repo.is-linked { + background: #f7fbff; +} + +.svn-server-repo strong, +.svn-server-repo small, +.svn-server-repo em { + display: block; +} + +.svn-server-repo strong { + overflow: hidden; + color: #172033; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.svn-server-repo small { + overflow: hidden; + margin-top: 3px; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.svn-server-repo em { + color: var(--blue); + font-size: 11px; + font-style: normal; + font-weight: 800; + white-space: nowrap; +} + +.svn-config-details summary { + display: flex; + min-height: 38px; + align-items: center; + justify-content: space-between; + color: #111827; + cursor: pointer; + font-weight: 850; +} + +.svn-config-details form { + margin-top: 12px; +} + +.svn-config-details .field-grid { + grid-template-columns: 1fr; +} + +.svn-repo-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: start; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.svn-repo-header h1 { + margin: 0 0 8px; + color: #0f172a; + font-size: clamp(28px, 4vw, 42px); + line-height: 1.12; + letter-spacing: 0; +} + +.svn-repo-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.svn-repo-meta-grid > div { + min-width: 0; + padding: 12px; + background: #f8fafc; + border: 1px solid var(--line); + border-radius: 8px; +} + +.svn-repo-meta-grid span, +.svn-repo-meta-grid strong { + display: block; +} + +.svn-repo-meta-grid span { + margin-bottom: 5px; + color: var(--muted); + font-size: 12px; +} + +.svn-repo-meta-grid strong { + overflow: hidden; + color: #182235; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.svn-quick-actions { + justify-content: flex-start; + margin-bottom: 18px; +} + .svn-action-row, .svn-output-toolbar { display: flex; @@ -1016,7 +1232,7 @@ main, .svn-command-grid { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 18px; } @@ -1128,10 +1344,21 @@ main, .topic-grid, .admin-layout, .login-panel, - .svn-console { + .svn-console, + .svn-hero, + .svn-github-layout { grid-template-columns: 1fr; } + .svn-sidebar { + position: static; + max-height: none; + } + + .svn-repo-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .svn-command-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -1193,10 +1420,24 @@ main, } .field-grid, - .token-row { + .token-row, + .svn-repo-header, + .svn-repo-meta-grid { grid-template-columns: 1fr; } + .svn-toolbar-actions, + .svn-header-actions, + .svn-quick-actions { + justify-content: stretch; + } + + .svn-toolbar-actions button, + .svn-header-actions button, + .svn-quick-actions button { + width: 100%; + } + .soft-footer { flex-direction: column; align-items: center; diff --git a/assets/js/svn-admin.js b/assets/js/svn-admin.js index 58451f3..419fe22 100644 --- a/assets/js/svn-admin.js +++ b/assets/js/svn-admin.js @@ -10,9 +10,19 @@ const els = { repoReset: document.querySelector("#repo-reset"), deleteRepo: document.querySelector("#delete-repo"), reloadRepos: document.querySelector("#reload-repos"), + syncServerRepos: document.querySelector("#sync-server-repos"), + repoSearch: document.querySelector("#repo-search"), repoList: document.querySelector("#repo-list"), repoSummary: document.querySelector("#repo-summary"), + serverRepoList: document.querySelector("#server-repo-list"), + serverSummary: document.querySelector("#server-summary"), selectedRepo: document.querySelector("#selected-repo"), + selectedRepoName: document.querySelector("#selected-repo-name"), + selectedRepoUrl: document.querySelector("#selected-repo-url"), + selectedRepoPath: document.querySelector("#selected-repo-path"), + selectedRepoRevision: document.querySelector("#selected-repo-revision"), + selectedRepoSource: document.querySelector("#selected-repo-source"), + repoConfigDetails: document.querySelector("#repo-config-details"), tabs: [...document.querySelectorAll(".svn-tab")], panels: [...document.querySelectorAll(".svn-tab-panel")], actionForm: document.querySelector("#action-form"), @@ -24,7 +34,9 @@ const els = { }; let repositories = []; +let serverRepositories = []; let selectedRepoId = ""; +let repoFilter = ""; const ACTION_LABELS = { checkout: "Checkout", @@ -87,6 +99,12 @@ function formatDate(value) { return date.toLocaleString("zh-CN", { hour12: false }); } +function compactPath(value = "") { + const text = String(value || ""); + if (text.length <= 56) return text || "--"; + return `${text.slice(0, 24)}...${text.slice(-28)}`; +} + async function apiFetch(url, options = {}) { const token = getToken(); const headers = { @@ -125,33 +143,65 @@ function formToObject(form) { } function renderRepos() { - els.repoSummary.textContent = `共 ${repositories.length} 个仓库`; - els.repoList.innerHTML = repositories.map((repo) => ``).join("") || `

暂无 SVN 仓库,先新增一个配置。

`; + ${escapeHtml(repo.source === "server" ? "服务器仓库" : "手动配置")} · ${escapeHtml(repo.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"} + `).join("") || `

${repositories.length ? "没有匹配的仓库。" : "暂无 SVN 仓库,可以先点击“接入旧仓库”。"}

`; const repo = selectedRepo(); + els.selectedRepoName.textContent = repo ? repo.name : "选择仓库"; els.selectedRepo.textContent = repo - ? `${repo.name} · ${repo.url} · ${repo.localPath || repo.checkoutPath}` - : "请选择一个 SVN 仓库。"; + ? (repo.description || `${repo.name} · ${repo.url}`) + : "请选择一个 SVN 仓库,或先从服务器 SVN 服务接入旧仓库。"; + els.selectedRepoUrl.textContent = compactPath(repo?.url); + els.selectedRepoPath.textContent = compactPath(repo?.localPath || repo?.checkoutPath); + els.selectedRepoRevision.textContent = repo?.serverRevision ? `r${repo.serverRevision}` : (repo?.lastAction?.at ? formatDate(repo.lastAction.at) : "--"); + els.selectedRepoSource.textContent = repo ? (repo.source === "server" ? "服务器旧仓库" : "手动配置") : "--"; + els.deleteRepo.disabled = !repo; +} + +function renderServerRepos() { + const configuredCount = serverRepositories.filter((repo) => repo.configured).length; + els.serverSummary.textContent = serverRepositories.length + ? `发现 ${serverRepositories.length} 个,已接入 ${configuredCount} 个` + : "未发现旧仓库"; + els.serverRepoList.innerHTML = serverRepositories.map((repo) => ``).join("") || `

没有扫描到服务器 SVN 仓库。

`; } async function loadRepos() { try { setStatus(els.tokenStatus, "正在读取 SVN 仓库..."); - const payload = await apiFetch("/api/admin/svn/repositories"); + const [payload, serverPayload] = await Promise.all([ + apiFetch("/api/admin/svn/repositories"), + apiFetch("/api/admin/svn/server-repositories"), + ]); repositories = payload.repositories || []; + serverRepositories = serverPayload.repositories || []; if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) { selectedRepoId = repositories[0]?.id || ""; } renderRepos(); - setStatus(els.tokenStatus, `SVN 根目录:${payload.svnRoot || "-"}`, "ok"); + renderServerRepos(); + setStatus(els.tokenStatus, `工作副本:${payload.svnRoot || "-"};服务根:${serverPayload.repositoryRoots?.filter((root) => root.exists).map((root) => root.rootPath).join("、") || "-"}`, "ok"); } catch (error) { repositories = []; + serverRepositories = []; selectedRepoId = ""; renderRepos(); + renderServerRepos(); setStatus(els.tokenStatus, `读取失败:${error.message}`, "error"); } } @@ -166,6 +216,7 @@ function resetRepoForm() { els.repoForm.elements.noAuthCache.checked = true; els.repoFormTitle.textContent = "新增仓库"; els.repoSubmit.textContent = "保存仓库"; + if (els.repoConfigDetails) els.repoConfigDetails.open = true; setStatus(els.repoFormStatus, ""); } @@ -184,8 +235,8 @@ function fillRepoForm(repo) { els.repoForm.elements.noAuthCache.checked = repo.noAuthCache !== false; els.repoFormTitle.textContent = `编辑 ${repo.name}`; els.repoSubmit.textContent = "更新仓库"; + if (els.repoConfigDetails) els.repoConfigDetails.open = true; setStatus(els.repoFormStatus, repo.hasPassword ? "已载入仓库,密码留空会保持原密码。" : "已载入仓库。", "ok"); - window.scrollTo({ top: 0, behavior: "smooth" }); } async function saveRepo(event) { @@ -219,6 +270,32 @@ async function deleteRepo(repo) { } } +function fillRepoFormFromServer(repo) { + resetRepoForm(); + els.repoForm.elements.name.value = repo.name || ""; + els.repoForm.elements.url.value = repo.url || ""; + els.repoForm.elements.checkoutPath.value = repo.id || repo.name || ""; + els.repoForm.elements.description.value = `从服务器 SVN 服务接入:${repo.repositoryPath || repo.name}`; + setStatus(els.repoFormStatus, "已填入服务器旧仓库,保存后即可纳入管理。", "ok"); +} + +async function syncServerRepos() { + try { + setStatus(els.tokenStatus, "正在接入服务器旧仓库..."); + const payload = await apiFetch("/api/admin/svn/server-repositories/sync", { method: "POST", json: {} }); + repositories = payload.repositories || []; + serverRepositories = payload.discovered || []; + if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) { + selectedRepoId = repositories[0]?.id || ""; + } + renderRepos(); + renderServerRepos(); + setStatus(els.tokenStatus, `接入完成:新增 ${payload.created || 0} 个,更新 ${payload.updated || 0} 个。`, "ok"); + } catch (error) { + setStatus(els.tokenStatus, `接入失败:${error.message}`, "error"); + } +} + function actionPayload() { const payload = formToObject(els.actionForm); payload.value = payload.pattern; @@ -268,7 +345,7 @@ async function checkHealth() { try { setStatus(els.tokenStatus, "正在检测 svn 命令..."); const payload = await apiFetch("/api/admin/svn/health"); - setStatus(els.tokenStatus, payload.ok ? `SVN CLI 可用:${payload.version || "unknown"},根目录:${payload.svnRoot}` : payload.stderr, payload.ok ? "ok" : "error"); + setStatus(els.tokenStatus, payload.ok ? `SVN CLI 可用:${payload.version || "unknown"},工作副本:${payload.svnRoot},服务地址:${payload.publicBaseUrl || "-"}` : payload.stderr, payload.ok ? "ok" : "error"); } catch (error) { setStatus(els.tokenStatus, `检测失败:${error.message}`, "error"); } @@ -283,6 +360,11 @@ els.saveToken.addEventListener("click", () => { els.health.addEventListener("click", checkHealth); els.reloadRepos.addEventListener("click", loadRepos); +els.syncServerRepos.addEventListener("click", syncServerRepos); +els.repoSearch.addEventListener("input", () => { + repoFilter = els.repoSearch.value; + renderRepos(); +}); els.repoForm.addEventListener("submit", saveRepo); els.repoReset.addEventListener("click", resetRepoForm); els.deleteRepo.addEventListener("click", () => { @@ -304,6 +386,21 @@ els.repoList.addEventListener("click", (event) => { if (repo) fillRepoForm(repo); }); +els.serverRepoList.addEventListener("click", (event) => { + const item = event.target.closest(".svn-server-repo"); + if (!item) return; + const configuredId = item.dataset.configuredId; + if (configuredId) { + selectedRepoId = configuredId; + renderRepos(); + const repo = selectedRepo(); + if (repo) fillRepoForm(repo); + return; + } + const repo = serverRepositories.find((entry) => entry.id === item.dataset.id); + if (repo) fillRepoFormFromServer(repo); +}); + els.repoList.addEventListener("contextmenu", (event) => { const item = event.target.closest(".svn-repo-item"); if (!item) return; diff --git a/server/server.js b/server/server.js index 8ee2dce..339fe57 100644 --- a/server/server.js +++ b/server/server.js @@ -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"); } diff --git a/server/svn-service.js b/server/svn-service.js index 9700b35..3332808 100644 --- a/server/svn-service.js +++ b/server/svn-service.js @@ -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);