dsh-tiddlywiki 0.19.2 → 0.19.3
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 +4 -0
- package/lib/client.js +1 -1
- package/lib/index.js +333 -141
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/settings-page.ts +2 -2
- package/src/host/admin.ts +153 -52
- package/src/host/clip-bridge.ts +29 -2
- package/src/host/http.ts +35 -0
- package/src/host/routes.ts +141 -44
- package/src/host/session-summary.ts +30 -8
- package/src/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "TiddlyWiki 5 as the DSH persistent knowledge base: fifteen tiddlywiki_* agent tools (filtered search/recent/tags/batch/rewrite/trash/lint + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a right-side wiki tab, a per-session summary tab, a quick-note card with draft auto-save behind a single knowledge FAB, an optional clip bookmarklet that also saves selected images as attachments, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
-
"version": "0.19.
|
|
4
|
+
"version": "0.19.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"smoke:client": "node scripts/smoke-client.mjs",
|
|
86
86
|
"selftest": "node scripts/selftest.mjs",
|
|
87
87
|
"verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs",
|
|
88
|
-
"verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs",
|
|
88
|
+
"verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs",
|
|
89
89
|
"verify:e2e": "node scripts/verify-auth.mjs && node scripts/verify-tools.mjs && node scripts/verify-git-resolve.mjs && node scripts/verify-resilience.mjs",
|
|
90
90
|
"verify:large": "node scripts/verify-large-wiki.mjs",
|
|
91
91
|
"verify": "npm run verify:static && npm run verify:unit && npm run verify:e2e",
|
|
@@ -267,13 +267,13 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
267
267
|
const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
|
|
268
268
|
checkField('ui.sendToAgent.enabled', '启用「发送给 Agent」(TW 笔记 → DSH 会话注入)', sendToAgent.enabled !== false)
|
|
269
269
|
textField('ui.sendToAgent.endpoint', 'TW 端请求基址(空=自动取当前 DSH origin)', typeof sendToAgent.endpoint === 'string' ? sendToAgent.endpoint : '')
|
|
270
|
-
textField('ui.sendToAgent.token', '共享 token(非空时路由校验 x-send-to-agent-token
|
|
270
|
+
textField('ui.sendToAgent.token', '共享 token(非空时路由校验 x-send-to-agent-token 头;已设置时显示为 ********,原样保存=不改,清空=删除)', typeof sendToAgent.token === 'string' ? sendToAgent.token : '')
|
|
271
271
|
const allArticles = (ui.allArticles ?? {}) as Record<string, unknown>
|
|
272
272
|
numField('ui.allArticles.pageSize', '「所有文章」每页条数', typeof allArticles.pageSize === 'number' ? allArticles.pageSize : 10)
|
|
273
273
|
const bridge = (config.bridge ?? {}) as Record<string, unknown>
|
|
274
274
|
checkField('bridge.enabled', '启用「本地剪藏桥」(书签小工具后端监听 127.0.0.1 端口,保存后立即生效)', bridge.enabled === true)
|
|
275
275
|
numField('bridge.port', '剪藏桥端口(改端口需重启 dsh web 生效)', typeof bridge.port === 'number' && bridge.port > 0 ? bridge.port : 8618)
|
|
276
|
-
textField('bridge.token', '剪藏桥 token(非空时校验书签的 x-clip-token
|
|
276
|
+
textField('bridge.token', '剪藏桥 token(非空时校验书签的 x-clip-token 头;强烈建议设置。已设置时显示为 ********,原样保存=不改,清空=删除)', typeof bridge.token === 'string' ? bridge.token : '')
|
|
277
277
|
textField('bridge.tag', '剪藏笔记默认 tag', typeof bridge.tag === 'string' && bridge.tag.trim().length > 0 ? bridge.tag.trim() : 'clip')
|
|
278
278
|
// 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
|
|
279
279
|
// 注意:uiLanguage 目前只影响 TW 侧语言,客户端插件文案(FAB/快速笔记/侧边栏/本页)
|
package/src/host/admin.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { TiddlyWebClient } from './tw-api.ts'
|
|
|
24
24
|
import type { WikiServer } from './wiki.ts'
|
|
25
25
|
import { ROUTE_PREFIX, redactLogLines, redactRemoteUrl, type WebServerFace } from './routes.ts'
|
|
26
26
|
import { CONFIG_TIDDLER, type ConfigStore, type PluginConfigShape } from './config.ts'
|
|
27
|
-
import { readBody, json, rejectCrossSiteWrite, rejectNonRead } from './http.ts'
|
|
27
|
+
import { readBody, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead } from './http.ts'
|
|
28
28
|
import { waitForFileWrite, needsRestartAfterSeeds, flushPendingWrites } from './seeds.ts'
|
|
29
29
|
import { RENDER_PLUGIN_FILE } from './seed-render.ts'
|
|
30
30
|
import { GitFace } from './git.ts'
|
|
@@ -85,21 +85,28 @@ export async function readWikiInfo(wikiPath: string): Promise<WikiInfo> {
|
|
|
85
85
|
}
|
|
86
86
|
throw new Error(`读取 tiddlywiki.info 失败:${err instanceof Error ? err.message : String(err)}`)
|
|
87
87
|
}
|
|
88
|
-
let parsed:
|
|
88
|
+
let parsed: unknown
|
|
89
89
|
try {
|
|
90
|
-
parsed = JSON.parse(raw)
|
|
90
|
+
parsed = JSON.parse(raw)
|
|
91
91
|
} catch {
|
|
92
92
|
throw new Error('tiddlywiki.info 不是合法 JSON;请先修复该文件再保存设置(已保留原文件)')
|
|
93
93
|
}
|
|
94
|
+
// A top-level `null`/array/primitive is NOT a valid tiddlywiki.info; the old
|
|
95
|
+
// code spread it and crashed with "Cannot read properties of null" (a
|
|
96
|
+
// confusing 500 instead of the intended readable error).
|
|
97
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
98
|
+
throw new Error('tiddlywiki.info 顶层不是对象;请先修复该文件再保存设置(已保留原文件)')
|
|
99
|
+
}
|
|
100
|
+
const obj = parsed as Partial<WikiInfo>
|
|
94
101
|
return {
|
|
95
102
|
// Spread FIRST, then the normalized fields: the old order let a literal
|
|
96
103
|
// `"plugins": null` (or a non-array) overwrite the `[]` default and crash
|
|
97
104
|
// every consumer with `.includes is not a function`.
|
|
98
|
-
...
|
|
99
|
-
description:
|
|
100
|
-
plugins: Array.isArray(
|
|
101
|
-
themes: Array.isArray(
|
|
102
|
-
languages: Array.isArray(
|
|
105
|
+
...obj,
|
|
106
|
+
description: obj.description,
|
|
107
|
+
plugins: Array.isArray(obj.plugins) ? obj.plugins : [],
|
|
108
|
+
themes: Array.isArray(obj.themes) ? obj.themes : [],
|
|
109
|
+
languages: Array.isArray(obj.languages) ? obj.languages : [],
|
|
103
110
|
}
|
|
104
111
|
}
|
|
105
112
|
|
|
@@ -297,6 +304,79 @@ export interface AdminDeps {
|
|
|
297
304
|
}
|
|
298
305
|
}
|
|
299
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Sentinel returned instead of a stored secret (`bridge.token` /
|
|
309
|
+
* `ui.sendToAgent.token`) by `GET /admin/state` and by `POST /admin/config`'s
|
|
310
|
+
* echo. `/admin/*` is an unauthenticated surface (only CSRF-hardened), and the
|
|
311
|
+
* config tiddler legitimately carries shared tokens — handing them to any
|
|
312
|
+
* caller of the loopback/LAN-reachable GUI is a real leak (v0.19.3). The
|
|
313
|
+
* settings page renders the sentinel verbatim; posting it back is a no-op
|
|
314
|
+
* (`stripMaskedSecrets` drops it), so the real value never leaves the host.
|
|
315
|
+
*/
|
|
316
|
+
export const MASKED_SECRET = '********'
|
|
317
|
+
|
|
318
|
+
/** Mask secrets (and credentials inside the git remote URL) for an HTTP caller. */
|
|
319
|
+
export function maskConfigSecrets(config: PluginConfigShape): PluginConfigShape {
|
|
320
|
+
const bridge = (config.bridge ?? {}) as Record<string, unknown>
|
|
321
|
+
const ui = (config.ui ?? {}) as Record<string, unknown>
|
|
322
|
+
const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
|
|
323
|
+
const git = (config.git ?? {}) as Record<string, unknown>
|
|
324
|
+
const maskToken = (value: unknown): string => (typeof value === 'string' && value.length > 0 ? MASKED_SECRET : '')
|
|
325
|
+
return {
|
|
326
|
+
...config,
|
|
327
|
+
git: { ...git, remote: typeof git.remote === 'string' ? redactRemoteUrl(git.remote) : git.remote },
|
|
328
|
+
bridge: {
|
|
329
|
+
...bridge,
|
|
330
|
+
token: maskToken(bridge.token),
|
|
331
|
+
tokenSet: typeof bridge.token === 'string' && bridge.token.length > 0,
|
|
332
|
+
},
|
|
333
|
+
ui: {
|
|
334
|
+
...ui,
|
|
335
|
+
sendToAgent: {
|
|
336
|
+
...sendToAgent,
|
|
337
|
+
token: maskToken(sendToAgent.token),
|
|
338
|
+
tokenSet: typeof sendToAgent.token === 'string' && sendToAgent.token.length > 0,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
} as PluginConfigShape
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Drop the masked placeholders from an incoming config patch so saving the
|
|
346
|
+
* settings page never overwrites a stored secret with `********` (or the
|
|
347
|
+
* redacted git remote with `https://***@…`). `bridge.token`/`ui.sendToAgent.token`
|
|
348
|
+
* are cleared only when the caller actually sends an empty string.
|
|
349
|
+
*/
|
|
350
|
+
export function stripMaskedSecrets<T extends Record<string, unknown>>(patch: T, current: PluginConfigShape): T {
|
|
351
|
+
const copy = { ...patch } as Record<string, unknown>
|
|
352
|
+
const cleanToken = (container: unknown): unknown => {
|
|
353
|
+
if (typeof container !== 'object' || container === null) return container
|
|
354
|
+
const obj = { ...(container as Record<string, unknown>) }
|
|
355
|
+
if (obj.token === MASKED_SECRET) delete obj.token
|
|
356
|
+
delete obj.tokenSet
|
|
357
|
+
return obj
|
|
358
|
+
}
|
|
359
|
+
if (copy.bridge !== undefined) copy.bridge = cleanToken(copy.bridge)
|
|
360
|
+
if (copy.ui !== undefined) {
|
|
361
|
+
const ui = typeof copy.ui === 'object' && copy.ui !== null
|
|
362
|
+
? { ...(copy.ui as Record<string, unknown>) }
|
|
363
|
+
: copy.ui
|
|
364
|
+
if (typeof ui === 'object' && ui !== null && (ui as Record<string, unknown>).sendToAgent !== undefined) {
|
|
365
|
+
;(ui as Record<string, unknown>).sendToAgent = cleanToken((ui as Record<string, unknown>).sendToAgent)
|
|
366
|
+
}
|
|
367
|
+
copy.ui = ui
|
|
368
|
+
}
|
|
369
|
+
if (copy.git !== undefined && typeof copy.git === 'object' && copy.git !== null) {
|
|
370
|
+
const git = { ...(copy.git as Record<string, unknown>) }
|
|
371
|
+
const storedRemote = typeof current.git?.remote === 'string' ? current.git.remote : ''
|
|
372
|
+
if (typeof git.remote === 'string' && storedRemote.length > 0 && git.remote === redactRemoteUrl(storedRemote)) {
|
|
373
|
+
delete git.remote
|
|
374
|
+
}
|
|
375
|
+
copy.git = git
|
|
376
|
+
}
|
|
377
|
+
return copy as T
|
|
378
|
+
}
|
|
379
|
+
|
|
300
380
|
export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: AdminDeps): () => void {
|
|
301
381
|
const handleState = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
302
382
|
try {
|
|
@@ -317,7 +397,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
317
397
|
server: { ...view, logs: redactLogLines(view.logs) },
|
|
318
398
|
info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] },
|
|
319
399
|
catalog,
|
|
320
|
-
config: deps.config.get(),
|
|
400
|
+
config: maskConfigSecrets(deps.config.get()),
|
|
321
401
|
git,
|
|
322
402
|
})
|
|
323
403
|
} catch (err) {
|
|
@@ -343,6 +423,10 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
343
423
|
const catalog = await bundledCatalog(deps.twRoot())
|
|
344
424
|
const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name))
|
|
345
425
|
const knownLangs = new Set(catalog.languages.map((c) => c.name))
|
|
426
|
+
/** Snapshot for the no-op guard below (JSON compare is enough here). */
|
|
427
|
+
const beforeInfo = JSON.stringify(info)
|
|
428
|
+
/** Set only when the request carried a themes array with a resolvable active pick. */
|
|
429
|
+
let activatedTheme: string | undefined
|
|
346
430
|
const applyList = (field: 'plugins' | 'themes', raw: unknown): string[] => {
|
|
347
431
|
if (!Array.isArray(raw)) return info[field]
|
|
348
432
|
const next: string[] = []
|
|
@@ -367,45 +451,58 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
367
451
|
}
|
|
368
452
|
return next
|
|
369
453
|
}
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// it to the loaded set so its dependency closure is loaded. Without an
|
|
383
|
-
// explicit pick, activate the deepest loaded overlay (old single-radio).
|
|
384
|
-
const explicitActive = typeof body.themeActive === 'string' && body.themeActive.length > 0
|
|
385
|
-
if (explicitActive) {
|
|
386
|
-
const activeName = body.themeActive as string
|
|
387
|
-
if (known.has(activeName) || info.themes.includes(activeName)) {
|
|
388
|
-
if (!selected.includes(activeName)) selected.push(activeName)
|
|
389
|
-
activatedTheme = activeName
|
|
454
|
+
// Validation + in-memory mutation only: a rejected name must NOT touch
|
|
455
|
+
// tiddlywiki.info (400 straight out), while a failure of the write/restart
|
|
456
|
+
// below is an internal error (500) — the old code reported both as 400.
|
|
457
|
+
try {
|
|
458
|
+
info.plugins = applyList('plugins', body.plugins)
|
|
459
|
+
// Activate the chosen theme: load its full dependency chain AND set
|
|
460
|
+
// `$:/theme` so the browser's themeManager actually applies it (the
|
|
461
|
+
// `themes` array alone only makes the plugin available).
|
|
462
|
+
if (Array.isArray(body.themes)) {
|
|
463
|
+
const themeDeps: Record<string, string[]> = {}
|
|
464
|
+
for (const theme of catalog.themes) {
|
|
465
|
+
if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents
|
|
390
466
|
}
|
|
467
|
+
const selected = applyList('themes', body.themes)
|
|
468
|
+
// Explicit active-theme pick (new two-layer UI): validate and auto-add
|
|
469
|
+
// it to the loaded set so its dependency closure is loaded. Without an
|
|
470
|
+
// explicit pick, activate the deepest loaded overlay (old single-radio).
|
|
471
|
+
const explicitActive = typeof body.themeActive === 'string' && body.themeActive.length > 0
|
|
472
|
+
if (explicitActive) {
|
|
473
|
+
const activeName = body.themeActive as string
|
|
474
|
+
if (known.has(activeName) || info.themes.includes(activeName)) {
|
|
475
|
+
if (!selected.includes(activeName)) selected.push(activeName)
|
|
476
|
+
activatedTheme = activeName
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
info.themes = normalizeThemes(selected, themeDeps)
|
|
480
|
+
if (activatedTheme === undefined && info.themes.length > 0) {
|
|
481
|
+
activatedTheme = info.themes[info.themes.length - 1]
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
info.themes = applyList('themes', body.themes)
|
|
391
485
|
}
|
|
392
|
-
info.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
486
|
+
if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages)
|
|
487
|
+
} catch (err) {
|
|
488
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
// NO-OP GUARD (v0.19.3): a body that changes nothing (e.g. `{}`, or the
|
|
492
|
+
// same lists re-sent) used to rewrite tiddlywiki.info + restart TW — a
|
|
493
|
+
// visible TW interruption for a request that asked for nothing.
|
|
494
|
+
const changed = JSON.stringify(info) !== beforeInfo
|
|
495
|
+
if (changed) {
|
|
496
|
+
await writeWikiInfo(wikiPath, info)
|
|
497
|
+
await deps.server.restart()
|
|
398
498
|
}
|
|
399
|
-
if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages)
|
|
400
|
-
await writeWikiInfo(wikiPath, info)
|
|
401
|
-
await deps.server.restart()
|
|
402
499
|
// Activate the chosen theme tiddler (mirrors TW's own Control Panel).
|
|
403
|
-
if (activatedTheme !== undefined) {
|
|
500
|
+
if (Array.isArray(body.themes) && activatedTheme !== undefined) {
|
|
404
501
|
const client = deps.getClient()
|
|
405
502
|
if (client !== undefined) {
|
|
406
503
|
await client
|
|
407
504
|
.put({ title: '$:/theme', text: `$:/themes/${activatedTheme}`, type: 'text/vnd.tiddlywiki', tags: [] })
|
|
408
|
-
.catch(() =>
|
|
505
|
+
.catch((err) => { console.warn('[dsh-tiddlywiki] activating theme failed:', err) })
|
|
409
506
|
}
|
|
410
507
|
}
|
|
411
508
|
// After a languages change, pin the active language tiddler: first
|
|
@@ -416,7 +513,8 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
416
513
|
if (client !== undefined) {
|
|
417
514
|
const langs = info.languages ?? []
|
|
418
515
|
const active = langs.length > 0 ? `$:/languages/${langs[0]}` : '$:/languages/en-GB'
|
|
419
|
-
await client.put({ title: '$:/language', text: active, type: 'text/plain', tags: [] })
|
|
516
|
+
await client.put({ title: '$:/language', text: active, type: 'text/plain', tags: [] })
|
|
517
|
+
.catch((err) => { console.warn('[dsh-tiddlywiki] pinning $:/language failed:', err) })
|
|
420
518
|
// Keep the startup auto-apply hint (config uiLanguage) consistent with
|
|
421
519
|
// the active language, so a later dsh-web restart doesn't re-enable
|
|
422
520
|
// a language the user just disabled here.
|
|
@@ -426,9 +524,9 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
426
524
|
}
|
|
427
525
|
}
|
|
428
526
|
}
|
|
429
|
-
json(res, { ok: true, info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] } })
|
|
527
|
+
json(res, { ok: true, changed, info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] } })
|
|
430
528
|
} catch (err) {
|
|
431
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
529
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
432
530
|
}
|
|
433
531
|
}
|
|
434
532
|
|
|
@@ -441,8 +539,10 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
441
539
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
442
540
|
return
|
|
443
541
|
}
|
|
444
|
-
|
|
445
|
-
|
|
542
|
+
// Never persist the masked placeholders the page read back from /state
|
|
543
|
+
// (v0.19.3): saving an untouched form must not clobber a real token.
|
|
544
|
+
await deps.config.set(client, stripMaskedSecrets(body as Record<string, unknown>, deps.config.get()) as PluginConfigShape)
|
|
545
|
+
json(res, { ok: true, config: maskConfigSecrets(deps.config.get()) })
|
|
446
546
|
} catch (err) {
|
|
447
547
|
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
|
|
448
548
|
}
|
|
@@ -552,14 +652,15 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
552
652
|
}
|
|
553
653
|
}
|
|
554
654
|
|
|
655
|
+
// Same rejection safety net as routes.ts (v0.19.3).
|
|
555
656
|
const disposers = [
|
|
556
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/state`, handler: (
|
|
557
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/info`, handler: (
|
|
558
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/config`, handler: (
|
|
559
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/restart`, handler: (
|
|
560
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds`, handler: (
|
|
561
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/run`, handler: (
|
|
562
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/remove`, handler: (
|
|
657
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/state`, handler: guardHandler(handleState) }),
|
|
658
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/info`, handler: guardHandler(handleInfo) }),
|
|
659
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/config`, handler: guardHandler(handleConfig) }),
|
|
660
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/restart`, handler: guardHandler(handleRestart) }),
|
|
661
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds`, handler: guardHandler(handleSeeds) }),
|
|
662
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/run`, handler: guardHandler(handleSeedsRun) }),
|
|
663
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/seeds/remove`, handler: guardHandler(handleSeedsRemove) }),
|
|
563
664
|
]
|
|
564
665
|
return () => {
|
|
565
666
|
for (const dispose of disposers) dispose()
|
package/src/host/clip-bridge.ts
CHANGED
|
@@ -137,8 +137,14 @@ const PRIVATE_V4_PATTERNS = [
|
|
|
137
137
|
/^169\.254\./, // link-local (cloud metadata 169.254.169.254)
|
|
138
138
|
/^172\.(1[6-9]|2\d|3[01])\./, // private
|
|
139
139
|
/^192\.0\.0\./, // IETF protocol assignments
|
|
140
|
+
/^192\.0\.2\./, // TEST-NET-1
|
|
141
|
+
/^192\.88\.99\./, // 6to4 relay anycast
|
|
140
142
|
/^192\.168\./, // private
|
|
141
|
-
/^198\.1[89]\./, // benchmarking
|
|
143
|
+
/^198\.1[89]\./, // benchmarking 198.18/15
|
|
144
|
+
/^198\.51\.100\./, // TEST-NET-2
|
|
145
|
+
/^203\.0\.113\./, // TEST-NET-3
|
|
146
|
+
/^22[4-9]\./, /^23\d\./, // multicast 224/4
|
|
147
|
+
/^24\d\./, /^25[0-5]\./, // reserved 240/4 (incl. 255.255.255.255 broadcast)
|
|
142
148
|
]
|
|
143
149
|
|
|
144
150
|
/** Parse a dotted-quad IPv4 into 4 bytes, or null. */
|
|
@@ -659,6 +665,13 @@ export class ClipBridge {
|
|
|
659
665
|
}
|
|
660
666
|
const onListening = (): void => {
|
|
661
667
|
server.removeListener('error', onError)
|
|
668
|
+
// Replace the bind-time handler with a PERMANENT one (v0.19.3): without
|
|
669
|
+
// any 'error' listener a later server error (e.g. EMFILE/ECONNRESET on a
|
|
670
|
+
// accept) is an unhandled 'error' event → Node throws → the whole dsh
|
|
671
|
+
// web process dies. Log and keep serving.
|
|
672
|
+
server.on('error', (err: Error) => {
|
|
673
|
+
this.deps.log?.(`server error after listen: ${err.message}`)
|
|
674
|
+
})
|
|
662
675
|
const addr = server.address() as AddressInfo
|
|
663
676
|
this.boundPort = addr.port
|
|
664
677
|
resolve()
|
|
@@ -675,8 +688,22 @@ export class ClipBridge {
|
|
|
675
688
|
this.server = undefined
|
|
676
689
|
this.boundPort = 0
|
|
677
690
|
if (server === undefined) return Promise.resolve()
|
|
691
|
+
// `server.close()` only resolves once every open connection ends, and the
|
|
692
|
+
// bookmarklet's keep-alive socket can hold it open forever — the effect
|
|
693
|
+
// disposer (plugin unload) would then hang (v0.19.3). Force-close the
|
|
694
|
+
// connections and cap the wait.
|
|
678
695
|
return new Promise<void>((resolve) => {
|
|
679
|
-
|
|
696
|
+
let done = false
|
|
697
|
+
const finish = (): void => {
|
|
698
|
+
if (done) return
|
|
699
|
+
done = true
|
|
700
|
+
resolve()
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
;(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.()
|
|
704
|
+
} catch { /* not available on this Node */ }
|
|
705
|
+
server.close(() => finish())
|
|
706
|
+
setTimeout(finish, 1_000).unref?.()
|
|
680
707
|
})
|
|
681
708
|
}
|
|
682
709
|
|
package/src/host/http.ts
CHANGED
|
@@ -75,6 +75,41 @@ export function json(res: ServerResponse, payload: unknown, status = 200): void
|
|
|
75
75
|
res.end(JSON.stringify(payload))
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Map a thrown error to an HTTP status. Oversized bodies are a client problem
|
|
80
|
+
* (413), everything else is ours (500) — the routes used to answer 500 for
|
|
81
|
+
* `/note` and `/admin/*` but 413 for `/upload` for the identical condition.
|
|
82
|
+
*/
|
|
83
|
+
export function errorStatus(err: unknown): number {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
85
|
+
return /body too large/i.test(message) ? 413 : 500
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The route-handler shape the registries take. */
|
|
89
|
+
export type RouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Wrap an async route handler so a rejected promise can never become an
|
|
93
|
+
* unhandled rejection (v0.19.3).
|
|
94
|
+
*
|
|
95
|
+
* WHY: every registration used to be `void handleX(req, res)`, and the host
|
|
96
|
+
* installs no `unhandledRejection` handler — Node's default policy then EXITS
|
|
97
|
+
* the whole dsh web process, while the client request hangs with no response.
|
|
98
|
+
* A handler that throws (including a sync throw before its own try block, e.g.
|
|
99
|
+
* `new URL(req.url)` outside the try in the proxies) now always ends in a
|
|
100
|
+
* response: 413/500 JSON, or a bare `end()` when headers are already sent.
|
|
101
|
+
*/
|
|
102
|
+
export function guardHandler(handler: RouteHandler): (req: IncomingMessage, res: ServerResponse) => void {
|
|
103
|
+
return (req, res) => {
|
|
104
|
+
void handler(req, res).catch((err) => {
|
|
105
|
+
try {
|
|
106
|
+
if (!res.headersSent) json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
107
|
+
else res.end()
|
|
108
|
+
} catch { /* response already gone */ }
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
/**
|
|
79
114
|
* True when a NON-GET/HEAD/OPTIONS request is a cross-site (CSRF) request.
|
|
80
115
|
*
|