dsh-remote-plugin 0.4.9 → 0.5.0

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
@@ -9,7 +9,7 @@ DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点
9
9
  ```sh
10
10
  dsh plugin --profile web add dsh-remote-plugin
11
11
  # 或 pin 版本
12
- dsh plugin --profile web add dsh-remote-plugin@0.4.8
12
+ dsh plugin --profile web add dsh-remote-plugin@0.5.0
13
13
  ```
14
14
 
15
15
  重启 DSH Web 后 Ctrl+F5,左侧边栏底部出现 App 图标入口。
@@ -24,13 +24,13 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
24
24
 
25
25
  - 默认**自动启动**:DSH 启动或抽屉刷新时,插件会拉起内置 `gateway.cjs`(`0.0.0.0:8787`),独立于 DSH 进程。
26
26
  - 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
27
- - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制。
27
+ - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
28
28
  - 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
29
- - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(上传,默认上限 2GB);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
29
+ - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(分块续传,支持暂停/取消,落盘前 SHA-256 校验);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
30
30
 
31
31
  ## 手机 App
32
32
 
33
- 从 [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk`,App「设置」里可添加多个服务器地址(局域网 + Tailscale),点「测速」自动选最快;填抽屉里的令牌即可。聊天记录会本地缓存,断网也能看历史;下载的文件统一放系统「下载/dsh-remote」。
33
+ 从 [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk`。推荐在抽屉点「二维码」,App「设置 → 扫码连接」直接配对;也可手动添加多个服务器地址(局域网 + Tailscale),点「测速」自动选最快。聊天记录会本地缓存,断网也能看历史;下载的文件统一放系统「下载/dsh-remote」。
34
34
 
35
35
  ## License
36
36
 
Binary file
package/gateway.cjs CHANGED
@@ -143,7 +143,22 @@ function loadToken() {
143
143
  return token
144
144
  }
145
145
 
146
- const TOKEN = loadToken()
146
+ const TOKEN_FROM_ENV = !!process.env.TOKEN
147
+ let TOKEN = loadToken()
148
+
149
+ /** 一键轮换令牌: 写回 TOKEN_FILE 并立即生效(旧令牌/旧连接全部失效)。 */
150
+ function rotateToken() {
151
+ if (TOKEN_FROM_ENV) return { error: 'token-from-env', detail: '令牌来自 TOKEN 环境变量, 请修改环境变量后重启' }
152
+ const next = crypto.randomBytes(24).toString('base64url')
153
+ try {
154
+ fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
155
+ fs.writeFileSync(TOKEN_FILE, next + '\n', { mode: 0o600 })
156
+ } catch (err) {
157
+ return { error: 'write-failed', detail: err.message }
158
+ }
159
+ TOKEN = next
160
+ return { ok: true, token: next }
161
+ }
147
162
 
148
163
  function tokenOf(req, url) {
149
164
  const auth = req.headers.authorization || ''
@@ -453,6 +468,8 @@ function serveAdminApi(req, res, url) {
453
468
  error: latestState.error,
454
469
  newer: !!(latestState.version && cmpVersion(latestState.version, VERSION) > 0)
455
470
  },
471
+ token: TOKEN,
472
+ tokenFromEnv: TOKEN_FROM_ENV,
456
473
  tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
457
474
  tokenLength: TOKEN.length,
458
475
  totalRequests,
@@ -464,6 +481,29 @@ function serveAdminApi(req, res, url) {
464
481
  })
465
482
  return
466
483
  }
484
+ if (sub === '/token/rotate' && req.method === 'POST') {
485
+ if (!authorized(req, url)) {
486
+ authFailures++
487
+ touchDevice(req, { failedAuth: true })
488
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
489
+ res.end(JSON.stringify({ error: 'unauthorized' }))
490
+ return
491
+ }
492
+ const r = rotateToken()
493
+ if (!r.ok) {
494
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
495
+ res.end(JSON.stringify({ error: r.error, detail: r.detail }))
496
+ return
497
+ }
498
+ // 旧令牌立即失效: 断开已连接的 App/浏览器, 让它们重新扫码/输入
499
+ for (const d of devices.values()) {
500
+ if (d.kind !== 'admin') kickDevice(d.ip)
501
+ }
502
+ touchDevice(req)
503
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
504
+ res.end(JSON.stringify({ ok: true, token: r.token, tokenMasked: r.token.slice(0, 4) + '…' + r.token.slice(-4) }))
505
+ return
506
+ }
467
507
  if (sub === '/shutdown' && req.method === 'POST') {
468
508
  if (!authorized(req, url)) {
469
509
  authFailures++
@@ -708,6 +748,27 @@ function fsValidName(name) {
708
748
  return true
709
749
  }
710
750
 
751
+ /** 流式计算文件 SHA-256(十六进制)。2GB 也就一次顺序读, 落盘前校验足够快。 */
752
+ function sha256FileHex(file, cb) {
753
+ const hash = crypto.createHash('sha256')
754
+ let stream
755
+ try {
756
+ stream = fs.createReadStream(file)
757
+ } catch (err) {
758
+ cb(err)
759
+ return
760
+ }
761
+ stream.on('error', (err) => cb(err))
762
+ stream.on('data', (chunk) => hash.update(chunk))
763
+ stream.on('end', () => cb(null, hash.digest('hex')))
764
+ }
765
+
766
+ /* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
767
+ const activeUploads = new Map()
768
+ function fsActiveKey(dirReal, name, session) {
769
+ return dirReal + '\n' + name + '\n' + (session || '')
770
+ }
771
+
711
772
  /** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
712
773
  function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
713
774
  if (!fsValidName(name)) {
@@ -943,6 +1004,10 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
943
1004
  }
944
1005
  const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
945
1006
  const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
1007
+ const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
1008
+ if (sha256Expected && !/^[0-9a-f]{64}$/.test(sha256Expected)) {
1009
+ return fsJson(res, 400, { error: 'bad-sha256', detail: 'sha256 必须是 64 位十六进制' })
1010
+ }
946
1011
  const part = fsPartPath(dirReal, name, session)
947
1012
  const target = path.join(dirReal, name)
948
1013
 
@@ -982,12 +1047,15 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
982
1047
  } catch (err) {
983
1048
  return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
984
1049
  }
1050
+ const activeKey = fsActiveKey(dirReal, name, session)
1051
+ activeUploads.set(activeKey, stream)
985
1052
 
986
1053
  let bytes = 0
987
1054
  let finished = false
988
1055
  const abort = (status, msg, extra = {}) => {
989
1056
  if (finished) return
990
1057
  finished = true
1058
+ activeUploads.delete(activeKey)
991
1059
  try { stream.destroy() } catch {}
992
1060
  // 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
993
1061
  if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
@@ -1013,6 +1081,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1013
1081
  if (finished) return
1014
1082
  finished = true
1015
1083
  stream.end(() => {
1084
+ activeUploads.delete(activeKey)
1016
1085
  const total = offset + bytes
1017
1086
  try {
1018
1087
  const st = fs.statSync(part)
@@ -1021,12 +1090,31 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1021
1090
  fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
1022
1091
  return
1023
1092
  }
1024
- const ts = fsTargetState(target)
1025
- if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
1026
- if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
1027
- if (ts.exists) fs.rmSync(target, { force: true })
1028
- fs.renameSync(part, target)
1029
- fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session })
1093
+ const commit = (actualSha256) => {
1094
+ try {
1095
+ const ts = fsTargetState(target)
1096
+ if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
1097
+ if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
1098
+ if (ts.exists) fs.rmSync(target, { force: true })
1099
+ fs.renameSync(part, target)
1100
+ fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
1101
+ } catch (err) {
1102
+ if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
1103
+ else try { res.destroy() } catch {}
1104
+ }
1105
+ }
1106
+ if (sha256Expected) {
1107
+ // 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
1108
+ sha256FileHex(part, (err, actual) => {
1109
+ if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
1110
+ if (actual !== sha256Expected) {
1111
+ return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
1112
+ }
1113
+ commit(actual)
1114
+ })
1115
+ } else {
1116
+ commit(null)
1117
+ }
1030
1118
  } catch (err) {
1031
1119
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
1032
1120
  else try { res.destroy() } catch {}
@@ -1035,6 +1123,42 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1035
1123
  })
1036
1124
  }
1037
1125
 
1126
+ /* POST /fs/upload-control?path&name&session&action=cancel
1127
+ * 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
1128
+ function fsUploadControl(req, res, url) {
1129
+ if (req.method !== 'POST') {
1130
+ res.writeHead(405, { allow: 'POST' })
1131
+ res.end()
1132
+ return
1133
+ }
1134
+ if (!fsAuthorized(req, url, res)) return
1135
+ touchDevice(req)
1136
+ const resolved = fsResolve(url.searchParams.get('path') ?? '')
1137
+ if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1138
+ const checked = fsRealChecked(resolved.abs)
1139
+ if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
1140
+ const name = url.searchParams.get('name') || ''
1141
+ if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
1142
+ const session = url.searchParams.get('session') || 'default'
1143
+ const action = url.searchParams.get('action') || ''
1144
+ if (action !== 'cancel' && action !== 'abort') return fsJson(res, 400, { error: 'bad-action', detail: 'action 只支持 cancel' })
1145
+
1146
+ const part = fsPartPath(checked.abs, name, session)
1147
+ const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
1148
+ if (active) {
1149
+ try { active.destroy() } catch {}
1150
+ activeUploads.delete(fsActiveKey(checked.abs, name, session))
1151
+ }
1152
+ // 等写流关闭后再删, 防止 write 把分片重新创建出来
1153
+ setTimeout(() => {
1154
+ let removed = false
1155
+ try { fs.unlinkSync(part); removed = true } catch (err) {
1156
+ if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1157
+ }
1158
+ fsJson(res, 200, { ok: true, cancelled: true, removed, session })
1159
+ }, 80)
1160
+ }
1161
+
1038
1162
  function serveFs(req, res, url) {
1039
1163
  const sub = url.pathname.slice('/fs'.length)
1040
1164
 
@@ -1049,6 +1173,7 @@ function serveFs(req, res, url) {
1049
1173
  if (sub === '/list') return fsList(req, res, url)
1050
1174
  if (sub === '/file') return fsFile(req, res, url)
1051
1175
  if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
1176
+ if (sub === '/upload-control') return fsUploadControl(req, res, url)
1052
1177
 
1053
1178
  if (sub === '/upload') {
1054
1179
  if (req.method !== 'POST') {
package/index.mjs CHANGED
@@ -317,14 +317,16 @@ async function serveStatic(req, res) {
317
317
  })
318
318
  return
319
319
  }
320
- if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick`) {
320
+ if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick` || pathname === `${MOUNT}/admin/api/token/rotate`) {
321
321
  if (req.method !== 'POST') {
322
322
  res.writeHead(405, { allow: 'POST' })
323
323
  res.end()
324
324
  return
325
325
  }
326
326
  const body = await readBody(req, 4096)
327
- const sub = pathname.endsWith('/note') ? '/note' : '/kick'
327
+ const sub = pathname.endsWith('/note') ? '/note'
328
+ : pathname.endsWith('/kick') ? '/kick'
329
+ : '/token/rotate'
328
330
  const proxied = await proxyGateway(`/admin/api${sub}`, 'POST', body)
329
331
  if (proxied !== null) {
330
332
  sendJson(res, proxied.status, proxied.json)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -32,6 +32,11 @@
32
32
  .ua { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
33
33
  .token-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 14px; }
34
34
  .token-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 12px; word-break: break-all; }
35
+ .pair-box { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; margin-bottom: 14px; text-align: center; }
36
+ .pair-box .pair-title { font-weight: 600; margin-bottom: 8px; }
37
+ .pair-qr { display: inline-block; background: #fff; border-radius: 8px; padding: 6px; }
38
+ .pair-qr svg { display: block; }
39
+ .pair-box .muted { margin-top: 8px; font-size: 12px; }
35
40
  .empty { text-align: center; color: var(--muted); padding: 30px 0; }
36
41
  .empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
37
42
  a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
@@ -66,10 +71,18 @@
66
71
  <div class="token-row">
67
72
  <code id="token-full">—</code>
68
73
  <button id="btn-gateway" class="mini-btn hidden">启动网关</button>
74
+ <button id="btn-qr" class="mini-btn hidden">二维码</button>
75
+ <button id="btn-rotate" class="mini-btn hidden">轮换令牌</button>
69
76
  <button id="btn-copy" class="mini-btn">复制令牌</button>
70
77
  <button id="btn-logout" class="mini-btn">退出</button>
71
78
  </div>
72
79
 
80
+ <div id="pair-box" class="pair-box hidden">
81
+ <div class="pair-title">手机 App 扫码配对</div>
82
+ <div class="pair-qr" id="pair-qr"></div>
83
+ <div class="muted" id="pair-hint">用 App 内「设置 → 扫码连接」,或系统相机扫一扫自动打开 App</div>
84
+ </div>
85
+
73
86
  <div class="stat-grid" id="stats"></div>
74
87
 
75
88
  <div class="section-head"><span>已连接设备</span><span id="device-summary" class="muted"></span></div>
@@ -84,6 +97,7 @@
84
97
  </div>
85
98
 
86
99
  <div id="toast" class="toast hidden"></div>
100
+ <script src="../qrcode.min.js"></script>
87
101
  <script src="../admin.js"></script>
88
102
  </body>
89
103
  </html>
package/public/admin.js CHANGED
@@ -19,6 +19,8 @@ let timer = null
19
19
  let gatewayRunning = false
20
20
  let gatewayBusy = false
21
21
  let shownToken = token
22
+ let lastState = null
23
+ let qrShown = false
22
24
 
23
25
  function toast(text, kind = '') {
24
26
  const el = $('toast')
@@ -62,15 +64,20 @@ async function loadState() {
62
64
  }
63
65
 
64
66
  function render(st) {
67
+ lastState = st
65
68
  const isPlugin = st.mode === 'plugin'
66
69
  const isGateway = st.mode === 'gateway'
67
- shownToken = st.token || (isGateway ? '' : token)
70
+ shownToken = st.token || token
68
71
  $('conn-badge').textContent = isPlugin ? '内嵌' : isGateway ? '网关' : '已连接'
69
72
  $('conn-badge').className = 'conn-badge on'
70
73
  $('token-full').textContent = shownToken || (isPlugin ? '插件模式 · 未接网关, 无需令牌' : '未获取到令牌')
71
74
  // 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
72
75
  $('btn-copy').classList.toggle('hidden', !shownToken)
73
76
  $('btn-logout').classList.toggle('hidden', pluginMode)
77
+ // 二维码与轮换只在网关模式下可用(二维码里有完整令牌, 不能在没有网关时生成)
78
+ $('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
79
+ $('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
80
+ renderQr(st)
74
81
  // 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
75
82
  gatewayRunning = isGateway
76
83
  $('btn-gateway').classList.toggle('hidden', !pluginMode)
@@ -141,6 +148,37 @@ function render(st) {
141
148
  btn.addEventListener('click', () => setNote(btn.dataset.noteIp, btn.dataset.note)))
142
149
  }
143
150
 
151
+ function pairTarget(st) {
152
+ const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
153
+ const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
154
+ const port = st.port || 8787
155
+ const base = `http://${host}:${port}`
156
+ return {
157
+ url: `dshremote://pair?token=${encodeURIComponent(shownToken)}&server=${encodeURIComponent(base)}`,
158
+ base
159
+ }
160
+ }
161
+
162
+ function renderQr(st) {
163
+ const box = $('pair-box')
164
+ if (!qrShown || !shownToken || st.mode !== 'gateway') {
165
+ box.classList.add('hidden')
166
+ return
167
+ }
168
+ try {
169
+ const t = pairTarget(st)
170
+ const qr = window.qrcode(0, 'M')
171
+ qr.addData(t.url)
172
+ qr.make()
173
+ $('pair-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
174
+ $('pair-hint').textContent = `${t.base} · App「设置 → 扫码连接」,或系统相机扫一扫自动打开 App`
175
+ box.classList.remove('hidden')
176
+ } catch (e) {
177
+ $('pair-qr').textContent = '二维码生成失败'
178
+ box.classList.remove('hidden')
179
+ }
180
+ }
181
+
144
182
  async function setNote(ip, current) {
145
183
  const name = prompt('给 ' + ip + ' 设置备注(留空清除):', current || '')
146
184
  if (name === null) return
@@ -215,6 +253,32 @@ $('btn-copy').addEventListener('click', async () => {
215
253
  }
216
254
  })
217
255
 
256
+ $('btn-qr').addEventListener('click', () => {
257
+ qrShown = !qrShown
258
+ renderQr(lastState || { mode: '', token: shownToken })
259
+ })
260
+
261
+ $('btn-rotate').addEventListener('click', async () => {
262
+ if (!confirm('轮换后旧令牌立即失效,手机与浏览器都需要重新扫码/输入。继续?')) return
263
+ try {
264
+ const res = await fetch(`${API}/token/rotate`, {
265
+ method: 'POST',
266
+ headers: { authorization: 'Bearer ' + (token || shownToken), 'x-dsh-remote-client': 'admin' }
267
+ })
268
+ const out = await res.json().catch(() => ({}))
269
+ if (out.ok && out.token) {
270
+ token = out.token
271
+ store.set('dshAdminToken', out.token)
272
+ toast('令牌已轮换,请重新给设备配对', 'ok')
273
+ setTimeout(loadState, 300)
274
+ } else {
275
+ toast(out.detail || out.error || '轮换失败', 'err')
276
+ }
277
+ } catch (e) {
278
+ toast('轮换失败:' + (e.message || e), 'err')
279
+ }
280
+ })
281
+
218
282
  $('btn-gateway').addEventListener('click', async () => {
219
283
  if (gatewayBusy) return
220
284
  gatewayBusy = true