dsh-remote-plugin 0.4.9 → 0.5.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 +4 -4
- package/apk/dsh-remote.apk +0 -0
- package/client.js +0 -12
- package/gateway.cjs +173 -11
- package/index.mjs +4 -2
- package/package.json +1 -1
- package/public/admin.html +15 -1
- package/public/admin.js +81 -2
- package/public/app.js +500 -81
- package/public/index.html +43 -4
- package/public/jsqr.min.js +10104 -0
- package/public/qrcode.min.js +2297 -0
- package/public/sha256.js +119 -0
- package/public/styles.css +60 -6
- package/public/update.json +3 -3
- package/public/version.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/client.js
CHANGED
|
@@ -19,11 +19,6 @@ window.__ModuleLoader__.load({
|
|
|
19
19
|
transform: 'translateX(102%)', transition: 'transform .22s ease',
|
|
20
20
|
display: 'flex', flexDirection: 'column',
|
|
21
21
|
}
|
|
22
|
-
var HEAD_STYLE = {
|
|
23
|
-
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
24
|
-
gap: 8, padding: '9px 12px', background: '#0f766e', color: '#fff',
|
|
25
|
-
font: '600 13px/1 system-ui, sans-serif', flexShrink: 0,
|
|
26
|
-
}
|
|
27
22
|
// DSH 原生 footerActions 容器默认 flex-row(官方类名已编译定型);
|
|
28
23
|
// 动态注入列布局, 让各 footer action 上下堆叠。
|
|
29
24
|
var FOOTER_FIX_ID = 'dsh-remote-footer-stack'
|
|
@@ -87,13 +82,6 @@ window.__ModuleLoader__.load({
|
|
|
87
82
|
'aria-hidden': !open,
|
|
88
83
|
style: Object.assign({}, DRAWER_STYLE, { transform: open ? 'translateX(0)' : 'translateX(102%)' }),
|
|
89
84
|
},
|
|
90
|
-
React.createElement('div', { style: HEAD_STYLE },
|
|
91
|
-
React.createElement('span', null, '🖥 DSH Remote'),
|
|
92
|
-
React.createElement('button', {
|
|
93
|
-
type: 'button', 'aria-label': '关闭',
|
|
94
|
-
onClick: function () { props.actions.close() },
|
|
95
|
-
style: { border: 'none', background: 'transparent', color: '#fff', font: '600 16px/1 system-ui, sans-serif', cursor: 'pointer', padding: '2px 6px' },
|
|
96
|
-
}, '✕')),
|
|
97
85
|
loaded.current ? React.createElement('iframe', {
|
|
98
86
|
src: '/remote/admin/?embedded=1',
|
|
99
87
|
title: 'DSH Remote',
|
package/gateway.cjs
CHANGED
|
@@ -143,7 +143,22 @@ function loadToken() {
|
|
|
143
143
|
return token
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
const
|
|
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 || ''
|
|
@@ -240,13 +255,29 @@ function kickDevice(ip) {
|
|
|
240
255
|
}
|
|
241
256
|
|
|
242
257
|
// ---------- GitHub/镜像 更新检查 ----------
|
|
258
|
+
function parseVersion(v) {
|
|
259
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
|
|
260
|
+
if (!m) return { core: [0, 0, 0], pre: null }
|
|
261
|
+
return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
|
|
262
|
+
}
|
|
243
263
|
function cmpVersion(a, b) {
|
|
244
|
-
const pa =
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
264
|
+
const pa = parseVersion(a), pb = parseVersion(b)
|
|
265
|
+
for (let i = 0; i < 3; i++) {
|
|
266
|
+
const d = pa.core[i] - pb.core[i]
|
|
248
267
|
if (d) return d
|
|
249
268
|
}
|
|
269
|
+
if (!pa.pre && !pb.pre) return 0
|
|
270
|
+
if (!pa.pre) return 1
|
|
271
|
+
if (!pb.pre) return -1
|
|
272
|
+
const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
|
|
273
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
274
|
+
const x = sa[i] ?? '', y = sb[i] ?? ''
|
|
275
|
+
if (x === y) continue
|
|
276
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
|
|
277
|
+
if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
|
|
278
|
+
else if (nx !== ny) return nx ? -1 : 1
|
|
279
|
+
else { const d = x.localeCompare(y); if (d) return d }
|
|
280
|
+
}
|
|
250
281
|
return 0
|
|
251
282
|
}
|
|
252
283
|
|
|
@@ -379,6 +410,26 @@ function serveStatic(req, res, url) {
|
|
|
379
410
|
}
|
|
380
411
|
if (pathname === '/') pathname = '/index.html'
|
|
381
412
|
if (pathname === '/admin') pathname = '/admin.html'
|
|
413
|
+
// 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
|
|
414
|
+
// 引导升级到新 APK; 新 App 带 ?local= 拿到真实 rc 版本, 不会循环提示。
|
|
415
|
+
if (pathname === '/update.json') {
|
|
416
|
+
fs.readFile(path.join(PUBLIC_DIR, 'update.json'), 'utf8', (err, raw) => {
|
|
417
|
+
if (err) {
|
|
418
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
419
|
+
res.end('404 Not Found')
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
let json = {}
|
|
423
|
+
try { json = JSON.parse(raw) } catch {}
|
|
424
|
+
if (!url.searchParams.has('local')) {
|
|
425
|
+
json = { ...json, version: String(json.version || '').replace(/-.*$/, '') }
|
|
426
|
+
}
|
|
427
|
+
cors(res)
|
|
428
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
429
|
+
res.end(JSON.stringify(json))
|
|
430
|
+
})
|
|
431
|
+
return
|
|
432
|
+
}
|
|
382
433
|
const apkOverride = pathname === '/dsh-remote.apk'
|
|
383
434
|
const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
|
|
384
435
|
const filePath = path.normalize(path.join(baseDir, pathname))
|
|
@@ -436,6 +487,7 @@ function serveAdminApi(req, res, url) {
|
|
|
436
487
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
437
488
|
res.end(JSON.stringify({
|
|
438
489
|
ok: true,
|
|
490
|
+
mode: 'gateway',
|
|
439
491
|
version: VERSION,
|
|
440
492
|
pid: process.pid,
|
|
441
493
|
hostname: os.hostname(),
|
|
@@ -453,6 +505,8 @@ function serveAdminApi(req, res, url) {
|
|
|
453
505
|
error: latestState.error,
|
|
454
506
|
newer: !!(latestState.version && cmpVersion(latestState.version, VERSION) > 0)
|
|
455
507
|
},
|
|
508
|
+
token: TOKEN,
|
|
509
|
+
tokenFromEnv: TOKEN_FROM_ENV,
|
|
456
510
|
tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
|
|
457
511
|
tokenLength: TOKEN.length,
|
|
458
512
|
totalRequests,
|
|
@@ -464,6 +518,29 @@ function serveAdminApi(req, res, url) {
|
|
|
464
518
|
})
|
|
465
519
|
return
|
|
466
520
|
}
|
|
521
|
+
if (sub === '/token/rotate' && req.method === 'POST') {
|
|
522
|
+
if (!authorized(req, url)) {
|
|
523
|
+
authFailures++
|
|
524
|
+
touchDevice(req, { failedAuth: true })
|
|
525
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
526
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
const r = rotateToken()
|
|
530
|
+
if (!r.ok) {
|
|
531
|
+
res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
|
|
532
|
+
res.end(JSON.stringify({ error: r.error, detail: r.detail }))
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
// 旧令牌立即失效: 断开已连接的 App/浏览器, 让它们重新扫码/输入
|
|
536
|
+
for (const d of devices.values()) {
|
|
537
|
+
if (d.kind !== 'admin') kickDevice(d.ip)
|
|
538
|
+
}
|
|
539
|
+
touchDevice(req)
|
|
540
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
541
|
+
res.end(JSON.stringify({ ok: true, token: r.token, tokenMasked: r.token.slice(0, 4) + '…' + r.token.slice(-4) }))
|
|
542
|
+
return
|
|
543
|
+
}
|
|
467
544
|
if (sub === '/shutdown' && req.method === 'POST') {
|
|
468
545
|
if (!authorized(req, url)) {
|
|
469
546
|
authFailures++
|
|
@@ -708,6 +785,27 @@ function fsValidName(name) {
|
|
|
708
785
|
return true
|
|
709
786
|
}
|
|
710
787
|
|
|
788
|
+
/** 流式计算文件 SHA-256(十六进制)。2GB 也就一次顺序读, 落盘前校验足够快。 */
|
|
789
|
+
function sha256FileHex(file, cb) {
|
|
790
|
+
const hash = crypto.createHash('sha256')
|
|
791
|
+
let stream
|
|
792
|
+
try {
|
|
793
|
+
stream = fs.createReadStream(file)
|
|
794
|
+
} catch (err) {
|
|
795
|
+
cb(err)
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
stream.on('error', (err) => cb(err))
|
|
799
|
+
stream.on('data', (chunk) => hash.update(chunk))
|
|
800
|
+
stream.on('end', () => cb(null, hash.digest('hex')))
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
|
|
804
|
+
const activeUploads = new Map()
|
|
805
|
+
function fsActiveKey(dirReal, name, session) {
|
|
806
|
+
return dirReal + '\n' + name + '\n' + (session || '')
|
|
807
|
+
}
|
|
808
|
+
|
|
711
809
|
/** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
|
|
712
810
|
function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
713
811
|
if (!fsValidName(name)) {
|
|
@@ -943,6 +1041,10 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
943
1041
|
}
|
|
944
1042
|
const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
|
|
945
1043
|
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
1044
|
+
const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
|
|
1045
|
+
if (sha256Expected && !/^[0-9a-f]{64}$/.test(sha256Expected)) {
|
|
1046
|
+
return fsJson(res, 400, { error: 'bad-sha256', detail: 'sha256 必须是 64 位十六进制' })
|
|
1047
|
+
}
|
|
946
1048
|
const part = fsPartPath(dirReal, name, session)
|
|
947
1049
|
const target = path.join(dirReal, name)
|
|
948
1050
|
|
|
@@ -982,12 +1084,15 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
982
1084
|
} catch (err) {
|
|
983
1085
|
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
984
1086
|
}
|
|
1087
|
+
const activeKey = fsActiveKey(dirReal, name, session)
|
|
1088
|
+
activeUploads.set(activeKey, stream)
|
|
985
1089
|
|
|
986
1090
|
let bytes = 0
|
|
987
1091
|
let finished = false
|
|
988
1092
|
const abort = (status, msg, extra = {}) => {
|
|
989
1093
|
if (finished) return
|
|
990
1094
|
finished = true
|
|
1095
|
+
activeUploads.delete(activeKey)
|
|
991
1096
|
try { stream.destroy() } catch {}
|
|
992
1097
|
// 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
|
|
993
1098
|
if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
|
|
@@ -1013,6 +1118,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
1013
1118
|
if (finished) return
|
|
1014
1119
|
finished = true
|
|
1015
1120
|
stream.end(() => {
|
|
1121
|
+
activeUploads.delete(activeKey)
|
|
1016
1122
|
const total = offset + bytes
|
|
1017
1123
|
try {
|
|
1018
1124
|
const st = fs.statSync(part)
|
|
@@ -1021,12 +1127,31 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
1021
1127
|
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
|
|
1022
1128
|
return
|
|
1023
1129
|
}
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1130
|
+
const commit = (actualSha256) => {
|
|
1131
|
+
try {
|
|
1132
|
+
const ts = fsTargetState(target)
|
|
1133
|
+
if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
|
|
1134
|
+
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
1135
|
+
if (ts.exists) fs.rmSync(target, { force: true })
|
|
1136
|
+
fs.renameSync(part, target)
|
|
1137
|
+
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
1140
|
+
else try { res.destroy() } catch {}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (sha256Expected) {
|
|
1144
|
+
// 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
|
|
1145
|
+
sha256FileHex(part, (err, actual) => {
|
|
1146
|
+
if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
|
|
1147
|
+
if (actual !== sha256Expected) {
|
|
1148
|
+
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
|
|
1149
|
+
}
|
|
1150
|
+
commit(actual)
|
|
1151
|
+
})
|
|
1152
|
+
} else {
|
|
1153
|
+
commit(null)
|
|
1154
|
+
}
|
|
1030
1155
|
} catch (err) {
|
|
1031
1156
|
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
1032
1157
|
else try { res.destroy() } catch {}
|
|
@@ -1035,6 +1160,42 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
1035
1160
|
})
|
|
1036
1161
|
}
|
|
1037
1162
|
|
|
1163
|
+
/* POST /fs/upload-control?path&name&session&action=cancel
|
|
1164
|
+
* 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
|
|
1165
|
+
function fsUploadControl(req, res, url) {
|
|
1166
|
+
if (req.method !== 'POST') {
|
|
1167
|
+
res.writeHead(405, { allow: 'POST' })
|
|
1168
|
+
res.end()
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
if (!fsAuthorized(req, url, res)) return
|
|
1172
|
+
touchDevice(req)
|
|
1173
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
1174
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1175
|
+
const checked = fsRealChecked(resolved.abs)
|
|
1176
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
1177
|
+
const name = url.searchParams.get('name') || ''
|
|
1178
|
+
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
1179
|
+
const session = url.searchParams.get('session') || 'default'
|
|
1180
|
+
const action = url.searchParams.get('action') || ''
|
|
1181
|
+
if (action !== 'cancel' && action !== 'abort') return fsJson(res, 400, { error: 'bad-action', detail: 'action 只支持 cancel' })
|
|
1182
|
+
|
|
1183
|
+
const part = fsPartPath(checked.abs, name, session)
|
|
1184
|
+
const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
|
|
1185
|
+
if (active) {
|
|
1186
|
+
try { active.destroy() } catch {}
|
|
1187
|
+
activeUploads.delete(fsActiveKey(checked.abs, name, session))
|
|
1188
|
+
}
|
|
1189
|
+
// 等写流关闭后再删, 防止 write 把分片重新创建出来
|
|
1190
|
+
setTimeout(() => {
|
|
1191
|
+
let removed = false
|
|
1192
|
+
try { fs.unlinkSync(part); removed = true } catch (err) {
|
|
1193
|
+
if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
1194
|
+
}
|
|
1195
|
+
fsJson(res, 200, { ok: true, cancelled: true, removed, session })
|
|
1196
|
+
}, 80)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1038
1199
|
function serveFs(req, res, url) {
|
|
1039
1200
|
const sub = url.pathname.slice('/fs'.length)
|
|
1040
1201
|
|
|
@@ -1049,6 +1210,7 @@ function serveFs(req, res, url) {
|
|
|
1049
1210
|
if (sub === '/list') return fsList(req, res, url)
|
|
1050
1211
|
if (sub === '/file') return fsFile(req, res, url)
|
|
1051
1212
|
if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
|
|
1213
|
+
if (sub === '/upload-control') return fsUploadControl(req, res, url)
|
|
1052
1214
|
|
|
1053
1215
|
if (sub === '/upload') {
|
|
1054
1216
|
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'
|
|
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.
|
|
3
|
+
"version": "0.5.2",
|
|
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; line-height: 0; }
|
|
38
|
+
.pair-qr svg { display: block; width: 180px; height: 180px; }
|
|
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; }
|
|
@@ -53,7 +58,7 @@
|
|
|
53
58
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
|
54
59
|
仓库
|
|
55
60
|
</a>
|
|
56
|
-
<
|
|
61
|
+
<button id="conn-badge" class="conn-badge off" title="网关面板">未认证</button>
|
|
57
62
|
</div>
|
|
58
63
|
</div>
|
|
59
64
|
|
|
@@ -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,21 @@ 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 ||
|
|
70
|
+
shownToken = st.token || token
|
|
68
71
|
$('conn-badge').textContent = isPlugin ? '内嵌' : isGateway ? '网关' : '已连接'
|
|
69
|
-
$('conn-badge').className = 'conn-badge on'
|
|
72
|
+
$('conn-badge').className = 'conn-badge ' + (isPlugin || isGateway ? 'on' : 'off')
|
|
73
|
+
$('conn-badge').title = isGateway ? '新标签页打开网关管理面板' : '网关未运行'
|
|
70
74
|
$('token-full').textContent = shownToken || (isPlugin ? '插件模式 · 未接网关, 无需令牌' : '未获取到令牌')
|
|
71
75
|
// 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
|
|
72
76
|
$('btn-copy').classList.toggle('hidden', !shownToken)
|
|
73
77
|
$('btn-logout').classList.toggle('hidden', pluginMode)
|
|
78
|
+
// 二维码与轮换只在网关模式下可用(二维码里有完整令牌, 不能在没有网关时生成)
|
|
79
|
+
$('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
|
|
80
|
+
$('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
|
|
81
|
+
renderQr(st)
|
|
74
82
|
// 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
|
|
75
83
|
gatewayRunning = isGateway
|
|
76
84
|
$('btn-gateway').classList.toggle('hidden', !pluginMode)
|
|
@@ -141,6 +149,37 @@ function render(st) {
|
|
|
141
149
|
btn.addEventListener('click', () => setNote(btn.dataset.noteIp, btn.dataset.note)))
|
|
142
150
|
}
|
|
143
151
|
|
|
152
|
+
function pairTarget(st) {
|
|
153
|
+
const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
|
|
154
|
+
const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
|
|
155
|
+
const port = st.port || 8787
|
|
156
|
+
const base = `http://${host}:${port}`
|
|
157
|
+
return {
|
|
158
|
+
url: `dshremote://pair?token=${encodeURIComponent(shownToken)}&server=${encodeURIComponent(base)}`,
|
|
159
|
+
base
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderQr(st) {
|
|
164
|
+
const box = $('pair-box')
|
|
165
|
+
if (!qrShown || !shownToken || st.mode !== 'gateway') {
|
|
166
|
+
box.classList.add('hidden')
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const t = pairTarget(st)
|
|
171
|
+
const qr = window.qrcode(0, 'M')
|
|
172
|
+
qr.addData(t.url)
|
|
173
|
+
qr.make()
|
|
174
|
+
$('pair-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
|
|
175
|
+
$('pair-hint').textContent = `${t.base} · App「设置 → 扫码连接」,或系统相机扫一扫自动打开 App`
|
|
176
|
+
box.classList.remove('hidden')
|
|
177
|
+
} catch (e) {
|
|
178
|
+
$('pair-qr').textContent = '二维码生成失败'
|
|
179
|
+
box.classList.remove('hidden')
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
async function setNote(ip, current) {
|
|
145
184
|
const name = prompt('给 ' + ip + ' 设置备注(留空清除):', current || '')
|
|
146
185
|
if (name === null) return
|
|
@@ -215,6 +254,46 @@ $('btn-copy').addEventListener('click', async () => {
|
|
|
215
254
|
}
|
|
216
255
|
})
|
|
217
256
|
|
|
257
|
+
$('btn-qr').addEventListener('click', () => {
|
|
258
|
+
qrShown = !qrShown
|
|
259
|
+
renderQr(lastState || { mode: '', token: shownToken })
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
/* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
|
|
263
|
+
$('conn-badge').addEventListener('click', () => {
|
|
264
|
+
const st = lastState
|
|
265
|
+
if (!st || st.mode !== 'gateway') { toast('网关未运行', 'err'); return }
|
|
266
|
+
const host = location.hostname || '127.0.0.1'
|
|
267
|
+
const port = st.port || 8787
|
|
268
|
+
const url = `http://${host}:${port}/admin?token=${encodeURIComponent(shownToken || token)}`
|
|
269
|
+
try {
|
|
270
|
+
window.open(url, '_blank', 'noopener')
|
|
271
|
+
} catch {
|
|
272
|
+
toast('浏览器阻止了新窗口,请允许弹窗', 'err')
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
$('btn-rotate').addEventListener('click', async () => {
|
|
277
|
+
if (!confirm('轮换后旧令牌立即失效,手机与浏览器都需要重新扫码/输入。继续?')) return
|
|
278
|
+
try {
|
|
279
|
+
const res = await fetch(`${API}/token/rotate`, {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: { authorization: 'Bearer ' + (token || shownToken), 'x-dsh-remote-client': 'admin' }
|
|
282
|
+
})
|
|
283
|
+
const out = await res.json().catch(() => ({}))
|
|
284
|
+
if (out.ok && out.token) {
|
|
285
|
+
token = out.token
|
|
286
|
+
store.set('dshAdminToken', out.token)
|
|
287
|
+
toast('令牌已轮换,请重新给设备配对', 'ok')
|
|
288
|
+
setTimeout(loadState, 300)
|
|
289
|
+
} else {
|
|
290
|
+
toast(out.detail || out.error || '轮换失败', 'err')
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {
|
|
293
|
+
toast('轮换失败:' + (e.message || e), 'err')
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
218
297
|
$('btn-gateway').addEventListener('click', async () => {
|
|
219
298
|
if (gatewayBusy) return
|
|
220
299
|
gatewayBusy = true
|