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/README.md +1 -1
- package/docs/design-notes.md +47 -4
- package/lib/cache-store.js +113 -14
- package/lib/client.js +132 -11
- package/lib/index.js +87 -21
- package/lib/sync.js +288 -57
- package/lib/webdav.js +67 -15
- package/package.json +1 -1
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(
|
|
662
|
-
|
|
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
|
-
//
|
|
867
|
-
// interleaving PUTs
|
|
868
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1213
|
-
//
|
|
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')
|