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/README.en.md +23 -3
- package/README.md +23 -3
- package/apk/dsh-remote.apk +0 -0
- package/client.js +56 -4
- package/gateway-stats.cjs +40 -11
- package/gateway.cjs +539 -42
- package/index.mjs +3 -2
- package/package.json +1 -1
- package/public/announcements.json +8 -0
- package/public/app.js +785 -69
- package/public/index.html +132 -16
- package/public/plugin-icon.svg +5 -0
- package/public/styles.css +126 -1
- package/public/theme.js +9 -2
- package/public/update.json +12 -8
- 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
|
*/
|
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
865
|
+
if (dshControlOperation && !dshControlOperation.done) {
|
|
607
866
|
cors(res)
|
|
608
|
-
res.writeHead(
|
|
609
|
-
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 }))
|
|
610
869
|
return
|
|
611
870
|
}
|
|
612
|
-
const
|
|
613
|
-
|
|
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(
|
|
616
|
-
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))
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1338
|
-
|
|
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
|
-
|
|
1346
|
-
|
|
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)
|