dsh-remote-plugin 0.4.8 → 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
@@ -1,5 +1,7 @@
1
1
  # dsh-remote-plugin
2
2
 
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
4
+
3
5
  DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App 实现手机远程操控与文件互传(`/fs/list`、`/fs/file`、`/fs/upload`)。
4
6
 
5
7
  ## 安装
@@ -7,7 +9,7 @@ DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点
7
9
  ```sh
8
10
  dsh plugin --profile web add dsh-remote-plugin
9
11
  # 或 pin 版本
10
- dsh plugin --profile web add dsh-remote-plugin@0.4.8
12
+ dsh plugin --profile web add dsh-remote-plugin@0.5.0
11
13
  ```
12
14
 
13
15
  重启 DSH Web 后 Ctrl+F5,左侧边栏底部出现 App 图标入口。
@@ -22,13 +24,13 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
22
24
 
23
25
  - 默认**自动启动**:DSH 启动或抽屉刷新时,插件会拉起内置 `gateway.cjs`(`0.0.0.0:8787`),独立于 DSH 进程。
24
26
  - 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
25
- - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制。
27
+ - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
26
28
  - 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
27
- - 文件端点:`/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` 可开多根(`:` 分隔)。
28
30
 
29
31
  ## 手机 App
30
32
 
31
- 从 [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」。
32
34
 
33
35
  ## License
34
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)) {
@@ -873,6 +934,231 @@ function fsUploadMultipart(req, res, url, dirLex, dirReal, boundary) {
873
934
  })
874
935
  }
875
936
 
937
+ /* ---------- /fs/upload 断点续传 ----------
938
+ * 分块模式: POST /fs/upload?path=..&name=..&session=<uuid>&offset=N[&finish=1][&overwrite=1]
939
+ * - 每一块是 raw body, 服务端写到 .<name>.dsh-remote-part-<session> 的 offset 处
940
+ * - offset=0 重开; offset<已有大小 = 回卷重写; offset>已有大小 = 409 offset-mismatch
941
+ * - finish=1 时原子 rename 到目标名; 否则返回 {partial:true,size,offset}
942
+ * 查询进度: GET /fs/upload-probe?path=..&name=..&session=<uuid>
943
+ */
944
+ function fsPartPath(dirReal, name, session) {
945
+ const s = String(session || 'default').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64) || 'default'
946
+ return path.join(dirReal, `.${name}.dsh-remote-part-${s}`)
947
+ }
948
+
949
+ function fsTargetState(target) {
950
+ try {
951
+ const st = fs.lstatSync(target)
952
+ if (st.isSymbolicLink()) return { status: 403, error: 'symlink-forbidden', detail: '拒绝覆盖符号链接' }
953
+ return { exists: true }
954
+ } catch (err) {
955
+ if (err.code === 'ENOENT') return { exists: false }
956
+ return { status: 403, error: 'permission-denied', detail: err.message }
957
+ }
958
+ }
959
+
960
+ function fsUploadProbe(req, res, url) {
961
+ if (req.method !== 'GET') {
962
+ res.writeHead(405, { allow: 'GET' })
963
+ res.end()
964
+ return
965
+ }
966
+ if (!fsAuthorized(req, url, res)) return
967
+ touchDevice(req)
968
+ const resolved = fsResolve(url.searchParams.get('path') ?? '')
969
+ if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
970
+ const checked = fsRealChecked(resolved.abs)
971
+ if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
972
+ const name = url.searchParams.get('name') || ''
973
+ if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
974
+ const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
975
+ let partialSize = 0, partExists = false
976
+ try {
977
+ const st = fs.statSync(part)
978
+ if (st.isFile()) { partialSize = st.size; partExists = true }
979
+ } catch {}
980
+ const target = fsTargetState(path.join(checked.abs, name))
981
+ let targetSize = 0
982
+ if (target.exists) {
983
+ try { targetSize = fs.statSync(path.join(checked.abs, name)).size } catch {}
984
+ }
985
+ fsJson(res, 200, {
986
+ ok: true,
987
+ name,
988
+ partialSize,
989
+ partExists,
990
+ targetExists: !!target.exists,
991
+ targetSize
992
+ })
993
+ }
994
+
995
+ function fsUploadResumable(req, res, url, dirLex, dirReal) {
996
+ const name = url.searchParams.get('name') || ''
997
+ if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
998
+ const session = url.searchParams.get('session') || ''
999
+ if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
1000
+ const offsetRaw = url.searchParams.get('offset')
1001
+ const offset = Number(offsetRaw)
1002
+ if (!Number.isSafeInteger(offset) || offset < 0) {
1003
+ return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
1004
+ }
1005
+ const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
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
+ }
1011
+ const part = fsPartPath(dirReal, name, session)
1012
+ const target = path.join(dirReal, name)
1013
+
1014
+ // 已有分片尺寸对齐: 回卷重写允许, 越界/缺洞拒绝
1015
+ let existing = 0
1016
+ try {
1017
+ const st = fs.statSync(part)
1018
+ if (!st.isFile()) return fsJson(res, 409, { error: 'part-conflict', detail: '分片路径被占用' })
1019
+ existing = st.size
1020
+ } catch (err) {
1021
+ if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1022
+ }
1023
+ if (offset === 0) {
1024
+ try { if (existing > 0) fs.truncateSync(part, 0) } catch (err) {
1025
+ return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1026
+ }
1027
+ } else {
1028
+ if (offset > existing) return fsJson(res, 409, { error: 'offset-mismatch', partialSize: existing, detail: 'offset 超过已有分片大小, 请先 probe' })
1029
+ if (offset < existing) {
1030
+ try { fs.truncateSync(part, offset) } catch (err) {
1031
+ return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ if (finish) {
1037
+ const st = fsTargetState(target)
1038
+ if (st.status) return fsJson(res, st.status, { error: st.error, detail: st.detail })
1039
+ if (st.exists && !overwrite) {
1040
+ return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
1041
+ }
1042
+ }
1043
+
1044
+ let stream
1045
+ try {
1046
+ stream = fs.createWriteStream(part, { flags: offset === 0 ? 'w' : 'r+', start: offset, mode: 0o600 })
1047
+ } catch (err) {
1048
+ return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1049
+ }
1050
+ const activeKey = fsActiveKey(dirReal, name, session)
1051
+ activeUploads.set(activeKey, stream)
1052
+
1053
+ let bytes = 0
1054
+ let finished = false
1055
+ const abort = (status, msg, extra = {}) => {
1056
+ if (finished) return
1057
+ finished = true
1058
+ activeUploads.delete(activeKey)
1059
+ try { stream.destroy() } catch {}
1060
+ // 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
1061
+ if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
1062
+ if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
1063
+ else try { res.destroy() } catch {}
1064
+ }
1065
+
1066
+ stream.on('error', (err) => {
1067
+ abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
1068
+ })
1069
+ req.on('aborted', () => abort(400, 'client-aborted', { partialSize: offset + bytes }))
1070
+ req.on('error', () => abort(400, 'client-aborted', { partialSize: offset + bytes }))
1071
+ req.on('data', (chunk) => {
1072
+ if (finished) return
1073
+ bytes += chunk.length
1074
+ if (offset + bytes > FS_MAX_UPLOAD) {
1075
+ abort(413, 'too-large', { limit: FS_MAX_UPLOAD })
1076
+ return
1077
+ }
1078
+ stream.write(chunk)
1079
+ })
1080
+ req.on('end', () => {
1081
+ if (finished) return
1082
+ finished = true
1083
+ stream.end(() => {
1084
+ activeUploads.delete(activeKey)
1085
+ const total = offset + bytes
1086
+ try {
1087
+ const st = fs.statSync(part)
1088
+ if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
1089
+ if (!finish) {
1090
+ fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
1091
+ return
1092
+ }
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
+ }
1118
+ } catch (err) {
1119
+ if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
1120
+ else try { res.destroy() } catch {}
1121
+ }
1122
+ })
1123
+ })
1124
+ }
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
+
876
1162
  function serveFs(req, res, url) {
877
1163
  const sub = url.pathname.slice('/fs'.length)
878
1164
 
@@ -886,6 +1172,8 @@ function serveFs(req, res, url) {
886
1172
 
887
1173
  if (sub === '/list') return fsList(req, res, url)
888
1174
  if (sub === '/file') return fsFile(req, res, url)
1175
+ if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
1176
+ if (sub === '/upload-control') return fsUploadControl(req, res, url)
889
1177
 
890
1178
  if (sub === '/upload') {
891
1179
  if (req.method !== 'POST') {
@@ -909,6 +1197,10 @@ function serveFs(req, res, url) {
909
1197
  if (Number.isFinite(contentLength) && contentLength > FS_MAX_UPLOAD) {
910
1198
  return fsJson(res, 413, { error: 'too-large', limit: FS_MAX_UPLOAD })
911
1199
  }
1200
+ // 带 session/offset 进入分块续传模式; 不带则保持 raw/multipart 一次性上传
1201
+ if (url.searchParams.has('session') || url.searchParams.has('offset')) {
1202
+ return fsUploadResumable(req, res, url, resolved.abs, checked.abs)
1203
+ }
912
1204
  const contentType = String(req.headers['content-type'] || '')
913
1205
  if (contentType.startsWith('multipart/form-data')) {
914
1206
  const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType)
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.8",
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