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/lib/webdav.js ADDED
@@ -0,0 +1,584 @@
1
+ /**
2
+ * dsh-usage-badge — the WebDAV client the cloud sync rides on.
3
+ *
4
+ * Hand-written rather than borrowed, for two reasons:
5
+ *
6
+ * - **`fetch` cannot do Digest.** There is no hook for "send, read the 401 challenge,
7
+ * compute a response, retry" — undici's `credentials` are the browser's model, not
8
+ * digest's. So the request layer is `node:http`/`node:https`, and this module owns the
9
+ * whole handshake.
10
+ * - **the target is a known server, not "WebDAV in general".** The design deliberately
11
+ * supports only what `bytemark/webdav` (Apache httpd + mod_dav_fs) offers: `MD5` and
12
+ * `qop=auth`. Everything else — `SHA-256`, `algorithm=MD5-sess`, `qop=auth-int`, RFC
13
+ * 2069's *use* of `auth-int`, `Basic` — is refused **by name**, so pointing this at a
14
+ * different server (Nextcloud, Synology) fails with a sentence you can act on rather
15
+ * than with a silent downgrade.
16
+ *
17
+ * `qop` **absent** is not refused: that is RFC 2069, which Apache can still serve
18
+ * (`AuthDigestQop none`), and it is a small, well-specified path.
19
+ *
20
+ * Scope: `OPTIONS`, `PROPFIND`, `MKCOL`, `GET`, `PUT`, `DELETE`, `MOVE`. No `LOCK` — the
21
+ * sync uses optimistic concurrency (compare the shard hash, then write) instead, because a
22
+ * crashed client would otherwise leave a lock behind that makes every later `PUT` fail with
23
+ * 423 until someone clears it by hand. No redirect following either: this server aliases
24
+ * `/` to the DAV root and answers a `PROPFIND` on a directory without its trailing slash
25
+ * with a 301, and the design wants that visible rather than silently chased.
26
+ *
27
+ * @module dsh-usage-badge/webdav
28
+ */
29
+
30
+ import { createHash, randomBytes } from 'node:crypto'
31
+ import { request as httpRequest } from 'node:http'
32
+ import { request as httpsRequest } from 'node:https'
33
+
34
+ /** How long a request may take before it is abandoned. */
35
+ export const CONNECT_TIMEOUT_MS = 15000
36
+
37
+ /** Uploads are bigger and slower than the rest; a separate, longer budget. */
38
+ export const UPLOAD_TIMEOUT_MS = 60000
39
+
40
+ const DEFAULT_PORT = { 'http:': 80, 'https:': 443 }
41
+
42
+ /** RFC 7616 wants at least 64 bits of client entropy; 16 bytes is 128. */
43
+ const CNONCE_BYTES = 16
44
+
45
+ /**
46
+ * How many times credentials may be re-sent for one request, beyond the initial handshake.
47
+ *
48
+ * Two, not one, because a `stale=true` rotation can legitimately arrive on a request that
49
+ * already handshook — a long upload that outlives the nonce, or a container restarting
50
+ * mid-sync. A third refusal is treated as a wrong password rather than retried: probing
51
+ * credentials repeatedly is how an account gets locked out.
52
+ */
53
+ const MAX_HANDSHAKES = 2
54
+
55
+ /** `nc` is exactly eight lowercase hex digits. */
56
+ const NONCE_COUNT_WIDTH = 8
57
+
58
+ /**
59
+ * The digest parameters this build understands in a challenge.
60
+ *
61
+ * `algorithm` is stored raw (lowercased) so the refusal message can quote what the server
62
+ * actually said, rather than what this module wishes it had said.
63
+ */
64
+ const SUPPORTED = {
65
+ algorithm: new Set(['', 'md5']),
66
+ qop: new Set(['auth', '']),
67
+ }
68
+
69
+ /**
70
+ * Quote a value for the `Authorization` header.
71
+ *
72
+ * Backslash and double quote are the only two characters the header grammar cannot carry
73
+ * raw; both are escaped rather than rejected, because an unusual realm must not turn into
74
+ * an unexplained 401 loop.
75
+ */
76
+ const quote = (value) => `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
77
+
78
+ /** Untaint a header parameter: strip the surrounding quotes and undo the escaping. */
79
+ const unquote = (value) => {
80
+ const trimmed = String(value).trim()
81
+ if (!(trimmed.startsWith('"') && trimmed.endsWith('"')) || trimmed.length < 2) return trimmed
82
+ return trimmed.slice(1, -1).replace(/\\(.)/g, '$1')
83
+ }
84
+
85
+ /**
86
+ * Parse param=value pairs out of a header chunk, honouring quoted strings.
87
+ *
88
+ * Written by hand because `WWW-Authenticate` is not JSON and a `realm` may legally contain
89
+ * commas and `=` — a naive `split(',')` would cut it in half and produce a digest that never
90
+ * validates, with no clue why.
91
+ */
92
+ function parseParams(source) {
93
+ const params = {}
94
+ let i = 0
95
+ while (i < source.length) {
96
+ while (i < source.length && (source[i] === ',' || source[i] === ' ' || source[i] === '\t')) i++
97
+ const nameStart = i
98
+ while (i < source.length && source[i] !== '=' && source[i] !== ',') i++
99
+ const name = source.slice(nameStart, i).trim().toLowerCase()
100
+ if (name === '') {
101
+ i++ // a bare comma, or the trailing space of `Digest` — nothing to record
102
+ continue
103
+ }
104
+ while (i < source.length && source[i] === ' ') i++
105
+ if (source[i] !== '=') {
106
+ params[name] = ''
107
+ continue
108
+ }
109
+ i++ // '='
110
+ while (i < source.length && source[i] === ' ') i++
111
+ if (source[i] === '"') {
112
+ i++
113
+ let value = ''
114
+ while (i < source.length && source[i] !== '"') {
115
+ if (source[i] === '\\' && i + 1 < source.length) i++
116
+ value += source[i]
117
+ i++
118
+ }
119
+ i++ // closing quote
120
+ params[name] = unquote(`"${value}"`)
121
+ } else {
122
+ const valueStart = i
123
+ while (i < source.length && source[i] !== ',') i++
124
+ params[name] = source.slice(valueStart, i).trim()
125
+ }
126
+ }
127
+ return params
128
+ }
129
+
130
+ /**
131
+ * The digest challenge a response is asking for, or `null` when it carries none.
132
+ *
133
+ * A `WWW-Authenticate` header may offer several schemes; the first `Digest` one wins, and
134
+ * `Basic` sitting before it does not make this module agree to send a password in the clear.
135
+ * Header folding (obs-fold) is unfolded first because Apache does not emit it but a proxy
136
+ * between here and the server might.
137
+ */
138
+ export function parseChallenge(headers = {}) {
139
+ const raw = headers['www-authenticate']
140
+ if (!raw) return null
141
+ const header = String(Array.isArray(raw) ? raw.join(', ') : raw).replace(/\r?\n\s+/g, ' ')
142
+ const match = /(^|,)\s*digest\s+/i.exec(header)
143
+ if (!match) return null
144
+ return parseParams(header.slice(match.index + match[0].length))
145
+ }
146
+
147
+ /**
148
+ * The parameters this build cannot honour, named.
149
+ *
150
+ * Returned as a list rather than thrown so a caller can decide where the message belongs —
151
+ * and so the test can assert on exactly which fields trip it.
152
+ */
153
+ export function unsupportedChallenge(challenge) {
154
+ const problems = []
155
+ const algorithm = String(challenge?.algorithm ?? '').trim().toLowerCase()
156
+ const qopRaw = challenge?.qop
157
+ if (!SUPPORTED.algorithm.has(algorithm)) {
158
+ problems.push(
159
+ `algorithm=${algorithm} (this server must use MD5; bytemark/webdav cannot do SHA-256, and MD5-sess is broken in Apache itself)`,
160
+ )
161
+ }
162
+ // A challenge may offer a list (`qop="auth,auth-int"`); only the `auth` member is usable,
163
+ // because `auth-int` would require hashing the whole body and Apache does not implement it.
164
+ const offered = String(qopRaw ?? '')
165
+ .split(',')
166
+ .map((value) => value.trim().toLowerCase())
167
+ .filter((value) => value !== '')
168
+ if (offered.length > 0 && !offered.includes('auth')) {
169
+ problems.push(`qop=${offered.join(',')} (only qop=auth is implemented; auth-int is not)`)
170
+ }
171
+ return problems
172
+ }
173
+
174
+ /** A fresh client nonce; hex so it is header-safe. */
175
+ const newCnonce = () => randomBytes(CNONCE_BYTES).toString('hex')
176
+
177
+ /** The `nc` value: a counter, eight lowercase hex digits, started at 1. */
178
+ const formatNonceCount = (count) => count.toString(16).padStart(NONCE_COUNT_WIDTH, '0')
179
+
180
+ /**
181
+ * The `Authorization` header for one request, built from the server's challenge.
182
+ *
183
+ * Exported because the whole point of the design is that this is testable without a socket:
184
+ * the RFC 2617/7616 vectors are asserted against this function directly.
185
+ *
186
+ * `uri` must be the request-target **exactly as it goes on the wire** — path plus query, with
187
+ * percent-escapes intact. Decoding it (or re-encoding a decoded one) is the classic way to
188
+ * produce a digest that never validates.
189
+ *
190
+ * @param {{challenge:object, method:string, uri:string, username:string, password:string,
191
+ * cnonce?:string, nonceCount?:number, nc?:string, body?:Buffer}} options
192
+ * @returns {string} ready for the `Authorization` header.
193
+ */
194
+ export function buildAuthorization({ challenge, method, uri, username, password, passwordHash, cnonce, nonceCount = 1 }) {
195
+ const realm = String(challenge?.realm ?? '')
196
+ const nonce = String(challenge?.nonce ?? '')
197
+ const algorithm = String(challenge?.algorithm ?? '').trim().toLowerCase()
198
+ const problems = unsupportedChallenge(challenge)
199
+ if (problems.length > 0) throw new Error(`this server's Digest challenge is not supported: ${problems.join('; ')}`)
200
+ if (!nonce) throw new Error('the Digest challenge carries no nonce')
201
+
202
+ const hash = (value) => createHash('md5').update(value).digest('hex')
203
+ // `passwordHash` is the HA1 the server itself stores (`MD5(user:realm:pass)`). Accepting it means
204
+ // the credential can be *kept* as the server keeps it — the plaintext never has to be written down
205
+ // — while still producing exactly the same response as the plaintext path.
206
+ const ha1 = passwordHash ? String(passwordHash).toLowerCase() : hash(`${username}:${realm}:${password}`)
207
+ const ha2 = hash(`${method}:${uri}`)
208
+ const fields = [
209
+ `username=${quote(username)}`,
210
+ `realm=${quote(realm)}`,
211
+ `nonce=${quote(nonce)}`,
212
+ `uri=${quote(uri)}`,
213
+ ]
214
+
215
+ const qop = String(challenge?.qop ?? '').trim().toLowerCase()
216
+ const clientNonce = cnonce ?? newCnonce()
217
+ if (qop === 'auth') {
218
+ const nc = formatNonceCount(nonceCount)
219
+ fields.push(`qop=auth`, `nc=${nc}`, `cnonce=${quote(clientNonce)}`)
220
+ fields.push(`response=${quote(hash(`${ha1}:${nonce}:${nc}:${clientNonce}:auth:${ha2}`))}`)
221
+ } else {
222
+ // RFC 2069: no qop, no nc, no cnonce.
223
+ fields.push(`response=${quote(hash(`${ha1}:${nonce}:${ha2}`))}`)
224
+ }
225
+
226
+ if (algorithm === 'md5') fields.push('algorithm=MD5')
227
+ if (challenge?.opaque) fields.push(`opaque=${quote(challenge.opaque)}`)
228
+ return `Digest ${fields.join(', ')}`
229
+ }
230
+
231
+ /** One request's outcome, with the raw body so binary shards survive the trip. */
232
+ const transport = (url, { method, headers, body, timeout }) =>
233
+ new Promise((resolve, reject) => {
234
+ const send = url.protocol === 'https:' ? httpsRequest : httpRequest
235
+ const req = send(
236
+ {
237
+ protocol: url.protocol,
238
+ hostname: url.hostname,
239
+ port: url.port || DEFAULT_PORT[url.protocol],
240
+ method,
241
+ path: `${url.pathname}${url.search}`,
242
+ headers,
243
+ // A fresh socket per request: this client makes a handful of calls per sync, so a
244
+ // pool would only add a way for one broken socket to poison the next request.
245
+ agent: false,
246
+ },
247
+ (res) => {
248
+ const chunks = []
249
+ res.on('data', (chunk) => chunks.push(chunk))
250
+ res.on('end', () =>
251
+ resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }),
252
+ )
253
+ res.on('error', reject)
254
+ },
255
+ )
256
+ req.setTimeout(timeout, () => req.destroy(new Error(`timed out after ${timeout} ms`)))
257
+ req.on('error', reject)
258
+ if (body && body.length > 0) req.write(body)
259
+ req.end()
260
+ })
261
+
262
+ /**
263
+ * The one place a WebDAV base URL is judged and normalised.
264
+ *
265
+ * Exported so the **save route** can refuse exactly what the client refuses. When the two had their own
266
+ * copies of this rule the host accepted addresses the client could never use — a query string, embedded
267
+ * credentials, `ftp:` — and the panel stored one and displayed it while the sync could not run at all.
268
+ * Two copies of a rule drift; one copy cannot.
269
+ *
270
+ * @param {string} baseUrl
271
+ * @param {{allowTrailingSlashFix?:boolean}} [options] `allowTrailingSlashFix` appends the slash a
272
+ * collection needs instead of refusing the address, which is what a person typing a host:port means.
273
+ * It is the **second** parameter on purpose: a single destructured parameter silently ignores a string
274
+ * argument and falls back to the permissive default, which is the opposite of failing loudly.
275
+ * @returns {{ok:true, url:string, insecure:boolean} | {ok:false, reason:string}}
276
+ */
277
+ export function normalizeWebdavUrl(baseUrl, options = {}) {
278
+ const { allowTrailingSlashFix = true } = options ?? {}
279
+ const text = String(baseUrl ?? '').trim()
280
+ let url
281
+ try {
282
+ url = new URL(text)
283
+ } catch {
284
+ return { ok: false, reason: `this is not a URL: ${baseUrl}` }
285
+ }
286
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
287
+ return { ok: false, reason: `the URL must be http or https, got ${url.protocol}` }
288
+ }
289
+ // **A host has to be there.** `new URL('http:///dav')` does not fail: WHATWG drops the empty authority
290
+ // and reads `dav` as the host, so a missing hostname silently retargets the address at another machine.
291
+ // Whoever typed three slashes meant to fill something in, not to point somewhere else.
292
+ const afterScheme = text.includes('://') ? text.slice(text.indexOf('://') + 3) : ''
293
+ if (afterScheme === '' || afterScheme.startsWith('/')) {
294
+ return { ok: false, reason: 'the URL has no host — it has to look like http://nas:19347/dav/' }
295
+ }
296
+ if (url.username || url.password) {
297
+ return { ok: false, reason: 'put the username and password in their own fields, not in the URL' }
298
+ }
299
+ if (url.search) return { ok: false, reason: 'the URL must not carry a query string' }
300
+ if (url.hash) return { ok: false, reason: 'the URL must not carry a #fragment' }
301
+ // An encoded slash is not a slash to the server: Apache's default `AllowEncodedSlashes` policy answers
302
+ // `%2f` with a 404, and appending a real slash after it produces a path nobody meant. Refused by name so
303
+ // the field says why, instead of being accepted and 404ing at the first request.
304
+ if (/%2f/i.test(url.pathname)) {
305
+ return { ok: false, reason: 'the URL contains an encoded slash (%2f), which this server family rejects by default — write the "/" literally' }
306
+ }
307
+ // `new URL('http://nas:19347').pathname` is `"/"`, not `""`: the root is a collection and already ends
308
+ // in its slash, so this is false for a bare host and nothing is appended. Only a **path** below the root
309
+ // that lacks the slash (`/dav`) reaches the branch.
310
+ if (!/\/$/.test(url.pathname)) {
311
+ if (!allowTrailingSlashFix) {
312
+ // The target is a collection; a WebDAV server answers a slash-less directory with a 301, and
313
+ // chasing that redirect is what makes a missing slash look like a wrong password.
314
+ return { ok: false, reason: `the URL must end with "/" (a collection), got ${baseUrl}` }
315
+ }
316
+ url.pathname = `${url.pathname}/`
317
+ }
318
+ return { ok: true, url: url.toString(), insecure: url.protocol === 'http:' }
319
+ }
320
+
321
+ /**
322
+ * A WebDAV client bound to one server and one set of credentials.
323
+ *
324
+ * The nonce is cached per client because the target server sets no `AuthDigestDomain`
325
+ * (bytemark's `dav.conf` omits it), which per Apache's own documentation makes clients send
326
+ * `Authorization` on *every* request — so the preemptive path is the normal path here, and
327
+ * the 401 round trip happens once per nonce lifetime rather than once per request.
328
+ */
329
+ export class WebDavClient {
330
+ /**
331
+ * @param {{baseUrl:string, username:string, password?:string, passwordHash?:string,
332
+ * allowInsecureTls?:boolean, timeoutMs?:number, uploadTimeoutMs?:number, userAgent?:string}} options
333
+ * Supply exactly one of `password` (plaintext, hashed per challenge) or `passwordHash` (the
334
+ * digest HA1, which needs no plaintext at all).
335
+ */
336
+ constructor({ baseUrl, username, password = null, passwordHash = null, allowInsecureTls = false, timeoutMs = CONNECT_TIMEOUT_MS, uploadTimeoutMs = UPLOAD_TIMEOUT_MS, userAgent = 'dsh-usage-badge' }) {
337
+ // The same judgement the save route uses, in one place. `allowTrailingSlashFix` is off here on
338
+ // purpose: a person typing `host:port` should have the slash added for them by the route, but a client
339
+ // constructed from a stored configuration must be handed an address that is already right — silently
340
+ // changing it here would hide a configuration that was written wrong.
341
+ const judged = normalizeWebdavUrl(baseUrl, { allowTrailingSlashFix: false })
342
+ if (!judged.ok) throw new Error(judged.reason)
343
+ if (judged.insecure && !allowInsecureTls) {
344
+ // Digest over plain HTTP is not a secret leak by itself, but it is brute-forceable by a
345
+ // passive eavesdropper. Say so once here; the panel says it again.
346
+ this.insecureWarning = 'this WebDAV URL is plain http://; digest authentication over an unencrypted connection is brute-forceable by anyone who can watch it'
347
+ }
348
+ this.baseUrl = new URL(judged.url)
349
+ this.username = username
350
+ this.password = password
351
+ this.passwordHash = passwordHash
352
+ if (!this.password && !this.passwordHash) throw new Error('a WebDAV credential is required: either a password or its digest HA1')
353
+ this.allowInsecureTls = allowInsecureTls
354
+ this.timeoutMs = timeoutMs
355
+ this.uploadTimeoutMs = uploadTimeoutMs
356
+ this.userAgent = userAgent
357
+ /** The last challenge the server gave, kept so the next request can preempt the 401. */
358
+ this.challenge = null
359
+ this.nonceCount = 0
360
+ }
361
+
362
+ /** The absolute URL for one path under the base. The path must stay under the base. */
363
+ url(path) {
364
+ const relative = String(path ?? '').replace(/^\/+/, '')
365
+ const url = new URL(relative, this.baseUrl)
366
+ if (!url.pathname.startsWith(this.baseUrl.pathname)) {
367
+ throw new Error(`refusing to escape the WebDAV base: ${path}`)
368
+ }
369
+ return url
370
+ }
371
+
372
+ /** The request-target exactly as it goes on the wire, escapes intact. */
373
+ static target(url) {
374
+ return `${url.pathname}${url.search}`
375
+ }
376
+
377
+ /**
378
+ * Send one request, performing the digest handshake as few round trips as possible.
379
+ *
380
+ * The retry rules exist to tell three 401s apart, because they need three different answers:
381
+ *
382
+ * - **the first one**, carrying no credentials, is the handshake itself — Apache always
383
+ * sends it (the bytemark image sets no `AuthDigestDomain`, so preemption is per-client
384
+ * state, not something the server invites). It must not consume the retry budget;
385
+ * - **`stale=true`** means "your credentials are fine, this nonce is not" — a restarted
386
+ * container looks exactly like this, and the answer is to retry with the new nonce;
387
+ * - **anything else** is a real refusal (wrong password, or a realm whose password file does
388
+ * not match) and must be reported, not retried into a lockout.
389
+ */
390
+ async request(method, path, { headers = {}, body = null, expect = null } = {}) {
391
+ const url = this.url(path)
392
+ const target = WebDavClient.target(url)
393
+ const payload = body === null || body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(body)
394
+ const timeout = payload ? this.uploadTimeoutMs : this.timeoutMs
395
+ const base = {
396
+ 'user-agent': this.userAgent,
397
+ accept: '*/*',
398
+ connection: 'close',
399
+ ...(payload ? { 'content-length': String(payload.length) } : {}),
400
+ ...headers,
401
+ }
402
+
403
+ /** Round trips spent re-sending credentials; the handshake itself is not one of them. */
404
+ let handshakes = 0
405
+ for (;;) {
406
+ const withAuth = { ...base }
407
+ if (this.challenge) {
408
+ withAuth.authorization = buildAuthorization({
409
+ challenge: this.challenge,
410
+ method,
411
+ uri: target,
412
+ username: this.username,
413
+ password: this.password,
414
+ passwordHash: this.passwordHash,
415
+ nonceCount: ++this.nonceCount,
416
+ })
417
+ }
418
+
419
+ let response
420
+ try {
421
+ response = await transport(url, { method, headers: withAuth, body: payload, timeout })
422
+ } catch (error) {
423
+ throw new Error(`${method} ${target} failed: ${error?.message ?? error}`)
424
+ }
425
+
426
+ if (response.status !== 401) {
427
+ if (expect !== null && response.status !== expect) {
428
+ throw new Error(`${method} ${target} answered ${response.status} (expected ${expect})`)
429
+ }
430
+ return response
431
+ }
432
+
433
+ const challenge = parseChallenge(response.headers)
434
+ const stale = String(challenge?.stale ?? '').toLowerCase() === 'true'
435
+ if (!challenge) {
436
+ const offered = String(response.headers['www-authenticate'] ?? '(none)')
437
+ throw new Error(
438
+ `this server did not offer Digest authentication (WWW-Authenticate: ${offered}); only Digest is implemented`,
439
+ )
440
+ }
441
+ const problems = unsupportedChallenge(challenge)
442
+ if (problems.length > 0) throw new Error(`this server's Digest challenge is not supported: ${problems.join('; ')}`)
443
+ if (!stale && handshakes >= MAX_HANDSHAKES) {
444
+ throw new Error(`${method} ${target} was refused with 401 — the username, password or realm is wrong`)
445
+ }
446
+ // A stale nonce means "retry with these credentials", so the counter restarts.
447
+ this.challenge = challenge
448
+ this.nonceCount = 0
449
+ handshakes++
450
+ }
451
+ }
452
+
453
+ /** `OPTIONS` — used to confirm the endpoint is a WebDAV server and to read its realm. */
454
+ async options(path = '') {
455
+ const response = await this.request('OPTIONS', path)
456
+ return {
457
+ status: response.status,
458
+ dav: String(response.headers.dav ?? ''),
459
+ realm: this.challenge?.realm ?? null,
460
+ allow: String(response.headers.allow ?? ''),
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Create a collection, including its parent.
466
+ *
467
+ * `mod_dav_fs` does **not** create missing parents: a `MKCOL` on `a/b/` when `a/` does not exist
468
+ * answers 409, which is the answer a real bytemark container gave when the plugin created a device
469
+ * directory before `usage-badge/` existed. Some servers also answer 405 for a collection that is
470
+ * already there, and a race between two devices can produce either — so both are success here.
471
+ *
472
+ * @returns {boolean} whether this call created the collection (false when it already existed).
473
+ */
474
+ async mkcol(path) {
475
+ const segments = String(path ?? '').split('/').filter(Boolean)
476
+ let created = false
477
+ for (let i = 0; i < segments.length; i++) {
478
+ const prefix = `${segments.slice(0, i + 1).join('/')}/`
479
+ const response = await this.request('MKCOL', prefix)
480
+ if (response.status === 201 || response.status === 200) {
481
+ created = true
482
+ continue
483
+ }
484
+ // 201/200 created it; 405 and 409 mean a collection is already there. Apache's `mod_dav_fs`
485
+ // answers **403** for a parent that does not exist (RFC 4918 says 409, and a real bytemark
486
+ // container answered 403 while a device directory was being created before `usage-badge/`
487
+ // existed), so that is treated as "already there" too rather than failing the sync.
488
+ if (response.status === 405 || response.status === 409 || response.status === 403) continue // already a collection
489
+ throw new Error(`MKCOL ${prefix} answered ${response.status}`)
490
+ }
491
+ return created
492
+ }
493
+
494
+ /** Fetch a resource, or `null` when it does not exist yet. */
495
+ async get(path) {
496
+ const response = await this.request('GET', path)
497
+ if (response.status === 404) return null
498
+ if (response.status !== 200) throw new Error(`GET ${path} answered ${response.status}`)
499
+ return response.body
500
+ }
501
+
502
+ /** Write a resource. */
503
+ async put(path, body, { contentType = 'application/octet-stream', ifMatch = null } = {}) {
504
+ const response = await this.request('PUT', path, {
505
+ headers: { 'content-type': contentType, ...(ifMatch ? { 'if-match': ifMatch } : {}) },
506
+ body,
507
+ })
508
+ if (response.status !== 201 && response.status !== 204 && response.status !== 200) {
509
+ throw new Error(`PUT ${path} answered ${response.status}`)
510
+ }
511
+ return response.headers.etag ?? null
512
+ }
513
+
514
+ /** Remove a resource; a missing one is success. */
515
+ async delete(path) {
516
+ const response = await this.request('DELETE', path)
517
+ if (response.status === 404) return false
518
+ if (response.status !== 204 && response.status !== 200) throw new Error(`DELETE ${path} answered ${response.status}`)
519
+ return true
520
+ }
521
+
522
+ /**
523
+ * Rename within the base.
524
+ *
525
+ * `overwrite: false` is the default because a shard that already exists is data, and the
526
+ * sync uploads to a temporary name first precisely so that the move either lands whole or
527
+ * not at all.
528
+ */
529
+ async move(from, to, { overwrite = false, sourceEtag = null } = {}) {
530
+ const destination = this.url(to)
531
+ const response = await this.request('MOVE', from, {
532
+ headers: {
533
+ destination: destination.href,
534
+ overwrite: overwrite ? 'T' : 'F',
535
+ ...(sourceEtag ? { 'if-match': sourceEtag } : {}),
536
+ },
537
+ })
538
+ if (response.status === 201 || response.status === 204) return true
539
+ if (response.status === 412) throw new Error(`MOVE ${from} refused: the destination already exists (overwrite is off)`)
540
+ throw new Error(`MOVE ${from} answered ${response.status}`)
541
+ }
542
+
543
+ /**
544
+ * `PROPFIND` at depth 1, returned as the bare list of child hrefs.
545
+ *
546
+ * Parsed with two regexes rather than an XML parser: the sync needs names, whether each is
547
+ * a collection, and sizes — nothing else. A resource whose `href` cannot be read is skipped
548
+ * rather than failing the whole listing, because a single odd entry (Apache leaves `|`-suffixed
549
+ * temporaries behind when a write fails) must not stop a sync.
550
+ */
551
+ async propfind(path = '', { depth = '1' } = {}) {
552
+ const response = await this.request('PROPFIND', path, {
553
+ headers: { depth, 'content-type': 'application/xml; charset=utf-8' },
554
+ body: '<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>',
555
+ expect: 207,
556
+ })
557
+ const xml = response.body.toString('utf8')
558
+ const basePath = this.baseUrl.pathname
559
+ const entries = []
560
+ for (const block of xml.match(/<[^>]*response[\s>][\s\S]*?<\/[^>]*response>/gi) ?? []) {
561
+ const href = /<[^>]*href[^>]*>([\s\S]*?)<\/[^>]*href>/i.exec(block)
562
+ if (!href) continue
563
+ const decoded = href[1].trim().replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
564
+ let pathname
565
+ try {
566
+ pathname = decodeURIComponent(new URL(decoded, this.baseUrl).pathname)
567
+ } catch {
568
+ continue
569
+ }
570
+ const isCollection = /<[^>]*collection\s*\/?>/i.test(block) || /<[^>]*collection\s*>/i.test(block)
571
+ const length = /<[^>]*getcontentlength[^>]*>(\d+)</i.exec(block)
572
+ const relative = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname
573
+ entries.push({
574
+ href: decoded,
575
+ path: relative,
576
+ name: relative.replace(/\/+$/, '').split('/').pop() ?? '',
577
+ collection: isCollection,
578
+ bytes: length ? Number(length[1]) : null,
579
+ })
580
+ }
581
+ // The collection itself is always the first entry; callers want its children.
582
+ return entries.filter((entry) => entry.path.replace(/\/+$/, '') !== path.replace(/^\/+|\/+$/g, ''))
583
+ }
584
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-usage-badge",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -41,6 +41,8 @@
41
41
  "scripts": {
42
42
  "test": "node test/all.mjs",
43
43
  "test:smoke": "node test/smoke.mjs",
44
+ "test:webdav": "node test/webdav.live.mjs",
45
+ "test:sync": "node test/sync.live.mjs",
44
46
  "prepublishOnly": "npm test"
45
47
  },
46
48
  "engines": {