dsh-remote-plugin 0.6.14 → 0.6.15
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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +202 -23
- package/package.json +1 -1
- package/public/app.js +260 -34
- package/public/desktop/desktop.css +22 -7
- package/public/desktop/desktop.html +24 -4
- package/public/desktop/desktop.js +275 -26
- package/public/index.html +25 -8
- package/public/morphicons-init.js +41 -0
- package/public/motion.js +469 -0
- package/public/plugin.html +4 -1
- package/public/plugin.js +1 -0
- package/public/styles.css +20 -6
- package/public/update.json +8 -8
- package/public/vendor/gsap/NOTICE.md +10 -0
- package/public/vendor/gsap/gsap.min.js +11 -0
- package/public/vendor/morphicons/LICENSE +21 -0
- package/public/vendor/morphicons/README.md +10 -0
- package/public/vendor/morphicons/controller-CXZuwJ_M.js +152 -0
- package/public/vendor/morphicons/dom.js +206 -0
- package/public/vendor/morphicons/element.js +261 -0
- package/public/vendor/morphicons/normalize-CYnN3Npw.js +540 -0
- package/public/vendor/morphicons/spring-CFHloqPP.js +623 -0
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -114,6 +114,8 @@ const CAPABILITIES = Object.freeze({
|
|
|
114
114
|
centralAnnouncements: 2,
|
|
115
115
|
feedback: 1,
|
|
116
116
|
deviceKeys: 1,
|
|
117
|
+
healthProbes: 1,
|
|
118
|
+
resumableUploads: 2,
|
|
117
119
|
})
|
|
118
120
|
|
|
119
121
|
const MIME = {
|
|
@@ -144,6 +146,7 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
|
144
146
|
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
145
147
|
const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
|
|
146
148
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
149
|
+
const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
|
|
147
150
|
let FS_ROOT_REALS = null
|
|
148
151
|
let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
|
|
149
152
|
let fsWorkspaceRootsFetch = null
|
|
@@ -924,6 +927,8 @@ function failDshOperation(operation, code, message, detail = '', status = null)
|
|
|
924
927
|
detail: String(detail || '').slice(0, 1000),
|
|
925
928
|
...(status ? { status } : {}),
|
|
926
929
|
})
|
|
930
|
+
if (status) operation.observed = status
|
|
931
|
+
operation.evidence = { observed: status || operation.observed || null, upstream: operation.upstream || null, events: operation.events || null }
|
|
927
932
|
}
|
|
928
933
|
|
|
929
934
|
function dshEventChannelStatus() {
|
|
@@ -947,6 +952,7 @@ async function runDshControlOperation(operation) {
|
|
|
947
952
|
dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
|
|
948
953
|
const initial = await dshServiceStatus()
|
|
949
954
|
operation.initialStatus = initial
|
|
955
|
+
operation.observed = initial
|
|
950
956
|
if (!initial.supported) {
|
|
951
957
|
failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
|
|
952
958
|
return
|
|
@@ -980,6 +986,7 @@ async function runDshControlOperation(operation) {
|
|
|
980
986
|
const status = await dshServiceStatus()
|
|
981
987
|
lastStatus = status
|
|
982
988
|
operation.status = status
|
|
989
|
+
operation.observed = status
|
|
983
990
|
if (!status.supported) {
|
|
984
991
|
failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
|
|
985
992
|
return
|
|
@@ -1011,6 +1018,7 @@ async function runDshControlOperation(operation) {
|
|
|
1011
1018
|
dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
|
|
1012
1019
|
ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
|
|
1013
1020
|
})
|
|
1021
|
+
operation.evidence = { observed: status, upstream: lastProbe, events }
|
|
1014
1022
|
return
|
|
1015
1023
|
}
|
|
1016
1024
|
}
|
|
@@ -1110,6 +1118,9 @@ async function serveDshControl(req, res, url) {
|
|
|
1110
1118
|
const now = Date.now()
|
|
1111
1119
|
dshControlOperation = {
|
|
1112
1120
|
operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
|
|
1121
|
+
desired: { service: DSH_SERVICE, running: true, action },
|
|
1122
|
+
observed: null,
|
|
1123
|
+
evidence: null,
|
|
1113
1124
|
ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
|
|
1114
1125
|
message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
|
|
1115
1126
|
startedAt: now, updatedAt: now, steps: [],
|
|
@@ -1125,12 +1136,14 @@ async function serveDshControl(req, res, url) {
|
|
|
1125
1136
|
// 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
|
|
1126
1137
|
// 阻断时改走 GET /api/events.poll 增量拉取。
|
|
1127
1138
|
const EVENT_BUFFER_MAX = durationEnv('GATEWAY_EVENT_BUFFER_MAX', 1000, 100, 10000)
|
|
1139
|
+
const EVENT_POLL_WAIT_MAX = durationEnv('GATEWAY_EVENT_POLL_WAIT_MS', 25000, 0, 60000)
|
|
1128
1140
|
const EVENT_MAX_STRING = 16 * 1024
|
|
1129
1141
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
1130
1142
|
const eventBuffers = { mux: [], host: [] }
|
|
1131
1143
|
const eventNextSeq = { mux: 1, host: 1 }
|
|
1132
1144
|
const collectorClients = { mux: new Set(), host: new Set() }
|
|
1133
1145
|
const collectorReplay = { mux: new Map(), host: new Map() }
|
|
1146
|
+
const eventPollWaiters = { mux: new Set(), host: new Set() }
|
|
1134
1147
|
const eventCollectorState = {
|
|
1135
1148
|
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
1136
1149
|
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
@@ -1213,6 +1226,38 @@ function pushEvent(kind, full, raw = JSON.stringify(full)) {
|
|
|
1213
1226
|
if (buf.length > EVENT_BUFFER_MAX) buf.shift()
|
|
1214
1227
|
rememberCollectorReplay(kind, full, raw)
|
|
1215
1228
|
broadcastCollectorFrame(kind, raw)
|
|
1229
|
+
flushEventPollWaiters(kind)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function eventPollPayload(kind, since, waitSupported = false) {
|
|
1233
|
+
const buf = eventBuffers[kind]
|
|
1234
|
+
const events = buf.filter(r => r.seq > since)
|
|
1235
|
+
const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
|
|
1236
|
+
const truncated = buf.length > 0 && since < buf[0].seq - 1
|
|
1237
|
+
return { ok: true, kind, since, latestSeq, truncated, waitSupported, events }
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function sendEventPollResponse(waiter, payload) {
|
|
1241
|
+
if (waiter.req.destroyed || waiter.res.destroyed) return
|
|
1242
|
+
cors(waiter.res)
|
|
1243
|
+
waiter.res.writeHead(200, {
|
|
1244
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1245
|
+
'cache-control': 'no-store'
|
|
1246
|
+
})
|
|
1247
|
+
waiter.res.end(JSON.stringify(payload))
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function finishEventPollWaiter(waiter, send = true) {
|
|
1251
|
+
if (!eventPollWaiters[waiter.kind]?.delete(waiter)) return
|
|
1252
|
+
clearTimeout(waiter.timer)
|
|
1253
|
+
if (send) sendEventPollResponse(waiter, eventPollPayload(waiter.kind, waiter.since, true))
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function flushEventPollWaiters(kind) {
|
|
1257
|
+
for (const waiter of [...eventPollWaiters[kind]]) {
|
|
1258
|
+
const payload = eventPollPayload(kind, waiter.since, true)
|
|
1259
|
+
if (payload.events.length) finishEventPollWaiter(waiter, true)
|
|
1260
|
+
}
|
|
1216
1261
|
}
|
|
1217
1262
|
|
|
1218
1263
|
function serveWsTicket(req, res, url) {
|
|
@@ -1270,16 +1315,33 @@ function serveEventPoll(req, res, url) {
|
|
|
1270
1315
|
res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
|
|
1271
1316
|
return
|
|
1272
1317
|
}
|
|
1273
|
-
const
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1318
|
+
const waitRaw = url.searchParams.get('wait')
|
|
1319
|
+
const requestedWait = waitRaw === null ? 0 : Number(waitRaw)
|
|
1320
|
+
if (!Number.isFinite(requestedWait) || requestedWait < 0) {
|
|
1321
|
+
cors(res)
|
|
1322
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1323
|
+
res.end(JSON.stringify({ error: 'bad-wait', detail: 'wait 必须是非负数字' }))
|
|
1324
|
+
return
|
|
1325
|
+
}
|
|
1326
|
+
const wait = Math.min(Math.floor(requestedWait), EVENT_POLL_WAIT_MAX)
|
|
1327
|
+
const waitSupported = wait > 0 && EVENT_POLL_WAIT_MAX > 0
|
|
1328
|
+
const payload = eventPollPayload(kind, since, waitSupported)
|
|
1329
|
+
if (payload.events.length || wait <= 0 || EVENT_POLL_WAIT_MAX <= 0) {
|
|
1330
|
+
cors(res)
|
|
1331
|
+
res.writeHead(200, {
|
|
1332
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1333
|
+
'cache-control': 'no-store'
|
|
1334
|
+
})
|
|
1335
|
+
res.end(JSON.stringify(payload))
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
const waiter = { req, res, kind, since, timer: null }
|
|
1339
|
+
eventPollWaiters[kind].add(waiter)
|
|
1340
|
+
waiter.timer = setTimeout(() => finishEventPollWaiter(waiter, true), wait)
|
|
1341
|
+
waiter.timer.unref?.()
|
|
1342
|
+
req.once('close', () => finishEventPollWaiter(waiter, false))
|
|
1343
|
+
// 事件可能刚好在首次检查和加入等待集合之间到达,加入后再检查一次避免漏唤醒。
|
|
1344
|
+
if (eventPollPayload(kind, since, true).events.length) finishEventPollWaiter(waiter, true)
|
|
1283
1345
|
}
|
|
1284
1346
|
|
|
1285
1347
|
/** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
|
|
@@ -2059,9 +2121,9 @@ function serveAdminApi(req, res, url) {
|
|
|
2059
2121
|
}
|
|
2060
2122
|
|
|
2061
2123
|
// ---------- /fs 文件传输: 实现 ----------
|
|
2062
|
-
function fsJson(res, status, body) {
|
|
2124
|
+
function fsJson(res, status, body, extraHeaders = {}) {
|
|
2063
2125
|
cors(res)
|
|
2064
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
2126
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
|
|
2065
2127
|
res.end(JSON.stringify(body))
|
|
2066
2128
|
}
|
|
2067
2129
|
|
|
@@ -2173,6 +2235,17 @@ function fsParseRange(header, size) {
|
|
|
2173
2235
|
return { start, end: Math.min(end, size - 1) }
|
|
2174
2236
|
}
|
|
2175
2237
|
|
|
2238
|
+
function fsEntityTag(st) {
|
|
2239
|
+
return `"${Number(st.size).toString(16)}-${Math.floor(Number(st.mtimeMs)).toString(16)}"`
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function fsIfRangeMatches(value, etag, mtimeMs) {
|
|
2243
|
+
if (!value) return true
|
|
2244
|
+
if (String(value).trim() === etag) return true
|
|
2245
|
+
const time = Date.parse(String(value))
|
|
2246
|
+
return Number.isFinite(time) && time >= Math.floor(Number(mtimeMs) / 1000) * 1000
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2176
2249
|
async function fsList(req, res, url) {
|
|
2177
2250
|
if (req.method !== 'GET') {
|
|
2178
2251
|
res.writeHead(405, { allow: 'GET' })
|
|
@@ -2241,7 +2314,17 @@ async function fsFile(req, res, url) {
|
|
|
2241
2314
|
}
|
|
2242
2315
|
if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
|
|
2243
2316
|
|
|
2244
|
-
const
|
|
2317
|
+
const etag = fsEntityTag(st)
|
|
2318
|
+
const lastModified = st.mtime.toUTCString()
|
|
2319
|
+
if (!req.headers.range && req.headers['if-none-match'] === etag) {
|
|
2320
|
+
cors(res)
|
|
2321
|
+
res.writeHead(304, { etag, 'last-modified': lastModified, 'cache-control': 'no-cache' })
|
|
2322
|
+
res.end()
|
|
2323
|
+
return
|
|
2324
|
+
}
|
|
2325
|
+
const range = fsIfRangeMatches(req.headers['if-range'], etag, st.mtimeMs)
|
|
2326
|
+
? fsParseRange(req.headers.range, st.size)
|
|
2327
|
+
: null
|
|
2245
2328
|
if (range && range.start >= st.size) {
|
|
2246
2329
|
cors(res)
|
|
2247
2330
|
res.writeHead(416, {
|
|
@@ -2260,6 +2343,8 @@ async function fsFile(req, res, url) {
|
|
|
2260
2343
|
'content-length': range ? range.end - range.start + 1 : st.size,
|
|
2261
2344
|
'content-disposition': fsContentDisposition(path.basename(checked.abs)),
|
|
2262
2345
|
'accept-ranges': 'bytes',
|
|
2346
|
+
etag,
|
|
2347
|
+
'last-modified': lastModified,
|
|
2263
2348
|
'cache-control': 'no-cache',
|
|
2264
2349
|
...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
|
|
2265
2350
|
})
|
|
@@ -2335,10 +2420,49 @@ function sha256FileHex(file, cb) {
|
|
|
2335
2420
|
|
|
2336
2421
|
/* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
|
|
2337
2422
|
const activeUploads = new Map()
|
|
2423
|
+
const uploadPartDirs = new Set()
|
|
2338
2424
|
function fsActiveKey(dirReal, name, session) {
|
|
2339
2425
|
return dirReal + '\n' + name + '\n' + (session || '')
|
|
2340
2426
|
}
|
|
2341
2427
|
|
|
2428
|
+
function rememberUploadDir(dirReal) {
|
|
2429
|
+
uploadPartDirs.add(dirReal)
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
function uploadDirHasActive(dirReal) {
|
|
2433
|
+
const prefix = dirReal + '\n'
|
|
2434
|
+
for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
|
|
2435
|
+
return false
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function cleanupExpiredUploadParts(dirReal = '') {
|
|
2439
|
+
const dirs = dirReal ? [dirReal] : [...uploadPartDirs]
|
|
2440
|
+
const now = Date.now()
|
|
2441
|
+
for (const dir of dirs) {
|
|
2442
|
+
if (uploadDirHasActive(dir)) continue
|
|
2443
|
+
let entries
|
|
2444
|
+
try { entries = fs.readdirSync(dir) } catch { continue }
|
|
2445
|
+
for (const name of entries) {
|
|
2446
|
+
if (!name.startsWith('.') || !name.includes('.dsh-remote-part-')) continue
|
|
2447
|
+
const file = path.join(dir, name)
|
|
2448
|
+
try {
|
|
2449
|
+
const st = fs.statSync(file)
|
|
2450
|
+
if (st.isFile() && now - st.mtimeMs > FS_UPLOAD_TTL_MS) fs.unlinkSync(file)
|
|
2451
|
+
} catch {}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
const uploadCleanupTimer = setInterval(() => cleanupExpiredUploadParts(), 15 * 60 * 1000)
|
|
2457
|
+
uploadCleanupTimer.unref?.()
|
|
2458
|
+
|
|
2459
|
+
function fsUploadHeaders(offset, length = null, expiresAt = null) {
|
|
2460
|
+
const headers = { 'upload-offset': String(Math.max(0, Number(offset) || 0)) }
|
|
2461
|
+
if (Number.isSafeInteger(length) && length >= 0) headers['upload-length'] = String(length)
|
|
2462
|
+
if (Number.isFinite(expiresAt)) headers['upload-expires'] = new Date(expiresAt).toUTCString()
|
|
2463
|
+
return headers
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2342
2466
|
/** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
|
|
2343
2467
|
function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
2344
2468
|
if (!fsValidName(name)) {
|
|
@@ -2539,14 +2663,22 @@ async function fsUploadProbe(req, res, url) {
|
|
|
2539
2663
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2540
2664
|
const checked = fsRealChecked(resolved.abs)
|
|
2541
2665
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
2666
|
+
rememberUploadDir(checked.abs)
|
|
2667
|
+
cleanupExpiredUploadParts(checked.abs)
|
|
2542
2668
|
const name = url.searchParams.get('name') || ''
|
|
2543
2669
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
2544
2670
|
const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
|
|
2545
2671
|
let partialSize = 0, partExists = false
|
|
2672
|
+
let expiresAt = null
|
|
2546
2673
|
try {
|
|
2547
2674
|
const st = fs.statSync(part)
|
|
2548
|
-
if (st.isFile()) { partialSize = st.size; partExists = true }
|
|
2675
|
+
if (st.isFile()) { partialSize = st.size; partExists = true; expiresAt = st.mtimeMs + FS_UPLOAD_TTL_MS }
|
|
2549
2676
|
} catch {}
|
|
2677
|
+
const lengthRaw = url.searchParams.get('size')
|
|
2678
|
+
const uploadLength = lengthRaw === null || lengthRaw === '' ? null : Number(lengthRaw)
|
|
2679
|
+
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
2680
|
+
return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
|
|
2681
|
+
}
|
|
2550
2682
|
const target = fsTargetState(path.join(checked.abs, name))
|
|
2551
2683
|
let targetSize = 0
|
|
2552
2684
|
if (target.exists) {
|
|
@@ -2558,8 +2690,10 @@ async function fsUploadProbe(req, res, url) {
|
|
|
2558
2690
|
partialSize,
|
|
2559
2691
|
partExists,
|
|
2560
2692
|
targetExists: !!target.exists,
|
|
2561
|
-
targetSize
|
|
2562
|
-
|
|
2693
|
+
targetSize,
|
|
2694
|
+
uploadLength,
|
|
2695
|
+
expiresAt
|
|
2696
|
+
}, fsUploadHeaders(partialSize, uploadLength, expiresAt))
|
|
2563
2697
|
}
|
|
2564
2698
|
|
|
2565
2699
|
/** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
|
|
@@ -2594,15 +2728,27 @@ async function fsMkdir(req, res, url) {
|
|
|
2594
2728
|
}
|
|
2595
2729
|
|
|
2596
2730
|
function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
2731
|
+
rememberUploadDir(dirReal)
|
|
2732
|
+
cleanupExpiredUploadParts(dirReal)
|
|
2597
2733
|
const name = url.searchParams.get('name') || ''
|
|
2598
2734
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
2599
2735
|
const session = url.searchParams.get('session') || ''
|
|
2600
2736
|
if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
|
|
2601
|
-
const
|
|
2737
|
+
const queryOffsetRaw = url.searchParams.get('offset')
|
|
2738
|
+
const headerOffsetRaw = req.headers['upload-offset']
|
|
2739
|
+
const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
|
|
2602
2740
|
const offset = Number(offsetRaw)
|
|
2603
2741
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
2604
2742
|
return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
|
|
2605
2743
|
}
|
|
2744
|
+
if (queryOffsetRaw !== null && headerOffsetRaw !== undefined && Number(headerOffsetRaw) !== offset) {
|
|
2745
|
+
return fsJson(res, 400, { error: 'offset-mismatch', detail: 'URL offset 与 Upload-Offset 不一致' })
|
|
2746
|
+
}
|
|
2747
|
+
const lengthRaw = url.searchParams.get('size') ?? req.headers['upload-length']
|
|
2748
|
+
const uploadLength = lengthRaw === null || lengthRaw === undefined || lengthRaw === '' ? null : Number(lengthRaw)
|
|
2749
|
+
if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
|
|
2750
|
+
return fsJson(res, 400, { error: 'bad-length', detail: 'size/Upload-Length 必须是非负整数' })
|
|
2751
|
+
}
|
|
2606
2752
|
const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
|
|
2607
2753
|
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
2608
2754
|
const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
|
|
@@ -2687,8 +2833,16 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2687
2833
|
try {
|
|
2688
2834
|
const st = fs.statSync(part)
|
|
2689
2835
|
if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
|
|
2836
|
+
if (uploadLength !== null && total > uploadLength) {
|
|
2837
|
+
fsJson(res, 409, { error: 'length-exceeded', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2838
|
+
return
|
|
2839
|
+
}
|
|
2690
2840
|
if (!finish) {
|
|
2691
|
-
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
|
|
2841
|
+
fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session, uploadLength }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2842
|
+
return
|
|
2843
|
+
}
|
|
2844
|
+
if (uploadLength !== null && total !== uploadLength) {
|
|
2845
|
+
fsJson(res, 409, { error: 'length-incomplete', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2692
2846
|
return
|
|
2693
2847
|
}
|
|
2694
2848
|
const commit = (actualSha256) => {
|
|
@@ -2698,7 +2852,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2698
2852
|
if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
|
|
2699
2853
|
if (ts.exists) fs.rmSync(target, { force: true })
|
|
2700
2854
|
fs.renameSync(part, target)
|
|
2701
|
-
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
|
|
2855
|
+
fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
|
|
2702
2856
|
} catch (err) {
|
|
2703
2857
|
if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
|
|
2704
2858
|
else try { res.destroy() } catch {}
|
|
@@ -2709,7 +2863,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
|
2709
2863
|
sha256FileHex(part, (err, actual) => {
|
|
2710
2864
|
if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
|
|
2711
2865
|
if (actual !== sha256Expected) {
|
|
2712
|
-
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
|
|
2866
|
+
return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
|
|
2713
2867
|
}
|
|
2714
2868
|
commit(actual)
|
|
2715
2869
|
})
|
|
@@ -2738,6 +2892,8 @@ async function fsUploadControl(req, res, url) {
|
|
|
2738
2892
|
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
2739
2893
|
const checked = fsRealChecked(resolved.abs)
|
|
2740
2894
|
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
2895
|
+
rememberUploadDir(checked.abs)
|
|
2896
|
+
cleanupExpiredUploadParts(checked.abs)
|
|
2741
2897
|
const name = url.searchParams.get('name') || ''
|
|
2742
2898
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
|
|
2743
2899
|
const session = url.searchParams.get('session') || 'default'
|
|
@@ -2998,7 +3154,24 @@ function proxyApi(req, res, url) {
|
|
|
2998
3154
|
}
|
|
2999
3155
|
|
|
3000
3156
|
// ---------- 其它 ----------
|
|
3001
|
-
async function serveHealth(res) {
|
|
3157
|
+
async function serveHealth(req, res, url) {
|
|
3158
|
+
const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
|
|
3159
|
+
connected: state.connected,
|
|
3160
|
+
lastEventAt: state.lastEventAt,
|
|
3161
|
+
eventLagMs: state.lastEventAt ? Math.max(0, Date.now() - state.lastEventAt) : null,
|
|
3162
|
+
lastConnectAt: state.lastConnectAt,
|
|
3163
|
+
reconnects: state.reconnects,
|
|
3164
|
+
attempt: state.attempt,
|
|
3165
|
+
lastError: state.lastError,
|
|
3166
|
+
clients: state.clients,
|
|
3167
|
+
}]))
|
|
3168
|
+
const liveness = { ok: true, pid: process.pid, uptimeMs: Math.max(0, Date.now() - STARTED_AT), runtime: runtimeState }
|
|
3169
|
+
if (url?.searchParams.get('probe') === 'live') {
|
|
3170
|
+
cors(res)
|
|
3171
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
3172
|
+
res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, probe: 'live', liveness }))
|
|
3173
|
+
return
|
|
3174
|
+
}
|
|
3002
3175
|
let upstreamOk = false
|
|
3003
3176
|
let upstreamReachable = false
|
|
3004
3177
|
let upstreamStatus = 0
|
|
@@ -3017,11 +3190,17 @@ async function serveHealth(res) {
|
|
|
3017
3190
|
} finally {
|
|
3018
3191
|
if (timer) clearTimeout(timer)
|
|
3019
3192
|
}
|
|
3193
|
+
const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
|
|
3194
|
+
const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
|
|
3195
|
+
const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
|
|
3020
3196
|
cors(res)
|
|
3021
3197
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
3022
3198
|
res.end(JSON.stringify({
|
|
3023
3199
|
ok: true,
|
|
3024
3200
|
service: 'dsh-remote',
|
|
3201
|
+
status,
|
|
3202
|
+
liveness,
|
|
3203
|
+
readiness,
|
|
3025
3204
|
version: VERSION,
|
|
3026
3205
|
protocol: { version: PROTOCOL_VERSION },
|
|
3027
3206
|
capabilities: CAPABILITIES,
|
|
@@ -3032,7 +3211,7 @@ async function serveHealth(res) {
|
|
|
3032
3211
|
upstreamReachable,
|
|
3033
3212
|
upstreamStatus,
|
|
3034
3213
|
...(upstreamError ? { upstreamError } : {}),
|
|
3035
|
-
events:
|
|
3214
|
+
events: eventHealth,
|
|
3036
3215
|
runtime: runtimeState,
|
|
3037
3216
|
}))
|
|
3038
3217
|
}
|
|
@@ -3061,7 +3240,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
3061
3240
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
3062
3241
|
if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
|
|
3063
3242
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
3064
|
-
if (url.pathname === '/health') return serveHealth(res)
|
|
3243
|
+
if (url.pathname === '/health') return serveHealth(req, res, url)
|
|
3065
3244
|
touchDevice(req)
|
|
3066
3245
|
return serveStatic(req, res, url)
|
|
3067
3246
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|