Add SVN management console
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="../index.html">首页</a>
|
||||
<a href="../software.html">软件下载</a>
|
||||
<a class="is-active" href="software.html">管理后台</a>
|
||||
<a href="svn.html">SVN 管理</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#edf5ff" />
|
||||
<title>sumi.work - SVN 管理</title>
|
||||
<link rel="stylesheet" href="../assets/css/software.css?v=20260618-1" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="soft-topbar">
|
||||
<div class="soft-topbar-inner">
|
||||
<a class="soft-brand" href="../software.html" aria-label="返回软件中心">
|
||||
<img src="../assets/icons/software.svg" alt="" aria-hidden="true" />
|
||||
<span>SVN 管理</span>
|
||||
</a>
|
||||
<nav class="soft-nav" aria-label="SVN 管理导航">
|
||||
<a href="../index.html">首页</a>
|
||||
<a href="../software.html">软件下载</a>
|
||||
<a href="software.html">软件后台</a>
|
||||
<a class="is-active" href="svn.html">SVN 管理</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-shell svn-shell">
|
||||
<section class="admin-panel" aria-labelledby="svn-token-title">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h1 id="svn-token-title">SVN 控制台</h1>
|
||||
<p class="admin-note">集中管理 SVN 仓库、工作副本、提交、更新、分支标签、合并、锁和属性。</p>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="svn-health">检测 SVN</button>
|
||||
</div>
|
||||
<div class="token-row">
|
||||
<input id="admin-token" type="password" autocomplete="current-password" placeholder="输入 ADMIN_TOKEN" />
|
||||
<button class="primary-action" type="button" id="save-token">保存令牌</button>
|
||||
</div>
|
||||
<p class="status-message" id="token-status"></p>
|
||||
</section>
|
||||
|
||||
<div class="svn-console">
|
||||
<aside class="admin-panel svn-sidebar" aria-labelledby="repo-title">
|
||||
<div class="section-title-row compact">
|
||||
<div>
|
||||
<h2 id="repo-title">仓库</h2>
|
||||
<p id="repo-summary">正在加载...</p>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="reload-repos">刷新</button>
|
||||
</div>
|
||||
<div class="svn-repo-list" id="repo-list"></div>
|
||||
<div class="form-actions">
|
||||
<button class="danger-button" type="button" id="delete-repo">删除选中配置</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="admin-panel svn-form-panel" aria-labelledby="repo-form-title">
|
||||
<h2 id="repo-form-title">新增仓库</h2>
|
||||
<form id="repo-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="repo-name">仓库名称</label>
|
||||
<input id="repo-name" name="name" required maxlength="120" placeholder="game-server" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="checkout-path">本地目录</label>
|
||||
<input id="checkout-path" name="checkoutPath" maxlength="200" placeholder="game-server" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="repo-url">仓库 URL</label>
|
||||
<input id="repo-url" name="url" required maxlength="1000" placeholder="https://svn.example.com/project" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="repo-username">用户名</label>
|
||||
<input id="repo-username" name="username" autocomplete="username" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="repo-password">密码</label>
|
||||
<input id="repo-password" name="password" type="password" autocomplete="current-password" placeholder="留空则保持原密码" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="trunk-path">Trunk 目录</label>
|
||||
<input id="trunk-path" name="trunkPath" value="trunk" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="branches-path">Branches 目录</label>
|
||||
<input id="branches-path" name="branchesPath" value="branches" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tags-path">Tags 目录</label>
|
||||
<input id="tags-path" name="tagsPath" value="tags" maxlength="200" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="repo-description">备注</label>
|
||||
<textarea id="repo-description" name="description" maxlength="500"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="trustServerCert" type="checkbox" checked /> 自动信任服务端证书</label>
|
||||
<label class="checkbox-field"><input name="noAuthCache" type="checkbox" checked /> 不缓存 SVN 凭据</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="primary-action" type="submit" id="repo-submit">保存仓库</button>
|
||||
<button class="secondary-action" type="button" id="repo-reset">清空</button>
|
||||
</div>
|
||||
<p class="status-message" id="repo-form-status"></p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="admin-panel svn-workbench" aria-labelledby="workbench-title">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2 id="workbench-title">工作副本</h2>
|
||||
<p id="selected-repo">请选择一个 SVN 仓库。</p>
|
||||
</div>
|
||||
<div class="svn-action-row">
|
||||
<button class="text-button" type="button" data-action="info">Info</button>
|
||||
<button class="text-button" type="button" data-action="status">Status</button>
|
||||
<button class="text-button" type="button" data-action="log">Log</button>
|
||||
<button class="text-button" type="button" data-action="diff">Diff</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tabs" role="tablist" aria-label="SVN 操作分类">
|
||||
<button class="svn-tab is-active" type="button" data-tab="daily">日常操作</button>
|
||||
<button class="svn-tab" type="button" data-tab="history">历史差异</button>
|
||||
<button class="svn-tab" type="button" data-tab="branch">分支标签</button>
|
||||
<button class="svn-tab" type="button" data-tab="advanced">高级维护</button>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel is-active" data-panel="daily">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="checkout">Checkout</button>
|
||||
<button type="button" data-action="update">Update</button>
|
||||
<button type="button" data-action="cleanup">Cleanup</button>
|
||||
<button type="button" data-action="revert">Revert</button>
|
||||
<button type="button" data-action="resolve">Resolve</button>
|
||||
<button type="button" data-action="commit">Commit</button>
|
||||
<button type="button" data-action="add">Add</button>
|
||||
<button type="button" data-action="delete">Delete</button>
|
||||
<button type="button" data-action="lock">Lock</button>
|
||||
<button type="button" data-action="unlock">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel" data-panel="history">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="list">List</button>
|
||||
<button type="button" data-action="blame">Blame</button>
|
||||
<button type="button" data-action="propget">Propget</button>
|
||||
<button type="button" data-action="propset">Propset</button>
|
||||
<button type="button" data-action="ignore">Ignore</button>
|
||||
<button type="button" data-action="export">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel" data-panel="branch">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="branch">Create Branch</button>
|
||||
<button type="button" data-action="tag">Create Tag</button>
|
||||
<button type="button" data-action="switch">Switch</button>
|
||||
<button type="button" data-action="merge">Merge</button>
|
||||
<button type="button" data-action="copy">Copy</button>
|
||||
<button type="button" data-action="move">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel" data-panel="advanced">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="mkdir">Mkdir</button>
|
||||
<button type="button" data-action="relocate">Relocate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="svn-action-form" id="action-form">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="action-target">目标路径</label>
|
||||
<input id="action-target" name="target" placeholder="相对工作副本路径,默认 ." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-revision">版本范围</label>
|
||||
<input id="action-revision" name="revision" placeholder="123 或 100:HEAD" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-source">来源</label>
|
||||
<input id="action-source" name="source" placeholder="URL 或相对路径" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-destination">目标/导出目录</label>
|
||||
<input id="action-destination" name="destination" placeholder="URL、相对路径或导出目录名" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-url">切换/迁移 URL</label>
|
||||
<input id="action-url" name="url" placeholder="switch 或 relocate 目标 URL" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-name">分支/标签名</label>
|
||||
<input id="action-name" name="name" placeholder="release-1.0.0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-property">属性名</label>
|
||||
<input id="action-property" name="property" placeholder="svn:ignore" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-pattern">忽略规则/属性值</label>
|
||||
<input id="action-pattern" name="pattern" placeholder="*.log 或 dist" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-depth">Depth</label>
|
||||
<select id="action-depth" name="depth">
|
||||
<option value="">默认</option>
|
||||
<option value="empty">empty</option>
|
||||
<option value="files">files</option>
|
||||
<option value="immediates">immediates</option>
|
||||
<option value="infinity">infinity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-accept">冲突接受策略</label>
|
||||
<select id="action-accept" name="accept">
|
||||
<option value="working">working</option>
|
||||
<option value="mine-conflict">mine-conflict</option>
|
||||
<option value="theirs-conflict">theirs-conflict</option>
|
||||
<option value="base">base</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="action-message">提交说明</label>
|
||||
<textarea id="action-message" name="message" placeholder="commit、branch、tag、lock 等操作需要提交说明"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="recursive" type="checkbox" checked /> 递归</label>
|
||||
<label class="checkbox-field"><input name="dryRun" type="checkbox" /> Merge dry-run</label>
|
||||
<label class="checkbox-field"><input name="force" type="checkbox" checked /> 强制/覆盖</label>
|
||||
<label class="checkbox-field"><input name="verbose" type="checkbox" /> Log verbose</label>
|
||||
<label class="checkbox-field"><input name="showUpdates" type="checkbox" /> Status show-updates</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="svn-output-toolbar">
|
||||
<div>
|
||||
<strong id="last-action">等待操作</strong>
|
||||
<span id="last-command"></span>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="clear-output">清空输出</button>
|
||||
</div>
|
||||
<pre class="svn-output" id="svn-output" aria-live="polite"></pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="../assets/js/svn-admin.js?v=20260618-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
+182
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, """)
|
||||
.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) => `<button class="svn-repo-item${repo.id === selectedRepoId ? " is-active" : ""}" type="button" data-id="${escapeHtml(repo.id)}">
|
||||
<span class="repo-name">${escapeHtml(repo.name)}</span>
|
||||
<span class="repo-url">${escapeHtml(repo.url)}</span>
|
||||
<span class="repo-meta">${escapeHtml(repo.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"} · ${formatDate(repo.updatedAt)}</span>
|
||||
</button>`).join("") || `<p class="empty-state">暂无 SVN 仓库,先新增一个配置。</p>`;
|
||||
|
||||
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 仓库。");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="index.html">首页</a>
|
||||
<a class="is-active" href="software.html">软件下载</a>
|
||||
<a href="admin/software.html" data-player-auth data-next="/admin/software.html">管理后台</a>
|
||||
<a href="admin/svn.html" data-player-auth data-next="/admin/svn.html">SVN 管理</a>
|
||||
</nav>
|
||||
<form class="soft-search" role="search">
|
||||
<label class="sr-only" for="software-keyword">搜索软件</label>
|
||||
|
||||
Reference in New Issue
Block a user