dsh-usage-badge 0.1.3 → 0.1.5
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/assets/usage-dialog.png +0 -0
- package/docs/design-notes.md +389 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +574 -129
- package/lib/client.js +350 -5
- package/lib/fold.js +20 -10
- package/lib/index.js +469 -5
- package/lib/sync.js +1179 -0
- package/lib/webdav.js +532 -0
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -15,11 +15,14 @@
|
|
|
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'
|
|
23
26
|
import { describeCalendar, loadBundledTable, resolveHolidayRules } from './holidays.js'
|
|
24
27
|
import { OFFICIAL_SOURCES, applyOfficialPricing, fetchOfficialPricing } from './official-pricing.js'
|
|
25
28
|
import {
|
|
@@ -53,7 +56,7 @@ const ROUTE_PREFIX = '/usage-badge'
|
|
|
53
56
|
* (an unknown patch key is accepted with a 200 and ignored). The panel compares this number
|
|
54
57
|
* and says so instead. `test/client.render.mjs` asserts the two halves agree.
|
|
55
58
|
*/
|
|
56
|
-
export const HOST_API_VERSION =
|
|
59
|
+
export const HOST_API_VERSION = 7
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
62
|
* Directory under `$DSH_HOME/storages` holding every file this plugin owns, so its
|
|
@@ -75,6 +78,48 @@ const DAYS_KEPT = 400
|
|
|
75
78
|
/** A snapshot younger than this is reused instead of rebuilt on every poll. */
|
|
76
79
|
const SNAPSHOT_TTL_MS = 2000
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* How long after an edit the price table is published.
|
|
83
|
+
*
|
|
84
|
+
* Long enough that editing three rows in a row is one publish rather than three, short enough that the
|
|
85
|
+
* person is still on the page when it happens. Only ever reached when sync is on and configured.
|
|
86
|
+
*/
|
|
87
|
+
export const PRICING_PUSH_DELAY_MS = 3000
|
|
88
|
+
|
|
89
|
+
/** How long after startup the first automatic sync runs, when sync is enabled. */
|
|
90
|
+
export const STARTUP_SYNC_DELAY_MS = 8000
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How long the panel waits for a status read before it is told the answer is not ready.
|
|
94
|
+
*
|
|
95
|
+
* The status read may ask the server what devices it holds. That happens in the background, but this
|
|
96
|
+
* bound is what keeps a slow or black-holed server from making the whole dialog look frozen.
|
|
97
|
+
*/
|
|
98
|
+
const STATUS_TIMEOUT_MS = 5000
|
|
99
|
+
|
|
100
|
+
/** Resolve `promise`, or reject once `ms` have passed, so a route can always answer. */
|
|
101
|
+
function withTimeout(promise, ms, what) {
|
|
102
|
+
return Promise.race([
|
|
103
|
+
promise,
|
|
104
|
+
new Promise((_, reject) => {
|
|
105
|
+
const timer = setTimeout(() => reject(new Error(`${what} took longer than ${Math.round(ms / 1000)}s`)), ms)
|
|
106
|
+
timer.unref?.()
|
|
107
|
+
}),
|
|
108
|
+
])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The delay to actually use, overridable so a test does not have to wait out a real one.
|
|
113
|
+
*
|
|
114
|
+
* Reading it per call rather than at import time is what lets a test set the variable, mount the plugin,
|
|
115
|
+
* and still get the short delay — the host half is imported once per scenario with a cache-busting
|
|
116
|
+
* query, but a constant captured at module load would freeze whatever the first reader saw.
|
|
117
|
+
*/
|
|
118
|
+
function delayFor(name, fallback) {
|
|
119
|
+
const configured = Number(process.env[name])
|
|
120
|
+
return Number.isFinite(configured) && configured >= 0 ? configured : fallback
|
|
121
|
+
}
|
|
122
|
+
|
|
78
123
|
/**
|
|
79
124
|
* How long a fetched price list is reused. Opening the dialog asks for a fresh
|
|
80
125
|
* copy every time, and this window only coalesces the requests that arrive within
|
|
@@ -517,6 +562,57 @@ export function apply(ctx) {
|
|
|
517
562
|
const aggregator = createAggregator({ sessionsRoot, store, log })
|
|
518
563
|
aggregator.loadCache()
|
|
519
564
|
|
|
565
|
+
/**
|
|
566
|
+
* The sync system, created **on first use** rather than when the plugin mounts.
|
|
567
|
+
*
|
|
568
|
+
* Creating it eagerly would write `host.lock` and `sync.json` into the data directory of every
|
|
569
|
+
* install, including the many that never turn sync on — and would quietly change the directory a
|
|
570
|
+
* user is looking at. Mounting a plugin should not do that; asking it to sync should.
|
|
571
|
+
*
|
|
572
|
+
* The lock decides who may **write**: a second DSH instance pointed at the same home cannot share a
|
|
573
|
+
* `deviceId`, because two processes would each believe the same remote shard is theirs to overwrite.
|
|
574
|
+
* The instance that loses is not broken and does not refuse to start — it reports why and declines to
|
|
575
|
+
* sync, which is the same answer the design gives to a read-only cache.
|
|
576
|
+
*/
|
|
577
|
+
let system = null
|
|
578
|
+
function syncSystem() {
|
|
579
|
+
if (system) return system
|
|
580
|
+
const document = readSyncDocument(dataDir, log)
|
|
581
|
+
const instanceId = document.instanceId || `${hostname()}-${process.pid}`
|
|
582
|
+
const lock = acquireLock(dataDir, { instanceId, log })
|
|
583
|
+
if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
|
|
584
|
+
const engine = new SyncEngine({
|
|
585
|
+
dataDir,
|
|
586
|
+
store: lock.acquired ? store : null,
|
|
587
|
+
log,
|
|
588
|
+
config: document,
|
|
589
|
+
state: document.state,
|
|
590
|
+
instanceId,
|
|
591
|
+
})
|
|
592
|
+
if (!document.deviceId) engine.save({ config: { deviceId: `${hostname()}-${randomBytes(3).toString('hex')}` } })
|
|
593
|
+
// **This** is where the lock becomes ours, not inside `run()`: the host takes it once, when the system
|
|
594
|
+
// comes into being, and hands the already-held lock to every run. Without this the engine reported
|
|
595
|
+
// `ownsLock: false`, so `release()` declined to remove a lock this very process was holding — and the
|
|
596
|
+
// next mount of the same directory was refused by this plugin's own lock, naming a live pid.
|
|
597
|
+
engine.ownsLock = lock.acquired
|
|
598
|
+
system = {
|
|
599
|
+
engine,
|
|
600
|
+
lock,
|
|
601
|
+
instanceId,
|
|
602
|
+
running: null,
|
|
603
|
+
pushing: null,
|
|
604
|
+
// The last run is **persisted** state, so a restart must show it rather than "从未": the panel's
|
|
605
|
+
// first question is "when did this last work", and the answer is in `sync.json` — the engine wrote
|
|
606
|
+
// it there precisely so that it would outlive the process.
|
|
607
|
+
last: {
|
|
608
|
+
result: document.state?.lastResult || (lock.acquired ? 'never' : 'locked'),
|
|
609
|
+
message: document.state?.message ?? (lock.acquired ? '' : lock.reason),
|
|
610
|
+
at: Number(document.state?.lastSyncAt) || 0,
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
return system
|
|
614
|
+
}
|
|
615
|
+
|
|
520
616
|
const { path: pricingSourcePath } = readRawPricing(pricingPaths)
|
|
521
617
|
if (pricingSourcePath === legacyPricingPath) {
|
|
522
618
|
log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
|
|
@@ -543,6 +639,173 @@ export function apply(ctx) {
|
|
|
543
639
|
return inFlight
|
|
544
640
|
}
|
|
545
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The sync state the panel reads: configuration, lock ownership, and the last run's outcome.
|
|
644
|
+
*
|
|
645
|
+
* The configuration is **rebuilt field by field** rather than spread. A spread would forward whatever a
|
|
646
|
+
* hand-edited `sync.json` happens to contain — and an older build of this plugin wrote exactly such a
|
|
647
|
+
* field, `password`, in plaintext. A blacklist that removes `ha1` and forwards everything else is one
|
|
648
|
+
* forgotten key away from putting a credential in an HTTP response.
|
|
649
|
+
*/
|
|
650
|
+
function safeSyncConfig(config = {}) {
|
|
651
|
+
return {
|
|
652
|
+
enabled: Boolean(config.enabled),
|
|
653
|
+
url: String(config.url ?? ''),
|
|
654
|
+
username: String(config.username ?? ''),
|
|
655
|
+
realm: String(config.realm ?? ''),
|
|
656
|
+
allowInsecureTls: Boolean(config.allowInsecureTls),
|
|
657
|
+
deviceId: String(config.deviceId ?? ''),
|
|
658
|
+
deviceLabel: String(config.deviceLabel ?? ''),
|
|
659
|
+
windowYears: Number(config.windowYears) || 0,
|
|
660
|
+
instanceId: String(config.instanceId ?? ''),
|
|
661
|
+
hasCredential: Boolean(config.ha1 || config.passwordHash || config.password),
|
|
662
|
+
credentialPrefix: config.ha1 ? `${String(config.ha1).slice(0, 6)}…` : '',
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function conflictFiles() {
|
|
667
|
+
try {
|
|
668
|
+
return readdirSync(dataDir).filter((name) => name.startsWith('.conflict-'))
|
|
669
|
+
} catch {
|
|
670
|
+
return []
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The devices the **server** holds, remembered between polls. Never a network call. */
|
|
675
|
+
let knownRemote = null
|
|
676
|
+
/** How long that answer is reused: the panel polls every 2s while a run is in flight. */
|
|
677
|
+
const REMOTE_CACHE_MS = 30_000
|
|
678
|
+
|
|
679
|
+
async function refreshRemoteDevices() {
|
|
680
|
+
if (!system?.engine?.store || !system.engine.config.enabled) return
|
|
681
|
+
if (system.syncAbort) return
|
|
682
|
+
if (system.remoteInFlight) return
|
|
683
|
+
system.remoteInFlight = true
|
|
684
|
+
try {
|
|
685
|
+
const client = system.engine.connect()
|
|
686
|
+
knownRemote = { devices: await system.engine.discover(client), at: Date.now() }
|
|
687
|
+
} catch (error) {
|
|
688
|
+
// A site the panel can show as "unknown" is better than an exception on every poll.
|
|
689
|
+
knownRemote = { devices: null, at: Date.now(), error: String(error?.message ?? error) }
|
|
690
|
+
} finally {
|
|
691
|
+
system.remoteInFlight = false
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function syncStatus() {
|
|
696
|
+
// Reading the status must not create anything either, so an untouched install answers with the
|
|
697
|
+
// sensible defaults rather than acquiring a lock it has no intention of using.
|
|
698
|
+
if (!system) {
|
|
699
|
+
const document = readSyncDocument(dataDir, log)
|
|
700
|
+
return {
|
|
701
|
+
config: safeSyncConfig({ ...document, hasCredential: Boolean(document.ha1 || document.passwordHash || document.password) }),
|
|
702
|
+
writable: true,
|
|
703
|
+
lock: null,
|
|
704
|
+
running: false,
|
|
705
|
+
last: {
|
|
706
|
+
result: document.state?.lastResult || 'never',
|
|
707
|
+
message: document.state?.message ?? '',
|
|
708
|
+
at: Number(document.state?.lastSyncAt) || 0,
|
|
709
|
+
},
|
|
710
|
+
collision: document.state?.collision ?? null,
|
|
711
|
+
remoteDevices: {},
|
|
712
|
+
// `null` rather than `{}`: nothing has asked the server yet, and an empty map would read as "there
|
|
713
|
+
// are no other devices".
|
|
714
|
+
remoteKnown: false,
|
|
715
|
+
remoteError: null,
|
|
716
|
+
published: {},
|
|
717
|
+
conflictFiles: conflictFiles(),
|
|
718
|
+
paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const { engine, lock, running, last } = system
|
|
722
|
+
const pulled = {}
|
|
723
|
+
for (const context of Object.keys(engine.state.held ?? {})) {
|
|
724
|
+
const device = context.split('/')[0]
|
|
725
|
+
pulled[device] = (pulled[device] ?? 0) + 1
|
|
726
|
+
}
|
|
727
|
+
// The count of shards this device has actually read, and the names the server holds, are two
|
|
728
|
+
// different questions. The panel shows both: "已同步到的月份" from `held`, and "服务器上的设备" from
|
|
729
|
+
// this list, so a device that published but was never pulled from still shows up. Refreshed at most
|
|
730
|
+
// every `REMOTE_CACHE_MS` — the panel polls during a run, and a `PROPFIND` per poll would be a lot of
|
|
731
|
+
// requests for a list that changes when someone else syncs.
|
|
732
|
+
if (!knownRemote || Date.now() - (knownRemote.at ?? 0) > REMOTE_CACHE_MS) void refreshRemoteDevices()
|
|
733
|
+
return {
|
|
734
|
+
config: safeSyncConfig(engine.config),
|
|
735
|
+
writable: Boolean(engine.store),
|
|
736
|
+
lock: lock.acquired ? null : { reason: lock.reason, owner: lock.owner ?? null, pid: process.pid },
|
|
737
|
+
running: Boolean(running),
|
|
738
|
+
last,
|
|
739
|
+
collision: engine.state.collision ?? null,
|
|
740
|
+
remoteDevices: pulled,
|
|
741
|
+
remoteKnown: Boolean(knownRemote?.devices),
|
|
742
|
+
remoteError: knownRemote?.error ?? null,
|
|
743
|
+
remoteDevicesOnServer: knownRemote?.devices ?? null,
|
|
744
|
+
published: engine.state.published ?? {},
|
|
745
|
+
conflictFiles: conflictFiles(),
|
|
746
|
+
paths: { sync: join(dataDir, 'sync.json'), pricing: pricingPath },
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Run one sync, at most one at a time.
|
|
752
|
+
*
|
|
753
|
+
* The host owns the lock for as long as it lives, so an automatic pricing push that lands mid-run
|
|
754
|
+
* waits on the same chain rather than racing it — two writers on one data directory is the case the
|
|
755
|
+
* lock exists for, and a lock cannot serialise work inside a single process.
|
|
756
|
+
*/
|
|
757
|
+
function runSync() {
|
|
758
|
+
const instance = syncSystem()
|
|
759
|
+
if (!instance.running) {
|
|
760
|
+
instance.running = (async () => {
|
|
761
|
+
if (!instance.engine.store) {
|
|
762
|
+
// The cache is read-only here (another instance holds the lock, a permission problem, a newer
|
|
763
|
+
// schema). The sync would publish a history this process cannot even fold, so it declines
|
|
764
|
+
// rather than doing half of it.
|
|
765
|
+
return { result: 'locked', message: instance.lock.reason ?? 'the cache is read-only here', at: 0 }
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
await refreshOnce()
|
|
769
|
+
} catch (error) {
|
|
770
|
+
// Refreshing is what makes the store worth publishing; without it there is nothing to send. A
|
|
771
|
+
// rejection here used to escape the route and the startup timer as an unhandled rejection.
|
|
772
|
+
return { result: 'failed', message: `could not fold the logs: ${error?.message ?? error}`, at: 0 }
|
|
773
|
+
}
|
|
774
|
+
// The system already owns the lock from the moment it was created; taking it again in the same
|
|
775
|
+
// process would see this process's own lock file and refuse. It is re-acquired only when the
|
|
776
|
+
// system was created without one (a second instance that lost the race).
|
|
777
|
+
const attempt = instance.lock?.acquired
|
|
778
|
+
? instance.lock
|
|
779
|
+
: acquireLock(dataDir, { instanceId: instance.instanceId, log })
|
|
780
|
+
if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
|
|
781
|
+
try {
|
|
782
|
+
const result = await instance.engine.run({ lock: attempt })
|
|
783
|
+
// A shard may have carried a newer price table; the panel's next poll should see it.
|
|
784
|
+
pricing = readPricing(pricingPaths)
|
|
785
|
+
rules = resolveHolidayRules(pricing.holidays)
|
|
786
|
+
// The engine's own record of when this happened, not "now": `locked` and `skipped` deliberately
|
|
787
|
+
// leave `lastSyncAt` alone, and showing a fresh timestamp for them would claim a sync that did
|
|
788
|
+
// not happen.
|
|
789
|
+
return { ...result, at: Number(instance.engine.state.lastSyncAt) || 0 }
|
|
790
|
+
} catch (error) {
|
|
791
|
+
// `SyncEngine.run` catches its own failures; this is the belt to its braces, so a bug there
|
|
792
|
+
// cannot take the host half down.
|
|
793
|
+
log(`sync failed unexpectedly: ${error?.message ?? error}`)
|
|
794
|
+
return { result: 'failed', message: String(error?.message ?? error), at: 0 }
|
|
795
|
+
}
|
|
796
|
+
// Nothing to release: the lock belongs to the system and lives as long as this process does.
|
|
797
|
+
})()
|
|
798
|
+
.then((result) => {
|
|
799
|
+
instance.last = { result: result.result, message: result.message, at: result.at ?? 0 }
|
|
800
|
+
return result
|
|
801
|
+
})
|
|
802
|
+
.finally(() => {
|
|
803
|
+
instance.running = null
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
return instance.running
|
|
807
|
+
}
|
|
808
|
+
|
|
546
809
|
async function getSnapshot() {
|
|
547
810
|
const status = await refreshOnce()
|
|
548
811
|
// Nothing changed and the previous payload is still fresh: reuse it rather
|
|
@@ -577,8 +840,75 @@ export function apply(ctx) {
|
|
|
577
840
|
pricing = normalizePricing(next)
|
|
578
841
|
rules = resolveHolidayRules(pricing.holidays)
|
|
579
842
|
snapshot = null // force a rebuild on the next read
|
|
843
|
+
// The price table is the one hand-written thing this plugin owns, so an edit is worth publishing
|
|
844
|
+
// while the person is still looking at the page. It is fire-and-forget: a sync that cannot reach
|
|
845
|
+
// the server must never make a local edit fail, and the state it leaves behind is what the panel
|
|
846
|
+
// reports.
|
|
847
|
+
schedulePricingPush()
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Publish just the price table, a moment after the last edit.
|
|
852
|
+
*
|
|
853
|
+
* Debounced because editing a row and then the one below it is one intention, not two publishes, and
|
|
854
|
+
* because the fetch it needs would otherwise happen on every keystroke-sized save. It only ever runs
|
|
855
|
+
* when sync was already configured and enabled: a local edit is not a reason to create `sync.json`.
|
|
856
|
+
*/
|
|
857
|
+
let pricingPushTimer = null
|
|
858
|
+
function schedulePricingPush() {
|
|
859
|
+
const configured = system ? system.engine.config : readSyncDocument(dataDir, log)
|
|
860
|
+
if (!configured.enabled || !configured.url || !configured.ha1) return
|
|
861
|
+
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
862
|
+
pricingPushTimer = setTimeout(() => {
|
|
863
|
+
pricingPushTimer = null
|
|
864
|
+
const instance = syncSystem()
|
|
865
|
+
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 }))
|
|
871
|
+
.then((result) => {
|
|
872
|
+
log(`pricing pushed after an edit: ${result.result} — ${result.status}`)
|
|
873
|
+
instance.last = { result: result.result, message: `prices: ${result.status}`, at: result.result === 'ok' ? Number(instance.engine.state.lastSyncAt) || 0 : instance.last?.at ?? 0 }
|
|
874
|
+
return result
|
|
875
|
+
})
|
|
876
|
+
.catch((error) => {
|
|
877
|
+
log(`pricing push failed: ${error?.message ?? error}`)
|
|
878
|
+
instance.last = { result: 'failed', message: `prices: ${error?.message ?? error}`, at: instance.last?.at ?? 0 }
|
|
879
|
+
})
|
|
880
|
+
}, delayFor('DSH_USAGE_BADGE_PRICING_PUSH_MS', PRICING_PUSH_DELAY_MS))
|
|
881
|
+
pricingPushTimer.unref?.()
|
|
580
882
|
}
|
|
581
883
|
|
|
884
|
+
/**
|
|
885
|
+
* The first automatic sync, a little after startup.
|
|
886
|
+
*
|
|
887
|
+
* Deliberately conditional: it only runs when sync is **already configured and enabled**, so a home
|
|
888
|
+
* that never turned sync on stays untouched (no `sync.json`, no lock, no network). The delay exists so
|
|
889
|
+
* the cold fold and the first panel paint come first — a sync on the way up would compete with the
|
|
890
|
+
* work that makes the badge show anything at all.
|
|
891
|
+
*
|
|
892
|
+
* It is also skipped when the instance is not the writer: on a second DSH instance the lock belongs to
|
|
893
|
+
* the first, and the honest answer is to do nothing rather than take the lock away from it.
|
|
894
|
+
*/
|
|
895
|
+
function scheduleStartupSync(delayMs = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)) {
|
|
896
|
+
const configured = readSyncDocument(dataDir, log)
|
|
897
|
+
if (!configured.enabled || !configured.url || !configured.ha1) return
|
|
898
|
+
const timer = setTimeout(() => {
|
|
899
|
+
startupSyncTimer = null
|
|
900
|
+
const instance = syncSystem()
|
|
901
|
+
if (!instance.lock?.acquired) {
|
|
902
|
+
log(`startup sync skipped: ${instance.lock?.reason ?? 'no lock'}`)
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
void runSync().then((result) => log(`startup sync: ${result.result} — ${result.message}`))
|
|
906
|
+
}, delayMs)
|
|
907
|
+
timer.unref?.()
|
|
908
|
+
startupSyncTimer = timer
|
|
909
|
+
}
|
|
910
|
+
let startupSyncTimer = null
|
|
911
|
+
|
|
582
912
|
async function warm() {
|
|
583
913
|
try {
|
|
584
914
|
const status = await refreshOnce()
|
|
@@ -736,6 +1066,119 @@ export function apply(ctx) {
|
|
|
736
1066
|
return
|
|
737
1067
|
}
|
|
738
1068
|
|
|
1069
|
+
// ── cloud sync ──────────────────────────────────────────────────────────
|
|
1070
|
+
//
|
|
1071
|
+
// The panel's third tab. The credential never travels back: `ha1` is reported as a short prefix
|
|
1072
|
+
// plus a `hasCredential` flag, so the state of "is one configured" is visible without the secret
|
|
1073
|
+
// leaving the host half.
|
|
1074
|
+
if (route === '/sync' && req.method === 'GET') {
|
|
1075
|
+
// Loopback only, like every other sync route. The answer names the server and the account, and
|
|
1076
|
+
// reading it *is* engaging with sync — this is where the system comes into being, and with it the
|
|
1077
|
+
// owner lock. A page that never opens this tab still touches nothing.
|
|
1078
|
+
if (!isLoopback(req)) {
|
|
1079
|
+
sendJson(res, 403, { error: 'the sync status is visible to loopback clients only' })
|
|
1080
|
+
return
|
|
1081
|
+
}
|
|
1082
|
+
syncSystem()
|
|
1083
|
+
let body
|
|
1084
|
+
try {
|
|
1085
|
+
body = await withTimeout(syncStatus(), STATUS_TIMEOUT_MS, 'reading the sync status')
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
sendJson(res, 200, { error: String(error?.message ?? error) })
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
sendJson(res, 200, body)
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (route === '/sync' && (req.method === 'PUT' || req.method === 'POST')) {
|
|
1095
|
+
if (!isLoopback(req)) {
|
|
1096
|
+
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
const instance = syncSystem()
|
|
1100
|
+
const patch = JSON.parse((await readBody(req)) || '{}')
|
|
1101
|
+
const config = {}
|
|
1102
|
+
for (const field of ['enabled', 'url', 'username', 'allowInsecureTls', 'deviceLabel', 'deviceId', 'realm']) {
|
|
1103
|
+
if (patch[field] !== undefined) config[field] = patch[field]
|
|
1104
|
+
}
|
|
1105
|
+
if (patch.windowYears !== undefined) config.windowYears = Math.max(0, Math.min(50, Number(patch.windowYears) || 0))
|
|
1106
|
+
if (typeof patch.deviceId === 'string' && patch.deviceId !== '') {
|
|
1107
|
+
try {
|
|
1108
|
+
config.deviceId = safeDeviceId(patch.deviceId)
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
sendJson(res, 400, { error: error.message })
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
// Either a plaintext password (hashed here, immediately, and never stored) or an HA1 someone
|
|
1115
|
+
// pasted in — the same value the server keeps in its password file.
|
|
1116
|
+
//
|
|
1117
|
+
// The realm is part of the digest input, so it has to be right before the password can be hashed.
|
|
1118
|
+
// The panel does not offer it as a field: a person has no reason to care, and the servers this is
|
|
1119
|
+
// built for use `WebDAV`. So it defaults here rather than being demanded — requiring it made
|
|
1120
|
+
// "fill in address/user/password and press 保存" fail with a sentence about a term the UI never
|
|
1121
|
+
// showed. A connection test replaces the guess with the realm the server actually announced.
|
|
1122
|
+
if (typeof patch.password === 'string' && patch.password !== '') {
|
|
1123
|
+
const username = String(patch.username ?? instance.engine.config.username ?? '')
|
|
1124
|
+
const realm = String(patch.realm ?? instance.engine.config.realm ?? '').trim() || 'WebDAV'
|
|
1125
|
+
if (!username) {
|
|
1126
|
+
sendJson(res, 400, { error: 'a username is needed to hash the password' })
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
config.ha1 = createHash('md5').update(`${username}:${realm}:${patch.password}`).digest('hex')
|
|
1130
|
+
config.realm = realm
|
|
1131
|
+
} else if (typeof patch.ha1 === 'string' && /^[0-9a-f]{32}$/i.test(patch.ha1)) {
|
|
1132
|
+
config.ha1 = patch.ha1.toLowerCase()
|
|
1133
|
+
}
|
|
1134
|
+
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}` })
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
instance.engine.save({ config })
|
|
1143
|
+
log(`sync configuration updated (${instance.engine.config.enabled ? 'on' : 'off'}, device ${instance.engine.config.deviceId})`)
|
|
1144
|
+
sendJson(res, 200, { ok: true, ...(await syncStatus()) })
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// A connection check: it must not sync, and it must not write anything anywhere.
|
|
1149
|
+
if (route === '/sync/test' && req.method === 'POST') {
|
|
1150
|
+
if (!isLoopback(req)) {
|
|
1151
|
+
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1154
|
+
try {
|
|
1155
|
+
const client = syncSystem().engine.connect()
|
|
1156
|
+
const options = await client.options()
|
|
1157
|
+
const devices = await syncSystem().engine.discover(client)
|
|
1158
|
+
sendJson(res, 200, {
|
|
1159
|
+
ok: true,
|
|
1160
|
+
realm: options.realm,
|
|
1161
|
+
dav: options.dav,
|
|
1162
|
+
devices,
|
|
1163
|
+
insecure: Boolean(client.insecureWarning),
|
|
1164
|
+
warning: client.insecureWarning ?? null,
|
|
1165
|
+
})
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
sendJson(res, 200, { ok: false, error: String(error?.message ?? error) })
|
|
1168
|
+
}
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (route === '/sync/run' && req.method === 'POST') {
|
|
1173
|
+
if (!isLoopback(req)) {
|
|
1174
|
+
sendJson(res, 403, { error: 'sync writes are accepted from loopback clients only' })
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
const result = await runSync()
|
|
1178
|
+
sendJson(res, 200, { ok: result.result === 'ok' || result.result === 'partial', ...result, ...(await syncStatus()) })
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
|
|
739
1182
|
sendJson(res, 404, { error: `no such usage-badge route: ${route}` })
|
|
740
1183
|
} catch (error) {
|
|
741
1184
|
// A caller's mistake is reported as one; only genuinely unexpected failures are 500s.
|
|
@@ -751,9 +1194,30 @@ export function apply(ctx) {
|
|
|
751
1194
|
)
|
|
752
1195
|
|
|
753
1196
|
ctx.effect(() => {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1197
|
+
// The delay is read **here**, synchronously, rather than inside the warm-up callback: warming is
|
|
1198
|
+
// asynchronous, and a value read after it would be whatever the environment holds by then — which a
|
|
1199
|
+
// test that sets an override for exactly this mount would already have taken away.
|
|
1200
|
+
const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
|
|
1201
|
+
void warm().then(() => {
|
|
1202
|
+
scheduleStartupSync(startupDelay)
|
|
1203
|
+
// Fire and forget: this only fills in what the server holds, for the panel's next read.
|
|
1204
|
+
void refreshRemoteDevices()
|
|
1205
|
+
})
|
|
1206
|
+
return () => {
|
|
1207
|
+
// Any pending automatic work is dropped on unload: a push that fires while the host is going away
|
|
1208
|
+
// would write state for a process that no longer exists.
|
|
1209
|
+
if (pricingPushTimer) clearTimeout(pricingPushTimer)
|
|
1210
|
+
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.
|
|
1214
|
+
if (system) {
|
|
1215
|
+
system.syncAbort = true
|
|
1216
|
+
system.engine.release()
|
|
1217
|
+
}
|
|
1218
|
+
aggregator.close()
|
|
1219
|
+
}
|
|
1220
|
+
}, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
|
|
757
1221
|
|
|
758
1222
|
log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
|
|
759
1223
|
}
|