dsh-remote-plugin 0.4.5 → 0.4.9
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 -3
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +618 -0
- package/package.json +2 -2
- package/public/app.js +548 -18
- package/public/index.html +25 -2
- package/public/styles.css +71 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# dsh-remote-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
4
|
+
|
|
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
|
## 安装
|
|
6
8
|
|
|
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.
|
|
12
|
+
dsh plugin --profile web add dsh-remote-plugin@0.4.8
|
|
11
13
|
```
|
|
12
14
|
|
|
13
15
|
重启 DSH Web 后 Ctrl+F5,左侧边栏底部出现 App 图标入口。
|
|
@@ -24,10 +26,11 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
|
|
|
24
26
|
- 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
|
|
25
27
|
- 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制。
|
|
26
28
|
- 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
|
|
29
|
+
- 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(上传,默认上限 2GB);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
|
|
27
30
|
|
|
28
31
|
## 手机 App
|
|
29
32
|
|
|
30
|
-
从 [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」。
|
|
31
34
|
|
|
32
35
|
## License
|
|
33
36
|
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* DSH_UPSTREAM DSH web 服务地址, 默认 http://127.0.0.1:3080
|
|
19
19
|
* TOKEN 访问令牌; 不设置则读 TOKEN_FILE, 仍没有则自动生成
|
|
20
20
|
* TOKEN_FILE 令牌文件, 默认 ~/.dsh-remote/token
|
|
21
|
+
* DSH_REMOTE_FS_ROOT 文件传输允许根, 默认 ~, 多个用 ':' 分隔
|
|
22
|
+
* DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
|
|
21
23
|
*/
|
|
22
24
|
'use strict'
|
|
23
25
|
|
|
@@ -69,6 +71,63 @@ const MIME = {
|
|
|
69
71
|
'.apk': 'application/vnd.android.package-archive'
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
// ---------- /fs 文件传输 ----------
|
|
75
|
+
// 允许访问的根目录: DSH_REMOTE_FS_ROOT 用 ':' 分隔多个根, 默认仅 ~。
|
|
76
|
+
// 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
|
|
77
|
+
// 复核一次, 防止 ../ 穿越与符号链接逃逸。
|
|
78
|
+
const FS_DEFAULT_ROOT = path.resolve(os.homedir())
|
|
79
|
+
const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
80
|
+
.split(':')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
83
|
+
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
84
|
+
let FS_ROOT_REALS = null
|
|
85
|
+
function fsRootReals() {
|
|
86
|
+
if (!FS_ROOT_REALS) {
|
|
87
|
+
FS_ROOT_REALS = FS_ROOTS.map(r => { try { return fs.realpathSync(r) } catch { return null } }).filter(Boolean)
|
|
88
|
+
}
|
|
89
|
+
return FS_ROOT_REALS
|
|
90
|
+
}
|
|
91
|
+
function fsInsideReal(real) {
|
|
92
|
+
for (const root of fsRootReals()) {
|
|
93
|
+
if (real === root || real.startsWith(root + path.sep)) return true
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FS_MIME = {
|
|
99
|
+
...MIME,
|
|
100
|
+
'.jpg': 'image/jpeg',
|
|
101
|
+
'.jpeg': 'image/jpeg',
|
|
102
|
+
'.gif': 'image/gif',
|
|
103
|
+
'.webp': 'image/webp',
|
|
104
|
+
'.bmp': 'image/bmp',
|
|
105
|
+
'.mp3': 'audio/mpeg',
|
|
106
|
+
'.wav': 'audio/wav',
|
|
107
|
+
'.ogg': 'audio/ogg',
|
|
108
|
+
'.flac': 'audio/flac',
|
|
109
|
+
'.m4a': 'audio/mp4',
|
|
110
|
+
'.mp4': 'video/mp4',
|
|
111
|
+
'.mkv': 'video/x-matroska',
|
|
112
|
+
'.webm': 'video/webm',
|
|
113
|
+
'.mov': 'video/quicktime',
|
|
114
|
+
'.avi': 'video/x-msvideo',
|
|
115
|
+
'.pdf': 'application/pdf',
|
|
116
|
+
'.zip': 'application/zip',
|
|
117
|
+
'.gz': 'application/gzip',
|
|
118
|
+
'.tar': 'application/x-tar',
|
|
119
|
+
'.7z': 'application/x-7z-compressed',
|
|
120
|
+
'.rar': 'application/vnd.rar',
|
|
121
|
+
'.doc': 'application/msword',
|
|
122
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
123
|
+
'.xls': 'application/vnd.ms-excel',
|
|
124
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
125
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
126
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
127
|
+
'.epub': 'application/epub+zip',
|
|
128
|
+
'.wasm': 'application/wasm',
|
|
129
|
+
}
|
|
130
|
+
|
|
72
131
|
// ---------- token ----------
|
|
73
132
|
function loadToken() {
|
|
74
133
|
if (process.env.TOKEN) return process.env.TOKEN
|
|
@@ -472,6 +531,564 @@ function serveAdminApi(req, res, url) {
|
|
|
472
531
|
res.end(JSON.stringify({ error: 'not-found' }))
|
|
473
532
|
}
|
|
474
533
|
|
|
534
|
+
// ---------- /fs 文件传输: 实现 ----------
|
|
535
|
+
function fsJson(res, status, body) {
|
|
536
|
+
cors(res)
|
|
537
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
538
|
+
res.end(JSON.stringify(body))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function fsAuthorized(req, url, res) {
|
|
542
|
+
const ok = authorized(req, url)
|
|
543
|
+
touchDevice(req, ok ? {} : { failedAuth: true })
|
|
544
|
+
if (!ok) {
|
|
545
|
+
authFailures++
|
|
546
|
+
fsJson(res, 401, { error: 'unauthorized' })
|
|
547
|
+
return false
|
|
548
|
+
}
|
|
549
|
+
return true
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 把用户给的 path 解析为绝对路径并做词法根检查; 返回 {abs} 或 {error}。 */
|
|
553
|
+
function fsResolve(input) {
|
|
554
|
+
const raw = String(input ?? '').trim()
|
|
555
|
+
let abs
|
|
556
|
+
if (!raw || raw === '~') abs = FS_ROOTS[0]
|
|
557
|
+
else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
558
|
+
else if (path.isAbsolute(raw)) abs = path.resolve(raw)
|
|
559
|
+
else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
|
|
560
|
+
for (const root of FS_ROOTS) {
|
|
561
|
+
if (abs === root || abs.startsWith(root + path.sep)) return { abs }
|
|
562
|
+
}
|
|
563
|
+
return { error: 'forbidden' }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** realpath 复核: 符号链接目标也必须落在允许根内。 */
|
|
567
|
+
function fsRealChecked(abs) {
|
|
568
|
+
let real
|
|
569
|
+
try {
|
|
570
|
+
real = fs.realpathSync(abs)
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' }
|
|
573
|
+
}
|
|
574
|
+
if (!fsInsideReal(real)) return { error: 'forbidden' }
|
|
575
|
+
return { abs: real }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function fsContentDisposition(name) {
|
|
579
|
+
const ascii = String(name).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') || 'download'
|
|
580
|
+
const star = encodeURIComponent(name).replace(/['()*]/g, c =>
|
|
581
|
+
'%' + c.charCodeAt(0).toString(16).toUpperCase())
|
|
582
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${star}`
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 单段 Range: bytes=a-b / bytes=a- / bytes=-n。多段或不合法返回 null(按 200 整文件处理)。 */
|
|
586
|
+
function fsParseRange(header, size) {
|
|
587
|
+
if (!header || size <= 0) return null
|
|
588
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
|
|
589
|
+
if (!m) return null
|
|
590
|
+
const s = m[1], e = m[2]
|
|
591
|
+
if (s === '' && e === '') return null
|
|
592
|
+
if (s === '') { // 末尾 n 字节
|
|
593
|
+
const n = Number(e)
|
|
594
|
+
if (!Number.isFinite(n) || n <= 0) return null
|
|
595
|
+
return { start: Math.max(0, size - n), end: size - 1 }
|
|
596
|
+
}
|
|
597
|
+
const start = Number(s)
|
|
598
|
+
if (!Number.isFinite(start) || start < 0) return null
|
|
599
|
+
if (e === '') return { start, end: size - 1 }
|
|
600
|
+
const end = Number(e)
|
|
601
|
+
if (!Number.isFinite(end) || end < start) return null
|
|
602
|
+
return { start, end: Math.min(end, size - 1) }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function fsList(req, res, url) {
|
|
606
|
+
if (req.method !== 'GET') {
|
|
607
|
+
res.writeHead(405, { allow: 'GET' })
|
|
608
|
+
res.end()
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
if (!fsAuthorized(req, url, res)) return
|
|
612
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
613
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
614
|
+
const checked = fsRealChecked(resolved.abs)
|
|
615
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
616
|
+
|
|
617
|
+
let st
|
|
618
|
+
try { st = fs.statSync(checked.abs) } catch (err) {
|
|
619
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
620
|
+
}
|
|
621
|
+
if (!st.isDirectory()) return fsJson(res, 400, { error: 'not-a-directory' })
|
|
622
|
+
|
|
623
|
+
let dirents
|
|
624
|
+
try { dirents = fs.readdirSync(checked.abs, { withFileTypes: true }) } catch {
|
|
625
|
+
return fsJson(res, 403, { error: 'permission-denied' })
|
|
626
|
+
}
|
|
627
|
+
const entries = []
|
|
628
|
+
for (const d of dirents) {
|
|
629
|
+
const full = path.join(checked.abs, d.name)
|
|
630
|
+
try {
|
|
631
|
+
// 符号链接指向允许根之外时直接不展示, 点进去/下载也必然被 realpath 复核拒绝
|
|
632
|
+
if (d.isSymbolicLink()) {
|
|
633
|
+
const real = fs.realpathSync(full)
|
|
634
|
+
if (!fsInsideReal(real)) continue
|
|
635
|
+
}
|
|
636
|
+
const info = fs.statSync(full)
|
|
637
|
+
if (!info.isFile() && !info.isDirectory()) continue
|
|
638
|
+
entries.push({
|
|
639
|
+
name: d.name,
|
|
640
|
+
type: info.isDirectory() ? 'dir' : 'file',
|
|
641
|
+
size: info.isDirectory() ? 0 : info.size,
|
|
642
|
+
mtimeMs: Math.round(info.mtimeMs)
|
|
643
|
+
})
|
|
644
|
+
} catch {
|
|
645
|
+
// 单个条目无权限/已消失: 跳过, 不让整个列表失败
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
entries.sort((a, b) => {
|
|
649
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
650
|
+
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true })
|
|
651
|
+
})
|
|
652
|
+
fsJson(res, 200, { path: resolved.abs, entries })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function fsFile(req, res, url) {
|
|
656
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
657
|
+
res.writeHead(405, { allow: 'GET, HEAD' })
|
|
658
|
+
res.end()
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
if (!fsAuthorized(req, url, res)) return
|
|
662
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
663
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
664
|
+
const checked = fsRealChecked(resolved.abs)
|
|
665
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
666
|
+
|
|
667
|
+
let st
|
|
668
|
+
try { st = fs.statSync(checked.abs) } catch (err) {
|
|
669
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
670
|
+
}
|
|
671
|
+
if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
|
|
672
|
+
|
|
673
|
+
const range = fsParseRange(req.headers.range, st.size)
|
|
674
|
+
if (range && range.start >= st.size) {
|
|
675
|
+
cors(res)
|
|
676
|
+
res.writeHead(416, {
|
|
677
|
+
'content-type': 'application/json; charset=utf-8',
|
|
678
|
+
'content-range': `bytes */${st.size}`,
|
|
679
|
+
'accept-ranges': 'bytes'
|
|
680
|
+
})
|
|
681
|
+
res.end(JSON.stringify({ error: 'range-not-satisfiable', size: st.size }))
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const ext = path.extname(checked.abs).toLowerCase()
|
|
686
|
+
cors(res)
|
|
687
|
+
res.writeHead(range ? 206 : 200, {
|
|
688
|
+
'content-type': FS_MIME[ext] || 'application/octet-stream',
|
|
689
|
+
'content-length': range ? range.end - range.start + 1 : st.size,
|
|
690
|
+
'content-disposition': fsContentDisposition(path.basename(checked.abs)),
|
|
691
|
+
'accept-ranges': 'bytes',
|
|
692
|
+
'cache-control': 'no-cache',
|
|
693
|
+
...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
|
|
694
|
+
})
|
|
695
|
+
if (req.method === 'HEAD') { res.end(); return }
|
|
696
|
+
const stream = range
|
|
697
|
+
? fs.createReadStream(checked.abs, { start: range.start, end: range.end })
|
|
698
|
+
: fs.createReadStream(checked.abs)
|
|
699
|
+
stream.on('error', () => { try { res.destroy() } catch {} })
|
|
700
|
+
stream.pipe(res)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function fsValidName(name) {
|
|
704
|
+
if (typeof name !== 'string') return false
|
|
705
|
+
if (!name || name === '.' || name === '..') return false
|
|
706
|
+
if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false
|
|
707
|
+
if (path.basename(name) !== name) return false
|
|
708
|
+
return true
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
|
|
712
|
+
function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
713
|
+
if (!fsValidName(name)) {
|
|
714
|
+
fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
const target = path.join(dirReal, name)
|
|
718
|
+
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
719
|
+
let exists = false
|
|
720
|
+
try {
|
|
721
|
+
const st = fs.lstatSync(target)
|
|
722
|
+
exists = true
|
|
723
|
+
if (st.isSymbolicLink()) {
|
|
724
|
+
fsJson(res, 403, { error: 'symlink-forbidden', detail: '拒绝覆盖符号链接' })
|
|
725
|
+
return null
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (err.code !== 'ENOENT') {
|
|
729
|
+
fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (exists && !overwrite) {
|
|
734
|
+
fsJson(res, 409, { error: 'conflict', detail: '文件已存在, 追加 overwrite=1 可覆盖' })
|
|
735
|
+
return null
|
|
736
|
+
}
|
|
737
|
+
const tmp = path.join(dirReal, `.${name}.dsh-remote-part-${process.pid}-${crypto.randomBytes(4).toString('hex')}`)
|
|
738
|
+
let stream
|
|
739
|
+
try {
|
|
740
|
+
stream = fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 })
|
|
741
|
+
} catch (err) {
|
|
742
|
+
fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
743
|
+
return null
|
|
744
|
+
}
|
|
745
|
+
return { stream, tmp, target, displayPath: path.join(dirLex, name), name, overwrite, bytes: 0 }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** 上传管道: 计数限量, 成功后 rename(先写 .part 再原子落位)。 */
|
|
749
|
+
function fsUploadPipe(res, url, dirLex, dirReal, name) {
|
|
750
|
+
const up = fsOpenUploadTarget(res, url, dirLex, dirReal, name)
|
|
751
|
+
return up ? fsUploadPipeFromTarget(res, up) : null
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function fsUploadPipeFromTarget(res, up) {
|
|
755
|
+
let finished = false
|
|
756
|
+
const cleanup = () => {
|
|
757
|
+
if (finished) return
|
|
758
|
+
finished = true
|
|
759
|
+
try { up.stream.destroy() } catch {}
|
|
760
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
761
|
+
}
|
|
762
|
+
up.stream.on('error', () => {
|
|
763
|
+
if (finished) return
|
|
764
|
+
finished = true
|
|
765
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
766
|
+
if (!res.headersSent) fsJson(res, 500, { error: 'write-failed' })
|
|
767
|
+
else try { res.destroy() } catch {}
|
|
768
|
+
})
|
|
769
|
+
return {
|
|
770
|
+
write(chunk) {
|
|
771
|
+
if (finished) return
|
|
772
|
+
up.bytes += chunk.length
|
|
773
|
+
if (up.bytes > FS_MAX_UPLOAD) {
|
|
774
|
+
cleanup()
|
|
775
|
+
if (!res.headersSent) fsJson(res, 413, { error: 'too-large', limit: FS_MAX_UPLOAD })
|
|
776
|
+
else try { res.destroy() } catch {}
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
up.stream.write(chunk)
|
|
780
|
+
},
|
|
781
|
+
end() {
|
|
782
|
+
if (finished) return
|
|
783
|
+
finished = true
|
|
784
|
+
up.stream.end(() => {
|
|
785
|
+
try {
|
|
786
|
+
if (up.overwrite) fs.rmSync(up.target, { force: true })
|
|
787
|
+
fs.renameSync(up.tmp, up.target)
|
|
788
|
+
} catch (err) {
|
|
789
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
790
|
+
if (!res.headersSent) return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
|
|
794
|
+
})
|
|
795
|
+
},
|
|
796
|
+
abort(status, msg) {
|
|
797
|
+
cleanup()
|
|
798
|
+
if (!res.headersSent) fsJson(res, status, { error: msg })
|
|
799
|
+
else try { res.destroy() } catch {}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function fsUploadRaw(req, res, url, dirLex, dirReal) {
|
|
805
|
+
const name = url.searchParams.get('name') || ''
|
|
806
|
+
const pipe = fsUploadPipe(res, url, dirLex, dirReal, name)
|
|
807
|
+
if (!pipe) return
|
|
808
|
+
req.on('aborted', () => pipe.abort(400, 'client-aborted'))
|
|
809
|
+
req.on('error', () => pipe.abort(400, 'client-aborted'))
|
|
810
|
+
req.on('data', (chunk) => pipe.write(chunk))
|
|
811
|
+
req.on('end', () => pipe.end())
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** 零依赖流式 multipart 解析: 只取第一个文件部分, 2GB 也不会整块进内存。 */
|
|
815
|
+
function fsUploadMultipart(req, res, url, dirLex, dirReal, boundary) {
|
|
816
|
+
const queryName = url.searchParams.get('name') || ''
|
|
817
|
+
const marker = Buffer.from('\r\n--' + boundary)
|
|
818
|
+
let head = Buffer.alloc(0)
|
|
819
|
+
let tail = Buffer.alloc(0)
|
|
820
|
+
let state = 'headers' // headers -> data -> done
|
|
821
|
+
let pipe = null
|
|
822
|
+
|
|
823
|
+
const fail = (status, msg) => {
|
|
824
|
+
if (pipe) pipe.abort(status, msg)
|
|
825
|
+
else if (!res.headersSent) fsJson(res, status, { error: msg })
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const process = (buf) => {
|
|
829
|
+
if (state === 'done') return
|
|
830
|
+
if (state === 'headers') {
|
|
831
|
+
head = Buffer.concat([head, buf])
|
|
832
|
+
if (head.length > 64 * 1024) return fail(400, 'multipart-headers-too-large')
|
|
833
|
+
const idx = head.indexOf('\r\n\r\n')
|
|
834
|
+
if (idx === -1) return
|
|
835
|
+
const headerText = head.slice(0, idx).toString('utf8')
|
|
836
|
+
let partName = queryName
|
|
837
|
+
if (!partName) {
|
|
838
|
+
const m = /filename="([^"]*)"/i.exec(headerText)
|
|
839
|
+
partName = m ? path.basename(String(m[1]).replace(/\\/g, '/')) : ''
|
|
840
|
+
}
|
|
841
|
+
if (!fsValidName(partName)) return fail(400, 'bad-name')
|
|
842
|
+
pipe = fsUploadPipe(res, url, dirLex, dirReal, partName)
|
|
843
|
+
if (!pipe) { state = 'done'; return }
|
|
844
|
+
const rest = head.slice(idx + 4)
|
|
845
|
+
head = null
|
|
846
|
+
state = 'data'
|
|
847
|
+
if (rest.length) process(rest)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
// data: 滑动窗口找 \r\n--boundary, 未命中时保留尾部防跨 chunk 边界
|
|
851
|
+
buf = Buffer.concat([tail, buf])
|
|
852
|
+
const idx = buf.indexOf(marker)
|
|
853
|
+
if (idx === -1) {
|
|
854
|
+
const keep = Math.min(buf.length, marker.length - 1)
|
|
855
|
+
if (buf.length > keep) pipe.write(buf.slice(0, buf.length - keep))
|
|
856
|
+
tail = buf.slice(buf.length - keep)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
if (idx > 0) pipe.write(buf.slice(0, idx))
|
|
860
|
+
state = 'done'
|
|
861
|
+
pipe.end()
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
req.on('aborted', () => { if (pipe) pipe.abort(400, 'client-aborted') })
|
|
865
|
+
req.on('error', () => { if (pipe) pipe.abort(400, 'client-aborted') })
|
|
866
|
+
req.on('data', (chunk) => process(chunk))
|
|
867
|
+
req.on('end', () => {
|
|
868
|
+
if (state === 'headers') return fail(400, 'no-file-part')
|
|
869
|
+
if (state === 'data' && pipe) {
|
|
870
|
+
if (tail.length) pipe.write(tail)
|
|
871
|
+
pipe.end()
|
|
872
|
+
}
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/* ---------- /fs/upload 断点续传 ----------
|
|
877
|
+
* 分块模式: POST /fs/upload?path=..&name=..&session=<uuid>&offset=N[&finish=1][&overwrite=1]
|
|
878
|
+
* - 每一块是 raw body, 服务端写到 .<name>.dsh-remote-part-<session> 的 offset 处
|
|
879
|
+
* - offset=0 重开; offset<已有大小 = 回卷重写; offset>已有大小 = 409 offset-mismatch
|
|
880
|
+
* - finish=1 时原子 rename 到目标名; 否则返回 {partial:true,size,offset}
|
|
881
|
+
* 查询进度: GET /fs/upload-probe?path=..&name=..&session=<uuid>
|
|
882
|
+
*/
|
|
883
|
+
function fsPartPath(dirReal, name, session) {
|
|
884
|
+
const s = String(session || 'default').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64) || 'default'
|
|
885
|
+
return path.join(dirReal, `.${name}.dsh-remote-part-${s}`)
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function fsTargetState(target) {
|
|
889
|
+
try {
|
|
890
|
+
const st = fs.lstatSync(target)
|
|
891
|
+
if (st.isSymbolicLink()) return { status: 403, error: 'symlink-forbidden', detail: '拒绝覆盖符号链接' }
|
|
892
|
+
return { exists: true }
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (err.code === 'ENOENT') return { exists: false }
|
|
895
|
+
return { status: 403, error: 'permission-denied', detail: err.message }
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function fsUploadProbe(req, res, url) {
|
|
900
|
+
if (req.method !== 'GET') {
|
|
901
|
+
res.writeHead(405, { allow: 'GET' })
|
|
902
|
+
res.end()
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
if (!fsAuthorized(req, url, res)) return
|
|
906
|
+
touchDevice(req)
|
|
907
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
908
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
909
|
+
const checked = fsRealChecked(resolved.abs)
|
|
910
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
911
|
+
const name = url.searchParams.get('name') || ''
|
|
912
|
+
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
913
|
+
const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
|
|
914
|
+
let partialSize = 0, partExists = false
|
|
915
|
+
try {
|
|
916
|
+
const st = fs.statSync(part)
|
|
917
|
+
if (st.isFile()) { partialSize = st.size; partExists = true }
|
|
918
|
+
} catch {}
|
|
919
|
+
const target = fsTargetState(path.join(checked.abs, name))
|
|
920
|
+
let targetSize = 0
|
|
921
|
+
if (target.exists) {
|
|
922
|
+
try { targetSize = fs.statSync(path.join(checked.abs, name)).size } catch {}
|
|
923
|
+
}
|
|
924
|
+
fsJson(res, 200, {
|
|
925
|
+
ok: true,
|
|
926
|
+
name,
|
|
927
|
+
partialSize,
|
|
928
|
+
partExists,
|
|
929
|
+
targetExists: !!target.exists,
|
|
930
|
+
targetSize
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
935
|
+
const name = url.searchParams.get('name') || ''
|
|
936
|
+
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
937
|
+
const session = url.searchParams.get('session') || ''
|
|
938
|
+
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
939
|
+
const offsetRaw = url.searchParams.get('offset')
|
|
940
|
+
const offset = Number(offsetRaw)
|
|
941
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
942
|
+
return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
|
|
943
|
+
}
|
|
944
|
+
const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
|
|
945
|
+
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
946
|
+
const part = fsPartPath(dirReal, name, session)
|
|
947
|
+
const target = path.join(dirReal, name)
|
|
948
|
+
|
|
949
|
+
// 已有分片尺寸对齐: 回卷重写允许, 越界/缺洞拒绝
|
|
950
|
+
let existing = 0
|
|
951
|
+
try {
|
|
952
|
+
const st = fs.statSync(part)
|
|
953
|
+
if (!st.isFile()) return fsJson(res, 409, { error: 'part-conflict', detail: '分片路径被占用' })
|
|
954
|
+
existing = st.size
|
|
955
|
+
} catch (err) {
|
|
956
|
+
if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
957
|
+
}
|
|
958
|
+
if (offset === 0) {
|
|
959
|
+
try { if (existing > 0) fs.truncateSync(part, 0) } catch (err) {
|
|
960
|
+
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
961
|
+
}
|
|
962
|
+
} else {
|
|
963
|
+
if (offset > existing) return fsJson(res, 409, { error: 'offset-mismatch', partialSize: existing, detail: 'offset 超过已有分片大小, 请先 probe' })
|
|
964
|
+
if (offset < existing) {
|
|
965
|
+
try { fs.truncateSync(part, offset) } catch (err) {
|
|
966
|
+
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (finish) {
|
|
972
|
+
const st = fsTargetState(target)
|
|
973
|
+
if (st.status) return fsJson(res, st.status, { error: st.error, detail: st.detail })
|
|
974
|
+
if (st.exists && !overwrite) {
|
|
975
|
+
return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let stream
|
|
980
|
+
try {
|
|
981
|
+
stream = fs.createWriteStream(part, { flags: offset === 0 ? 'w' : 'r+', start: offset, mode: 0o600 })
|
|
982
|
+
} catch (err) {
|
|
983
|
+
return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
let bytes = 0
|
|
987
|
+
let finished = false
|
|
988
|
+
const abort = (status, msg, extra = {}) => {
|
|
989
|
+
if (finished) return
|
|
990
|
+
finished = true
|
|
991
|
+
try { stream.destroy() } catch {}
|
|
992
|
+
// 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
|
|
993
|
+
if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
|
|
994
|
+
if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
|
|
995
|
+
else try { res.destroy() } catch {}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
stream.on('error', (err) => {
|
|
999
|
+
abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
|
|
1000
|
+
})
|
|
1001
|
+
req.on('aborted', () => abort(400, 'client-aborted', { partialSize: offset + bytes }))
|
|
1002
|
+
req.on('error', () => abort(400, 'client-aborted', { partialSize: offset + bytes }))
|
|
1003
|
+
req.on('data', (chunk) => {
|
|
1004
|
+
if (finished) return
|
|
1005
|
+
bytes += chunk.length
|
|
1006
|
+
if (offset + bytes > FS_MAX_UPLOAD) {
|
|
1007
|
+
abort(413, 'too-large', { limit: FS_MAX_UPLOAD })
|
|
1008
|
+
return
|
|
1009
|
+
}
|
|
1010
|
+
stream.write(chunk)
|
|
1011
|
+
})
|
|
1012
|
+
req.on('end', () => {
|
|
1013
|
+
if (finished) return
|
|
1014
|
+
finished = true
|
|
1015
|
+
stream.end(() => {
|
|
1016
|
+
const total = offset + bytes
|
|
1017
|
+
try {
|
|
1018
|
+
const st = fs.statSync(part)
|
|
1019
|
+
if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
|
|
1020
|
+
if (!finish) {
|
|
1021
|
+
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
|
|
1022
|
+
return
|
|
1023
|
+
}
|
|
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 })
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
1032
|
+
else try { res.destroy() } catch {}
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function serveFs(req, res, url) {
|
|
1039
|
+
const sub = url.pathname.slice('/fs'.length)
|
|
1040
|
+
|
|
1041
|
+
// 跨域预检: 浏览器控制台可能从 DSH /remote 页访问网关(Authorization 非简单头)
|
|
1042
|
+
if (req.method === 'OPTIONS') {
|
|
1043
|
+
cors(res)
|
|
1044
|
+
res.writeHead(204)
|
|
1045
|
+
res.end()
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (sub === '/list') return fsList(req, res, url)
|
|
1050
|
+
if (sub === '/file') return fsFile(req, res, url)
|
|
1051
|
+
if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
|
|
1052
|
+
|
|
1053
|
+
if (sub === '/upload') {
|
|
1054
|
+
if (req.method !== 'POST') {
|
|
1055
|
+
res.writeHead(405, { allow: 'POST' })
|
|
1056
|
+
res.end()
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1059
|
+
if (!fsAuthorized(req, url, res)) return
|
|
1060
|
+
touchDevice(req)
|
|
1061
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
1062
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1063
|
+
const checked = fsRealChecked(resolved.abs)
|
|
1064
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
1065
|
+
try {
|
|
1066
|
+
const st = fs.statSync(checked.abs)
|
|
1067
|
+
if (!st.isDirectory()) return fsJson(res, 400, { error: 'not-a-directory' })
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
1070
|
+
}
|
|
1071
|
+
const contentLength = Number(req.headers['content-length'])
|
|
1072
|
+
if (Number.isFinite(contentLength) && contentLength > FS_MAX_UPLOAD) {
|
|
1073
|
+
return fsJson(res, 413, { error: 'too-large', limit: FS_MAX_UPLOAD })
|
|
1074
|
+
}
|
|
1075
|
+
// 带 session/offset 进入分块续传模式; 不带则保持 raw/multipart 一次性上传
|
|
1076
|
+
if (url.searchParams.has('session') || url.searchParams.has('offset')) {
|
|
1077
|
+
return fsUploadResumable(req, res, url, resolved.abs, checked.abs)
|
|
1078
|
+
}
|
|
1079
|
+
const contentType = String(req.headers['content-type'] || '')
|
|
1080
|
+
if (contentType.startsWith('multipart/form-data')) {
|
|
1081
|
+
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType)
|
|
1082
|
+
const boundary = (m ? (m[1] || m[2]) : '').trim()
|
|
1083
|
+
if (!boundary) return fsJson(res, 400, { error: 'bad-multipart', detail: '缺少 boundary' })
|
|
1084
|
+
return fsUploadMultipart(req, res, url, resolved.abs, checked.abs, boundary)
|
|
1085
|
+
}
|
|
1086
|
+
return fsUploadRaw(req, res, url, resolved.abs, checked.abs)
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
fsJson(res, 404, { error: 'not-found' })
|
|
1090
|
+
}
|
|
1091
|
+
|
|
475
1092
|
// ---------- /api 代理 ----------
|
|
476
1093
|
function proxyApi(req, res, url) {
|
|
477
1094
|
if (req.method === 'OPTIONS') {
|
|
@@ -547,6 +1164,7 @@ function lanAddresses() {
|
|
|
547
1164
|
const server = http.createServer((req, res) => {
|
|
548
1165
|
try {
|
|
549
1166
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
1167
|
+
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
|
|
550
1168
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
551
1169
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
552
1170
|
if (url.pathname === '/health') return serveHealth(res)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元)
|
|
3
|
+
"version": "0.4.9",
|
|
4
|
+
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": {
|