dsh-remote-plugin 0.6.13 → 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.
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
@@ -400,7 +403,8 @@ function issueWsTicket(auth) {
400
403
  }
401
404
 
402
405
  // ---------- 设备监控 ----------
403
- const devices = new Map() // ip -> device
406
+ const devices = new Map() // ip[|clientId] -> device
407
+ const legacyDeviceAliases = new Map() // ip -> { clientId, ua, expiresAt }
404
408
  // 设备 TTL 是“记录保留时间”,和下方 online 判断的 60s 活跃窗口是两回事:
405
409
  // online 只看最近 60s 是否有请求;TTL 用于防止长期运行的网关内存/响应无限膨胀。
406
410
  const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
@@ -417,6 +421,9 @@ function pruneDevices(now = Date.now()) {
417
421
  for (const [ip, d] of devices) {
418
422
  if (now - d.lastSeen > DEVICE_TTL_MS) devices.delete(ip)
419
423
  }
424
+ for (const [ip, alias] of legacyDeviceAliases) {
425
+ if (!alias || alias.expiresAt <= now) legacyDeviceAliases.delete(ip)
426
+ }
420
427
  }
421
428
 
422
429
  function loadNotes() {
@@ -444,10 +451,53 @@ function kindOf(req) {
444
451
  return 'browser'
445
452
  }
446
453
 
454
+ function mergeDeviceRecords(target, legacy) {
455
+ if (!target || !legacy || target === legacy) return
456
+ target.firstSeen = Math.min(target.firstSeen || Date.now(), legacy.firstSeen || Date.now())
457
+ target.lastSeen = Math.max(target.lastSeen || 0, legacy.lastSeen || 0)
458
+ target.requests += legacy.requests || 0
459
+ target.authFailures += legacy.authFailures || 0
460
+ target.credentialId ||= legacy.credentialId || ''
461
+ if (!target.ua || (legacy.ua && legacy.ua.length > target.ua.length)) target.ua = legacy.ua
462
+ for (const channel of new Set([...Object.keys(legacy.channelCounts || {}), ...Object.keys(target.channelCounts || {})])) {
463
+ target.channelCounts[channel] = (target.channelCounts[channel] || 0) + (legacy.channelCounts?.[channel] || 0)
464
+ target.channels[channel] = !!(target.channelCounts[channel] || target.channels[channel] || legacy.channels?.[channel])
465
+ }
466
+ for (const socket of legacy.sockets || []) target.sockets.add(socket)
467
+ }
468
+
469
+ function legacyDeviceFor(ip, clientId, req) {
470
+ if (!clientId) return null
471
+ const legacy = devices.get(ip)
472
+ if (!legacy || legacy.clientId) return null
473
+ const requestUa = String(req.headers['user-agent'] || '')
474
+ const sameUa = requestUa && legacy.ua && requestUa === legacy.ua
475
+ const legacyBackgroundPoll = /^Dalvik\/2\.1\.0/i.test(legacy.ua || '') && req.headers['x-dsh-remote-client'] === 'app'
476
+ if (!sameUa && !legacyBackgroundPoll) return null
477
+ return legacy
478
+ }
479
+
480
+ function knownDeviceForLegacy(ip, req) {
481
+ const requestUa = String(req.headers['user-agent'] || '')
482
+ if (!requestUa) return null
483
+ const candidates = [...devices.values()].filter(d => {
484
+ if (d.ip !== ip || !d.clientId) return false
485
+ return (d.ua && d.ua === requestUa) || (d.kind === 'app' && /^Dalvik\/2\.1\.0/i.test(requestUa))
486
+ })
487
+ return candidates.length === 1 ? candidates[0] : null
488
+ }
489
+
447
490
  function touchDevice(req, extra = {}) {
448
491
  pruneDevices()
449
492
  const ip = ipOf(req)
450
- const clientId = String(extra.clientId || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
493
+ const headerClientId = req.headers['x-dsh-remote-client-id']
494
+ let clientId = String(extra.clientId || headerClientId || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
495
+ const requestUa = String(req.headers['user-agent'] || '')
496
+ if (!clientId) {
497
+ const alias = legacyDeviceAliases.get(ip)
498
+ if (alias && alias.expiresAt > Date.now() && alias.ua && alias.ua === requestUa) clientId = alias.clientId
499
+ else clientId = knownDeviceForLegacy(ip, req)?.clientId || ''
500
+ }
451
501
  const deviceKey = clientId ? `${ip}|${clientId}` : ip
452
502
  totalRequests++
453
503
  let d = devices.get(deviceKey)
@@ -458,6 +508,14 @@ function touchDevice(req, extra = {}) {
458
508
  }
459
509
  devices.set(deviceKey, d)
460
510
  }
511
+ if (clientId && deviceKey !== ip) {
512
+ const legacy = legacyDeviceFor(ip, clientId, req)
513
+ if (legacy && legacy !== d) {
514
+ mergeDeviceRecords(d, legacy)
515
+ devices.delete(ip)
516
+ legacyDeviceAliases.set(ip, { clientId, ua: legacy.ua, expiresAt: Date.now() + DEVICE_TTL_MS })
517
+ }
518
+ }
461
519
  d.lastSeen = Date.now()
462
520
  d.requests++
463
521
  if (extra.channel) {
@@ -473,7 +531,7 @@ function touchDevice(req, extra = {}) {
473
531
  if (req.dshRemoteAuth?.type === 'device') d.credentialId = req.dshRemoteAuth.id
474
532
  const marked = req.headers['x-dsh-remote-client']
475
533
  if (marked) d.kind = marked
476
- const ua = String(req.headers['user-agent'] || '')
534
+ const ua = requestUa
477
535
  if (ua && ua.length > d.ua.length) d.ua = ua
478
536
  return d
479
537
  }
@@ -713,7 +771,7 @@ function cors(res, req = res.req) {
713
771
  }
714
772
  if (allowed) res.setHeader('access-control-allow-origin', origin || '*')
715
773
  res.setHeader('vary', 'Origin')
716
- res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client')
774
+ res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client, x-dsh-remote-client-id')
717
775
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
718
776
  res.setHeader('access-control-max-age', '600')
719
777
  }
@@ -869,6 +927,8 @@ function failDshOperation(operation, code, message, detail = '', status = null)
869
927
  detail: String(detail || '').slice(0, 1000),
870
928
  ...(status ? { status } : {}),
871
929
  })
930
+ if (status) operation.observed = status
931
+ operation.evidence = { observed: status || operation.observed || null, upstream: operation.upstream || null, events: operation.events || null }
872
932
  }
873
933
 
874
934
  function dshEventChannelStatus() {
@@ -892,6 +952,7 @@ async function runDshControlOperation(operation) {
892
952
  dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
893
953
  const initial = await dshServiceStatus()
894
954
  operation.initialStatus = initial
955
+ operation.observed = initial
895
956
  if (!initial.supported) {
896
957
  failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
897
958
  return
@@ -925,6 +986,7 @@ async function runDshControlOperation(operation) {
925
986
  const status = await dshServiceStatus()
926
987
  lastStatus = status
927
988
  operation.status = status
989
+ operation.observed = status
928
990
  if (!status.supported) {
929
991
  failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
930
992
  return
@@ -956,6 +1018,7 @@ async function runDshControlOperation(operation) {
956
1018
  dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
957
1019
  ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
958
1020
  })
1021
+ operation.evidence = { observed: status, upstream: lastProbe, events }
959
1022
  return
960
1023
  }
961
1024
  }
@@ -1055,6 +1118,9 @@ async function serveDshControl(req, res, url) {
1055
1118
  const now = Date.now()
1056
1119
  dshControlOperation = {
1057
1120
  operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
1121
+ desired: { service: DSH_SERVICE, running: true, action },
1122
+ observed: null,
1123
+ evidence: null,
1058
1124
  ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
1059
1125
  message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
1060
1126
  startedAt: now, updatedAt: now, steps: [],
@@ -1070,12 +1136,14 @@ async function serveDshControl(req, res, url) {
1070
1136
  // 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
1071
1137
  // 阻断时改走 GET /api/events.poll 增量拉取。
1072
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)
1073
1140
  const EVENT_MAX_STRING = 16 * 1024
1074
1141
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
1075
1142
  const eventBuffers = { mux: [], host: [] }
1076
1143
  const eventNextSeq = { mux: 1, host: 1 }
1077
1144
  const collectorClients = { mux: new Set(), host: new Set() }
1078
1145
  const collectorReplay = { mux: new Map(), host: new Map() }
1146
+ const eventPollWaiters = { mux: new Set(), host: new Set() }
1079
1147
  const eventCollectorState = {
1080
1148
  mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
1081
1149
  host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
@@ -1158,6 +1226,38 @@ function pushEvent(kind, full, raw = JSON.stringify(full)) {
1158
1226
  if (buf.length > EVENT_BUFFER_MAX) buf.shift()
1159
1227
  rememberCollectorReplay(kind, full, raw)
1160
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
+ }
1161
1261
  }
1162
1262
 
1163
1263
  function serveWsTicket(req, res, url) {
@@ -1215,16 +1315,33 @@ function serveEventPoll(req, res, url) {
1215
1315
  res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
1216
1316
  return
1217
1317
  }
1218
- const buf = eventBuffers[kind]
1219
- const events = buf.filter(r => r.seq > since)
1220
- const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
1221
- const truncated = buf.length > 0 && since < buf[0].seq - 1
1222
- cors(res)
1223
- res.writeHead(200, {
1224
- 'content-type': 'application/json; charset=utf-8',
1225
- 'cache-control': 'no-store'
1226
- })
1227
- res.end(JSON.stringify({ ok: true, kind, since, latestSeq, truncated, events }))
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)
1228
1345
  }
1229
1346
 
1230
1347
  /** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
@@ -2004,9 +2121,9 @@ function serveAdminApi(req, res, url) {
2004
2121
  }
2005
2122
 
2006
2123
  // ---------- /fs 文件传输: 实现 ----------
2007
- function fsJson(res, status, body) {
2124
+ function fsJson(res, status, body, extraHeaders = {}) {
2008
2125
  cors(res)
2009
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
2126
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
2010
2127
  res.end(JSON.stringify(body))
2011
2128
  }
2012
2129
 
@@ -2118,6 +2235,17 @@ function fsParseRange(header, size) {
2118
2235
  return { start, end: Math.min(end, size - 1) }
2119
2236
  }
2120
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
+
2121
2249
  async function fsList(req, res, url) {
2122
2250
  if (req.method !== 'GET') {
2123
2251
  res.writeHead(405, { allow: 'GET' })
@@ -2186,7 +2314,17 @@ async function fsFile(req, res, url) {
2186
2314
  }
2187
2315
  if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
2188
2316
 
2189
- const range = fsParseRange(req.headers.range, st.size)
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
2190
2328
  if (range && range.start >= st.size) {
2191
2329
  cors(res)
2192
2330
  res.writeHead(416, {
@@ -2205,6 +2343,8 @@ async function fsFile(req, res, url) {
2205
2343
  'content-length': range ? range.end - range.start + 1 : st.size,
2206
2344
  'content-disposition': fsContentDisposition(path.basename(checked.abs)),
2207
2345
  'accept-ranges': 'bytes',
2346
+ etag,
2347
+ 'last-modified': lastModified,
2208
2348
  'cache-control': 'no-cache',
2209
2349
  ...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
2210
2350
  })
@@ -2280,10 +2420,49 @@ function sha256FileHex(file, cb) {
2280
2420
 
2281
2421
  /* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
2282
2422
  const activeUploads = new Map()
2423
+ const uploadPartDirs = new Set()
2283
2424
  function fsActiveKey(dirReal, name, session) {
2284
2425
  return dirReal + '\n' + name + '\n' + (session || '')
2285
2426
  }
2286
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
+
2287
2466
  /** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
2288
2467
  function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
2289
2468
  if (!fsValidName(name)) {
@@ -2484,14 +2663,22 @@ async function fsUploadProbe(req, res, url) {
2484
2663
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2485
2664
  const checked = fsRealChecked(resolved.abs)
2486
2665
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
2666
+ rememberUploadDir(checked.abs)
2667
+ cleanupExpiredUploadParts(checked.abs)
2487
2668
  const name = url.searchParams.get('name') || ''
2488
2669
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
2489
2670
  const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
2490
2671
  let partialSize = 0, partExists = false
2672
+ let expiresAt = null
2491
2673
  try {
2492
2674
  const st = fs.statSync(part)
2493
- if (st.isFile()) { partialSize = st.size; partExists = true }
2675
+ if (st.isFile()) { partialSize = st.size; partExists = true; expiresAt = st.mtimeMs + FS_UPLOAD_TTL_MS }
2494
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
+ }
2495
2682
  const target = fsTargetState(path.join(checked.abs, name))
2496
2683
  let targetSize = 0
2497
2684
  if (target.exists) {
@@ -2503,8 +2690,10 @@ async function fsUploadProbe(req, res, url) {
2503
2690
  partialSize,
2504
2691
  partExists,
2505
2692
  targetExists: !!target.exists,
2506
- targetSize
2507
- })
2693
+ targetSize,
2694
+ uploadLength,
2695
+ expiresAt
2696
+ }, fsUploadHeaders(partialSize, uploadLength, expiresAt))
2508
2697
  }
2509
2698
 
2510
2699
  /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
@@ -2539,15 +2728,27 @@ async function fsMkdir(req, res, url) {
2539
2728
  }
2540
2729
 
2541
2730
  function fsUploadResumable(req, res, url, dirLex, dirReal) {
2731
+ rememberUploadDir(dirReal)
2732
+ cleanupExpiredUploadParts(dirReal)
2542
2733
  const name = url.searchParams.get('name') || ''
2543
2734
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
2544
2735
  const session = url.searchParams.get('session') || ''
2545
2736
  if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
2546
- const offsetRaw = url.searchParams.get('offset')
2737
+ const queryOffsetRaw = url.searchParams.get('offset')
2738
+ const headerOffsetRaw = req.headers['upload-offset']
2739
+ const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
2547
2740
  const offset = Number(offsetRaw)
2548
2741
  if (!Number.isSafeInteger(offset) || offset < 0) {
2549
2742
  return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
2550
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
+ }
2551
2752
  const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
2552
2753
  const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
2553
2754
  const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
@@ -2632,8 +2833,16 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2632
2833
  try {
2633
2834
  const st = fs.statSync(part)
2634
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
+ }
2635
2840
  if (!finish) {
2636
- 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))
2637
2846
  return
2638
2847
  }
2639
2848
  const commit = (actualSha256) => {
@@ -2643,7 +2852,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2643
2852
  if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
2644
2853
  if (ts.exists) fs.rmSync(target, { force: true })
2645
2854
  fs.renameSync(part, target)
2646
- 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))
2647
2856
  } catch (err) {
2648
2857
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
2649
2858
  else try { res.destroy() } catch {}
@@ -2654,7 +2863,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2654
2863
  sha256FileHex(part, (err, actual) => {
2655
2864
  if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
2656
2865
  if (actual !== sha256Expected) {
2657
- 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))
2658
2867
  }
2659
2868
  commit(actual)
2660
2869
  })
@@ -2683,6 +2892,8 @@ async function fsUploadControl(req, res, url) {
2683
2892
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2684
2893
  const checked = fsRealChecked(resolved.abs)
2685
2894
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
2895
+ rememberUploadDir(checked.abs)
2896
+ cleanupExpiredUploadParts(checked.abs)
2686
2897
  const name = url.searchParams.get('name') || ''
2687
2898
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
2688
2899
  const session = url.searchParams.get('session') || 'default'
@@ -2943,7 +3154,24 @@ function proxyApi(req, res, url) {
2943
3154
  }
2944
3155
 
2945
3156
  // ---------- 其它 ----------
2946
- 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
+ }
2947
3175
  let upstreamOk = false
2948
3176
  let upstreamReachable = false
2949
3177
  let upstreamStatus = 0
@@ -2962,11 +3190,17 @@ async function serveHealth(res) {
2962
3190
  } finally {
2963
3191
  if (timer) clearTimeout(timer)
2964
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'
2965
3196
  cors(res)
2966
3197
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
2967
3198
  res.end(JSON.stringify({
2968
3199
  ok: true,
2969
3200
  service: 'dsh-remote',
3201
+ status,
3202
+ liveness,
3203
+ readiness,
2970
3204
  version: VERSION,
2971
3205
  protocol: { version: PROTOCOL_VERSION },
2972
3206
  capabilities: CAPABILITIES,
@@ -2977,7 +3211,7 @@ async function serveHealth(res) {
2977
3211
  upstreamReachable,
2978
3212
  upstreamStatus,
2979
3213
  ...(upstreamError ? { upstreamError } : {}),
2980
- events: eventCollectorState,
3214
+ events: eventHealth,
2981
3215
  runtime: runtimeState,
2982
3216
  }))
2983
3217
  }
@@ -3006,7 +3240,7 @@ const server = http.createServer(async (req, res) => {
3006
3240
  if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
3007
3241
  if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
3008
3242
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
3009
- if (url.pathname === '/health') return serveHealth(res)
3243
+ if (url.pathname === '/health') return serveHealth(req, res, url)
3010
3244
  touchDevice(req)
3011
3245
  return serveStatic(req, res, url)
3012
3246
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.13",
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",
@@ -50,6 +50,31 @@
50
50
  { "id": "not-used", "label": "尚未使用,暂时无法判断" }
51
51
  ]
52
52
  }
53
+ },
54
+ {
55
+ "id": "2026-08-24-remote-plugin-demand-poll",
56
+ "title": "投票:你是否需要远程使用或管理插件?",
57
+ "content": "我们正在研究将插件管理和插件内具体功能接入 dsh-Remote 的可行性。后续可能由 DSH 端的插件管理工具负责实际处理,再通过远程接口提供给 App 使用。\n\n本投票用于了解大家的实际需求,结果仅作为后续版本规划参考,不代表功能已经确定或会自动执行插件操作。",
58
+ "minVersion": "0.6.11",
59
+ "maxVersion": "",
60
+ "publishedAt": "2026-08-24T23:28:14+08:00",
61
+ "poll": {
62
+ "id": "remote-plugin-demand-2026-08",
63
+ "question": "你更需要哪类远程插件能力?",
64
+ "options": [
65
+ { "id": "use-plugin-features", "label": "远程使用插件内具体功能" },
66
+ { "id": "manage-plugins-only", "label": "只需要远程安装、更新、卸载插件" },
67
+ { "id": "no-remote-plugin", "label": "没有远程使用或管理插件的需求" }
68
+ ]
69
+ }
70
+ },
71
+ {
72
+ "id": "2026-08-25-user-community-group",
73
+ "title": "dsh-Remote 用户交流群建立公告",
74
+ "content": "大家好,dsh-Remote 用户交流群已经建立,群号:1106138825。\n\n本群主要用于:\n\n- 用户需求和使用反馈\n- 后续版本更新说明\n- 使用过程中问题的交流与讨论\n\n欢迎有兴趣的用户加入。提交日志或截图前,请注意隐藏 Token、服务器地址等敏感信息。",
75
+ "minVersion": "",
76
+ "maxVersion": "",
77
+ "publishedAt": "2026-08-25T00:24:27+08:00"
53
78
  }
54
79
  ]
55
80
  }