dsh-usage-badge 0.1.5 → 0.1.6

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/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import { join } from 'node:path'
23
23
  import { createCacheStore } from './cache-store.js'
24
24
  import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
25
25
  import { SyncEngine, acquireLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
26
+ import { normalizeWebdavUrl } from './webdav.js'
26
27
  import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
27
28
  import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
28
29
  import {
@@ -454,6 +455,23 @@ function readPricing(paths) {
454
455
  }
455
456
 
456
457
  /** Read a request body, bounded. An oversized one is a 413, not a server error. */
458
+ /**
459
+ * Read a JSON request body, reporting a malformed one as the caller's mistake.
460
+ *
461
+ * A bare `JSON.parse` throws a `SyntaxError` with no status, which the route wrapper can only answer with a
462
+ * 500 — and a 500 for a body the client got wrong sends whoever is debugging it looking at the host.
463
+ */
464
+ async function readJsonBody(req) {
465
+ const text = (await readBody(req)) || '{}'
466
+ try {
467
+ return JSON.parse(text)
468
+ } catch (error) {
469
+ const bad = new Error(`the request body is not JSON: ${error?.message ?? error}`)
470
+ bad.statusCode = 400
471
+ throw bad
472
+ }
473
+ }
474
+
457
475
  function readBody(req) {
458
476
  return new Promise((resolvePromise, rejectPromise) => {
459
477
  const chunks = []
@@ -648,6 +666,7 @@ export function apply(ctx) {
648
666
  * forgotten key away from putting a credential in an HTTP response.
649
667
  */
650
668
  function safeSyncConfig(config = {}) {
669
+ const ha1 = String(config.ha1 ?? '')
651
670
  return {
652
671
  enabled: Boolean(config.enabled),
653
672
  url: String(config.url ?? ''),
@@ -658,8 +677,11 @@ export function apply(ctx) {
658
677
  deviceLabel: String(config.deviceLabel ?? ''),
659
678
  windowYears: Number(config.windowYears) || 0,
660
679
  instanceId: String(config.instanceId ?? ''),
661
- hasCredential: Boolean(config.ha1 || config.passwordHash || config.password),
662
- credentialPrefix: config.ha1 ? `${String(config.ha1).slice(0, 6)}…` : '',
680
+ hasCredential: Boolean(ha1),
681
+ // Only a real HA1 counts. `password` and `passwordHash` are fields an older build could leave behind,
682
+ // and `connect()` cannot use either — claiming a credential exists would enable 立即同步 and then
683
+ // fail with "no WebDAV credential is configured" the moment it was pressed.
684
+ credentialPrefix: ha1.length >= 16 ? `${ha1.slice(0, 6)}…` : '',
663
685
  }
664
686
  }
665
687
 
@@ -692,6 +714,25 @@ export function apply(ctx) {
692
714
  }
693
715
  }
694
716
 
717
+ /**
718
+ * Every piece of work that talks to the server goes through one queue.
719
+ *
720
+ * The owner lock keeps two **processes** apart; inside one process it cannot, because the host holds it for
721
+ * its whole life. Without this, 立即同步 and the automatic price push run as two independent chains: a run's
722
+ * `PROPFIND` and a push's `GET pricing.json` would already be interleaving on the wire (measured), and both
723
+ * mutate the same `engine.state` and rewrite `sync.json`, so whichever finishes last decides what the panel
724
+ * says about the other.
725
+ */
726
+ function enqueue(instance, work) {
727
+ const next = (instance.chain ?? Promise.resolve()).then(work, work)
728
+ // The chain must survive a rejection — neither breaking on it nor leaving it unhandled.
729
+ instance.chain = next.then(
730
+ () => {},
731
+ () => {},
732
+ )
733
+ return next
734
+ }
735
+
695
736
  async function syncStatus() {
696
737
  // Reading the status must not create anything either, so an untouched install answers with the
697
738
  // sensible defaults rather than acquiring a lock it has no intention of using.
@@ -757,7 +798,7 @@ export function apply(ctx) {
757
798
  function runSync() {
758
799
  const instance = syncSystem()
759
800
  if (!instance.running) {
760
- instance.running = (async () => {
801
+ instance.running = enqueue(instance, async () => {
761
802
  if (!instance.engine.store) {
762
803
  // The cache is read-only here (another instance holds the lock, a permission problem, a newer
763
804
  // schema). The sync would publish a history this process cannot even fold, so it declines
@@ -794,7 +835,7 @@ export function apply(ctx) {
794
835
  return { result: 'failed', message: String(error?.message ?? error), at: 0 }
795
836
  }
796
837
  // Nothing to release: the lock belongs to the system and lives as long as this process does.
797
- })()
838
+ })
798
839
  .then((result) => {
799
840
  instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
800
841
  return result
@@ -863,11 +904,9 @@ export function apply(ctx) {
863
904
  pricingPushTimer = null
864
905
  const instance = syncSystem()
865
906
  if (!instance.lock?.acquired || !instance.engine.store) return
866
- // Chained onto the same queue as a full run, so an edit landing mid-sync waits rather than
867
- // interleaving PUTs with it. The result is recorded like any other outcome: an edit whose publish
868
- // failed is exactly the thing the person would otherwise never learn about.
869
- instance.pushing = (instance.pushing ?? Promise.resolve())
870
- .then(() => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
907
+ // Onto the **same** queue as a full run, so an edit landing mid-sync waits for it instead of
908
+ // interleaving its PUTs and its `sync.json` write with the run's.
909
+ instance.pushing = enqueue(instance, () => instance.engine.pushPricing({ lock: instance.lock, instanceId: instance.instanceId }))
871
910
  .then((result) => {
872
911
  log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
873
912
  instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
@@ -1018,7 +1057,7 @@ export function apply(ctx) {
1018
1057
  sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
1019
1058
  return
1020
1059
  }
1021
- const patch = JSON.parse((await readBody(req)) || '{}')
1060
+ const patch = await readJsonBody(req)
1022
1061
  commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
1023
1062
  log('pricing config updated')
1024
1063
  sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
@@ -1039,7 +1078,7 @@ export function apply(ctx) {
1039
1078
  sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
1040
1079
  return
1041
1080
  }
1042
- const body = JSON.parse((await readBody(req)) || '{}')
1081
+ const body = await readJsonBody(req)
1043
1082
  // Apply the copy the user was looking at, reusing the recent fetch rather
1044
1083
  // than pulling a possibly different page under them.
1045
1084
  const parsed = await officialPricing(body.source ?? 'zh-cn')
@@ -1097,7 +1136,7 @@ export function apply(ctx) {
1097
1136
  return
1098
1137
  }
1099
1138
  const instance = syncSystem()
1100
- const patch = JSON.parse((await readBody(req)) || '{}')
1139
+ const patch = await readJsonBody(req)
1101
1140
  const config = {}
1102
1141
  for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
1103
1142
  if (patch[field] !== undefined) config[field] = patch[field]
@@ -1132,12 +1171,19 @@ export function apply(ctx) {
1132
1171
  config.ha1 = patch.ha1.toLowerCase()
1133
1172
  }
1134
1173
  if (typeof config.url === 'string' && config.url !== '') {
1135
- try {
1136
- new URL(config.url)
1137
- } catch {
1138
- sendJson(res, 400, { error: `the WebDAV URL is not a URL: ${config.url}` })
1174
+ // The **same** judgement the client makes, from the same function. A copy here is what let the
1175
+ // route accept a query string, embedded credentials and `ftp:` — addresses the client refuses —
1176
+ // so the panel stored one, showed it, and the sync could not run at all. Two copies of a rule
1177
+ // drift; one copy cannot.
1178
+ const judged = normalizeWebdavUrl(config.url)
1179
+ if (!judged.ok) {
1180
+ sendJson(res, 400, { error: judged.reason })
1139
1181
  return
1140
1182
  }
1183
+ // The trailing slash is **added** rather than demanded: someone typing `host:port` means the
1184
+ // collection at that address. The panel stores what this returns, so the field ends up showing
1185
+ // the address that will actually be used instead of the one that was typed.
1186
+ config.url = judged.url
1141
1187
  }
1142
1188
  instance.engine.save({ config })
1143
1189
  log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
@@ -1151,8 +1197,13 @@ export function apply(ctx) {
1151
1197
  sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
1152
1198
  return
1153
1199
  }
1200
+ // The client is kept across the catch so a **failure** can still report the realm the server
1201
+ // announced. That realm is the missing piece when someone's server does not use `WebDAV`: the
1202
+ // password was hashed against the default, so nothing can work until it is re-hashed against the
1203
+ // real one — and without this the panel had no way to learn it.
1204
+ let client = null
1154
1205
  try {
1155
- const client = syncSystem().engine.connect()
1206
+ client = syncSystem().engine.connect()
1156
1207
  const options = await client.options()
1157
1208
  const devices = await syncSystem().engine.discover(client)
1158
1209
  sendJson(res, 200, {
@@ -1164,7 +1215,7 @@ export function apply(ctx) {
1164
1215
  warning: client.insecureWarning ?? null,
1165
1216
  })
1166
1217
  } catch (error) {
1167
- sendJson(res, 200, { ok: false, error: String(error?.message ?? error) })
1218
+ sendJson(res, 200, { ok: false, error: String(error?.message ?? error), realm: client?.challenge?.realm ?? null })
1168
1219
  }
1169
1220
  return
1170
1221
  }
@@ -1198,23 +1249,38 @@ export function apply(ctx) {
1198
1249
  // asynchronous, and a value read after it would be whatever the environment holds by then — which a
1199
1250
  // test that sets an override for exactly this mount would already have taken away.
1200
1251
  const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
1252
+ // Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
1253
+ // callback ran anyway, took the lock back, and synced through a store that had already been closed
1254
+ // (`statement has been finalized`), leaving a lock file behind for a host that was gone.
1255
+ let disposed = false
1201
1256
  void warm().then(() => {
1257
+ if (disposed) return
1202
1258
  scheduleStartupSync(startupDelay)
1203
1259
  // Fire and forget: this only fills in what the server holds, for the panel's next read.
1204
1260
  void refreshRemoteDevices()
1205
1261
  })
1206
1262
  return () => {
1263
+ disposed = true
1207
1264
  // Any pending automatic work is dropped on unload: a push that fires while the host is going away
1208
1265
  // would write state for a process that no longer exists.
1209
1266
  if (pricingPushTimer) clearTimeout(pricingPushTimer)
1210
1267
  if (startupSyncTimer) clearTimeout(startupSyncTimer)
1211
- // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process,
1212
- // and a re-mount inside the same process — a hot reload, or a test mounting the same home twice —
1213
- // is then refused by this plugin's own lock, for a pid that is very much alive: its own.
1268
+ // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
1269
+ // re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
1270
+ // refused by this plugin's own lock, for a pid that is very much alive: its own.
1271
+ //
1272
+ // `engine.release()` alone is not enough: a run in flight was handed the host's lock object, and the
1273
+ // engine only releases one it acquired itself (`ownsLock`), so unloading mid-run would leave the file
1274
+ // behind. `releaseLock` checks the pid, so this can only ever remove our own.
1214
1275
  if (system) {
1215
1276
  system.syncAbort = true
1277
+ // Stops a run at its next step boundary. Requests already on the wire finish; the phases that would
1278
+ // touch the store do not start.
1279
+ system.engine.abort()
1216
1280
  system.engine.release()
1281
+ if (system.running) log('unloading while a sync is in flight; it stops at its next step boundary')
1217
1282
  }
1283
+ releaseLock(dataDir)
1218
1284
  aggregator.close()
1219
1285
  }
1220
1286
  }, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')