@goodandready/dsh-clinebot 0.3.8 → 0.3.10
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/CHANGELOG.md +32 -0
- package/README.md +15 -2
- package/{docs/README.ru.md → README.ru.md} +14 -1
- package/{docs/README.zh.md → README.zh.md} +14 -1
- package/lib/client.js +135 -99
- package/lib/cline-client.js +207 -117
- package/lib/http.js +62 -2
- package/lib/index.js +71 -148
- package/lib/updater.js +276 -0
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -82
package/lib/cline-client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ClineBot / ClinePass client helpers for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
4
|
* Implements OpenAI-compatible chat completions interface, usage quota tracking,
|
|
5
|
-
* and secure credential storage
|
|
5
|
+
* Stale-While-Revalidate caching, smart failover, and secure credential storage.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -19,6 +19,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
|
|
|
19
19
|
export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
|
|
20
20
|
export const DEFAULT_TIMEOUT_MS = 15000
|
|
21
21
|
export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
|
|
22
|
+
export const USAGE_CACHE_TTL_MS = 60000
|
|
22
23
|
|
|
23
24
|
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
|
|
24
25
|
|
|
@@ -130,8 +131,8 @@ function abortAfter(ms) {
|
|
|
130
131
|
return { signal: ac.signal, cancel: () => clearTimeout(timer) }
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
// In-memory
|
|
134
|
-
const usageCache = new Map()
|
|
134
|
+
// In-memory caches for quota queries and host health probes
|
|
135
|
+
export const usageCache = new Map()
|
|
135
136
|
export const probeCache = new Map()
|
|
136
137
|
|
|
137
138
|
export function clearProbeCache() {
|
|
@@ -155,7 +156,6 @@ export async function retryWithBackoff(fn, {
|
|
|
155
156
|
while (true) {
|
|
156
157
|
try {
|
|
157
158
|
const res = await fn()
|
|
158
|
-
// If HTTP response-like object with 5xx or 429 status and can retry
|
|
159
159
|
if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
|
|
160
160
|
attempt++
|
|
161
161
|
const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
|
|
@@ -179,14 +179,13 @@ export async function retryWithBackoff(fn, {
|
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
181
|
* Fetch official ClinePass rate limits and account quota.
|
|
182
|
-
*
|
|
183
|
-
* - GET /users/me/plan/usage-limits (5-hour, weekly, monthly rolling limits)
|
|
184
|
-
* - GET /users/me (account metadata)
|
|
182
|
+
* Uses Stale-While-Revalidate (SWR) caching with keep-alive and network retry.
|
|
185
183
|
*/
|
|
186
184
|
export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
187
185
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
188
186
|
fetchImpl = fetch,
|
|
189
187
|
bypassCache = false,
|
|
188
|
+
ttlMs = USAGE_CACHE_TTL_MS,
|
|
190
189
|
} = {}) {
|
|
191
190
|
const base = normalizeBaseUrl(baseUrl)
|
|
192
191
|
if (!apiKey) {
|
|
@@ -196,126 +195,144 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
|
|
|
196
195
|
const cacheKey = `cline:usage:${apiKey.slice(-8)}`
|
|
197
196
|
const now = Date.now()
|
|
198
197
|
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const { signal, cancel } = abortAfter(timeoutMs)
|
|
207
|
-
try {
|
|
208
|
-
const headers = {
|
|
209
|
-
Authorization: `Bearer ${apiKey}`,
|
|
210
|
-
Accept: 'application/json',
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// 1. Fetch usage limits
|
|
214
|
-
const limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
|
|
215
|
-
method: 'GET',
|
|
216
|
-
headers,
|
|
217
|
-
signal,
|
|
218
|
-
})
|
|
198
|
+
const doFetch = async () => {
|
|
199
|
+
const { signal, cancel } = abortAfter(timeoutMs)
|
|
200
|
+
try {
|
|
201
|
+
const headers = {
|
|
202
|
+
Authorization: `Bearer ${apiKey}`,
|
|
203
|
+
Accept: 'application/json',
|
|
204
|
+
}
|
|
219
205
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
206
|
+
let limitsRes
|
|
207
|
+
try {
|
|
208
|
+
limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
|
|
209
|
+
method: 'GET',
|
|
210
|
+
headers,
|
|
211
|
+
signal,
|
|
212
|
+
keepalive: true,
|
|
213
|
+
})
|
|
214
|
+
} catch (netErr) {
|
|
215
|
+
// Quick 1-retry fallback on transient network drop
|
|
216
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
217
|
+
limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
|
|
218
|
+
method: 'GET',
|
|
219
|
+
headers,
|
|
220
|
+
signal,
|
|
221
|
+
keepalive: true,
|
|
222
|
+
})
|
|
226
223
|
}
|
|
227
|
-
}
|
|
228
224
|
|
|
229
|
-
|
|
230
|
-
|
|
225
|
+
if (!limitsRes.ok) {
|
|
226
|
+
const errText = await limitsRes.text().catch(() => '')
|
|
227
|
+
const outcome = {
|
|
228
|
+
ok: false,
|
|
229
|
+
status: limitsRes.status,
|
|
230
|
+
error: `ClinePass limits error (HTTP ${limitsRes.status}): ${errText.slice(0, 150)}`,
|
|
231
|
+
checkedAt: Date.now(),
|
|
232
|
+
}
|
|
233
|
+
if (!usageCache.has(cacheKey)) {
|
|
234
|
+
usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
|
|
235
|
+
}
|
|
236
|
+
return outcome
|
|
237
|
+
}
|
|
231
238
|
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
percentUsed
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
const limitsData = await limitsRes.json().catch(() => ({}))
|
|
240
|
+
const rawLimits = limitsData?.data?.limits || limitsData?.limits || []
|
|
241
|
+
|
|
242
|
+
const parseWindow = (type) => {
|
|
243
|
+
const found = Array.isArray(rawLimits) ? rawLimits.find((l) => l.type === type) : null
|
|
244
|
+
if (!found) return null
|
|
245
|
+
const percentUsed = typeof found.percentUsed === 'number'
|
|
246
|
+
? Math.max(0, Math.min(100, Math.round(found.percentUsed * 10) / 10))
|
|
247
|
+
: 0
|
|
248
|
+
const remainingPercent = Math.max(0, Math.round((100 - percentUsed) * 10) / 10)
|
|
249
|
+
return {
|
|
250
|
+
type,
|
|
251
|
+
percentUsed,
|
|
252
|
+
remainingPercent,
|
|
253
|
+
resetsAt: found.resetsAt || null,
|
|
254
|
+
}
|
|
244
255
|
}
|
|
245
|
-
}
|
|
246
256
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
257
|
+
const fiveHour = parseWindow('5-hour')
|
|
258
|
+
const weekly = parseWindow('weekly')
|
|
259
|
+
const monthly = parseWindow('monthly')
|
|
250
260
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
let dynamicModels = []
|
|
261
|
+
let userEmail = null
|
|
262
|
+
let createdAt = null
|
|
263
|
+
let planDisplayName = 'ClinePass ($9.99/mo)'
|
|
264
|
+
let dynamicModels = []
|
|
256
265
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (meRes && meRes.ok) {
|
|
264
|
-
const meData = await meRes.json().catch(() => ({}))
|
|
265
|
-
const me = meData?.data || meData?.user || meData
|
|
266
|
-
userEmail = me?.email || null
|
|
267
|
-
createdAt = me?.createdAt || null
|
|
268
|
-
}
|
|
266
|
+
try {
|
|
267
|
+
const [meRes, planRes] = await Promise.all([
|
|
268
|
+
fetchImpl(`${base}/users/me`, { method: 'GET', headers, signal, keepalive: true }).catch(() => null),
|
|
269
|
+
fetchImpl(`${base}/users/me/plan`, { method: 'GET', headers, signal, keepalive: true }).catch(() => null),
|
|
270
|
+
])
|
|
269
271
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
planDisplayName = planObj.displayName
|
|
272
|
+
if (meRes?.ok) {
|
|
273
|
+
const meData = await meRes.json().catch(() => ({}))
|
|
274
|
+
userEmail = meData?.data?.email || meData?.email || null
|
|
275
|
+
createdAt = meData?.data?.createdAt || meData?.createdAt || null
|
|
275
276
|
}
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
|
|
278
|
+
if (planRes?.ok) {
|
|
279
|
+
const planData = await planRes.json().catch(() => ({}))
|
|
280
|
+
const plan = planData?.data || planData
|
|
281
|
+
if (plan?.title || plan?.name) {
|
|
282
|
+
planDisplayName = `${plan.title || plan.name} ($${((plan.priceInCents || 999) / 100).toFixed(2)}/mo)`
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(plan?.includedModels)) {
|
|
285
|
+
dynamicModels = parsePlanIncludedModels(plan.includedModels)
|
|
281
286
|
}
|
|
282
287
|
}
|
|
288
|
+
} catch {
|
|
289
|
+
/* best-effort secondary details */
|
|
283
290
|
}
|
|
284
|
-
} catch {
|
|
285
|
-
/* ignore user metadata / plan failure */
|
|
286
|
-
}
|
|
287
291
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
292
|
+
const result = {
|
|
293
|
+
ok: true,
|
|
294
|
+
plan: planDisplayName,
|
|
295
|
+
user: { email: userEmail, createdAt },
|
|
296
|
+
windows: { fiveHour, weekly, monthly },
|
|
297
|
+
dynamicModels,
|
|
298
|
+
checkedAt: Date.now(),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
usageCache.set(cacheKey, { data: result, expiresAt: Date.now() + ttlMs, isRevalidating: false })
|
|
302
|
+
return result
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const outcome = {
|
|
305
|
+
ok: false,
|
|
306
|
+
error: String(err?.message || err),
|
|
307
|
+
checkedAt: Date.now(),
|
|
308
|
+
}
|
|
309
|
+
if (!usageCache.has(cacheKey)) {
|
|
310
|
+
usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
|
|
311
|
+
}
|
|
312
|
+
return outcome
|
|
313
|
+
} finally {
|
|
314
|
+
cancel()
|
|
302
315
|
}
|
|
316
|
+
}
|
|
303
317
|
|
|
304
|
-
|
|
305
|
-
usageCache.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
ok: false,
|
|
310
|
-
error: String(err?.message || err),
|
|
318
|
+
if (!bypassCache && usageCache.has(cacheKey)) {
|
|
319
|
+
const cached = usageCache.get(cacheKey)
|
|
320
|
+
if (cached.expiresAt > now) {
|
|
321
|
+
return cached.data
|
|
311
322
|
}
|
|
312
|
-
|
|
313
|
-
|
|
323
|
+
// SWR: return stale data immediately, revalidate asynchronously
|
|
324
|
+
if (!cached.isRevalidating) {
|
|
325
|
+
cached.isRevalidating = true
|
|
326
|
+
doFetch().catch(() => {})
|
|
327
|
+
}
|
|
328
|
+
return cached.data
|
|
314
329
|
}
|
|
330
|
+
|
|
331
|
+
return doFetch()
|
|
315
332
|
}
|
|
316
333
|
|
|
317
334
|
/**
|
|
318
|
-
*
|
|
335
|
+
* Health check probe with SWR caching.
|
|
319
336
|
*/
|
|
320
337
|
export async function probeHealth(baseUrl, {
|
|
321
338
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
@@ -323,15 +340,16 @@ export async function probeHealth(baseUrl, {
|
|
|
323
340
|
bypassCache = false,
|
|
324
341
|
ttlMs = 25000,
|
|
325
342
|
} = {}) {
|
|
326
|
-
const
|
|
327
|
-
const
|
|
343
|
+
const base = normalizeBaseUrl(baseUrl)
|
|
344
|
+
const root = base.replace(/\/api\/v1$/i, '')
|
|
345
|
+
const cacheKey = `cline:health:${root}`
|
|
328
346
|
const now = Date.now()
|
|
329
347
|
|
|
330
348
|
const doProbe = async () => {
|
|
331
349
|
const { signal, cancel } = abortAfter(timeoutMs)
|
|
332
350
|
const start = Date.now()
|
|
333
351
|
try {
|
|
334
|
-
const res = await
|
|
352
|
+
const res = await retryWithBackoff(async () => {
|
|
335
353
|
return await fetchImpl(root, { method: 'HEAD', signal, keepalive: true })
|
|
336
354
|
})
|
|
337
355
|
const latencyMs = Date.now() - start
|
|
@@ -365,7 +383,6 @@ export async function probeHealth(baseUrl, {
|
|
|
365
383
|
if (cached.expiresAt > now) {
|
|
366
384
|
return cached.data
|
|
367
385
|
}
|
|
368
|
-
// Stale-While-Revalidate: trigger async refresh and return stale snapshot immediately
|
|
369
386
|
if (!cached.isRevalidating) {
|
|
370
387
|
cached.isRevalidating = true
|
|
371
388
|
doProbe().catch(() => {})
|
|
@@ -476,8 +493,27 @@ export function buildPiAiProvider({
|
|
|
476
493
|
input: hasImage ? ['text', 'image'] : ['text'],
|
|
477
494
|
provider: PROVIDER_ID,
|
|
478
495
|
}
|
|
496
|
+
const validEfforts = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
479
497
|
if (Array.isArray(item.reasoningEfforts) && item.reasoningEfforts.length) {
|
|
480
|
-
|
|
498
|
+
const efforts = {}
|
|
499
|
+
for (const effort of item.reasoningEfforts) {
|
|
500
|
+
const key = String(effort).trim().toLowerCase()
|
|
501
|
+
if (validEfforts.includes(key)) {
|
|
502
|
+
efforts[key] = key
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
res.reasoningEfforts = Object.keys(efforts).length ? efforts : false
|
|
506
|
+
} else if (item.reasoningEfforts && typeof item.reasoningEfforts === 'object' && !Array.isArray(item.reasoningEfforts)) {
|
|
507
|
+
const efforts = {}
|
|
508
|
+
for (const [k, v] of Object.entries(item.reasoningEfforts)) {
|
|
509
|
+
const key = String(k).trim().toLowerCase()
|
|
510
|
+
if (validEfforts.includes(key)) {
|
|
511
|
+
efforts[key] = typeof v === 'string' && v.trim() ? v.trim() : key
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
res.reasoningEfforts = Object.keys(efforts).length ? efforts : false
|
|
515
|
+
} else {
|
|
516
|
+
res.reasoningEfforts = false
|
|
481
517
|
}
|
|
482
518
|
return res
|
|
483
519
|
})
|
|
@@ -492,7 +528,7 @@ export function buildPiAiProvider({
|
|
|
492
528
|
}
|
|
493
529
|
|
|
494
530
|
/**
|
|
495
|
-
* Safely resolve key value from DSH credentials or process.env.
|
|
531
|
+
* Safely resolve key value from DSH credentials service or process.env.
|
|
496
532
|
*/
|
|
497
533
|
export async function resolveKeyValue(ctx, apiKeyEnv) {
|
|
498
534
|
const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
|
|
@@ -551,7 +587,28 @@ export async function resolveAccountPool(ctx, cfg) {
|
|
|
551
587
|
}
|
|
552
588
|
|
|
553
589
|
/**
|
|
554
|
-
*
|
|
590
|
+
* Check if a cached quota entry is currently exhausted.
|
|
591
|
+
* Checks 5-hour rolling limit and verifies if resetsAt timestamp has already elapsed.
|
|
592
|
+
*/
|
|
593
|
+
export function isAccountQuotaExhausted(usage) {
|
|
594
|
+
if (!usage?.windows?.fiveHour) return false
|
|
595
|
+
const fiveHour = usage.windows.fiveHour
|
|
596
|
+
if (typeof fiveHour.percentUsed !== 'number' || fiveHour.percentUsed < 95) {
|
|
597
|
+
return false
|
|
598
|
+
}
|
|
599
|
+
// Auto-recovery: if resetsAt is present and in the past, the account is recovered
|
|
600
|
+
if (fiveHour.resetsAt) {
|
|
601
|
+
const resetTime = new Date(fiveHour.resetsAt).getTime()
|
|
602
|
+
if (!Number.isNaN(resetTime) && Date.now() >= resetTime) {
|
|
603
|
+
return false
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return true
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Smart Quota-Aware Failover: rotates active account upon 429 or quota exhaustion.
|
|
611
|
+
* Prioritizes accounts with lowest percentUsed and respects resetsAt recovery.
|
|
555
612
|
*/
|
|
556
613
|
export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
|
|
557
614
|
const pool = await resolveAccountPool(ctx, cfg)
|
|
@@ -562,8 +619,33 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
|
|
|
562
619
|
|
|
563
620
|
const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
|
|
564
621
|
const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
|
|
565
|
-
|
|
566
|
-
|
|
622
|
+
|
|
623
|
+
// Candidate pool excluding current account if possible
|
|
624
|
+
const candidates = configured.filter((acc) => acc.apiKeyEnv !== active)
|
|
625
|
+
if (!candidates.length) {
|
|
626
|
+
return { rotated: false, reason, message: 'No alternative accounts configured' }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Assess quota for candidates if cached in memory
|
|
630
|
+
let bestCandidate = null
|
|
631
|
+
let lowestUsagePct = Infinity
|
|
632
|
+
|
|
633
|
+
for (const cand of candidates) {
|
|
634
|
+
const cacheKey = `cline:usage:${cand.value.slice(-8)}`
|
|
635
|
+
const cached = usageCache.get(cacheKey)?.data
|
|
636
|
+
const isExhausted = isAccountQuotaExhausted(cached)
|
|
637
|
+
|
|
638
|
+
if (!isExhausted) {
|
|
639
|
+
const pct = cached?.windows?.fiveHour?.percentUsed ?? 50
|
|
640
|
+
if (pct < lowestUsagePct) {
|
|
641
|
+
lowestUsagePct = pct
|
|
642
|
+
bestCandidate = cand
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Fallback if all candidates are either exhausted or uncached: pick next in round-robin
|
|
648
|
+
const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
|
|
567
649
|
|
|
568
650
|
let updated = false
|
|
569
651
|
if (settingsApi?.replace) {
|
|
@@ -571,7 +653,9 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
|
|
|
571
653
|
const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
|
|
572
654
|
await settingsApi.replace(next)
|
|
573
655
|
updated = true
|
|
574
|
-
} catch {
|
|
656
|
+
} catch (err) {
|
|
657
|
+
ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settingsApi: ' + (err?.message || err))
|
|
658
|
+
}
|
|
575
659
|
} else {
|
|
576
660
|
const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
577
661
|
if (settings?.mutate) {
|
|
@@ -580,10 +664,16 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
|
|
|
580
664
|
{ op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
|
|
581
665
|
])
|
|
582
666
|
updated = true
|
|
583
|
-
} catch {
|
|
667
|
+
} catch (err) {
|
|
668
|
+
ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
|
|
669
|
+
}
|
|
584
670
|
}
|
|
585
671
|
}
|
|
586
672
|
|
|
673
|
+
// Reset cached quota and host probes so new account immediately revalidates
|
|
674
|
+
clearUsageCache()
|
|
675
|
+
clearProbeCache()
|
|
676
|
+
|
|
587
677
|
return {
|
|
588
678
|
rotated: true,
|
|
589
679
|
previousAccount: active,
|
package/lib/http.js
CHANGED
|
@@ -28,7 +28,67 @@ export function readBody(req, maxBytes = 256 * 1024) {
|
|
|
28
28
|
})
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
function header(request, name) {
|
|
32
|
+
const value = request?.headers?.[name.toLowerCase()]
|
|
33
|
+
return Array.isArray(value) ? value[0] : value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isLoopbackAddress(value) {
|
|
37
|
+
const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
38
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
39
|
+
|| address.startsWith('127.')
|
|
40
|
+
|| address.startsWith('::ffff:127.')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Reject cross-site writes while allowing local, LAN, and reverse-proxy UIs. */
|
|
32
44
|
export function isTrustedSettingsRequest(request) {
|
|
33
|
-
|
|
45
|
+
const secFetchSite = header(request, 'sec-fetch-site')
|
|
46
|
+
if (secFetchSite === 'cross-site') {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const host = header(request, 'x-forwarded-host') || header(request, 'host')
|
|
51
|
+
const origin = header(request, 'origin')
|
|
52
|
+
if (origin) {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(origin)
|
|
55
|
+
if (host && url.host.toLowerCase() === host.toLowerCase()) {
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
return false
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const referer = header(request, 'referer')
|
|
68
|
+
if (referer) {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(referer)
|
|
71
|
+
if (host && url.host.toLowerCase() === host.toLowerCase()) {
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Requests without origin/referer (e.g. curl or internal requests): allow if loopback
|
|
84
|
+
if (isLoopbackAddress(request?.socket?.remoteAddress)) {
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If sec-fetch-site is explicitly same-origin or same-site, allow
|
|
89
|
+
if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false
|
|
34
94
|
}
|