diff --git a/.gitignore b/.gitignore index 90343a1..28df776 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ node_modules/ server/uploads/packages/* server/uploads/icons/* server/uploads/manifests/* +server/svn/* !server/uploads/packages/.gitkeep !server/uploads/icons/.gitkeep !server/uploads/manifests/.gitkeep +!server/svn/.gitkeep .env npm-debug.log* server-test.*.log diff --git a/DEPLOY.md b/DEPLOY.md index ea31d4f..5bd6bb0 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -8,6 +8,7 @@ - Node.js 20+,推荐 Node.js 22 LTS 或更高版本 - npm +- Subversion CLI:`svn` - 持久化可写目录:推荐 `/var/lib/sumi-work/` - 对外端口:默认 `3030`,可通过 `PORT` 修改 @@ -16,6 +17,7 @@ ```bash node --version npm --version +svn --version --quiet ``` ## 2. 获取代码 @@ -63,6 +65,7 @@ export SUMI_STORAGE_DIR="/var/lib/sumi-work" ```bash 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 MAX_UPLOAD_BYTES=1073741824 export PLAYER_SESSION_TTL_MS=604800000 ``` @@ -76,6 +79,7 @@ export PLAYER_SESSION_TTL_MS=604800000 - `SUMI_STORAGE_DIR`:运行时数据根目录,推荐放在部署目录外,例如 `/var/lib/sumi-work` - `SUMI_DATA_DIR`:软件列表和玩家账号数据目录;设置后会覆盖 `SUMI_STORAGE_DIR/data` - `SUMI_UPLOAD_DIR`:上传图标、安装包和 version.json 目录;设置后会覆盖 `SUMI_STORAGE_DIR/uploads` +- `SUMI_SVN_DIR`:SVN 工作副本和导出目录;设置后会覆盖 `SUMI_STORAGE_DIR/svn` - `MAX_UPLOAD_BYTES`:最大上传文件大小,默认 1GB - `PLAYER_SESSION_TTL_MS`:玩家登录有效期,默认 7 天 @@ -88,6 +92,7 @@ sudo mkdir -p /var/lib/sumi-work/data sudo mkdir -p /var/lib/sumi-work/uploads/icons sudo mkdir -p /var/lib/sumi-work/uploads/packages sudo mkdir -p /var/lib/sumi-work/uploads/manifests +sudo mkdir -p /var/lib/sumi-work/svn sudo chown -R "$USER":"$USER" /var/lib/sumi-work ``` @@ -112,6 +117,7 @@ npm start - 下载中心:`http://服务器IP:3030/software.html` - 玩家登录:`http://服务器IP:3030/login.html` - 管理后台:`http://服务器IP:3030/admin/software.html` +- SVN 管理:`http://服务器IP:3030/admin/svn.html` ## 6. 用 PM2 部署 @@ -405,8 +411,10 @@ curl -i "http://127.0.0.1:3030/api/software/sumi-launcher/check-update?version=1 - `/var/lib/sumi-work/uploads/icons/`:上传的软件图标 - `/var/lib/sumi-work/uploads/packages/`:上传的软件安装包 - `/var/lib/sumi-work/uploads/manifests/`:上传的 version.json 文件 +- `/var/lib/sumi-work/svn/`:SVN 工作副本和导出文件 +- `/var/lib/sumi-work/data/svn.json`:SVN 仓库配置 -未设置 `SUMI_STORAGE_DIR`、`SUMI_DATA_DIR`、`SUMI_UPLOAD_DIR` 时,本地开发会继续使用仓库里的 `server/data/` 和 `server/uploads/`。生产部署不要依赖这个默认路径。 +未设置 `SUMI_STORAGE_DIR`、`SUMI_DATA_DIR`、`SUMI_UPLOAD_DIR`、`SUMI_SVN_DIR` 时,本地开发会继续使用仓库里的 `server/data/`、`server/uploads/` 和 `server/svn/`。生产部署不要依赖这个默认路径。 建议备份命令: @@ -443,6 +451,13 @@ lsof -i :3030 - 检查 `MAX_UPLOAD_BYTES` - 检查 `SUMI_UPLOAD_DIR` 或 `/var/lib/sumi-work/uploads/` 是否可写 +SVN 管理不可用: + +- 检查服务器是否安装 `svn`:`svn --version --quiet` +- 检查 `SUMI_SVN_DIR` 或 `/var/lib/sumi-work/svn/` 是否可写 +- 检查仓库 URL、账号、密码和证书信任设置 +- 写操作需要玩家登录并提供正确的 `ADMIN_TOKEN` + 管理后台 401: - 确认前端输入的 `ADMIN_TOKEN` 与服务端环境变量一致 @@ -465,7 +480,7 @@ lsof -i :3030 cd /path/to/sumi.work git pull origin main npm ci -sudo mkdir -p /var/lib/sumi-work/data /var/lib/sumi-work/uploads/icons /var/lib/sumi-work/uploads/packages /var/lib/sumi-work/uploads/manifests +sudo mkdir -p /var/lib/sumi-work/data /var/lib/sumi-work/uploads/icons /var/lib/sumi-work/uploads/packages /var/lib/sumi-work/uploads/manifests /var/lib/sumi-work/svn sudo chown -R "$USER":"$USER" /var/lib/sumi-work export PORT=3030 export ADMIN_TOKEN="change-this-admin-token" @@ -523,6 +538,7 @@ npm ci node --check server/server.js node --check assets/js/software.js node --check assets/js/software-admin.js +node --check assets/js/svn-admin.js systemctl restart sumi-work curl -fsS http://127.0.0.1:3030/api/health ``` diff --git a/admin/software.html b/admin/software.html index 304260a..bd86031 100644 --- a/admin/software.html +++ b/admin/software.html @@ -19,6 +19,7 @@ 首页 软件下载 管理后台 + SVN 管理 diff --git a/admin/svn.html b/admin/svn.html new file mode 100644 index 0000000..976976d --- /dev/null +++ b/admin/svn.html @@ -0,0 +1,258 @@ + + + + + + + + sumi.work - SVN 管理 + + + +
+
+ + + SVN 管理 + + +
+
+ +
+
+
+
+

SVN 控制台

+

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

+
+ +
+
+ + +
+

+
+ +
+ + +
+

新增仓库

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

+
+
+
+ +
+
+
+

工作副本

+

请选择一个 SVN 仓库。

+
+
+ + + + +
+
+ +
+ + + + +
+ +
+
+ + + + + + + + + + +
+
+ +
+
+ + + + + + +
+
+ +
+
+ + + + + + +
+
+ +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ +
+
+ 等待操作 + +
+ +
+

+      
+
+ + + + diff --git a/assets/css/software.css b/assets/css/software.css index 730f70f..703d893 100644 --- a/assets/css/software.css +++ b/assets/css/software.css @@ -893,6 +893,178 @@ main, justify-content: flex-end; } +.svn-shell { + display: grid; + gap: 18px; +} + +.svn-console { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 18px; +} + +.svn-sidebar, +.svn-form-panel, +.svn-workbench { + min-width: 0; +} + +.section-title-row.compact { + margin-bottom: 14px; +} + +.svn-repo-list { + display: grid; + gap: 8px; +} + +.svn-repo-item { + display: grid; + width: 100%; + min-height: 86px; + gap: 5px; + padding: 12px; + color: inherit; + text-align: left; + background: #fbfcff; + border: 1px solid var(--line); + border-radius: 8px; +} + +.svn-repo-item:hover, +.svn-repo-item:focus-visible, +.svn-repo-item.is-active { + background: #edf5ff; + border-color: rgba(32, 73, 238, 0.28); + outline: 0; +} + +.repo-name { + color: #17171a; + font-size: 15px; + font-weight: 800; +} + +.repo-url, +.repo-meta { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.svn-action-row, +.svn-output-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.svn-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.svn-tab { + min-height: 34px; + padding: 0 14px; + color: #394150; + background: #f5f7fb; + border: 1px solid transparent; + border-radius: 4px; + font-weight: 700; +} + +.svn-tab:hover, +.svn-tab:focus-visible, +.svn-tab.is-active { + color: var(--blue); + background: rgba(32, 73, 238, 0.08); + border-color: rgba(32, 73, 238, 0.18); +} + +.svn-tab-panel { + display: none; +} + +.svn-tab-panel.is-active { + display: block; +} + +.svn-command-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 18px; +} + +.svn-command-grid button { + min-height: 38px; + color: #1f2a44; + background: #f8fbff; + border: 1px solid rgba(32, 73, 238, 0.14); + border-radius: 6px; + font-weight: 800; +} + +.svn-command-grid button:hover, +.svn-command-grid button:focus-visible { + color: #fff; + background: var(--blue); +} + +.svn-action-form { + padding: 16px; + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 8px; +} + +.svn-output-toolbar { + justify-content: space-between; + margin-top: 18px; + padding: 12px 0; + border-top: 1px solid var(--line); +} + +.svn-output-toolbar strong { + display: block; + color: #111827; + font-size: 14px; +} + +.svn-output-toolbar span { + display: block; + max-width: min(760px, 70vw); + overflow: hidden; + color: var(--muted); + font-family: Consolas, "SFMono-Regular", ui-monospace, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.svn-output { + min-height: 320px; + max-height: 540px; + margin: 0; + overflow: auto; + padding: 16px; + color: #d7e2ff; + background: #111827; + border-radius: 8px; + font-family: Consolas, "SFMono-Regular", ui-monospace, monospace; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; +} + .status-message { min-height: 21px; margin-top: 12px; @@ -938,10 +1110,15 @@ main, .software-grid, .topic-grid, .admin-layout, - .login-panel { + .login-panel, + .svn-console { grid-template-columns: 1fr; } + .svn-command-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .download-hero::before, .download-hero::after { opacity: 0.28; @@ -1009,4 +1186,8 @@ main, gap: 6px; text-align: center; } + + .svn-command-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/assets/js/svn-admin.js b/assets/js/svn-admin.js new file mode 100644 index 0000000..58451f3 --- /dev/null +++ b/assets/js/svn-admin.js @@ -0,0 +1,333 @@ +const els = { + token: document.querySelector("#admin-token"), + saveToken: document.querySelector("#save-token"), + tokenStatus: document.querySelector("#token-status"), + health: document.querySelector("#svn-health"), + repoForm: document.querySelector("#repo-form"), + repoFormTitle: document.querySelector("#repo-form-title"), + repoFormStatus: document.querySelector("#repo-form-status"), + repoSubmit: document.querySelector("#repo-submit"), + repoReset: document.querySelector("#repo-reset"), + deleteRepo: document.querySelector("#delete-repo"), + reloadRepos: document.querySelector("#reload-repos"), + repoList: document.querySelector("#repo-list"), + repoSummary: document.querySelector("#repo-summary"), + selectedRepo: document.querySelector("#selected-repo"), + tabs: [...document.querySelectorAll(".svn-tab")], + panels: [...document.querySelectorAll(".svn-tab-panel")], + actionForm: document.querySelector("#action-form"), + output: document.querySelector("#svn-output"), + lastAction: document.querySelector("#last-action"), + lastCommand: document.querySelector("#last-command"), + clearOutput: document.querySelector("#clear-output"), + actionButtons: [...document.querySelectorAll("[data-action]")], +}; + +let repositories = []; +let selectedRepoId = ""; + +const ACTION_LABELS = { + checkout: "Checkout", + info: "Info", + status: "Status", + update: "Update", + commit: "Commit", + log: "Log", + diff: "Diff", + cleanup: "Cleanup", + revert: "Revert", + resolve: "Resolve", + add: "Add", + delete: "Delete", + mkdir: "Mkdir", + copy: "Copy", + move: "Move", + branch: "Create Branch", + tag: "Create Tag", + switch: "Switch", + relocate: "Relocate", + merge: "Merge", + lock: "Lock", + unlock: "Unlock", + list: "List", + blame: "Blame", + propget: "Propget", + propset: "Propset", + ignore: "Ignore", + export: "Export", +}; + +function escapeHtml(value = "") { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function getToken() { + return localStorage.getItem("sumiSoftwareAdminToken") || ""; +} + +function setStatus(element, message, type = "") { + element.textContent = message; + element.classList.toggle("is-error", type === "error"); + element.classList.toggle("is-ok", type === "ok"); +} + +function selectedRepo() { + return repositories.find((repo) => repo.id === selectedRepoId) || null; +} + +function formatDate(value) { + if (!value) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +async function apiFetch(url, options = {}) { + const token = getToken(); + const headers = { + ...(options.headers || {}), + "X-Admin-Token": token, + }; + if (options.json) { + headers["Content-Type"] = "application/json"; + } + const response = await fetch(url, { + ...options, + headers, + body: options.json ? JSON.stringify(options.json) : options.body, + }); + const isJson = response.headers.get("content-type")?.includes("application/json"); + const payload = isJson ? await response.json() : await response.text(); + if (!response.ok) { + const message = typeof payload === "string" ? payload : payload.error || JSON.stringify(payload); + const error = new Error(message || `HTTP ${response.status}`); + error.payload = payload; + throw error; + } + return payload; +} + +function formToObject(form) { + const formData = new FormData(form); + const payload = {}; + for (const [key, value] of formData.entries()) { + payload[key] = value; + } + for (const checkbox of form.querySelectorAll("input[type='checkbox']")) { + payload[checkbox.name] = checkbox.checked; + } + return payload; +} + +function renderRepos() { + els.repoSummary.textContent = `共 ${repositories.length} 个仓库`; + els.repoList.innerHTML = repositories.map((repo) => ``).join("") || `

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

`; + + const repo = selectedRepo(); + els.selectedRepo.textContent = repo + ? `${repo.name} · ${repo.url} · ${repo.localPath || repo.checkoutPath}` + : "请选择一个 SVN 仓库。"; +} + +async function loadRepos() { + try { + setStatus(els.tokenStatus, "正在读取 SVN 仓库..."); + const payload = await apiFetch("/api/admin/svn/repositories"); + repositories = payload.repositories || []; + if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) { + selectedRepoId = repositories[0]?.id || ""; + } + renderRepos(); + setStatus(els.tokenStatus, `SVN 根目录:${payload.svnRoot || "-"}`, "ok"); + } catch (error) { + repositories = []; + selectedRepoId = ""; + renderRepos(); + setStatus(els.tokenStatus, `读取失败:${error.message}`, "error"); + } +} + +function resetRepoForm() { + els.repoForm.reset(); + els.repoForm.elements.id.value = ""; + els.repoForm.elements.trunkPath.value = "trunk"; + els.repoForm.elements.branchesPath.value = "branches"; + els.repoForm.elements.tagsPath.value = "tags"; + els.repoForm.elements.trustServerCert.checked = true; + els.repoForm.elements.noAuthCache.checked = true; + els.repoFormTitle.textContent = "新增仓库"; + els.repoSubmit.textContent = "保存仓库"; + setStatus(els.repoFormStatus, ""); +} + +function fillRepoForm(repo) { + els.repoForm.elements.id.value = repo.id; + els.repoForm.elements.name.value = repo.name || ""; + els.repoForm.elements.url.value = repo.url || ""; + els.repoForm.elements.checkoutPath.value = repo.localPath || repo.checkoutPath || ""; + els.repoForm.elements.username.value = repo.username || ""; + els.repoForm.elements.password.value = ""; + els.repoForm.elements.trunkPath.value = repo.trunkPath || "trunk"; + els.repoForm.elements.branchesPath.value = repo.branchesPath || "branches"; + els.repoForm.elements.tagsPath.value = repo.tagsPath || "tags"; + els.repoForm.elements.description.value = repo.description || ""; + els.repoForm.elements.trustServerCert.checked = repo.trustServerCert !== false; + els.repoForm.elements.noAuthCache.checked = repo.noAuthCache !== false; + els.repoFormTitle.textContent = `编辑 ${repo.name}`; + els.repoSubmit.textContent = "更新仓库"; + setStatus(els.repoFormStatus, repo.hasPassword ? "已载入仓库,密码留空会保持原密码。" : "已载入仓库。", "ok"); + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +async function saveRepo(event) { + event.preventDefault(); + const payload = formToObject(els.repoForm); + const id = payload.id; + delete payload.id; + try { + setStatus(els.repoFormStatus, "正在保存..."); + const repository = await apiFetch(id ? `/api/admin/svn/repositories/${encodeURIComponent(id)}` : "/api/admin/svn/repositories", { + method: id ? "PUT" : "POST", + json: payload, + }); + selectedRepoId = repository.repository?.id || selectedRepoId; + setStatus(els.repoFormStatus, id ? "仓库已更新。" : "仓库已新增。", "ok"); + resetRepoForm(); + await loadRepos(); + } catch (error) { + setStatus(els.repoFormStatus, `保存失败:${error.message}`, "error"); + } +} + +async function deleteRepo(repo) { + if (!window.confirm(`确定删除 SVN 配置 ${repo.name} 吗?不会删除服务器上的工作副本目录。`)) return; + try { + await apiFetch(`/api/admin/svn/repositories/${encodeURIComponent(repo.id)}`, { method: "DELETE" }); + if (selectedRepoId === repo.id) selectedRepoId = ""; + await loadRepos(); + } catch (error) { + setStatus(els.tokenStatus, `删除失败:${error.message}`, "error"); + } +} + +function actionPayload() { + const payload = formToObject(els.actionForm); + payload.value = payload.pattern; + return payload; +} + +function appendOutput(text, type = "") { + const prefix = type ? `[${type}] ` : ""; + els.output.textContent = `${els.output.textContent}${prefix}${text || ""}${text?.endsWith("\n") ? "" : "\n"}`; + els.output.scrollTop = els.output.scrollHeight; +} + +async function runAction(action) { + const repo = selectedRepo(); + if (!repo) { + appendOutput("请先选择一个 SVN 仓库。", "error"); + return; + } + const label = ACTION_LABELS[action] || action; + const payload = actionPayload(); + els.lastAction.textContent = `正在执行 ${label}...`; + els.lastCommand.textContent = ""; + appendOutput(`\n# ${repo.name} > ${label}`); + + try { + const data = await apiFetch(`/api/admin/svn/repositories/${encodeURIComponent(repo.id)}/actions/${encodeURIComponent(action)}`, { + method: "POST", + json: payload, + }); + els.lastAction.textContent = `${label} ${data.ok ? "完成" : "失败"}`; + els.lastCommand.textContent = data.result?.command || ""; + if (data.result?.stdout) appendOutput(data.result.stdout, "stdout"); + if (data.result?.stderr) appendOutput(data.result.stderr, data.ok ? "stderr" : "error"); + appendOutput(`退出码:${data.result?.code},耗时:${data.result?.durationMs || 0}ms`, data.ok ? "ok" : "error"); + await loadRepos(); + } catch (error) { + els.lastAction.textContent = `${label} 失败`; + const result = error.payload?.result; + if (result?.command) els.lastCommand.textContent = result.command; + if (result?.stdout) appendOutput(result.stdout, "stdout"); + if (result?.stderr) appendOutput(result.stderr, "error"); + appendOutput(error.message, "error"); + } +} + +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"); + } catch (error) { + setStatus(els.tokenStatus, `检测失败:${error.message}`, "error"); + } +} + +els.token.value = getToken(); +els.saveToken.addEventListener("click", () => { + localStorage.setItem("sumiSoftwareAdminToken", els.token.value.trim()); + setStatus(els.tokenStatus, "令牌已保存。", "ok"); + loadRepos(); +}); + +els.health.addEventListener("click", checkHealth); +els.reloadRepos.addEventListener("click", loadRepos); +els.repoForm.addEventListener("submit", saveRepo); +els.repoReset.addEventListener("click", resetRepoForm); +els.deleteRepo.addEventListener("click", () => { + const repo = selectedRepo(); + if (repo) deleteRepo(repo); +}); +els.clearOutput.addEventListener("click", () => { + els.output.textContent = ""; + els.lastAction.textContent = "等待操作"; + els.lastCommand.textContent = ""; +}); + +els.repoList.addEventListener("click", (event) => { + const item = event.target.closest(".svn-repo-item"); + if (!item) return; + selectedRepoId = item.dataset.id; + renderRepos(); + const repo = selectedRepo(); + if (repo) fillRepoForm(repo); +}); + +els.repoList.addEventListener("contextmenu", (event) => { + const item = event.target.closest(".svn-repo-item"); + if (!item) return; + event.preventDefault(); + const repo = repositories.find((entry) => entry.id === item.dataset.id); + if (repo) deleteRepo(repo); +}); + +els.tabs.forEach((tab) => { + tab.addEventListener("click", () => { + els.tabs.forEach((item) => item.classList.toggle("is-active", item === tab)); + els.panels.forEach((panel) => panel.classList.toggle("is-active", panel.dataset.panel === tab.dataset.tab)); + }); +}); + +els.actionButtons.forEach((button) => { + const action = button.dataset.action; + if (!action) return; + button.addEventListener("click", () => runAction(action)); +}); + +if (getToken()) { + loadRepos(); +} else { + renderRepos(); + setStatus(els.tokenStatus, "请输入服务端 ADMIN_TOKEN 后读取 SVN 仓库。"); +} diff --git a/server/server.js b/server/server.js index e2cb453..8ee2dce 100644 --- a/server/server.js +++ b/server/server.js @@ -7,16 +7,20 @@ import { fileURLToPath } from "node:url"; import express from "express"; import multer from "multer"; +import { ensureSvnStorage, registerSvnRoutes } from "./svn-service.js"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const storageRoot = process.env.SUMI_STORAGE_DIR ? path.resolve(process.env.SUMI_STORAGE_DIR) : ""; 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 packageDir = path.join(uploadDir, "packages"); const iconDir = path.join(uploadDir, "icons"); const manifestDir = path.join(uploadDir, "manifests"); const dataFile = path.join(dataDir, "software.json"); const playerFile = path.join(dataDir, "players.json"); +const svnDataFile = path.join(dataDir, "svn.json"); const port = Number(process.env.PORT || 3030); const adminToken = process.env.ADMIN_TOKEN || "sumi-admin"; const playerSessionCookie = "sumi_player_session"; @@ -70,6 +74,7 @@ async function ensureStorage() { fsp.mkdir(packageDir, { recursive: true }), fsp.mkdir(iconDir, { recursive: true }), fsp.mkdir(manifestDir, { recursive: true }), + fsp.mkdir(svnRoot, { recursive: true }), ]); try { await fsp.access(dataFile); @@ -90,6 +95,8 @@ async function ensureStorage() { if (process.env.PLAYER_USERNAME && process.env.PLAYER_PASSWORD) { await upsertEnvPlayer(); } + + await ensureSvnStorage({ svnRoot, svnDataFile }); } async function readStore() { @@ -1143,15 +1150,19 @@ app.delete("/api/admin/software/:id", requirePlayer, requireAdmin, async (req, r } }); +registerSvnRoutes(app, { svnRoot, svnDataFile, requirePlayer, requireAdmin }); + app.get("/software", (req, res) => res.redirect(301, "/software.html")); app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html"))); app.get("/login", (req, res) => res.redirect(301, "/login.html")); app.get("/admin/software", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html"))); +app.get("/admin/svn", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "svn.html"))); app.get("/", (req, res) => res.sendFile(path.join(projectRoot, "index.html"))); app.get("/index.html", (req, res) => res.sendFile(path.join(projectRoot, "index.html"))); app.get("/software.html", (req, res) => res.sendFile(path.join(projectRoot, "software.html"))); app.get("/login.html", (req, res) => res.sendFile(path.join(projectRoot, "login.html"))); app.get("/admin/software.html", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html"))); +app.get("/admin/svn.html", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "svn.html"))); app.use("/assets", express.static(path.join(projectRoot, "assets"), { fallthrough: false })); app.use(async (error, req, res, next) => { @@ -1166,6 +1177,7 @@ app.listen(port, () => { console.log(`sumi.work software center running at http://localhost:${port}`); console.log(`data directory: ${dataDir}`); console.log(`upload directory: ${uploadDir}`); + console.log(`svn directory: ${svnRoot}`); 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 new file mode 100644 index 0000000..9700b35 --- /dev/null +++ b/server/svn-service.js @@ -0,0 +1,529 @@ +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); + } + }); +} diff --git a/server/svn/.gitkeep b/server/svn/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/software.html b/software.html index 7aa037d..32042e3 100644 --- a/software.html +++ b/software.html @@ -19,6 +19,7 @@ 首页 软件下载 管理后台 + SVN 管理