dsh-remote-plugin 0.6.10 → 0.6.12

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/gateway.cjs CHANGED
@@ -18,7 +18,7 @@
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 文件传输允许根, 默认 ~, 使用系统路径分隔符配置多根
21
+ * DSH_REMOTE_FS_ROOT 文件传输额外允许根, 默认 ~, 使用系统路径分隔符配置多根
22
22
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
23
23
  * DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
24
24
  */
@@ -43,6 +43,13 @@ try {
43
43
 
44
44
  const ROOT = __dirname
45
45
  const PUBLIC_DIR = path.join(ROOT, 'public')
46
+ const ANNOUNCEMENTS_FILE = process.env.DSH_REMOTE_ANNOUNCEMENTS_FILE || path.join(PUBLIC_DIR, 'announcements.json')
47
+ const DEFAULT_ANNOUNCEMENTS_URL = 'https://vm-0-2-ubuntu.tail1f6fc4.ts.net/announcements.json'
48
+ const ANNOUNCEMENTS_URL = process.env.DSH_REMOTE_ANNOUNCEMENTS_URL === undefined
49
+ ? DEFAULT_ANNOUNCEMENTS_URL
50
+ : String(process.env.DSH_REMOTE_ANNOUNCEMENTS_URL || '').trim()
51
+ const ANNOUNCEMENTS_CACHE_MS = durationEnv('DSH_REMOTE_ANNOUNCEMENTS_CACHE_MS', 15_000, 100, 10 * 60_000)
52
+ const ANNOUNCEMENTS_MAX_BYTES = 512 * 1024
46
53
  const PORT = Number(process.env.PORT) || 8787
47
54
  const HOST = process.env.HOST || '0.0.0.0'
48
55
 
@@ -73,6 +80,9 @@ const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh
73
80
  const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
74
81
  const STARTED_AT = Date.now()
75
82
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
83
+ const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
84
+ const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
85
+ const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
76
86
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
77
87
  const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
78
88
  const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
@@ -119,8 +129,11 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
119
129
  .split(path.delimiter)
120
130
  .filter(Boolean)
121
131
  .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
132
+ const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
122
133
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
123
134
  let FS_ROOT_REALS = null
135
+ let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
136
+ let fsWorkspaceRootsFetch = null
124
137
  function fsRootReals() {
125
138
  if (!FS_ROOT_REALS) {
126
139
  FS_ROOT_REALS = FS_ROOTS.map(r => { try { return fs.realpathSync(r) } catch { return null } }).filter(Boolean)
@@ -128,7 +141,7 @@ function fsRootReals() {
128
141
  return FS_ROOT_REALS
129
142
  }
130
143
  function fsInsideReal(real) {
131
- for (const root of fsRootReals()) {
144
+ for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
132
145
  if (real === root || real.startsWith(root + path.sep)) return true
133
146
  }
134
147
  return false
@@ -166,6 +179,14 @@ const FS_MIME = {
166
179
  '.epub': 'application/epub+zip',
167
180
  '.wasm': 'application/wasm',
168
181
  }
182
+ const FS_PREVIEW_MAX = 1024 * 1024
183
+ const FS_PREVIEW_EXTENSIONS = new Set([
184
+ '.txt', '.md', '.markdown', '.log', '.json', '.jsonl', '.js', '.mjs', '.cjs', '.jsx',
185
+ '.ts', '.tsx', '.py', '.css', '.html', '.htm', '.xml', '.yaml', '.yml', '.toml',
186
+ '.ini', '.conf', '.env', '.sh', '.bash', '.zsh', '.fish', '.sql', '.java', '.kt',
187
+ '.kts', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.rb', '.vue',
188
+ '.svelte', '.gradle', '.properties', '.gitignore', '.dockerfile'
189
+ ])
169
190
 
170
191
  // ---------- token ----------
171
192
  function loadToken() {
@@ -548,6 +569,10 @@ function execFileResult(file, args, timeout = 5000) {
548
569
  resolvePromise({
549
570
  ok: !error,
550
571
  code: error?.code ?? 0,
572
+ signal: error?.signal || '',
573
+ killed: error?.killed === true,
574
+ timedOut: error?.code === 'ETIMEDOUT' || (error?.killed === true && error?.signal === 'SIGTERM'),
575
+ error: String(error?.message || '').trim(),
551
576
  stdout: String(stdout || '').trim(),
552
577
  stderr: String(stderr || '').trim(),
553
578
  })
@@ -555,15 +580,227 @@ function execFileResult(file, args, timeout = 5000) {
555
580
  })
556
581
  }
557
582
 
583
+ function parseSystemdShow(output) {
584
+ const values = {}
585
+ for (const line of String(output || '').split(/\r?\n/)) {
586
+ const split = line.indexOf('=')
587
+ if (split > 0) values[line.slice(0, split)] = line.slice(split + 1)
588
+ }
589
+ return values
590
+ }
591
+
592
+ function classifySystemctlFailure(result) {
593
+ const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
594
+ if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'systemctl 命令执行超时', detail }
595
+ if (result?.code === 'ENOENT' || /ENOENT|not found/i.test(detail)) return { code: 'SYSTEMCTL_NOT_FOUND', message: '系统中找不到 systemctl', detail }
596
+ if (/Failed to connect to bus|No medium found|user bus|DBUS/i.test(detail)) return { code: 'SYSTEMD_UNAVAILABLE', message: '无法连接当前用户的 systemd 会话', detail }
597
+ if (/access denied|permission denied|not authorized|authentication is required/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 DSH 服务', detail }
598
+ return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
599
+ }
600
+
558
601
  async function dshServiceStatus() {
559
602
  if (process.platform === 'win32') {
560
- return { ok: true, supported: false, running: false, service: DSH_SERVICE, message: 'Windows 请配置 DSH_REMOTE_DSH_SERVICE 后接入任务计划程序' }
603
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, code: 'PLATFORM_UNSUPPORTED', message: 'Windows 暂不支持通过 systemd 远程控制 DSH' }
561
604
  }
562
605
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
563
- return { ok: false, supported: false, running: false, service: DSH_SERVICE, message: '服务名配置不合法' }
606
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
607
+ }
608
+ const r = await execFileResult(SYSTEMCTL, [
609
+ '--user', 'show', DSH_SERVICE,
610
+ '--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
611
+ '--no-pager'
612
+ ], 5000)
613
+ if (!r.ok) {
614
+ const failure = classifySystemctlFailure(r)
615
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
616
+ }
617
+ const value = parseSystemdShow(r.stdout)
618
+ const loadState = value.LoadState || 'unknown'
619
+ const activeState = value.ActiveState || 'unknown'
620
+ const subState = value.SubState || 'unknown'
621
+ const mainPid = Number(value.MainPID) || 0
622
+ if (loadState === 'not-found') {
623
+ return {
624
+ ok: true, supported: false, running: false, service: DSH_SERVICE,
625
+ code: 'SERVICE_NOT_FOUND', message: `未找到 systemd 用户服务 ${DSH_SERVICE}`,
626
+ loadState, activeState, subState, mainPid,
627
+ }
628
+ }
629
+ return {
630
+ ok: true,
631
+ supported: true,
632
+ running: activeState === 'active' && (subState === 'running' || subState === 'exited'),
633
+ service: value.Id || DSH_SERVICE,
634
+ state: activeState,
635
+ loadState,
636
+ activeState,
637
+ subState,
638
+ unitFileState: value.UnitFileState || '',
639
+ mainPid,
640
+ result: value.Result || '',
641
+ execMainStatus: Number(value.ExecMainStatus) || 0,
642
+ }
643
+ }
644
+
645
+ function delay(ms) {
646
+ return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
647
+ }
648
+
649
+ async function probeDshUpstream() {
650
+ const startedAt = Date.now()
651
+ try {
652
+ const probe = await fetch(new URL(DSH_HEALTH_PATH, UPSTREAM), {
653
+ signal: AbortSignal.timeout(Math.min(2500, UPSTREAM_REQUEST_TIMEOUT_MS)),
654
+ cache: 'no-store',
655
+ })
656
+ return {
657
+ ok: probe.ok,
658
+ reachable: true,
659
+ status: probe.status,
660
+ elapsedMs: Date.now() - startedAt,
661
+ error: probe.ok ? '' : `DSH HTTP ${probe.status}`,
662
+ }
663
+ } catch (err) {
664
+ return { ok: false, reachable: false, status: 0, elapsedMs: Date.now() - startedAt, error: String(err?.message || err || '连接失败').slice(0, 500) }
665
+ }
666
+ }
667
+
668
+ let dshControlOperation = null
669
+
670
+ function dshOperationStep(operation, stage, message, extra = {}) {
671
+ const now = Date.now()
672
+ operation.stage = stage
673
+ operation.message = message
674
+ operation.updatedAt = now
675
+ Object.assign(operation, extra)
676
+ if (operation.done) operation.elapsedMs = now - operation.startedAt
677
+ operation.steps.push({ stage, message, at: now, elapsedMs: now - operation.startedAt })
678
+ }
679
+
680
+ function failDshOperation(operation, code, message, detail = '', status = null) {
681
+ dshOperationStep(operation, 'failed', message, {
682
+ ok: false,
683
+ done: true,
684
+ code,
685
+ detail: String(detail || '').slice(0, 1000),
686
+ ...(status ? { status } : {}),
687
+ })
688
+ }
689
+
690
+ function dshEventChannelStatus() {
691
+ const pick = kind => ({
692
+ connected: eventCollectorState[kind].connected,
693
+ attempt: eventCollectorState[kind].attempt,
694
+ lastError: eventCollectorState[kind].lastError,
695
+ })
696
+ const mux = pick('mux')
697
+ const host = pick('host')
698
+ return { ok: mux.connected && host.connected, mux, host }
699
+ }
700
+
701
+ function reconnectDshEventCollectors() {
702
+ eventCollectors.mux?.reconnectNow()
703
+ eventCollectors.host?.reconnectNow()
704
+ }
705
+
706
+ async function runDshControlOperation(operation) {
707
+ try {
708
+ dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
709
+ const initial = await dshServiceStatus()
710
+ operation.initialStatus = initial
711
+ if (!initial.supported) {
712
+ failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
713
+ return
714
+ }
715
+ if (operation.action === 'start' && initial.running) {
716
+ dshOperationStep(operation, 'complete', `DSH 已在运行(${initial.service},PID ${initial.mainPid || '未知'})`, {
717
+ ok: true, done: true, code: 'ALREADY_RUNNING', status: initial, upstream: await probeDshUpstream(),
718
+ })
719
+ return
720
+ }
721
+
722
+ dshOperationStep(operation, 'command', `正在向 systemd 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
723
+ const command = await execFileResult(SYSTEMCTL, ['--user', '--no-block', operation.action, DSH_SERVICE], 5000)
724
+ operation.command = { ok: command.ok, code: command.code, signal: command.signal }
725
+ if (!command.ok) {
726
+ const failure = classifySystemctlFailure(command)
727
+ failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
728
+ return
729
+ }
730
+
731
+ dshOperationStep(operation, 'waiting-service', `命令已接受,正在等待 ${initial.service} 进入运行状态`)
732
+ const initialPid = initial.mainPid || 0
733
+ let restartObserved = operation.action === 'start' || !initial.running || initialPid <= 0
734
+ let waitingUpstreamReported = false
735
+ let waitingEventsReported = false
736
+ let lastEventReconnectAt = 0
737
+ let lastStatus = initial
738
+ let lastProbe = null
739
+ const deadline = Date.now() + DSH_CONTROL_TIMEOUT_MS
740
+ while (Date.now() < deadline) {
741
+ const status = await dshServiceStatus()
742
+ lastStatus = status
743
+ operation.status = status
744
+ if (!status.supported) {
745
+ failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
746
+ return
747
+ }
748
+ if (operation.action === 'restart' && (status.mainPid > 0 && status.mainPid !== initialPid || status.activeState !== 'active')) restartObserved = true
749
+ if (status.activeState === 'failed') {
750
+ failDshOperation(operation, 'SERVICE_FAILED', `DSH 服务进入 failed 状态(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, '', status)
751
+ return
752
+ }
753
+ if (status.running && restartObserved) {
754
+ if (!waitingUpstreamReported) {
755
+ waitingUpstreamReported = true
756
+ dshOperationStep(operation, 'waiting-upstream', `服务进程已运行(PID ${status.mainPid || '未知'}),正在等待 DSH HTTP 接口 ${UPSTREAM.origin}${DSH_HEALTH_PATH} 恢复`)
757
+ }
758
+ lastProbe = await probeDshUpstream()
759
+ operation.upstream = lastProbe
760
+ if (lastProbe.ok) {
761
+ if (!waitingEventsReported) {
762
+ waitingEventsReported = true
763
+ dshOperationStep(operation, 'waiting-events', `DSH HTTP 已恢复(${lastProbe.status}),正在连接 mux/host 实时消息通道`)
764
+ }
765
+ if (Date.now() - lastEventReconnectAt >= 1500) {
766
+ lastEventReconnectAt = Date.now()
767
+ reconnectDshEventCollectors()
768
+ }
769
+ const events = dshEventChannelStatus()
770
+ operation.events = events
771
+ if (events.ok) {
772
+ dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
773
+ ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
774
+ })
775
+ return
776
+ }
777
+ }
778
+ }
779
+ await delay(DSH_CONTROL_POLL_MS)
780
+ }
781
+ if (!lastStatus.running || !restartObserved) {
782
+ const reason = operation.action === 'restart' && !restartObserved
783
+ ? `未观察到 ${initial.service} 进程完成重启(初始 PID ${initialPid || '未知'},当前 PID ${lastStatus.mainPid || '未知'})`
784
+ : `${initial.service} 未在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内进入运行状态(${lastStatus.activeState}/${lastStatus.subState})`
785
+ failDshOperation(operation, 'SERVICE_TIMEOUT', reason, '', lastStatus)
786
+ return
787
+ }
788
+ if (lastProbe?.ok) {
789
+ const events = dshEventChannelStatus()
790
+ failDshOperation(
791
+ operation,
792
+ 'EVENTS_TIMEOUT',
793
+ `DSH 服务和 HTTP 已恢复,但 mux/host 实时消息通道未在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内连接`,
794
+ ['mux', 'host'].map(kind => `${kind}: ${events[kind].connected ? 'connected' : events[kind].lastError || `retry ${events[kind].attempt}`}`).join(' · '),
795
+ lastStatus,
796
+ )
797
+ operation.events = events
798
+ return
799
+ }
800
+ failDshOperation(operation, 'UPSTREAM_TIMEOUT', `服务进程已运行,但 DSH HTTP 接口在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内未恢复`, lastProbe?.error || '', lastStatus)
801
+ } catch (err) {
802
+ failDshOperation(operation, 'INTERNAL_ERROR', 'DSH 控制流程发生未预期错误', String(err?.stack || err))
564
803
  }
565
- const r = await execFileResult('systemctl', ['--user', 'is-active', DSH_SERVICE], 3000)
566
- return { ok: true, supported: true, running: r.stdout === 'active', service: DSH_SERVICE, state: r.stdout || 'unknown', detail: r.stderr || '' }
567
804
  }
568
805
 
569
806
  async function serveDshControl(req, res, url) {
@@ -583,9 +820,26 @@ async function serveDshControl(req, res, url) {
583
820
  }
584
821
  touchDevice(req, { kind: 'admin' })
585
822
  if (req.method === 'GET') {
823
+ const operationId = String(url.searchParams.get('operation') || '').trim()
586
824
  cors(res)
825
+ if (operationId) {
826
+ if (!dshControlOperation || dshControlOperation.operationId !== operationId) {
827
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
828
+ res.end(JSON.stringify({ ok: false, done: true, code: 'OPERATION_NOT_FOUND', error: '找不到该 DSH 控制操作,网关可能已重启' }))
829
+ return
830
+ }
831
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
832
+ res.end(JSON.stringify({
833
+ ...dshControlOperation,
834
+ elapsedMs: dshControlOperation.done
835
+ ? dshControlOperation.elapsedMs
836
+ : Date.now() - dshControlOperation.startedAt,
837
+ }))
838
+ return
839
+ }
587
840
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
588
- res.end(JSON.stringify(await dshServiceStatus()))
841
+ const status = await dshServiceStatus()
842
+ res.end(JSON.stringify({ ...status, operation: dshControlOperation && !dshControlOperation.done ? dshControlOperation : null }))
589
843
  return
590
844
  }
591
845
  if (req.method !== 'POST') {
@@ -595,25 +849,36 @@ async function serveDshControl(req, res, url) {
595
849
  return
596
850
  }
597
851
  let body = {}
598
- try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch {}
852
+ try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch (err) {
853
+ cors(res)
854
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
855
+ res.end(JSON.stringify({ ok: false, code: 'INVALID_JSON', error: '请求体不是有效 JSON', detail: String(err?.message || err) }))
856
+ return
857
+ }
599
858
  const action = body?.action
600
859
  if (action !== 'start' && action !== 'restart') {
601
860
  cors(res)
602
861
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
603
- res.end(JSON.stringify({ ok: false, error: 'action 必须是 start 或 restart' }))
862
+ res.end(JSON.stringify({ ok: false, code: 'INVALID_ACTION', error: 'action 必须是 start 或 restart' }))
604
863
  return
605
864
  }
606
- if (process.platform === 'win32' || !/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
865
+ if (dshControlOperation && !dshControlOperation.done) {
607
866
  cors(res)
608
- res.writeHead(501, { 'content-type': 'application/json; charset=utf-8' })
609
- res.end(JSON.stringify({ ok: false, supported: false, error: '当前系统未配置可控的 dsh-web 服务', service: DSH_SERVICE }))
867
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
868
+ res.end(JSON.stringify({ ok: false, code: 'OPERATION_IN_PROGRESS', error: '已有 DSH 控制操作正在执行', operation: dshControlOperation }))
610
869
  return
611
870
  }
612
- const r = await execFileResult('systemctl', ['--user', action, DSH_SERVICE], 10000)
613
- const status = await dshServiceStatus()
871
+ const now = Date.now()
872
+ dshControlOperation = {
873
+ operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
874
+ ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
875
+ message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
876
+ startedAt: now, updatedAt: now, steps: [],
877
+ }
878
+ setImmediate(() => { void runDshControlOperation(dshControlOperation) })
614
879
  cors(res)
615
- res.writeHead(r.ok ? 200 : 502, { 'content-type': 'application/json; charset=utf-8' })
616
- res.end(JSON.stringify({ ...status, ok: r.ok, action, detail: r.stderr || r.stdout || '' }))
880
+ res.writeHead(202, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
881
+ res.end(JSON.stringify(dshControlOperation))
617
882
  }
618
883
 
619
884
  // ---------- 事件轮询缓冲 ----------
@@ -631,6 +896,7 @@ const eventCollectorState = {
631
896
  mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
632
897
  host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
633
898
  }
899
+ const eventCollectors = { mux: null, host: null }
634
900
 
635
901
  /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
636
902
  function truncateEventValue(v, depth = 0) {
@@ -867,6 +1133,13 @@ function startEventCollector(kind) {
867
1133
  connect()
868
1134
  return {
869
1135
  kind,
1136
+ reconnectNow() {
1137
+ if (stopped || state.connected || ws?.readyState === 0) return
1138
+ clearTimeout(retryTimer)
1139
+ retryTimer = null
1140
+ state.attempt = 0
1141
+ connect()
1142
+ },
870
1143
  close() {
871
1144
  stopped = true
872
1145
  clearTimeout(retryTimer)
@@ -991,6 +1264,132 @@ function maskIp(ip) {
991
1264
  return s
992
1265
  }
993
1266
 
1267
+ let announcementsCache = null
1268
+ let announcementsFetch = null
1269
+
1270
+ function parseAnnouncements(raw) {
1271
+ if (Buffer.byteLength(raw, 'utf8') > ANNOUNCEMENTS_MAX_BYTES) throw new Error('announcements too large')
1272
+ const data = JSON.parse(raw)
1273
+ const items = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
1274
+ if (items.length > 200 || items.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
1275
+ throw new Error('invalid announcements payload')
1276
+ }
1277
+ return { data: Array.isArray(data) ? { items: data } : data, items }
1278
+ }
1279
+
1280
+ function localAnnouncements() {
1281
+ try {
1282
+ const raw = fs.readFileSync(ANNOUNCEMENTS_FILE, 'utf8')
1283
+ const parsed = parseAnnouncements(raw)
1284
+ return { ...parsed, raw: JSON.stringify(parsed.data), source: 'local', stale: false, fetchedAt: Date.now() }
1285
+ } catch {
1286
+ const data = { items: [] }
1287
+ return { data, items: data.items, raw: JSON.stringify(data), source: 'empty', stale: false, fetchedAt: Date.now() }
1288
+ }
1289
+ }
1290
+
1291
+ function safeAnnouncementsUrl(value) {
1292
+ const target = new URL(value)
1293
+ const loopback = ['127.0.0.1', '::1', 'localhost'].includes(target.hostname)
1294
+ if (target.protocol !== 'https:' && !(target.protocol === 'http:' && loopback)) {
1295
+ throw new Error('central announcements URL must use HTTPS')
1296
+ }
1297
+ return target.href
1298
+ }
1299
+
1300
+ async function loadCentralAnnouncements(force = false) {
1301
+ const now = Date.now()
1302
+ if (!ANNOUNCEMENTS_URL) return localAnnouncements()
1303
+ if (!force && announcementsCache && now - announcementsCache.fetchedAt < ANNOUNCEMENTS_CACHE_MS) return announcementsCache
1304
+ if (announcementsFetch) return announcementsFetch
1305
+ announcementsFetch = (async () => {
1306
+ try {
1307
+ const headers = { accept: 'application/json' }
1308
+ if (announcementsCache?.etag) headers['if-none-match'] = announcementsCache.etag
1309
+ const res = await fetch(safeAnnouncementsUrl(ANNOUNCEMENTS_URL), {
1310
+ headers,
1311
+ cache: 'no-store',
1312
+ redirect: 'follow',
1313
+ signal: AbortSignal.timeout(8000),
1314
+ })
1315
+ safeAnnouncementsUrl(res.url)
1316
+ if (res.status === 304 && announcementsCache) {
1317
+ announcementsCache = { ...announcementsCache, fetchedAt: now, stale: false }
1318
+ return announcementsCache
1319
+ }
1320
+ if (!res.ok) throw new Error(`central announcements HTTP ${res.status}`)
1321
+ const declared = Number(res.headers.get('content-length') || 0)
1322
+ if (declared > ANNOUNCEMENTS_MAX_BYTES) throw new Error('announcements too large')
1323
+ const raw = await res.text()
1324
+ const parsed = parseAnnouncements(raw)
1325
+ announcementsCache = {
1326
+ ...parsed,
1327
+ raw: JSON.stringify(parsed.data),
1328
+ source: 'central',
1329
+ stale: false,
1330
+ fetchedAt: now,
1331
+ etag: String(res.headers.get('etag') || ''),
1332
+ }
1333
+ return announcementsCache
1334
+ } catch (err) {
1335
+ if (announcementsCache?.source === 'central') {
1336
+ return { ...announcementsCache, stale: true, error: String(err?.message || err) }
1337
+ }
1338
+ return { ...localAnnouncements(), error: String(err?.message || err) }
1339
+ } finally {
1340
+ announcementsFetch = null
1341
+ }
1342
+ })()
1343
+ return announcementsFetch
1344
+ }
1345
+
1346
+ async function serveAnnouncements(req, res) {
1347
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1348
+ res.writeHead(405, { allow: 'GET, HEAD' })
1349
+ res.end()
1350
+ return
1351
+ }
1352
+ const snapshot = await loadCentralAnnouncements()
1353
+ cors(res)
1354
+ res.writeHead(200, {
1355
+ 'content-type': 'application/json; charset=utf-8',
1356
+ 'content-length': Buffer.byteLength(snapshot.raw),
1357
+ 'cache-control': 'no-store',
1358
+ 'x-content-type-options': 'nosniff',
1359
+ 'x-dsh-announcements-source': snapshot.source,
1360
+ ...(snapshot.stale ? { warning: '110 - "Response is stale"' } : {}),
1361
+ })
1362
+ if (req.method === 'HEAD') res.end()
1363
+ else res.end(snapshot.raw)
1364
+ }
1365
+
1366
+ function findPollVote(items, announcementId, pollId, optionId) {
1367
+ const announcement = items.find(item => String(item?.id || '').trim() === announcementId)
1368
+ const poll = announcement?.poll
1369
+ if (!poll || String(poll.id || '').trim() !== pollId || !Array.isArray(poll.options)) return { error: 'poll not found' }
1370
+ const option = poll.options.find(item => String(item?.id || '').trim() === optionId)
1371
+ if (!option) return { error: 'poll option not found' }
1372
+ const optionLabel = String(option.label || '').trim().slice(0, 200)
1373
+ if (!optionLabel) return { error: 'poll option invalid' }
1374
+ return { announcementId, pollId, optionId, optionLabel }
1375
+ }
1376
+
1377
+ async function validatePollVote(payload) {
1378
+ const announcementId = String(payload.announcementId || '').trim()
1379
+ const pollId = String(payload.pollId || '').trim()
1380
+ const optionId = String(payload.optionId || '').trim()
1381
+ if (!announcementId || !pollId || !optionId) return { error: 'poll fields required' }
1382
+ if (announcementId.length > 120 || pollId.length > 120 || optionId.length > 120) return { error: 'poll fields too long' }
1383
+ let snapshot = await loadCentralAnnouncements()
1384
+ let result = findPollVote(snapshot.items, announcementId, pollId, optionId)
1385
+ // 中央公告刚发布、网关缓存尚未到期时,投票请求触发一次强制刷新,避免出现公告可见但选项暂不可投。
1386
+ if (result.error && ANNOUNCEMENTS_URL) {
1387
+ snapshot = await loadCentralAnnouncements(true)
1388
+ result = findPollVote(snapshot.items, announcementId, pollId, optionId)
1389
+ }
1390
+ return result
1391
+ }
1392
+
994
1393
  function serveFeedback(req, res, url) {
995
1394
  cors(res)
996
1395
  if (req.method === 'OPTIONS') {
@@ -1014,7 +1413,7 @@ function serveFeedback(req, res, url) {
1014
1413
 
1015
1414
  let body = ''
1016
1415
  req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy() })
1017
- req.on('end', () => {
1416
+ req.on('end', async () => {
1018
1417
  let payload
1019
1418
  try {
1020
1419
  payload = JSON.parse(body || '{}')
@@ -1024,14 +1423,23 @@ function serveFeedback(req, res, url) {
1024
1423
  return
1025
1424
  }
1026
1425
  const type = payload.type
1027
- const message = String(payload.message || '').trim()
1426
+ let message = String(payload.message || '').trim()
1028
1427
  const contact = String(payload.contact || '').trim()
1029
1428
  const appVersion = String(payload.appVersion || '').trim()
1030
- if (!['bug', 'suggestion', 'other'].includes(type)) {
1429
+ if (!['bug', 'suggestion', 'other', 'poll'].includes(type)) {
1430
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1431
+ res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
1432
+ return
1433
+ }
1434
+ const pollVote = type === 'poll' ? await validatePollVote(payload) : null
1435
+ if (pollVote?.error) {
1031
1436
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1032
- res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other' }))
1437
+ res.end(JSON.stringify({ error: pollVote.error }))
1033
1438
  return
1034
1439
  }
1440
+ // 公网收集器的旧版只保留 type/message 等通用字段。同时发送结构化
1441
+ // 字段和稳定的 message 编码,旧收集器也能用 scripts/summarize-polls.mjs 汇总。
1442
+ if (pollVote) message = 'POLL ' + JSON.stringify({ announcementId: pollVote.announcementId, pollId: pollVote.pollId, optionId: pollVote.optionId })
1035
1443
  if (!message) {
1036
1444
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1037
1445
  res.end(JSON.stringify({ error: 'message required' }))
@@ -1068,7 +1476,8 @@ function serveFeedback(req, res, url) {
1068
1476
  contact: contact || undefined,
1069
1477
  appVersion: appVersion || 'unknown',
1070
1478
  gatewayVersion: VERSION,
1071
- clientIp: maskIp(ip)
1479
+ clientIp: maskIp(ip),
1480
+ ...(pollVote || {})
1072
1481
  }),
1073
1482
  signal: AbortSignal.timeout(8000)
1074
1483
  }).then(async (r) => {
@@ -1108,6 +1517,10 @@ function serveStatic(req, res, url) {
1108
1517
  }
1109
1518
  if (pathname === '/') pathname = '/index.html'
1110
1519
  if (pathname === '/admin') pathname = '/admin.html'
1520
+ if (pathname === '/announcements.json') {
1521
+ void serveAnnouncements(req, res)
1522
+ return
1523
+ }
1111
1524
  // 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
1112
1525
  // 引导升级到新 APK; 新 App 带 ?local= 拿到真实 rc 版本, 不会循环提示。
1113
1526
  if (pathname === '/update.json') {
@@ -1334,17 +1747,61 @@ function fsAuthorized(req, url, res) {
1334
1747
  return true
1335
1748
  }
1336
1749
 
1337
- /** 把用户给的 path 解析为绝对路径并做词法根检查; 返回 {abs} {error}。 */
1338
- function fsResolve(input) {
1750
+ function fsInsideRoot(abs, root) {
1751
+ return abs === root || abs.startsWith(root + path.sep)
1752
+ }
1753
+
1754
+ function fsWorkspacePath(value) {
1755
+ const raw = String(value?.path || value?.cwd || value?.root || '').trim()
1756
+ if (!raw || !path.isAbsolute(raw)) return ''
1757
+ return path.resolve(raw)
1758
+ }
1759
+
1760
+ async function loadFsWorkspaceRoots(force = false) {
1761
+ const now = Date.now()
1762
+ if (!force && now - fsWorkspaceRootsCache.fetchedAt < FS_WORKSPACE_CACHE_MS) return fsWorkspaceRootsCache
1763
+ if (fsWorkspaceRootsFetch) return fsWorkspaceRootsFetch
1764
+ fsWorkspaceRootsFetch = (async () => {
1765
+ try {
1766
+ const target = new URL('/api/workspace.list', UPSTREAM)
1767
+ const res = await fetch(target, {
1768
+ method: 'POST',
1769
+ headers: { 'content-type': 'application/json' },
1770
+ body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
1771
+ signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
1772
+ })
1773
+ if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
1774
+ const body = await res.json()
1775
+ const value = body?.result?.ok ? body.result.value : null
1776
+ const items = Array.isArray(value?.items) ? value.items : []
1777
+ const roots = [...new Set(items.map(fsWorkspacePath).filter(Boolean))]
1778
+ const reals = roots.map(root => { try { return fs.realpathSync(root) } catch { return null } }).filter(Boolean)
1779
+ fsWorkspaceRootsCache = { roots, reals, fetchedAt: Date.now() }
1780
+ } catch {
1781
+ // DSH 重启期间保留上次成功的工作区根;缓存为空时仍仅允许显式 FS_ROOTS。
1782
+ fsWorkspaceRootsCache = { ...fsWorkspaceRootsCache, fetchedAt: Date.now() }
1783
+ } finally {
1784
+ fsWorkspaceRootsFetch = null
1785
+ }
1786
+ return fsWorkspaceRootsCache
1787
+ })()
1788
+ return fsWorkspaceRootsFetch
1789
+ }
1790
+
1791
+ /** 把用户给的 path 解析为绝对路径,并仅允许显式根或 DSH 已登记工作区。 */
1792
+ async function fsResolve(input) {
1339
1793
  const raw = String(input ?? '').trim()
1340
1794
  let abs
1341
1795
  if (!raw || raw === '~') abs = FS_ROOTS[0]
1342
1796
  else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
1343
1797
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
1344
1798
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
1345
- for (const root of FS_ROOTS) {
1346
- if (abs === root || abs.startsWith(root + path.sep)) return { abs }
1347
- }
1799
+ if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
1800
+ let workspaces = await loadFsWorkspaceRoots(false)
1801
+ if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
1802
+ // 新建/刚加入的工作区可能还没进入 15s 缓存,未命中时强制刷新一次。
1803
+ workspaces = await loadFsWorkspaceRoots(true)
1804
+ if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
1348
1805
  return { error: 'forbidden' }
1349
1806
  }
1350
1807
 
@@ -1387,14 +1844,14 @@ function fsParseRange(header, size) {
1387
1844
  return { start, end: Math.min(end, size - 1) }
1388
1845
  }
1389
1846
 
1390
- function fsList(req, res, url) {
1847
+ async function fsList(req, res, url) {
1391
1848
  if (req.method !== 'GET') {
1392
1849
  res.writeHead(405, { allow: 'GET' })
1393
1850
  res.end()
1394
1851
  return
1395
1852
  }
1396
1853
  if (!fsAuthorized(req, url, res)) return
1397
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
1854
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1398
1855
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1399
1856
  const checked = fsRealChecked(resolved.abs)
1400
1857
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1437,14 +1894,14 @@ function fsList(req, res, url) {
1437
1894
  fsJson(res, 200, { path: resolved.abs, entries })
1438
1895
  }
1439
1896
 
1440
- function fsFile(req, res, url) {
1897
+ async function fsFile(req, res, url) {
1441
1898
  if (req.method !== 'GET' && req.method !== 'HEAD') {
1442
1899
  res.writeHead(405, { allow: 'GET, HEAD' })
1443
1900
  res.end()
1444
1901
  return
1445
1902
  }
1446
1903
  if (!fsAuthorized(req, url, res)) return
1447
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
1904
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1448
1905
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1449
1906
  const checked = fsRealChecked(resolved.abs)
1450
1907
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1485,6 +1942,45 @@ function fsFile(req, res, url) {
1485
1942
  stream.pipe(res)
1486
1943
  }
1487
1944
 
1945
+ async function fsPreview(req, res, url) {
1946
+ if (req.method !== 'GET') {
1947
+ res.writeHead(405, { allow: 'GET' })
1948
+ res.end()
1949
+ return
1950
+ }
1951
+ if (!fsAuthorized(req, url, res)) return
1952
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1953
+ if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1954
+ const checked = fsRealChecked(resolved.abs)
1955
+ if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
1956
+
1957
+ let st
1958
+ try { st = fs.statSync(checked.abs) } catch (err) {
1959
+ return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
1960
+ }
1961
+ if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
1962
+
1963
+ const name = path.basename(checked.abs)
1964
+ const lowerName = name.toLowerCase()
1965
+ const extension = lowerName === 'dockerfile' ? '.dockerfile' : path.extname(lowerName)
1966
+ if (!FS_PREVIEW_EXTENSIONS.has(extension)) {
1967
+ return fsJson(res, 415, { error: 'preview-unsupported', extension })
1968
+ }
1969
+ if (st.size > FS_PREVIEW_MAX) {
1970
+ return fsJson(res, 413, { error: 'preview-too-large', size: st.size, limit: FS_PREVIEW_MAX })
1971
+ }
1972
+
1973
+ let content
1974
+ try {
1975
+ const bytes = fs.readFileSync(checked.abs)
1976
+ if (bytes.includes(0)) return fsJson(res, 415, { error: 'preview-binary' })
1977
+ content = bytes.toString('utf8')
1978
+ } catch (err) {
1979
+ return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1980
+ }
1981
+ fsJson(res, 200, { name, path: resolved.abs, extension, size: st.size, content })
1982
+ }
1983
+
1488
1984
  function fsValidName(name) {
1489
1985
  if (typeof name !== 'string') return false
1490
1986
  if (!name || name === '.' || name === '..') return false
@@ -1702,7 +2198,7 @@ function fsTargetState(target) {
1702
2198
  }
1703
2199
  }
1704
2200
 
1705
- function fsUploadProbe(req, res, url) {
2201
+ async function fsUploadProbe(req, res, url) {
1706
2202
  if (req.method !== 'GET') {
1707
2203
  res.writeHead(405, { allow: 'GET' })
1708
2204
  res.end()
@@ -1710,7 +2206,7 @@ function fsUploadProbe(req, res, url) {
1710
2206
  }
1711
2207
  if (!fsAuthorized(req, url, res)) return
1712
2208
  touchDevice(req)
1713
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2209
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1714
2210
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1715
2211
  const checked = fsRealChecked(resolved.abs)
1716
2212
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1738,7 +2234,7 @@ function fsUploadProbe(req, res, url) {
1738
2234
  }
1739
2235
 
1740
2236
  /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
1741
- function fsMkdir(req, res, url) {
2237
+ async function fsMkdir(req, res, url) {
1742
2238
  if (req.method !== 'POST') {
1743
2239
  res.writeHead(405, { allow: 'POST' })
1744
2240
  res.end()
@@ -1746,7 +2242,7 @@ function fsMkdir(req, res, url) {
1746
2242
  }
1747
2243
  if (!fsAuthorized(req, url, res)) return
1748
2244
  touchDevice(req)
1749
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2245
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1750
2246
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1751
2247
  const checked = fsRealChecked(resolved.abs)
1752
2248
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1901,7 +2397,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1901
2397
 
1902
2398
  /* POST /fs/upload-control?path&name&session&action=cancel
1903
2399
  * 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
1904
- function fsUploadControl(req, res, url) {
2400
+ async function fsUploadControl(req, res, url) {
1905
2401
  if (req.method !== 'POST') {
1906
2402
  res.writeHead(405, { allow: 'POST' })
1907
2403
  res.end()
@@ -1909,7 +2405,7 @@ function fsUploadControl(req, res, url) {
1909
2405
  }
1910
2406
  if (!fsAuthorized(req, url, res)) return
1911
2407
  touchDevice(req)
1912
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2408
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1913
2409
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1914
2410
  const checked = fsRealChecked(resolved.abs)
1915
2411
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1935,7 +2431,7 @@ function fsUploadControl(req, res, url) {
1935
2431
  }, 80)
1936
2432
  }
1937
2433
 
1938
- function serveFs(req, res, url) {
2434
+ async function serveFs(req, res, url) {
1939
2435
  const sub = url.pathname.slice('/fs'.length)
1940
2436
 
1941
2437
  // 跨域预检: 浏览器控制台可能从 DSH /remote 页访问网关(Authorization 非简单头)
@@ -1948,6 +2444,7 @@ function serveFs(req, res, url) {
1948
2444
 
1949
2445
  if (sub === '/list') return fsList(req, res, url)
1950
2446
  if (sub === '/file') return fsFile(req, res, url)
2447
+ if (sub === '/preview') return fsPreview(req, res, url)
1951
2448
  if (sub === '/mkdir') return fsMkdir(req, res, url)
1952
2449
  if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
1953
2450
  if (sub === '/upload-control') return fsUploadControl(req, res, url)
@@ -1960,7 +2457,7 @@ function serveFs(req, res, url) {
1960
2457
  }
1961
2458
  if (!fsAuthorized(req, url, res)) return
1962
2459
  touchDevice(req)
1963
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2460
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1964
2461
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1965
2462
  const checked = fsRealChecked(resolved.abs)
1966
2463
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -2221,10 +2718,10 @@ function lanAddresses() {
2221
2718
  return out
2222
2719
  }
2223
2720
 
2224
- const server = http.createServer((req, res) => {
2721
+ const server = http.createServer(async (req, res) => {
2225
2722
  try {
2226
2723
  const url = new URL(req.url, 'http://dsh-remote.local')
2227
- if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
2724
+ if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
2228
2725
  if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
2229
2726
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
2230
2727
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
@@ -2555,8 +3052,8 @@ server.listen(PORT, HOST, () => {
2555
3052
  }
2556
3053
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
2557
3054
  // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
2558
- startEventCollector('mux')
2559
- startEventCollector('host')
3055
+ eventCollectors.mux = startEventCollector('mux')
3056
+ eventCollectors.host = startEventCollector('host')
2560
3057
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
2561
3058
  setTimeout(() => checkForUpdates(false), 8000)
2562
3059
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)