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 +6 -4
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +293 -1
- package/index.mjs +4 -2
- package/package.json +1 -1
- package/public/admin.html +14 -0
- package/public/admin.js +65 -1
- package/public/app.js +313 -18
- package/public/index.html +9 -0
- package/public/qrcode.min.js +2297 -0
- package/public/sha256.js +119 -0
- package/public/styles.css +2 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-remote-plugin
|
|
2
2
|
|
|
3
|
+
[](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.
|
|
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
|
|
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
|
|
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
|
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
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 || ''
|
|
@@ -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'
|
|
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.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 ||
|
|
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
|