dsh-remote-plugin 0.6.11 → 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/README.en.md +3 -2
- package/README.md +3 -2
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +466 -65
- package/index.mjs +3 -2
- package/package.json +1 -1
- package/public/announcements.json +8 -0
- package/public/app.js +270 -35
- package/public/index.html +50 -12
- package/public/styles.css +41 -2
- package/public/update.json +9 -9
- package/public/version.json +1 -1
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
|
*/
|
|
@@ -44,6 +44,12 @@ try {
|
|
|
44
44
|
const ROOT = __dirname
|
|
45
45
|
const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
46
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
|
|
47
53
|
const PORT = Number(process.env.PORT) || 8787
|
|
48
54
|
const HOST = process.env.HOST || '0.0.0.0'
|
|
49
55
|
|
|
@@ -74,6 +80,9 @@ const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh
|
|
|
74
80
|
const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
|
|
75
81
|
const STARTED_AT = Date.now()
|
|
76
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)
|
|
77
86
|
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
78
87
|
const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
|
|
79
88
|
const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
|
|
@@ -120,8 +129,11 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
|
120
129
|
.split(path.delimiter)
|
|
121
130
|
.filter(Boolean)
|
|
122
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)
|
|
123
133
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
124
134
|
let FS_ROOT_REALS = null
|
|
135
|
+
let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
|
|
136
|
+
let fsWorkspaceRootsFetch = null
|
|
125
137
|
function fsRootReals() {
|
|
126
138
|
if (!FS_ROOT_REALS) {
|
|
127
139
|
FS_ROOT_REALS = FS_ROOTS.map(r => { try { return fs.realpathSync(r) } catch { return null } }).filter(Boolean)
|
|
@@ -129,7 +141,7 @@ function fsRootReals() {
|
|
|
129
141
|
return FS_ROOT_REALS
|
|
130
142
|
}
|
|
131
143
|
function fsInsideReal(real) {
|
|
132
|
-
for (const root of fsRootReals()) {
|
|
144
|
+
for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
|
|
133
145
|
if (real === root || real.startsWith(root + path.sep)) return true
|
|
134
146
|
}
|
|
135
147
|
return false
|
|
@@ -557,6 +569,10 @@ function execFileResult(file, args, timeout = 5000) {
|
|
|
557
569
|
resolvePromise({
|
|
558
570
|
ok: !error,
|
|
559
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(),
|
|
560
576
|
stdout: String(stdout || '').trim(),
|
|
561
577
|
stderr: String(stderr || '').trim(),
|
|
562
578
|
})
|
|
@@ -564,15 +580,227 @@ function execFileResult(file, args, timeout = 5000) {
|
|
|
564
580
|
})
|
|
565
581
|
}
|
|
566
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
|
+
|
|
567
601
|
async function dshServiceStatus() {
|
|
568
602
|
if (process.platform === 'win32') {
|
|
569
|
-
return { ok: true, supported: false, running: false, service: DSH_SERVICE, message: 'Windows
|
|
603
|
+
return { ok: true, supported: false, running: false, service: DSH_SERVICE, code: 'PLATFORM_UNSUPPORTED', message: 'Windows 暂不支持通过 systemd 远程控制 DSH' }
|
|
570
604
|
}
|
|
571
605
|
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
572
|
-
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))
|
|
573
803
|
}
|
|
574
|
-
const r = await execFileResult('systemctl', ['--user', 'is-active', DSH_SERVICE], 3000)
|
|
575
|
-
return { ok: true, supported: true, running: r.stdout === 'active', service: DSH_SERVICE, state: r.stdout || 'unknown', detail: r.stderr || '' }
|
|
576
804
|
}
|
|
577
805
|
|
|
578
806
|
async function serveDshControl(req, res, url) {
|
|
@@ -592,9 +820,26 @@ async function serveDshControl(req, res, url) {
|
|
|
592
820
|
}
|
|
593
821
|
touchDevice(req, { kind: 'admin' })
|
|
594
822
|
if (req.method === 'GET') {
|
|
823
|
+
const operationId = String(url.searchParams.get('operation') || '').trim()
|
|
595
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
|
+
}
|
|
596
840
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
597
|
-
|
|
841
|
+
const status = await dshServiceStatus()
|
|
842
|
+
res.end(JSON.stringify({ ...status, operation: dshControlOperation && !dshControlOperation.done ? dshControlOperation : null }))
|
|
598
843
|
return
|
|
599
844
|
}
|
|
600
845
|
if (req.method !== 'POST') {
|
|
@@ -604,25 +849,36 @@ async function serveDshControl(req, res, url) {
|
|
|
604
849
|
return
|
|
605
850
|
}
|
|
606
851
|
let body = {}
|
|
607
|
-
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
|
+
}
|
|
608
858
|
const action = body?.action
|
|
609
859
|
if (action !== 'start' && action !== 'restart') {
|
|
610
860
|
cors(res)
|
|
611
861
|
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
612
|
-
res.end(JSON.stringify({ ok: false, error: 'action 必须是 start 或 restart' }))
|
|
862
|
+
res.end(JSON.stringify({ ok: false, code: 'INVALID_ACTION', error: 'action 必须是 start 或 restart' }))
|
|
613
863
|
return
|
|
614
864
|
}
|
|
615
|
-
if (
|
|
865
|
+
if (dshControlOperation && !dshControlOperation.done) {
|
|
616
866
|
cors(res)
|
|
617
|
-
res.writeHead(
|
|
618
|
-
res.end(JSON.stringify({ ok: false,
|
|
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 }))
|
|
619
869
|
return
|
|
620
870
|
}
|
|
621
|
-
const
|
|
622
|
-
|
|
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) })
|
|
623
879
|
cors(res)
|
|
624
|
-
res.writeHead(
|
|
625
|
-
res.end(JSON.stringify(
|
|
880
|
+
res.writeHead(202, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
881
|
+
res.end(JSON.stringify(dshControlOperation))
|
|
626
882
|
}
|
|
627
883
|
|
|
628
884
|
// ---------- 事件轮询缓冲 ----------
|
|
@@ -640,6 +896,7 @@ const eventCollectorState = {
|
|
|
640
896
|
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
641
897
|
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
642
898
|
}
|
|
899
|
+
const eventCollectors = { mux: null, host: null }
|
|
643
900
|
|
|
644
901
|
/** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
|
|
645
902
|
function truncateEventValue(v, depth = 0) {
|
|
@@ -876,6 +1133,13 @@ function startEventCollector(kind) {
|
|
|
876
1133
|
connect()
|
|
877
1134
|
return {
|
|
878
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
|
+
},
|
|
879
1143
|
close() {
|
|
880
1144
|
stopped = true
|
|
881
1145
|
clearTimeout(retryTimer)
|
|
@@ -1000,15 +1264,106 @@ function maskIp(ip) {
|
|
|
1000
1264
|
return s
|
|
1001
1265
|
}
|
|
1002
1266
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
if (
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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) {
|
|
1012
1367
|
const announcement = items.find(item => String(item?.id || '').trim() === announcementId)
|
|
1013
1368
|
const poll = announcement?.poll
|
|
1014
1369
|
if (!poll || String(poll.id || '').trim() !== pollId || !Array.isArray(poll.options)) return { error: 'poll not found' }
|
|
@@ -1019,6 +1374,22 @@ function validatePollVote(payload) {
|
|
|
1019
1374
|
return { announcementId, pollId, optionId, optionLabel }
|
|
1020
1375
|
}
|
|
1021
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
|
+
|
|
1022
1393
|
function serveFeedback(req, res, url) {
|
|
1023
1394
|
cors(res)
|
|
1024
1395
|
if (req.method === 'OPTIONS') {
|
|
@@ -1042,7 +1413,7 @@ function serveFeedback(req, res, url) {
|
|
|
1042
1413
|
|
|
1043
1414
|
let body = ''
|
|
1044
1415
|
req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy() })
|
|
1045
|
-
req.on('end', () => {
|
|
1416
|
+
req.on('end', async () => {
|
|
1046
1417
|
let payload
|
|
1047
1418
|
try {
|
|
1048
1419
|
payload = JSON.parse(body || '{}')
|
|
@@ -1060,7 +1431,7 @@ function serveFeedback(req, res, url) {
|
|
|
1060
1431
|
res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
|
|
1061
1432
|
return
|
|
1062
1433
|
}
|
|
1063
|
-
const pollVote = type === 'poll' ? validatePollVote(payload) : null
|
|
1434
|
+
const pollVote = type === 'poll' ? await validatePollVote(payload) : null
|
|
1064
1435
|
if (pollVote?.error) {
|
|
1065
1436
|
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1066
1437
|
res.end(JSON.stringify({ error: pollVote.error }))
|
|
@@ -1147,21 +1518,7 @@ function serveStatic(req, res, url) {
|
|
|
1147
1518
|
if (pathname === '/') pathname = '/index.html'
|
|
1148
1519
|
if (pathname === '/admin') pathname = '/admin.html'
|
|
1149
1520
|
if (pathname === '/announcements.json') {
|
|
1150
|
-
|
|
1151
|
-
if (err) {
|
|
1152
|
-
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1153
|
-
res.end('404 Not Found')
|
|
1154
|
-
return
|
|
1155
|
-
}
|
|
1156
|
-
try { JSON.parse(raw) } catch {
|
|
1157
|
-
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1158
|
-
res.end(JSON.stringify({ error: 'invalid announcements config' }))
|
|
1159
|
-
return
|
|
1160
|
-
}
|
|
1161
|
-
cors(res)
|
|
1162
|
-
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1163
|
-
res.end(req.method === 'HEAD' ? '' : raw)
|
|
1164
|
-
})
|
|
1521
|
+
void serveAnnouncements(req, res)
|
|
1165
1522
|
return
|
|
1166
1523
|
}
|
|
1167
1524
|
// 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
|
|
@@ -1390,17 +1747,61 @@ function fsAuthorized(req, url, res) {
|
|
|
1390
1747
|
return true
|
|
1391
1748
|
}
|
|
1392
1749
|
|
|
1393
|
-
|
|
1394
|
-
|
|
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) {
|
|
1395
1793
|
const raw = String(input ?? '').trim()
|
|
1396
1794
|
let abs
|
|
1397
1795
|
if (!raw || raw === '~') abs = FS_ROOTS[0]
|
|
1398
1796
|
else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
1399
1797
|
else if (path.isAbsolute(raw)) abs = path.resolve(raw)
|
|
1400
1798
|
else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
}
|
|
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 }
|
|
1404
1805
|
return { error: 'forbidden' }
|
|
1405
1806
|
}
|
|
1406
1807
|
|
|
@@ -1443,14 +1844,14 @@ function fsParseRange(header, size) {
|
|
|
1443
1844
|
return { start, end: Math.min(end, size - 1) }
|
|
1444
1845
|
}
|
|
1445
1846
|
|
|
1446
|
-
function fsList(req, res, url) {
|
|
1847
|
+
async function fsList(req, res, url) {
|
|
1447
1848
|
if (req.method !== 'GET') {
|
|
1448
1849
|
res.writeHead(405, { allow: 'GET' })
|
|
1449
1850
|
res.end()
|
|
1450
1851
|
return
|
|
1451
1852
|
}
|
|
1452
1853
|
if (!fsAuthorized(req, url, res)) return
|
|
1453
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
1854
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
1454
1855
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1455
1856
|
const checked = fsRealChecked(resolved.abs)
|
|
1456
1857
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -1493,14 +1894,14 @@ function fsList(req, res, url) {
|
|
|
1493
1894
|
fsJson(res, 200, { path: resolved.abs, entries })
|
|
1494
1895
|
}
|
|
1495
1896
|
|
|
1496
|
-
function fsFile(req, res, url) {
|
|
1897
|
+
async function fsFile(req, res, url) {
|
|
1497
1898
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1498
1899
|
res.writeHead(405, { allow: 'GET, HEAD' })
|
|
1499
1900
|
res.end()
|
|
1500
1901
|
return
|
|
1501
1902
|
}
|
|
1502
1903
|
if (!fsAuthorized(req, url, res)) return
|
|
1503
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
1904
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
1504
1905
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1505
1906
|
const checked = fsRealChecked(resolved.abs)
|
|
1506
1907
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -1541,14 +1942,14 @@ function fsFile(req, res, url) {
|
|
|
1541
1942
|
stream.pipe(res)
|
|
1542
1943
|
}
|
|
1543
1944
|
|
|
1544
|
-
function fsPreview(req, res, url) {
|
|
1945
|
+
async function fsPreview(req, res, url) {
|
|
1545
1946
|
if (req.method !== 'GET') {
|
|
1546
1947
|
res.writeHead(405, { allow: 'GET' })
|
|
1547
1948
|
res.end()
|
|
1548
1949
|
return
|
|
1549
1950
|
}
|
|
1550
1951
|
if (!fsAuthorized(req, url, res)) return
|
|
1551
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
1952
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
1552
1953
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1553
1954
|
const checked = fsRealChecked(resolved.abs)
|
|
1554
1955
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -1797,7 +2198,7 @@ function fsTargetState(target) {
|
|
|
1797
2198
|
}
|
|
1798
2199
|
}
|
|
1799
2200
|
|
|
1800
|
-
function fsUploadProbe(req, res, url) {
|
|
2201
|
+
async function fsUploadProbe(req, res, url) {
|
|
1801
2202
|
if (req.method !== 'GET') {
|
|
1802
2203
|
res.writeHead(405, { allow: 'GET' })
|
|
1803
2204
|
res.end()
|
|
@@ -1805,7 +2206,7 @@ function fsUploadProbe(req, res, url) {
|
|
|
1805
2206
|
}
|
|
1806
2207
|
if (!fsAuthorized(req, url, res)) return
|
|
1807
2208
|
touchDevice(req)
|
|
1808
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
2209
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
1809
2210
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1810
2211
|
const checked = fsRealChecked(resolved.abs)
|
|
1811
2212
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -1833,7 +2234,7 @@ function fsUploadProbe(req, res, url) {
|
|
|
1833
2234
|
}
|
|
1834
2235
|
|
|
1835
2236
|
/** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
|
|
1836
|
-
function fsMkdir(req, res, url) {
|
|
2237
|
+
async function fsMkdir(req, res, url) {
|
|
1837
2238
|
if (req.method !== 'POST') {
|
|
1838
2239
|
res.writeHead(405, { allow: 'POST' })
|
|
1839
2240
|
res.end()
|
|
@@ -1841,7 +2242,7 @@ function fsMkdir(req, res, url) {
|
|
|
1841
2242
|
}
|
|
1842
2243
|
if (!fsAuthorized(req, url, res)) return
|
|
1843
2244
|
touchDevice(req)
|
|
1844
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
2245
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
1845
2246
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1846
2247
|
const checked = fsRealChecked(resolved.abs)
|
|
1847
2248
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -1996,7 +2397,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
1996
2397
|
|
|
1997
2398
|
/* POST /fs/upload-control?path&name&session&action=cancel
|
|
1998
2399
|
* 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
|
|
1999
|
-
function fsUploadControl(req, res, url) {
|
|
2400
|
+
async function fsUploadControl(req, res, url) {
|
|
2000
2401
|
if (req.method !== 'POST') {
|
|
2001
2402
|
res.writeHead(405, { allow: 'POST' })
|
|
2002
2403
|
res.end()
|
|
@@ -2004,7 +2405,7 @@ function fsUploadControl(req, res, url) {
|
|
|
2004
2405
|
}
|
|
2005
2406
|
if (!fsAuthorized(req, url, res)) return
|
|
2006
2407
|
touchDevice(req)
|
|
2007
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
2408
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
2008
2409
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2009
2410
|
const checked = fsRealChecked(resolved.abs)
|
|
2010
2411
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -2030,7 +2431,7 @@ function fsUploadControl(req, res, url) {
|
|
|
2030
2431
|
}, 80)
|
|
2031
2432
|
}
|
|
2032
2433
|
|
|
2033
|
-
function serveFs(req, res, url) {
|
|
2434
|
+
async function serveFs(req, res, url) {
|
|
2034
2435
|
const sub = url.pathname.slice('/fs'.length)
|
|
2035
2436
|
|
|
2036
2437
|
// 跨域预检: 浏览器控制台可能从 DSH /remote 页访问网关(Authorization 非简单头)
|
|
@@ -2056,7 +2457,7 @@ function serveFs(req, res, url) {
|
|
|
2056
2457
|
}
|
|
2057
2458
|
if (!fsAuthorized(req, url, res)) return
|
|
2058
2459
|
touchDevice(req)
|
|
2059
|
-
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
2460
|
+
const resolved = await fsResolve(url.searchParams.get('path') ?? '')
|
|
2060
2461
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2061
2462
|
const checked = fsRealChecked(resolved.abs)
|
|
2062
2463
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
@@ -2317,10 +2718,10 @@ function lanAddresses() {
|
|
|
2317
2718
|
return out
|
|
2318
2719
|
}
|
|
2319
2720
|
|
|
2320
|
-
const server = http.createServer((req, res) => {
|
|
2721
|
+
const server = http.createServer(async (req, res) => {
|
|
2321
2722
|
try {
|
|
2322
2723
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
2323
|
-
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)
|
|
2324
2725
|
if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
|
|
2325
2726
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
2326
2727
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
@@ -2651,8 +3052,8 @@ server.listen(PORT, HOST, () => {
|
|
|
2651
3052
|
}
|
|
2652
3053
|
console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
|
|
2653
3054
|
// 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
|
|
2654
|
-
startEventCollector('mux')
|
|
2655
|
-
startEventCollector('host')
|
|
3055
|
+
eventCollectors.mux = startEventCollector('mux')
|
|
3056
|
+
eventCollectors.host = startEventCollector('host')
|
|
2656
3057
|
// 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
|
|
2657
3058
|
setTimeout(() => checkForUpdates(false), 8000)
|
|
2658
3059
|
setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
|