@weibaohui/dsh-git-server 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,33 +3,35 @@
3
3
  [![DSH plugin](https://img.shields.io/badge/dsh-plugin-green)](https://github.com/topics/dsh-plugin)
4
4
  [![npm version](https://img.shields.io/npm/v/@weibaohui/dsh-git-server)](https://www.npmjs.com/package/@weibaohui/dsh-git-server)
5
5
 
6
- **Git 服务器插件**:内嵌 [ts-gogs](https://github.com/weibaohui/ts-gogs)(Gogs 的 TypeScript 平替),把一套**完整的自助 Git 服务**装进 dsh——HTTP clone/push、工单、PR、wiki、发版、标签、里程碑、webhook,全功能。网页侧全程是 dsh 原生界面(React + dsh 主题 token,零 iframe),账号可复用 user-management。
6
+ **Git 服务器插件**:把一套完整的自助 Git 服务装进 dsh——HTTP clone/push、工单、PR、wiki、发版、标签、里程碑、webhook 全功能,网页侧全程是 dsh 原生界面,账号可复用 user-management。
7
7
 
8
8
  <video src="docs/demo-tour.webm" controls muted playsinline width="100%"></video>
9
9
 
10
10
  ## 核心功能
11
11
 
12
- - **完整自助 Git 服务**:ts-gogs 作为受管子进程在独立端口跑起来,提供 HTTP smart 协议 clone/push、内核 API、webhook;仓库/工单/PR/wiki/发版/标签/里程碑一条龙
13
- - **dsh 原生管理页**:侧栏底部「Git」入口进全屏页——仓库列表(建仓/删除/克隆地址)、仓库浏览多标签页(**文件**树+内容、**提交**历史、**分支**、**工单**、**PR**、**Wiki**、**发版**、**标签**、**里程碑**、**设置**);React + dsh 主题 token(亮暗自动跟随)、zh/en 双语、经宿主同源 API 通信,零 iframe 零桥接
14
- - **账号打通**:装了 [user-management](https://github.com/weibaohui/user-management) 即复用其用户名密码——git 凭据与原生页登录免另记一套账号,打开管理页即已登录态,dsh 管理员即 Git 管理员;未装 user-management 则用内置账户(管理员 `root`,密码在设置页可见可轮换);网页注册关闭,账号只由 dsh 管理
15
- - **一键启停 + 自愈**:启用/停用/端口/数据目录全在设置页;ts-gogs 崩溃 3 秒自动拉起,配置指纹变化 3 秒热生效(毫秒级停顿重启子进程)
16
- - **身份链安全**:原生页所有操作按当前 dsh 登录用户身份执行——宿主解析会话 → 以本人身份铸个人令牌 → 进程内代理内核 API,每一步都是本人权限,无全局管理员透传
17
- - **局域网 git**:默认监听 `0.0.0.0`,局域网内机器可直接 `git clone/push`(git remote 走该端口,HTTP Basic 认证)
18
- - **数据自持**:数据目录 = 仓库 + 数据库 + 日志;升级插件后原样沿用,备份整个目录即备份全部
12
+ - **完整自助 Git 服务**:HTTP clone/push 开箱即用,仓库/工单/PR/wiki/发版/标签/里程碑一条龙,崩溃自动拉起
13
+ - **dsh 原生管理页**:侧栏底部「Git」进全屏页——建仓/删仓/克隆地址,仓库浏览多标签(文件树+内容、提交历史、分支、工单、PR、Wiki、发版、标签、里程碑、设置);React + dsh 主题(亮暗自动跟随)、zh/en 双语
14
+ - **账号打通**:账户来源 = [user-management](https://github.com/weibaohui/user-management)(必装)——git 凭据与页面登录均走 user-management 校验,免另记一套账号;网页注册关闭,账号只由 dsh 管理
15
+ - **一键启停**:启用/停用/端口/数据目录全在设置页,改完即生效
16
+ - **局域网 git**:默认监听 `0.0.0.0`,局域网内机器可直接 `git clone/push`
17
+ - **数据自持**:数据目录 = 仓库 + 数据库 + 日志,备份整个目录即备份全部
19
18
 
20
19
  ## 安装
21
20
 
21
+ 本插件依赖 [user-management](https://github.com/weibaohui/user-management) 提供登录与账户,必须先装:
22
+
22
23
  ```bash
24
+ dsh plugin --profile web add @weibaohui/user-management -w
23
25
  dsh plugin --profile web add @weibaohui/dsh-git-server -w
24
26
  ```
25
27
 
26
- 装完重启 `dsh web` 即生效。首次启动自检运行时依赖(better-sqlite3 等),缺失时自动安装,无需手动 `npm install`。
28
+ 装完重启 `dsh web` 即生效。
27
29
 
28
30
  ## 使用
29
31
 
30
- 1. 打开 Web UI → **设置 → Git 服务器**——**默认已启用**(监听 `0.0.0.0:3400`,无需勾选;要停用在设置页取消勾选),可在此改端口/数据目录/查看兜底 `root` 密码
32
+ 1. 打开 Web UI → **设置 → Git 服务器**——**默认已启用**(监听 `0.0.0.0:3400`,无需勾选;要停用在设置页取消勾选),可在此改端口/数据目录/查看 `root` 密码
31
33
  2. 侧栏底部点「**Git**」进全屏管理页:新建仓库(或导入已有),照常开发
32
- 3. `git clone/push` 走子进程端口,凭据用 dsh / user-management 的用户名密码(未装 user-management 则用内置管理员 `root` + 设置页展示的密码):
34
+ 3. `git clone/push` 走子进程端口,凭据用 user-management 的用户名密码:
33
35
 
34
36
  ```sh
35
37
  git clone http://127.0.0.1:3400/root/drill-repo.git
package/client/bundle.js CHANGED
@@ -57,6 +57,9 @@ window.__ModuleLoader__.load({
57
57
  noReleases: '暂无发版', noWiki: '暂无页面——创建第一个', deletePage: '删除页面',
58
58
  prMerged: '已合并', prOpen: '开启中', prClosed: '已关闭',
59
59
  labels: '标签管理', milestones: '里程碑', settings: '设置',
60
+ stRunning: '运行中', stStopped: '已停用', stError: '错误', stRetrying: '重试中',
61
+ stUnreachable: '无法连接', stFetchErr: '状态接口无响应,宿主进程可能已崩溃',
62
+ stPid: 'PID', stCrashes: '崩溃', stDepsFailed: '依赖缺失',
60
63
  }
61
64
  const EN = {
62
65
  nav: 'Git', title: 'Repositories',
@@ -78,6 +81,9 @@ window.__ModuleLoader__.load({
78
81
  noReleases: 'No releases', noWiki: 'No pages yet — create one', deletePage: 'Delete page',
79
82
  prMerged: 'Merged', prOpen: 'Open', prClosed: 'Closed',
80
83
  labels: 'Labels', milestones: 'Milestones', settings: 'Settings',
84
+ stRunning: 'Running', stStopped: 'Stopped', stError: 'Error', stRetrying: 'Retrying',
85
+ stUnreachable: 'Unreachable', stFetchErr: 'Status endpoint not responding — host process may have crashed',
86
+ stPid: 'PID', stCrashes: 'crashes', stDepsFailed: 'deps missing',
81
87
  }
82
88
 
83
89
  // ── styles(dsw token 原生) ───────────────────────────────────────────────
@@ -166,6 +172,21 @@ window.__ModuleLoader__.load({
166
172
  .dgs-comment{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;margin:8px 0}
167
173
  .dgs-empty{color:var(--dsw-alias-label-tertiary);text-align:center;padding:40px 0;font-size:13px}
168
174
  .dgs-err{color:var(--dsw-alias-state-error-primary);font-size:13px}
175
+ .dgs-status-row{display:flex;align-items:center;gap:8px;margin-bottom:2px}
176
+ .dgs-status-dot{width:8px;height:8px;border-radius:50%;flex:none}
177
+ .dgs-status-dot.running{background:var(--dsw-alias-state-success-primary)}
178
+ .dgs-status-dot.stopped{background:var(--dsw-alias-label-tertiary)}
179
+ .dgs-status-dot.error{background:var(--dsw-alias-state-error-primary)}
180
+ .dgs-status-dot.waiting{background:#f59e0b}
181
+ .dgs-status-dot.unreachable{background:var(--dsw-alias-state-error-primary);animation:dgs-pulse 1.5s ease-in-out infinite}
182
+ @keyframes dgs-pulse{0%,100%{opacity:1}50%{opacity:.3}}
183
+ .dgs-status-text{font-size:13px;font-weight:600}
184
+ .dgs-status-text.running{color:var(--dsw-alias-state-success-primary)}
185
+ .dgs-status-text.stopped{color:var(--dsw-alias-label-tertiary)}
186
+ .dgs-status-text.error{color:var(--dsw-alias-state-error-primary)}
187
+ .dgs-status-text.waiting{color:#f59e0b}
188
+ .dgs-status-text.unreachable{color:var(--dsw-alias-state-error-primary)}
189
+ .dgs-status-meta{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-left:4px}
169
190
  .dgs-ico{margin-right:8px;opacity:.75}
170
191
  .dgs-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--dsw-alias-toast-bg);color:#fff;border-radius:8px;padding:8px 16px;font-size:12px;z-index:2147483600}
171
192
  .dgs-settings{max-width:720px;display:flex;flex-direction:column;gap:12px;padding:12px 16px 24px;color:var(--dsw-alias-label-primary)}
@@ -2116,27 +2137,57 @@ window.__ModuleLoader__.load({
2116
2137
 
2117
2138
  function SettingsSection({ t }) {
2118
2139
  const [status, setStatus] = useState(null)
2140
+ const [unreachable, setUnreachable] = useState(false)
2119
2141
  const [form, setForm] = useState({ enabled: false, host: '127.0.0.1', port: 3400, dataDir: '' })
2120
2142
  const [busy, setBusy] = useState(false)
2121
2143
  const [msg, setMsg] = useState('')
2122
2144
  useEffect(() => {
2123
2145
  fetch(API + '/status').then((r) => r.json()).then((d) => {
2124
- setStatus(d)
2146
+ setStatus(d); setUnreachable(false)
2125
2147
  setForm({ enabled: !!d.enabled, host: d.host || '127.0.0.1', port: d.port || 3400, dataDir: d.dataDir || '' })
2126
- }).catch(() => {})
2148
+ }).catch(() => setUnreachable(true))
2149
+ }, [])
2150
+ // 定时轮询:保持状态新鲜但不覆盖用户正在编辑的表单
2151
+ useEffect(() => {
2152
+ const id = setInterval(() => {
2153
+ fetch(API + '/status').then((r) => r.json()).then((d) => { setStatus(d); setUnreachable(false) }).catch(() => setUnreachable(true))
2154
+ }, 5000)
2155
+ return () => clearInterval(id)
2127
2156
  }, [])
2128
2157
  const save = async () => {
2129
2158
  setBusy(true); setMsg('…')
2130
2159
  try {
2131
2160
  const res = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
2132
2161
  const d = await res.json()
2133
- setStatus(d); setMsg('')
2162
+ setStatus(d); setUnreachable(false); setMsg('')
2134
2163
  } catch (e) { setMsg(String(e)) } finally { setBusy(false) }
2135
2164
  }
2136
2165
  const field = (k) => ({ value: form[k], onChange: (e) => setForm({ ...form, [k]: e.target.value }) })
2166
+ // 推导徽章状态:fetch 失败优先(宿主进程可能崩溃)
2167
+ let stKey = 'stopped', stLabel = t('stStopped')
2168
+ if (unreachable) {
2169
+ stKey = 'unreachable'; stLabel = t('stUnreachable')
2170
+ } else if (status) {
2171
+ if (status.error) { stKey = 'error'; stLabel = t('stError') }
2172
+ else if (status.running) { stKey = 'running'; stLabel = t('stRunning') }
2173
+ else if (status.enabled) { stKey = 'waiting'; stLabel = t('stRetrying') }
2174
+ }
2175
+ const meta = []
2176
+ if (!unreachable && status) {
2177
+ if (status.running && status.pid) meta.push(t('stPid') + ' ' + status.pid)
2178
+ if (status.crashes > 0) meta.push(status.crashes + ' ' + t('stCrashes'))
2179
+ if (status.deps === 'failed') meta.push(t('stDepsFailed'))
2180
+ }
2137
2181
  return h('div', { className: 'dgs-settings' },
2138
2182
  h('div', { className: 'dgs-card' },
2139
- h('div', { className: 'dgs-h1' }, t('title')),
2183
+ h('div', { className: 'dgs-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
2184
+ h('div', { className: 'dgs-h1' }, t('title')),
2185
+ (status || unreachable) ? h('div', { className: 'dgs-status-row' },
2186
+ h('span', { className: 'dgs-status-dot ' + stKey }),
2187
+ h('span', { className: 'dgs-status-text ' + stKey }, stLabel),
2188
+ meta.length ? h('span', { className: 'dgs-status-meta' }, meta.join(' · ')) : null) : null),
2189
+ unreachable ? h('div', { className: 'dgs-err', style: { marginBottom: 8 } }, t('stFetchErr')) : null,
2190
+ status && status.error ? h('div', { className: 'dgs-err', style: { marginBottom: 8 } }, status.error) : null,
2140
2191
  h('div', { className: 'dgs-row' },
2141
2192
  h('label', null, '启用'),
2142
2193
  h('input', { type: 'checkbox', checked: form.enabled, onChange: (e) => setForm({ ...form, enabled: e.target.checked }) })),
package/client/index.js CHANGED
@@ -47,6 +47,9 @@ const ZH = {
47
47
  noReleases: '暂无发版', noWiki: '暂无页面——创建第一个', deletePage: '删除页面',
48
48
  prMerged: '已合并', prOpen: '开启中', prClosed: '已关闭',
49
49
  labels: '标签管理', milestones: '里程碑', settings: '设置',
50
+ stRunning: '运行中', stStopped: '已停用', stError: '错误', stRetrying: '重试中',
51
+ stUnreachable: '无法连接', stFetchErr: '状态接口无响应,宿主进程可能已崩溃',
52
+ stPid: 'PID', stCrashes: '崩溃', stDepsFailed: '依赖缺失',
50
53
  }
51
54
  const EN = {
52
55
  nav: 'Git', title: 'Repositories',
@@ -68,6 +71,9 @@ const EN = {
68
71
  noReleases: 'No releases', noWiki: 'No pages yet — create one', deletePage: 'Delete page',
69
72
  prMerged: 'Merged', prOpen: 'Open', prClosed: 'Closed',
70
73
  labels: 'Labels', milestones: 'Milestones', settings: 'Settings',
74
+ stRunning: 'Running', stStopped: 'Stopped', stError: 'Error', stRetrying: 'Retrying',
75
+ stUnreachable: 'Unreachable', stFetchErr: 'Status endpoint not responding — host process may have crashed',
76
+ stPid: 'PID', stCrashes: 'crashes', stDepsFailed: 'deps missing',
71
77
  }
72
78
 
73
79
  // ── styles(dsw token 原生) ───────────────────────────────────────────────
@@ -156,6 +162,21 @@ textarea.dgs-input,.dgs-card>.dgs-input{width:100%;box-sizing:border-box;flex:no
156
162
  .dgs-comment{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;margin:8px 0}
157
163
  .dgs-empty{color:var(--dsw-alias-label-tertiary);text-align:center;padding:40px 0;font-size:13px}
158
164
  .dgs-err{color:var(--dsw-alias-state-error-primary);font-size:13px}
165
+ .dgs-status-row{display:flex;align-items:center;gap:8px;margin-bottom:2px}
166
+ .dgs-status-dot{width:8px;height:8px;border-radius:50%;flex:none}
167
+ .dgs-status-dot.running{background:var(--dsw-alias-state-success-primary)}
168
+ .dgs-status-dot.stopped{background:var(--dsw-alias-label-tertiary)}
169
+ .dgs-status-dot.error{background:var(--dsw-alias-state-error-primary)}
170
+ .dgs-status-dot.waiting{background:#f59e0b}
171
+ .dgs-status-dot.unreachable{background:var(--dsw-alias-state-error-primary);animation:dgs-pulse 1.5s ease-in-out infinite}
172
+ @keyframes dgs-pulse{0%,100%{opacity:1}50%{opacity:.3}}
173
+ .dgs-status-text{font-size:13px;font-weight:600}
174
+ .dgs-status-text.running{color:var(--dsw-alias-state-success-primary)}
175
+ .dgs-status-text.stopped{color:var(--dsw-alias-label-tertiary)}
176
+ .dgs-status-text.error{color:var(--dsw-alias-state-error-primary)}
177
+ .dgs-status-text.waiting{color:#f59e0b}
178
+ .dgs-status-text.unreachable{color:var(--dsw-alias-state-error-primary)}
179
+ .dgs-status-meta{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-left:4px}
159
180
  .dgs-ico{margin-right:8px;opacity:.75}
160
181
  .dgs-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--dsw-alias-toast-bg);color:#fff;border-radius:8px;padding:8px 16px;font-size:12px;z-index:2147483600}
161
182
  .dgs-settings{max-width:720px;display:flex;flex-direction:column;gap:12px;padding:12px 16px 24px;color:var(--dsw-alias-label-primary)}
@@ -2106,27 +2127,57 @@ function GitPage({ onClose, t }) {
2106
2127
 
2107
2128
  function SettingsSection({ t }) {
2108
2129
  const [status, setStatus] = useState(null)
2130
+ const [unreachable, setUnreachable] = useState(false)
2109
2131
  const [form, setForm] = useState({ enabled: false, host: '127.0.0.1', port: 3400, dataDir: '' })
2110
2132
  const [busy, setBusy] = useState(false)
2111
2133
  const [msg, setMsg] = useState('')
2112
2134
  useEffect(() => {
2113
2135
  fetch(API + '/status').then((r) => r.json()).then((d) => {
2114
- setStatus(d)
2136
+ setStatus(d); setUnreachable(false)
2115
2137
  setForm({ enabled: !!d.enabled, host: d.host || '127.0.0.1', port: d.port || 3400, dataDir: d.dataDir || '' })
2116
- }).catch(() => {})
2138
+ }).catch(() => setUnreachable(true))
2139
+ }, [])
2140
+ // 定时轮询:保持状态新鲜但不覆盖用户正在编辑的表单
2141
+ useEffect(() => {
2142
+ const id = setInterval(() => {
2143
+ fetch(API + '/status').then((r) => r.json()).then((d) => { setStatus(d); setUnreachable(false) }).catch(() => setUnreachable(true))
2144
+ }, 5000)
2145
+ return () => clearInterval(id)
2117
2146
  }, [])
2118
2147
  const save = async () => {
2119
2148
  setBusy(true); setMsg('…')
2120
2149
  try {
2121
2150
  const res = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
2122
2151
  const d = await res.json()
2123
- setStatus(d); setMsg('')
2152
+ setStatus(d); setUnreachable(false); setMsg('')
2124
2153
  } catch (e) { setMsg(String(e)) } finally { setBusy(false) }
2125
2154
  }
2126
2155
  const field = (k) => ({ value: form[k], onChange: (e) => setForm({ ...form, [k]: e.target.value }) })
2156
+ // 推导徽章状态:fetch 失败优先(宿主进程可能崩溃)
2157
+ let stKey = 'stopped', stLabel = t('stStopped')
2158
+ if (unreachable) {
2159
+ stKey = 'unreachable'; stLabel = t('stUnreachable')
2160
+ } else if (status) {
2161
+ if (status.error) { stKey = 'error'; stLabel = t('stError') }
2162
+ else if (status.running) { stKey = 'running'; stLabel = t('stRunning') }
2163
+ else if (status.enabled) { stKey = 'waiting'; stLabel = t('stRetrying') }
2164
+ }
2165
+ const meta = []
2166
+ if (!unreachable && status) {
2167
+ if (status.running && status.pid) meta.push(t('stPid') + ' ' + status.pid)
2168
+ if (status.crashes > 0) meta.push(status.crashes + ' ' + t('stCrashes'))
2169
+ if (status.deps === 'failed') meta.push(t('stDepsFailed'))
2170
+ }
2127
2171
  return h('div', { className: 'dgs-settings' },
2128
2172
  h('div', { className: 'dgs-card' },
2129
- h('div', { className: 'dgs-h1' }, t('title')),
2173
+ h('div', { className: 'dgs-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
2174
+ h('div', { className: 'dgs-h1' }, t('title')),
2175
+ (status || unreachable) ? h('div', { className: 'dgs-status-row' },
2176
+ h('span', { className: 'dgs-status-dot ' + stKey }),
2177
+ h('span', { className: 'dgs-status-text ' + stKey }, stLabel),
2178
+ meta.length ? h('span', { className: 'dgs-status-meta' }, meta.join(' · ')) : null) : null),
2179
+ unreachable ? h('div', { className: 'dgs-err', style: { marginBottom: 8 } }, t('stFetchErr')) : null,
2180
+ status && status.error ? h('div', { className: 'dgs-err', style: { marginBottom: 8 } }, status.error) : null,
2130
2181
  h('div', { className: 'dgs-row' },
2131
2182
  h('label', null, '启用'),
2132
2183
  h('input', { type: 'checkbox', checked: form.enabled, onChange: (e) => setForm({ ...form, enabled: e.target.checked }) })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weibaohui/dsh-git-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "dsh 插件 · Git 服务器:内嵌 ts-gogs(Gogs 的 TypeScript 平替),独立端口跑完整 Git 服务(HTTP clone/push、网页端、issue/PR/wiki),可复用 user-management 的用户名密码,设置页一键启停。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,10 @@
24
24
  "cordis.patch.yml",
25
25
  "README.md"
26
26
  ],
27
+ "engines": {
28
+ "node": ">=22.5",
29
+ "dsh": ">=0.1.2-rc.1"
30
+ },
27
31
  "dsh": {
28
32
  "bundle": {
29
33
  "patch": "./cordis.patch.yml"
@@ -57,9 +61,6 @@
57
61
  "ini": "^5.0.0",
58
62
  "marked": "^15.0.0"
59
63
  },
60
- "engines": {
61
- "node": ">=22.5"
62
- },
63
64
  "devDependencies": {
64
65
  "@types/better-sqlite3": "^7.6.12",
65
66
  "@types/busboy": "^1.5.4",
package/src/index.js CHANGED
@@ -25,7 +25,7 @@ const path = require('node:path')
25
25
  const engine = require('./runner')
26
26
 
27
27
  const name = engine.PLUGIN_ID
28
- const inject = ['webServer', 'settings']
28
+ const inject = ['webServer', 'settings', 'connection']
29
29
  const API_PREFIX = '/' + engine.PLUGIN_ID + '/api'
30
30
  const SETTINGS_NS = engine.PLUGIN_ID
31
31
 
@@ -452,6 +452,14 @@ module.exports = {
452
452
  kind: 'prefix',
453
453
  path: API_PREFIX,
454
454
  handler: async (req, res) => {
455
+ // 与其它 host 路由一致的信任栅栏:connection 服务的 Host/Origin 检查
456
+ // 加浏览器认证,防止本机任意网页跨站调用。
457
+ const rejection = ctx.connection.requestRejection(req)
458
+ if (rejection !== undefined) {
459
+ res.writeHead(rejection)
460
+ res.end()
461
+ return
462
+ }
455
463
  const url = new URL(req.url || '/', 'http://dsh.local')
456
464
  const rest = url.pathname.slice(API_PREFIX.length)
457
465
  try {
@@ -1,43 +0,0 @@
1
- // serv subcommand: entrypoint for authorized_keys mode. The system sshd runs
2
- // `<ts-gogs> serv key-<id>` via a command= restriction on each public key line;
3
- // the actual git command arrives in SSH_ORIGINAL_COMMAND (gogs cmd/gogs/serv.go).
4
- import * as db from './db/db.js';
5
- export async function runServ(keyArg) {
6
- const keyID = Number(String(keyArg).replace(/^key-/, ''));
7
- const key = db.getPublicKeyByID(keyID);
8
- if (!key) {
9
- console.error('Serv: cannot find public key:', keyArg);
10
- process.exit(1);
11
- }
12
- const userID = key.owner_id;
13
- const identity = {
14
- userID,
15
- keyID,
16
- deployRepoID: null,
17
- };
18
- if (key.type === 2) {
19
- const dk = db.db().prepare('SELECT repo_id FROM deploy_key WHERE key_id = ?').get(keyID);
20
- identity.deployRepoID = dk?.repo_id ?? null;
21
- }
22
- const sshCmd = process.env.SSH_ORIGINAL_COMMAND ?? '';
23
- if (!sshCmd) {
24
- console.error('Serv: interactive shell is disabled.');
25
- process.exit(1);
26
- }
27
- const { dispatchSSHCommand } = await import('./sshx/dispatch.js');
28
- // keep the event loop alive until close() — async handlers (LFS transfer)
29
- // must never be cut off by a premature exit
30
- const keepAlive = setInterval(() => { }, 1 << 30);
31
- dispatchSSHCommand(sshCmd, identity, {
32
- write: (chunk) => process.stdout.write(chunk),
33
- onStdin: (cb) => {
34
- process.stdin.on('data', (d) => cb(d));
35
- process.stdin.resume();
36
- },
37
- close: (code) => {
38
- clearInterval(keepAlive);
39
- process.exit(code);
40
- },
41
- });
42
- }
43
- //# sourceMappingURL=serv.js.map
@@ -1,160 +0,0 @@
1
- // Shared SSH command dispatch used by both the builtin SSH server and the
2
- // `serv` subcommand (authorized_keys mode). Verbs mirror gogs cmd/gogs/serv.go
3
- // plus the LFS-over-SSH extensions (git-lfs-authenticate, git-lfs-transfer).
4
- import * as fs from 'node:fs';
5
- import * as path from 'node:path';
6
- import { spawn } from 'node:child_process';
7
- import { conf } from '../conf.js';
8
- import * as db from '../db/db.js';
9
- import { runLFSTransfer } from './lfs-transfer.js';
10
- export function parseSSHCmd(cmd) {
11
- const m = /^\s*(git[-\s][a-z-]+|git-lfs-authenticate|git-lfs-transfer)\s+'(.*)'\s*$/.exec(cmd) ??
12
- /^\s*(git[-\s][a-z-]+|git-lfs-authenticate|git-lfs-transfer)\s+"(.*)"\s*$/.exec(cmd) ??
13
- /^\s*(git[-\s][a-z-]+|git-lfs-authenticate|git-lfs-transfer)\s+(.*)\s*$/.exec(cmd);
14
- if (!m)
15
- return [];
16
- return [m[1], m[2].replace(/^~\//, '')];
17
- }
18
- function resolveRepo(repoFullName) {
19
- let repoName = repoFullName.replace(/^\//, '').replace(/\.git$/, '');
20
- let isWiki = false;
21
- if (repoName.endsWith('.wiki')) {
22
- isWiki = true;
23
- repoName = repoName.slice(0, -5);
24
- }
25
- const slash = repoName.indexOf('/');
26
- const ownerName = slash > 0 ? repoName.slice(0, slash) : repoName;
27
- const name = slash > 0 ? repoName.slice(slash + 1) : repoName;
28
- const owner = db.getUserByUsername(ownerName);
29
- const repo = owner ? db.getRepoByOwnerAndName(owner, name) : null;
30
- if (!owner || !repo)
31
- return null;
32
- const repoDir = isWiki ? repo.WikiPath() : repo.RepoPath();
33
- if (!fs.existsSync(repoDir))
34
- return null;
35
- return { owner, repo, repoDir };
36
- }
37
- /** git-upload-pack / git-receive-pack with gogs permission semantics. */
38
- export function serveGit(verb, repoFullName, identity, io) {
39
- let repoName = repoFullName.replace(/^\//, '').replace(/\.git$/, '');
40
- let isWiki = false;
41
- if (repoName.endsWith('.wiki')) {
42
- isWiki = true;
43
- repoName = repoName.slice(0, -5);
44
- }
45
- const slash = repoName.indexOf('/');
46
- const ownerName = slash > 0 ? repoName.slice(0, slash) : repoName;
47
- const name = slash > 0 ? repoName.slice(slash + 1) : repoName;
48
- const owner = db.getUserByUsername(ownerName);
49
- const repo = owner ? db.getRepoByOwnerAndName(owner, name) : null;
50
- const fail = (msg) => {
51
- io.write(Buffer.from(`\r\n${msg}\r\n`));
52
- io.close(1);
53
- };
54
- if (!owner || !repo)
55
- return fail('Repository does not exist');
56
- const repoDir = isWiki ? repo.WikiPath() : repo.RepoPath();
57
- if (!fs.existsSync(repoDir))
58
- return fail('Repository does not exist');
59
- const isPull = verb === 'git-upload-pack';
60
- // deploy keys: single-repo, read-only
61
- if (identity.deployRepoID !== null && (!isPull || identity.deployRepoID !== repo.id)) {
62
- return fail('Access denied');
63
- }
64
- const mode = db.accessMode(identity.userID, repo);
65
- const need = isPull ? db.AccessMode.READ : db.AccessMode.WRITE;
66
- if (!(isPull && !repo.is_private && !conf.requireSigninView) && mode < need) {
67
- return fail('Access denied');
68
- }
69
- if (!isPull && repo.is_mirror)
70
- return fail('Mirror repository is read-only');
71
- const args = isPull ? ['upload-pack', repoDir] : ['receive-pack', repoDir];
72
- const env = {};
73
- if (!isPull) {
74
- const user = db.getUserByID(identity.userID);
75
- Object.assign(env, {
76
- GOGS_AUTH_USER_ID: String(user?.id ?? 0),
77
- GOGS_AUTH_USER_NAME: String(user?.name ?? ''),
78
- GOGS_AUTH_USER_EMAIL: String(user?.email ?? ''),
79
- GOGS_REPO_OWNER_NAME: owner.name,
80
- GOGS_REPO_ID: String(repo.id),
81
- GOGS_REPO_NAME: String(repo.name),
82
- GOGS_REPO_CUSTOM_HOOKS_PATH: path.join(repoDir, 'custom_hooks'),
83
- });
84
- }
85
- const child = spawn('git', args, { env: { ...process.env, ...env } });
86
- child.stdout.on('data', (d) => io.write(d));
87
- child.stderr.on('data', (d) => io.write(Buffer.from(`remote: ${d.toString()}`)));
88
- io.onStdin((data) => child.stdin.write(data));
89
- child.on('close', (code) => io.close(code ?? 0));
90
- child.on('error', () => io.close(1));
91
- }
92
- /** git-lfs-authenticate <path> <download|upload> → JSON auth response on stdout. */
93
- export async function serveLFSAuthenticate(argStr, identity, io) {
94
- const parts = argStr.trim().split(/\s+/);
95
- const repoFullName = parts[0];
96
- const operation = parts[1] ?? 'download';
97
- if (!['download', 'upload'].includes(operation)) {
98
- io.write(Buffer.from(`lfs-authenticate: invalid operation: ${operation}\n`));
99
- io.close(1);
100
- return;
101
- }
102
- const resolved = resolveRepo(repoFullName);
103
- if (!resolved) {
104
- io.write(Buffer.from('Repository does not exist\n'));
105
- io.close(1);
106
- return;
107
- }
108
- const { owner, repo } = resolved;
109
- const mode = db.accessMode(identity.userID, repo);
110
- const need = operation === 'upload' ? db.AccessMode.WRITE : db.AccessMode.READ;
111
- if (!(operation === 'download' && !repo.is_private && !conf.requireSigninView) && mode < need) {
112
- io.write(Buffer.from('Access denied\n'));
113
- io.close(1);
114
- return;
115
- }
116
- const { mintLFSToken } = await import('../lfsx.js');
117
- const { token, expiresAt } = mintLFSToken(identity.userID, repo.id);
118
- const href = `${conf.externalURL}${owner.name}/${repo.name}.git/info/lfs/objects/batch`;
119
- io.write(Buffer.from(JSON.stringify({
120
- href,
121
- header: { Authorization: `RemoteAuth ${token}` },
122
- 'expires_in': 86400,
123
- 'expires_at': new Date(expiresAt * 1000).toISOString(),
124
- }) + '\n'));
125
- io.close(0);
126
- }
127
- /** git-lfs-transfer <path> <download|upload> — pure SSH LFS transfer protocol (lfs-transfer-1). */
128
- export async function serveLFSTransfer(argStr, identity, io) {
129
- await runLFSTransfer(argStr, identity, io);
130
- }
131
- /** Top-level dispatch: parse and run one SSH command. */
132
- export function dispatchSSHCommand(command, identity, io) {
133
- const [verb, args] = parseSSHCmd(command);
134
- if (!verb) {
135
- io.close(1);
136
- return;
137
- }
138
- switch (verb) {
139
- case 'git-upload-pack':
140
- case 'git-receive-pack':
141
- serveGit(verb, args, identity, io);
142
- return;
143
- case 'git-lfs-authenticate':
144
- serveLFSAuthenticate(args, identity, io).catch((e) => {
145
- console.error('[lfs-auth] error:', e);
146
- io.close(1);
147
- });
148
- return;
149
- case 'git-lfs-transfer':
150
- serveLFSTransfer(args, identity, io).catch((e) => {
151
- console.error('[lfs-transfer] error:', e);
152
- io.close(1);
153
- });
154
- return;
155
- default:
156
- io.write(Buffer.from(`Serv: unknown command: ${verb}\n`));
157
- io.close(1);
158
- }
159
- }
160
- //# sourceMappingURL=dispatch.js.map