dsh-usage-badge 0.1.4 → 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 +18 -3
- package/docs/design-notes.md +432 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +672 -128
- package/lib/client.js +470 -4
- package/lib/fold.js +20 -10
- package/lib/index.js +537 -7
- package/lib/sync.js +1410 -0
- package/lib/webdav.js +584 -0
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -15,11 +15,15 @@
|
|
|
15
15
|
* @module dsh-usage-badge
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { hostname } from 'node:os'
|
|
19
21
|
import { join } from 'node:path'
|
|
20
22
|
|
|
21
23
|
import { createCacheStore } from './cache-store.js'
|
|
22
24
|
import { createAggregator, mergeRecords, resolveDshHome } from './fold.js'
|
|
25
|
+
import { SyncEngine, acquireLock, readSyncDocument, releaseLock, safeDeviceId } from './sync.js'
|
|
26
|
+
import { normalizeWebdavUrl } from './webdav.js'
|
|
23
27
|
import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
|
|
24
28
|
import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
|
|
25
29
|
import {
|
|
@@ -53,7 +57,7 @@ const ROUTE_PREFIX = '/usage-badge'
|
|
|
53
57
|
* (an unknown patch key is accepted with a 200 and ignored). The panel compares this number
|
|
54
58
|
* and says so instead. `test/client.render.mjs` asserts the two halves agree.
|
|
55
59
|
*/
|
|
56
|
-
export const HOST_API_VERSION =
|
|
60
|
+
export const HOST_API_VERSION = 7
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
63
|
* Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
|
|
@@ -75,6 +79,48 @@ const DAYS_KEPT = 400
|
|
|
75
79
|
/** A snapshot younger than this is reused instead of rebuilt on every poll. */
|
|
76
80
|
const SNAPSHOT_TTL_MS = 2000
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* How long after an edit the price table is published.
|
|
84
|
+
*
|
|
85
|
+
* Long enough that editing three rows in a row is one publish rather than three, short enough that the
|
|
86
|
+
* person is still on the page when it happens. Only ever reached when sync is on and configured.
|
|
87
|
+
*/
|
|
88
|
+
export const PRICING_PUSH_DELAY_MS = 3000
|
|
89
|
+
|
|
90
|
+
/** How long after startup the first automatic sync runs, when sync is enabled. */
|
|
91
|
+
export const STARTUP_SYNC_DELAY_MS = 8000
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How long the panel waits for a status read before it is told the answer is not ready.
|
|
95
|
+
*
|
|
96
|
+
* The status read may ask the server what devices it holds. That happens in the background, but this
|
|
97
|
+
* bound is what keeps a slow or black-holed server from making the whole dialog look frozen.
|
|
98
|
+
*/
|
|
99
|
+
const STATUS_TIMEOUT_MS = 5000
|
|
100
|
+
|
|
101
|
+
/** Resolve `promise`, or reject once `ms` have passed, so a route can always answer. */
|
|
102
|
+
function withTimeout(promise, ms, what) {
|
|
103
|
+
return Promise.race([
|
|
104
|
+
promise,
|
|
105
|
+
new Promise((_, reject) => {
|
|
106
|
+
const timer = setTimeout(() => reject(new Error(`${what} took longer than ${Math.round(ms / 1000)}s`)), ms)
|
|
107
|
+
timer.unref?.()
|
|
108
|
+
}),
|
|
109
|
+
])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The delay to actually use, overridable so a test does not have to wait out a real one.
|
|
114
|
+
*
|
|
115
|
+
* Reading it per call rather than at import time is what lets a test set the variable, mount the plugin,
|
|
116
|
+
* and still get the short delay — the host half is imported once per scenario with a cache-busting
|
|
117
|
+
* query, but a constant captured at module load would freeze whatever the first reader saw.
|
|
118
|
+
*/
|
|
119
|
+
function delayFor(name, fallback) {
|
|
120
|
+
const configured = Number(process.env[name])
|
|
121
|
+
return Number.isFinite(configured) && configured >= 0 ? configured : fallback
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
/**
|
|
79
125
|
* How long a fetched price list is reused. Opening the dialog asks for a fresh
|
|
80
126
|
* copy every time, and this window only coalesces the requests that arrive within
|
|
@@ -409,6 +455,23 @@ function readPricing(paths) {
|
|
|
409
455
|
}
|
|
410
456
|
|
|
411
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
|
+
|
|
412
475
|
function readBody(req) {
|
|
413
476
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
414
477
|
const chunks = []
|
|
@@ -517,6 +580,57 @@ export function apply(ctx) {
|
|
|
517
580
|
const aggregator = createAggregator({ sessionsRoot, store, log })
|
|
518
581
|
aggregator.loadCache()
|
|
519
582
|
|
|
583
|
+
/**
|
|
584
|
+
* The sync system, created **on first use** rather than when the plugin mounts.
|
|
585
|
+
*
|
|
586
|
+
* Creating it eagerly would write `host.lock` and `sync.json` into the data directory of every
|
|
587
|
+
* install, including the many that never turn sync on — and would quietly change the directory a
|
|
588
|
+
* user is looking at. Mounting a plugin should not do that; asking it to sync should.
|
|
589
|
+
*
|
|
590
|
+
* The lock decides who may **write**: a second DSH instance pointed at the same home cannot share a
|
|
591
|
+
* `deviceId`, because two processes would each believe the same remote shard is theirs to overwrite.
|
|
592
|
+
* The instance that loses is not broken and does not refuse to start — it reports why and declines to
|
|
593
|
+
* sync, which is the same answer the design gives to a read-only cache.
|
|
594
|
+
*/
|
|
595
|
+
let system = null
|
|
596
|
+
function syncSystem() {
|
|
597
|
+
if (system) return system
|
|
598
|
+
const document = readSyncDocument(dataDir, log)
|
|
599
|
+
const instanceId = document.instanceId || `${hostname()}-${process.pid}`
|
|
600
|
+
const lock = acquireLock(dataDir, { instanceId, log })
|
|
601
|
+
if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
|
|
602
|
+
const engine = new SyncEngine({
|
|
603
|
+
dataDir,
|
|
604
|
+
store: lock.acquired ? store : null,
|
|
605
|
+
log,
|
|
606
|
+
config: document,
|
|
607
|
+
state: document.state,
|
|
608
|
+
instanceId,
|
|
609
|
+
})
|
|
610
|
+
if (!document.deviceId) engine.save({ config: { deviceId: `${hostname()}-${randomBytes(3).toString('hex')}` } })
|
|
611
|
+
// **This** is where the lock becomes ours, not inside `run()`: the host takes it once, when the system
|
|
612
|
+
// comes into being, and hands the already-held lock to every run. Without this the engine reported
|
|
613
|
+
// `ownsLock: false`, so `release()` declined to remove a lock this very process was holding — and the
|
|
614
|
+
// next mount of the same directory was refused by this plugin's own lock, naming a live pid.
|
|
615
|
+
engine.ownsLock = lock.acquired
|
|
616
|
+
system = {
|
|
617
|
+
engine,
|
|
618
|
+
lock,
|
|
619
|
+
instanceId,
|
|
620
|
+
running: null,
|
|
621
|
+
pushing: null,
|
|
622
|
+
// The last run is **persisted** state, so a restart must show it rather than "从未": the panel's
|
|
623
|
+
// first question is "when did this last work", and the answer is in `sync.json` — the engine wrote
|
|
624
|
+
// it there precisely so that it would outlive the process.
|
|
625
|
+
last: {
|
|
626
|
+
result: document.state?.lastResult || (lock.acquired ? 'never' : 'locked'),
|
|
627
|
+
message: document.state?.message ?? (lock.acquired ? '' : lock.reason),
|
|
628
|
+
at: Number(document.state?.lastSyncAt) || 0,
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
return system
|
|
632
|
+
}
|
|
633
|
+
|
|
520
634
|
const { path: pricingSourcePath } = readRawPricing(pricingPaths)
|
|
521
635
|
if (pricingSourcePath === legacyPricingPath) {
|
|
522
636
|
log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
|
|
@@ -543,6 +657,196 @@ export function apply(ctx) {
|
|
|
543
657
|
return inFlight
|
|
544
658
|
}
|
|
545
659
|
|
|
660
|
+
/**
|
|
661
|
+
* The sync state the panel reads: configuration, lock ownership, and the last run's outcome.
|
|
662
|
+
*
|
|
663
|
+
* The configuration is **rebuilt field by field** rather than spread. A spread would forward whatever a
|
|
664
|
+
* hand-edited `sync.json` happens to contain — and an older build of this plugin wrote exactly such a
|
|
665
|
+
* field, `password`, in plaintext. A blacklist that removes `ha1` and forwards everything else is one
|
|
666
|
+
* forgotten key away from putting a credential in an HTTP response.
|
|
667
|
+
*/
|
|
668
|
+
function safeSyncConfig(config = {}) {
|
|
669
|
+
const ha1 = String(config.ha1 ?? '')
|
|
670
|
+
return {
|
|
671
|
+
enabled: Boolean(config.enabled),
|
|
672
|
+
url: String(config.url ?? ''),
|
|
673
|
+
username: String(config.username ?? ''),
|
|
674
|
+
realm: String(config.realm ?? ''),
|
|
675
|
+
allowInsecureTls: Boolean(config.allowInsecureTls),
|
|
676
|
+
deviceId: String(config.deviceId ?? ''),
|
|
677
|
+
deviceLabel: String(config.deviceLabel ?? ''),
|
|
678
|
+
windowYears: Number(config.windowYears) || 0,
|
|
679
|
+
instanceId: String(config.instanceId ?? ''),
|
|
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)}…` : '',
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function conflictFiles() {
|
|
689
|
+
try {
|
|
690
|
+
return readdirSync(dataDir).filter((name) => name.startsWith('.conflict-'))
|
|
691
|
+
} catch {
|
|
692
|
+
return []
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The devices the **server** holds, remembered between polls. Never a network call. */
|
|
697
|
+
let knownRemote = null
|
|
698
|
+
/** How long that answer is reused: the panel polls every 2s while a run is in flight. */
|
|
699
|
+
const REMOTE_CACHE_MS = 30_000
|
|
700
|
+
|
|
701
|
+
async function refreshRemoteDevices() {
|
|
702
|
+
if (!system?.engine?.store || !system.engine.config.enabled) return
|
|
703
|
+
if (system.syncAbort) return
|
|
704
|
+
if (system.remoteInFlight) return
|
|
705
|
+
system.remoteInFlight = true
|
|
706
|
+
try {
|
|
707
|
+
const client = system.engine.connect()
|
|
708
|
+
knownRemote = { devices: await system.engine.discover(client), at: Date.now() }
|
|
709
|
+
} catch (error) {
|
|
710
|
+
// A site the panel can show as "unknown" is better than an exception on every poll.
|
|
711
|
+
knownRemote = { devices: null, at: Date.now(), error: String(error?.message ?? error) }
|
|
712
|
+
} finally {
|
|
713
|
+
system.remoteInFlight = false
|
|
714
|
+
}
|
|
715
|
+
}
|
|
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
|
+
|
|
736
|
+
async function syncStatus() {
|
|
737
|
+
// Reading the status must not create anything either, so an untouched install answers with the
|
|
738
|
+
// sensible defaults rather than acquiring a lock it has no intention of using.
|
|
739
|
+
if (!system) {
|
|
740
|
+
const document = readSyncDocument(dataDir, log)
|
|
741
|
+
return {
|
|
742
|
+
config: safeSyncConfig({ ...document, hasCredential: Boolean(document.ha1 || document.passwordHash || document.password) }),
|
|
743
|
+
writable: true,
|
|
744
|
+
lock: null,
|
|
745
|
+
running: false,
|
|
746
|
+
last: {
|
|
747
|
+
result: document.state?.lastResult || 'never',
|
|
748
|
+
message: document.state?.message ?? '',
|
|
749
|
+
at: Number(document.state?.lastSyncAt) || 0,
|
|
750
|
+
},
|
|
751
|
+
collision: document.state?.collision ?? null,
|
|
752
|
+
remoteDevices: {},
|
|
753
|
+
// `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
|
|
754
|
+
// are no other devices".
|
|
755
|
+
remoteKnown: false,
|
|
756
|
+
remoteError: null,
|
|
757
|
+
published: {},
|
|
758
|
+
conflictFiles: conflictFiles(),
|
|
759
|
+
paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const { engine, lock, running, last } = system
|
|
763
|
+
const pulled = {}
|
|
764
|
+
for (const context of Object.keys(engine.state.held ?? {})) {
|
|
765
|
+
const device = context.split('/')[0]
|
|
766
|
+
pulled[device] = (pulled[device] ?? 0) + 1
|
|
767
|
+
}
|
|
768
|
+
// The count of shards this device has actually read, and the names the server holds, are two
|
|
769
|
+
// different questions. The panel shows both: "已同步到的月份" from `held`, and "服务器上的设备" from
|
|
770
|
+
// this list, so a device that published but was never pulled from still shows up. Refreshed at most
|
|
771
|
+
// every `REMOTE_CACHE_MS` — the panel polls during a run, and a `PROPFIND` per poll would be a lot of
|
|
772
|
+
// requests for a list that changes when someone else syncs.
|
|
773
|
+
if (!knownRemote || Date.now() - (knownRemote.at ?? 0) > REMOTE_CACHE_MS) void refreshRemoteDevices()
|
|
774
|
+
return {
|
|
775
|
+
config: safeSyncConfig(engine.config),
|
|
776
|
+
writable: Boolean(engine.store),
|
|
777
|
+
lock: lock.acquired ? null : { reason: lock.reason, owner: lock.owner ?? null, pid: process.pid },
|
|
778
|
+
running: Boolean(running),
|
|
779
|
+
last,
|
|
780
|
+
collision: engine.state.collision ?? null,
|
|
781
|
+
remoteDevices: pulled,
|
|
782
|
+
remoteKnown: Boolean(knownRemote?.devices),
|
|
783
|
+
remoteError: knownRemote?.error ?? null,
|
|
784
|
+
remoteDevicesOnServer: knownRemote?.devices ?? null,
|
|
785
|
+
published: engine.state.published ?? {},
|
|
786
|
+
conflictFiles: conflictFiles(),
|
|
787
|
+
paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Run one sync, at most one at a time.
|
|
793
|
+
*
|
|
794
|
+
* The host owns the lock for as long as it lives, so an automatic pricing push that lands mid-run
|
|
795
|
+
* waits on the same chain rather than racing it — two writers on one data directory is the case the
|
|
796
|
+
* lock exists for, and a lock cannot serialise work inside a single process.
|
|
797
|
+
*/
|
|
798
|
+
function runSync() {
|
|
799
|
+
const instance = syncSystem()
|
|
800
|
+
if (!instance.running) {
|
|
801
|
+
instance.running = enqueue(instance, async () => {
|
|
802
|
+
if (!instance.engine.store) {
|
|
803
|
+
// The cache is read-only here (another instance holds the lock, a permission problem, a newer
|
|
804
|
+
// schema). The sync would publish a history this process cannot even fold, so it declines
|
|
805
|
+
// rather than doing half of it.
|
|
806
|
+
return { result: 'locked', message: instance.lock.reason ?? 'the cache is read-only here', at: 0 }
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
await refreshOnce()
|
|
810
|
+
} catch (error) {
|
|
811
|
+
// Refreshing is what makes the store worth publishing; without it there is nothing to send. A
|
|
812
|
+
// rejection here used to escape the route and the startup timer as an unhandled rejection.
|
|
813
|
+
return { result: 'failed', message: `could not fold the logs: ${error?.message ?? error}`, at: 0 }
|
|
814
|
+
}
|
|
815
|
+
// The system already owns the lock from the moment it was created; taking it again in the same
|
|
816
|
+
// process would see this process's own lock file and refuse. It is re-acquired only when the
|
|
817
|
+
// system was created without one (a second instance that lost the race).
|
|
818
|
+
const attempt = instance.lock?.acquired
|
|
819
|
+
? instance.lock
|
|
820
|
+
: acquireLock(dataDir, { instanceId: instance.instanceId, log })
|
|
821
|
+
if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
|
|
822
|
+
try {
|
|
823
|
+
const result = await instance.engine.run({ lock: attempt })
|
|
824
|
+
// A shard may have carried a newer price table; the panel's next poll should see it.
|
|
825
|
+
pricing = readPricing(pricingPaths)
|
|
826
|
+
rules = resolveHolidayRules(pricing.holidays)
|
|
827
|
+
// The engine's own record of when this happened, not "now": `locked` and `skipped` deliberately
|
|
828
|
+
// leave `lastSyncAt` alone, and showing a fresh timestamp for them would claim a sync that did
|
|
829
|
+
// not happen.
|
|
830
|
+
return { ...result, at: Number(instance.engine.state.lastSyncAt) || 0 }
|
|
831
|
+
} catch (error) {
|
|
832
|
+
// `SyncEngine.run` catches its own failures; this is the belt to its braces, so a bug there
|
|
833
|
+
// cannot take the host half down.
|
|
834
|
+
log(`sync failed unexpectedly: ${error?.message ?? error}`)
|
|
835
|
+
return { result: 'failed', message: String(error?.message ?? error), at: 0 }
|
|
836
|
+
}
|
|
837
|
+
// Nothing to release: the lock belongs to the system and lives as long as this process does.
|
|
838
|
+
})
|
|
839
|
+
.then((result) => {
|
|
840
|
+
instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
|
|
841
|
+
return result
|
|
842
|
+
})
|
|
843
|
+
.finally(() => {
|
|
844
|
+
instance.running = null
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
return instance.running
|
|
848
|
+
}
|
|
849
|
+
|
|
546
850
|
async function getSnapshot() {
|
|
547
851
|
const status = await refreshOnce()
|
|
548
852
|
// Nothing changed and the previous payload is still fresh: reuse it rather
|
|
@@ -577,8 +881,73 @@ export function apply(ctx) {
|
|
|
577
881
|
pricing = normalizePricing(next)
|
|
578
882
|
rules = resolveHolidayRules(pricing.holidays)
|
|
579
883
|
snapshot = null // force a rebuild on the next read
|
|
884
|
+
// The price table is the one hand-written thing this plugin owns, so an edit is worth publishing
|
|
885
|
+
// while the person is still looking at the page. It is fire-and-forget: a sync that cannot reach
|
|
886
|
+
// the server must never make a local edit fail, and the state it leaves behind is what the panel
|
|
887
|
+
// reports.
|
|
888
|
+
schedulePricingPush()
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Publish just the price table, a moment after the last edit.
|
|
893
|
+
*
|
|
894
|
+
* Debounced because editing a row and then the one below it is one intention, not two publishes, and
|
|
895
|
+
* because the fetch it needs would otherwise happen on every keystroke-sized save. It only ever runs
|
|
896
|
+
* when sync was already configured and enabled: a local edit is not a reason to create `sync.json`.
|
|
897
|
+
*/
|
|
898
|
+
let pricingPushTimer = null
|
|
899
|
+
function schedulePricingPush() {
|
|
900
|
+
const configured = system ? system.engine.config : readSyncDocument(dataDir, log)
|
|
901
|
+
if (!configured.enabled || !configured.url || !configured.ha1) return
|
|
902
|
+
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
903
|
+
pricingPushTimer = setTimeout(() => {
|
|
904
|
+
pricingPushTimer = null
|
|
905
|
+
const instance = syncSystem()
|
|
906
|
+
if (!instance.lock?.acquired || !instance.engine.store) return
|
|
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 }))
|
|
910
|
+
.then((result) => {
|
|
911
|
+
log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
|
|
912
|
+
instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
|
|
913
|
+
return result
|
|
914
|
+
})
|
|
915
|
+
.catch((error) => {
|
|
916
|
+
log(`pricing push failed: ${error?.message ?? error}`)
|
|
917
|
+
instance.last = { result: 'failed', message: `prices: ${error?.message ?? error}`, at: instance.last?.at ?? 0 }
|
|
918
|
+
})
|
|
919
|
+
}, delayFor('DSH_USAGE_BADGE_PRICING_PUSH_MS', PRICING_PUSH_DELAY_MS))
|
|
920
|
+
pricingPushTimer.unref?.()
|
|
580
921
|
}
|
|
581
922
|
|
|
923
|
+
/**
|
|
924
|
+
* The first automatic sync, a little after startup.
|
|
925
|
+
*
|
|
926
|
+
* Deliberately conditional: it only runs when sync is **already configured and enabled**, so a home
|
|
927
|
+
* that never turned sync on stays untouched (no `sync.json`, no lock, no network). The delay exists so
|
|
928
|
+
* the cold fold and the first panel paint come first — a sync on the way up would compete with the
|
|
929
|
+
* work that makes the badge show anything at all.
|
|
930
|
+
*
|
|
931
|
+
* It is also skipped when the instance is not the writer: on a second DSH instance the lock belongs to
|
|
932
|
+
* the first, and the honest answer is to do nothing rather than take the lock away from it.
|
|
933
|
+
*/
|
|
934
|
+
function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
|
|
935
|
+
const configured = readSyncDocument(dataDir, log)
|
|
936
|
+
if (!configured.enabled || !configured.url || !configured.ha1) return
|
|
937
|
+
const timer = setTimeout(() => {
|
|
938
|
+
startupSyncTimer = null
|
|
939
|
+
const instance = syncSystem()
|
|
940
|
+
if (!instance.lock?.acquired) {
|
|
941
|
+
log(`startup sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
|
|
942
|
+
return
|
|
943
|
+
}
|
|
944
|
+
void runSync().then((result) => log(`startup sync: ${result.result} — ${result.message}`))
|
|
945
|
+
}, delayMs)
|
|
946
|
+
timer.unref?.()
|
|
947
|
+
startupSyncTimer = timer
|
|
948
|
+
}
|
|
949
|
+
let startupSyncTimer = null
|
|
950
|
+
|
|
582
951
|
async function warm() {
|
|
583
952
|
try {
|
|
584
953
|
const status = await refreshOnce()
|
|
@@ -688,7 +1057,7 @@ export function apply(ctx) {
|
|
|
688
1057
|
sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
|
|
689
1058
|
return
|
|
690
1059
|
}
|
|
691
|
-
const patch =
|
|
1060
|
+
const patch = await readJsonBody(req)
|
|
692
1061
|
commitPricing(mergeConfig(readRawPricing(pricingPaths).raw, patch))
|
|
693
1062
|
log('pricing config updated')
|
|
694
1063
|
sendJson(res, 200, { ok: true, effective: pricing, calendar: describeCalendar(rules) })
|
|
@@ -709,7 +1078,7 @@ export function apply(ctx) {
|
|
|
709
1078
|
sendJson(res, 403, { error: 'config writes are accepted from loopback clients only' })
|
|
710
1079
|
return
|
|
711
1080
|
}
|
|
712
|
-
const body =
|
|
1081
|
+
const body = await readJsonBody(req)
|
|
713
1082
|
// Apply the copy the user was looking at, reusing the recent fetch rather
|
|
714
1083
|
// than pulling a possibly different page under them.
|
|
715
1084
|
const parsed = await officialPricing(body.source ?? 'zh-cn')
|
|
@@ -736,6 +1105,131 @@ export function apply(ctx) {
|
|
|
736
1105
|
return
|
|
737
1106
|
}
|
|
738
1107
|
|
|
1108
|
+
// ── cloud sync ──────────────────────────────────────────────────────────
|
|
1109
|
+
//
|
|
1110
|
+
// The panel's third tab. The credential never travels back: `ha1` is reported as a short prefix
|
|
1111
|
+
// plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
|
|
1112
|
+
// leaving the host half.
|
|
1113
|
+
if (route === '/sync' && req.method === 'GET') {
|
|
1114
|
+
// Loopback only, like every other sync route. The answer names the server and the account, and
|
|
1115
|
+
// reading it *is* engaging with sync — this is where the system comes into being, and with it the
|
|
1116
|
+
// owner lock. A page that never opens this tab still touches nothing.
|
|
1117
|
+
if (!isLoopback(req)) {
|
|
1118
|
+
sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
syncSystem()
|
|
1122
|
+
let body
|
|
1123
|
+
try {
|
|
1124
|
+
body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
sendJson(res, 200, { error: String(error?.message ?? error) })
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
sendJson(res, 200, body)
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (route === '/sync' && (req.method === 'PUT' || req.method === 'POST')) {
|
|
1134
|
+
if (!isLoopback(req)) {
|
|
1135
|
+
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
const instance = syncSystem()
|
|
1139
|
+
const patch = await readJsonBody(req)
|
|
1140
|
+
const config = {}
|
|
1141
|
+
for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
|
|
1142
|
+
if (patch[field] !== undefined) config[field] = patch[field]
|
|
1143
|
+
}
|
|
1144
|
+
if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
|
|
1145
|
+
if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
|
|
1146
|
+
try {
|
|
1147
|
+
config.deviceId = safeDeviceId(patch.deviceId)
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
sendJson(res, 400, { error: error.message })
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
// Either a plaintext password (hashed here, immediately, and never stored) or an HA1 someone
|
|
1154
|
+
// pasted in — the same value the server keeps in its password file.
|
|
1155
|
+
//
|
|
1156
|
+
// The realm is part of the digest input, so it has to be right before the password can be hashed.
|
|
1157
|
+
// The panel does not offer it as a field: a person has no reason to care, and the servers this is
|
|
1158
|
+
// built for use `WebDAV`. So it defaults here rather than being demanded — requiring it made
|
|
1159
|
+
// "fill in address/user/password and press 保存" fail with a sentence about a term the UI never
|
|
1160
|
+
// showed. A connection test replaces the guess with the realm the server actually announced.
|
|
1161
|
+
if (typeof patch.password === 'string' && patch.password !== '') {
|
|
1162
|
+
const username = String(patch.username ?? instance.engine.config.username ?? '')
|
|
1163
|
+
const realm = String(patch.realm ?? instance.engine.config.realm ?? '').trim() || 'WebDAV'
|
|
1164
|
+
if (!username) {
|
|
1165
|
+
sendJson(res, 400, { error: 'a username is needed to hash the password' })
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
config.ha1 = createHash('md5').update(`${username}:${realm}:${patch.password}`).digest('hex')
|
|
1169
|
+
config.realm = realm
|
|
1170
|
+
} else if (typeof patch.ha1 === 'string' && /^[0-9a-f]{32}$/i.test(patch.ha1)) {
|
|
1171
|
+
config.ha1 = patch.ha1.toLowerCase()
|
|
1172
|
+
}
|
|
1173
|
+
if (typeof config.url === 'string' && 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 })
|
|
1181
|
+
return
|
|
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
|
|
1187
|
+
}
|
|
1188
|
+
instance.engine.save({ config })
|
|
1189
|
+
log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
|
|
1190
|
+
sendJson(res, 200, { ok: true, ...(await syncStatus()) })
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// A connection check: it must not sync, and it must not write anything anywhere.
|
|
1195
|
+
if (route === '/sync/test' && req.method === 'POST') {
|
|
1196
|
+
if (!isLoopback(req)) {
|
|
1197
|
+
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1198
|
+
return
|
|
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
|
|
1205
|
+
try {
|
|
1206
|
+
client = syncSystem().engine.connect()
|
|
1207
|
+
const options = await client.options()
|
|
1208
|
+
const devices = await syncSystem().engine.discover(client)
|
|
1209
|
+
sendJson(res, 200, {
|
|
1210
|
+
ok: true,
|
|
1211
|
+
realm: options.realm,
|
|
1212
|
+
dav: options.dav,
|
|
1213
|
+
devices,
|
|
1214
|
+
insecure: Boolean(client.insecureWarning),
|
|
1215
|
+
warning: client.insecureWarning ?? null,
|
|
1216
|
+
})
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
sendJson(res, 200, { ok: false, error: String(error?.message ?? error), realm: client?.challenge?.realm ?? null })
|
|
1219
|
+
}
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (route === '/sync/run' && req.method === 'POST') {
|
|
1224
|
+
if (!isLoopback(req)) {
|
|
1225
|
+
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
const result = await runSync()
|
|
1229
|
+
sendJson(res, 200, { ok: result.result === 'ok' || result.result === 'partial', ...result, ...(await syncStatus()) })
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
|
|
739
1233
|
sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
|
|
740
1234
|
} catch (error) {
|
|
741
1235
|
// A caller's mistake is reported as one; only genuinely unexpected failures are 500s.
|
|
@@ -751,9 +1245,45 @@ export function apply(ctx) {
|
|
|
751
1245
|
)
|
|
752
1246
|
|
|
753
1247
|
ctx.effect(() => {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1248
|
+
// The delay is read **here**, synchronously, rather than inside the warm-up callback: warming is
|
|
1249
|
+
// asynchronous, and a value read after it would be whatever the environment holds by then — which a
|
|
1250
|
+
// test that sets an override for exactly this mount would already have taken away.
|
|
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
|
|
1256
|
+
void warm().then(() => {
|
|
1257
|
+
if (disposed) return
|
|
1258
|
+
scheduleStartupSync(startupDelay)
|
|
1259
|
+
// Fire and forget: this only fills in what the server holds, for the panel's next read.
|
|
1260
|
+
void refreshRemoteDevices()
|
|
1261
|
+
})
|
|
1262
|
+
return () => {
|
|
1263
|
+
disposed = true
|
|
1264
|
+
// Any pending automatic work is dropped on unload: a push that fires while the host is going away
|
|
1265
|
+
// would write state for a process that no longer exists.
|
|
1266
|
+
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
1267
|
+
if (startupSyncTimer) clearTimeout(startupSyncTimer)
|
|
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.
|
|
1275
|
+
if (system) {
|
|
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()
|
|
1280
|
+
system.engine.release()
|
|
1281
|
+
if (system.running) log('unloading while a sync is in flight; it stops at its next step boundary')
|
|
1282
|
+
}
|
|
1283
|
+
releaseLock(dataDir)
|
|
1284
|
+
aggregator.close()
|
|
1285
|
+
}
|
|
1286
|
+
}, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
|
|
757
1287
|
|
|
758
1288
|
log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
|
|
759
1289
|
}
|