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 CHANGED
@@ -1,13 +1,15 @@
1
1
  # dsh-remote-plugin
2
2
 
3
- DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App 实现手机远程操控。
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](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.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`,设置服务器 `http://电脑IP:8787` + 抽屉里的令牌即可。
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
 
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.5",
4
- "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控,配合 Android App 远程操控会话/审批/提问/goal。",
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": {