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/src/host/routes.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
|
|
23
23
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
24
|
-
import { mkdir, writeFile,
|
|
24
|
+
import { mkdir, writeFile, stat } from 'node:fs/promises'
|
|
25
25
|
import { basename, extname, isAbsolute, join } from 'node:path'
|
|
26
26
|
import { Readable } from 'node:stream'
|
|
27
27
|
import type { TiddlyWebClient } from './tw-api.ts'
|
|
@@ -30,7 +30,7 @@ import type { WikiServer } from './wiki.ts'
|
|
|
30
30
|
import type { GitFace } from './git.ts'
|
|
31
31
|
import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
|
|
32
32
|
import { writeSessionSummary, type SessionQueryFace } from './session-summary.ts'
|
|
33
|
-
import { readBody, readBodyBuffer, json, rejectCrossSiteWrite, rejectNonRead, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
|
|
33
|
+
import { readBody, readBodyBuffer, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
|
|
34
34
|
import { sanitizeTwFragment } from './sanitize.ts'
|
|
35
35
|
import { flushPendingWrites } from './seeds.ts'
|
|
36
36
|
import { WriteConflictError, assertNoConflict, buildWriteTiddler, flattenTiddlerFields } from './write-policy.ts'
|
|
@@ -247,6 +247,11 @@ function sanitizeUploadName(input: unknown): string {
|
|
|
247
247
|
.replace(/^\.+/, '')
|
|
248
248
|
.trim()
|
|
249
249
|
if (name.length === 0 || name === '.' || name === '..') return ''
|
|
250
|
+
// Windows stores a trailing dot/space verbatim but never strips it, and
|
|
251
|
+
// `extname('evil.html.')` is just `.` — so the extension denylist was
|
|
252
|
+
// bypassable with `evil.html.` (v0.19.3). Canonicalize before the check.
|
|
253
|
+
name = name.replace(/[. ]+$/, '')
|
|
254
|
+
if (name.length === 0) return ''
|
|
250
255
|
// `CON`, `NUL`, `COM1…` are not creatable on Windows — prefix instead of 500.
|
|
251
256
|
if (WINDOWS_RESERVED_NAMES.test(name)) name = `_${name}`
|
|
252
257
|
if (name.length > 160) name = name.slice(0, 160)
|
|
@@ -482,6 +487,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
482
487
|
json(res, { ok: false, error: 'session is required' }, 400)
|
|
483
488
|
return
|
|
484
489
|
}
|
|
490
|
+
// The id becomes part of a tiddler title (`$:/temp/dsh/session-summary/<id>`)
|
|
491
|
+
// and is echoed into the summary wikitext — keep it to a safe charset
|
|
492
|
+
// instead of trusting the request body (v0.19.3).
|
|
493
|
+
if (!/^[A-Za-z0-9._:-]{1,120}$/.test(session)) {
|
|
494
|
+
json(res, { ok: false, error: 'session id has an unsupported format' }, 400)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
485
497
|
const client = deps.getClient()
|
|
486
498
|
if (client === undefined) {
|
|
487
499
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
@@ -495,7 +507,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
495
507
|
const result = await writeSessionSummary(client, sq, session)
|
|
496
508
|
json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
|
|
497
509
|
} catch (err) {
|
|
498
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
510
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
499
511
|
}
|
|
500
512
|
}
|
|
501
513
|
|
|
@@ -545,7 +557,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
545
557
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
546
558
|
json(res, { ok: true, items })
|
|
547
559
|
} catch (err) {
|
|
548
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
560
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
549
561
|
}
|
|
550
562
|
}
|
|
551
563
|
|
|
@@ -622,7 +634,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
622
634
|
permissions,
|
|
623
635
|
})
|
|
624
636
|
} catch (err) {
|
|
625
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
637
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
626
638
|
}
|
|
627
639
|
}
|
|
628
640
|
|
|
@@ -659,7 +671,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
659
671
|
}
|
|
660
672
|
json(res, { ok: true, requestId, sessionId })
|
|
661
673
|
} catch (err) {
|
|
662
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
674
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
663
675
|
}
|
|
664
676
|
}
|
|
665
677
|
|
|
@@ -709,6 +721,26 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
709
721
|
json(res, { ok: false, error: 'session service unavailable' }, 503)
|
|
710
722
|
return
|
|
711
723
|
}
|
|
724
|
+
// Validate the agent preset (工作模式) BEFORE any side effect (v0.19.3):
|
|
725
|
+
// the old code created the cwd directory + workspace first, so an unknown
|
|
726
|
+
// mode left an orphaned directory behind a 500 from `sc.create`.
|
|
727
|
+
if (mode !== undefined) {
|
|
728
|
+
const ap = deps.getAgentPresets()
|
|
729
|
+
if (ap === undefined) {
|
|
730
|
+
json(res, { ok: false, error: 'agent presets service unavailable' }, 503)
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const presets = await ap.list()
|
|
735
|
+
if (!presets.some((preset) => preset.id === mode)) {
|
|
736
|
+
json(res, { ok: false, error: `unknown agent preset "${mode}" (available: ${presets.map((preset) => preset.id).join(', ')})` }, 400)
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
} catch (err) {
|
|
740
|
+
json(res, { ok: false, error: `cannot validate agent preset: ${err instanceof Error ? err.message : String(err)}` }, 503)
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
}
|
|
712
744
|
const ws = deps.getWorkspaceRegistry()
|
|
713
745
|
if (cwd.length > 0) {
|
|
714
746
|
// Only absolute paths, and never clobber an existing non-directory:
|
|
@@ -763,7 +795,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
763
795
|
permissionApplied,
|
|
764
796
|
})
|
|
765
797
|
} catch (err) {
|
|
766
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
798
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
767
799
|
}
|
|
768
800
|
}
|
|
769
801
|
|
|
@@ -812,7 +844,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
812
844
|
json(res, { ok: false, error: err.message, conflict: true }, 409)
|
|
813
845
|
return
|
|
814
846
|
}
|
|
815
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
847
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
816
848
|
}
|
|
817
849
|
}
|
|
818
850
|
|
|
@@ -843,7 +875,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
843
875
|
json(res, { ok: false, error: err.message, conflict: true }, 409)
|
|
844
876
|
return
|
|
845
877
|
}
|
|
846
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
878
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
847
879
|
}
|
|
848
880
|
}
|
|
849
881
|
|
|
@@ -875,7 +907,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
875
907
|
items: tags.map((tag) => ({ tag, count: counts.get(tag) ?? 0 })),
|
|
876
908
|
})
|
|
877
909
|
} catch (err) {
|
|
878
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
910
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
879
911
|
}
|
|
880
912
|
}
|
|
881
913
|
|
|
@@ -903,7 +935,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
903
935
|
})),
|
|
904
936
|
})
|
|
905
937
|
} catch (err) {
|
|
906
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
938
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
907
939
|
}
|
|
908
940
|
}
|
|
909
941
|
|
|
@@ -955,7 +987,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
955
987
|
fields: flattenTiddlerFields(t),
|
|
956
988
|
})
|
|
957
989
|
} catch (err) {
|
|
958
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
990
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
959
991
|
}
|
|
960
992
|
}
|
|
961
993
|
|
|
@@ -999,7 +1031,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
999
1031
|
})),
|
|
1000
1032
|
})
|
|
1001
1033
|
} catch (err) {
|
|
1002
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1034
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1003
1035
|
}
|
|
1004
1036
|
}
|
|
1005
1037
|
|
|
@@ -1017,7 +1049,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1017
1049
|
}
|
|
1018
1050
|
json(res, { ok: true, status: deps.server.status().status })
|
|
1019
1051
|
} catch (err) {
|
|
1020
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1052
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1021
1053
|
}
|
|
1022
1054
|
}
|
|
1023
1055
|
|
|
@@ -1087,7 +1119,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1087
1119
|
lastSync: new Date().toISOString(),
|
|
1088
1120
|
}, pushed.ok ? 200 : 502)
|
|
1089
1121
|
} catch (err) {
|
|
1090
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1122
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1091
1123
|
} finally {
|
|
1092
1124
|
endMutation()
|
|
1093
1125
|
}
|
|
@@ -1126,16 +1158,31 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1126
1158
|
}
|
|
1127
1159
|
const filesDir = join(deps.getWikiPath(), 'files')
|
|
1128
1160
|
await mkdir(filesDir, { recursive: true })
|
|
1161
|
+
// Collision avoidance with an ATOMIC create (v0.19.3): the old code did
|
|
1162
|
+
// `access(candidate)` then `writeFile(candidate)`, so (a) any access error
|
|
1163
|
+
// (EACCES/EBUSY, not just ENOENT) was read as "name free" and (b) two
|
|
1164
|
+
// concurrent uploads could both pick the same name and one silently
|
|
1165
|
+
// replaced the other — contradicting the route's own "nothing is ever
|
|
1166
|
+
// overwritten" contract. `flag: 'wx'` fails with EEXIST instead, and we
|
|
1167
|
+
// walk the suffix chain on that error only. Bounded, so a pathological
|
|
1168
|
+
// directory cannot spin this loop forever.
|
|
1129
1169
|
const ext = extname(name)
|
|
1130
1170
|
const stem = ext.length > 0 ? name.slice(0, -ext.length) : name
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
let
|
|
1134
|
-
|
|
1135
|
-
try {
|
|
1136
|
-
|
|
1171
|
+
let candidate = ''
|
|
1172
|
+
let wrote = false
|
|
1173
|
+
for (let i = 0; i <= 10_000 && !wrote; i++) {
|
|
1174
|
+
candidate = i === 0 ? name : `${stem}-${i}${ext}`
|
|
1175
|
+
try {
|
|
1176
|
+
await writeFile(join(filesDir, candidate), buf, { flag: 'wx' })
|
|
1177
|
+
wrote = true
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (!wrote) {
|
|
1183
|
+
json(res, { ok: false, error: '同名文件过多,请换一个文件名' }, 409)
|
|
1184
|
+
return
|
|
1137
1185
|
}
|
|
1138
|
-
await writeFile(join(filesDir, candidate), buf)
|
|
1139
1186
|
deps.autoCommit()
|
|
1140
1187
|
invalidateGitStatus()
|
|
1141
1188
|
json(res, {
|
|
@@ -1216,6 +1263,32 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1216
1263
|
}
|
|
1217
1264
|
}
|
|
1218
1265
|
|
|
1266
|
+
/**
|
|
1267
|
+
* Titles the browser-facing TW proxies must never serve (v0.19.3).
|
|
1268
|
+
*
|
|
1269
|
+
* `$:/plugins/dsh-tiddlywiki/config` holds the shared tokens and the git
|
|
1270
|
+
* remote (possibly with a PAT); both `/tw` and `/api` forward ANY path to the
|
|
1271
|
+
* loopback TW child, whose TiddlyWeb REST answers for `$:/…` titles — a
|
|
1272
|
+
* route-level guard on `/get` was therefore trivially bypassed by
|
|
1273
|
+
* `GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2F…`
|
|
1274
|
+
* (verified). The whole plugin namespace is blocked: nothing under it is
|
|
1275
|
+
* needed by the TW frontend, and the host itself talks to TW directly.
|
|
1276
|
+
*/
|
|
1277
|
+
const BLOCKED_PROXY_TITLE_PREFIXES = ['$:/plugins/dsh-tiddlywiki/']
|
|
1278
|
+
|
|
1279
|
+
/** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
|
|
1280
|
+
const isBlockedProxyPath = (pathname: string): boolean => {
|
|
1281
|
+
const marker = '/tiddlers/'
|
|
1282
|
+
const at = pathname.indexOf(marker)
|
|
1283
|
+
if (at < 0) return false
|
|
1284
|
+
const raw = pathname.slice(at + marker.length)
|
|
1285
|
+
let title = raw
|
|
1286
|
+
try {
|
|
1287
|
+
title = decodeURIComponent(raw)
|
|
1288
|
+
} catch { /* malformed encoding: check the raw form */ }
|
|
1289
|
+
return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1219
1292
|
/** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
|
|
1220
1293
|
const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1221
1294
|
if (rejectCrossSiteWrite(req, res)) return
|
|
@@ -1224,9 +1297,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1224
1297
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1225
1298
|
return
|
|
1226
1299
|
}
|
|
1227
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1228
|
-
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
|
|
1229
1300
|
try {
|
|
1301
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1302
|
+
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
|
|
1303
|
+
if (isBlockedProxyPath(rest)) {
|
|
1304
|
+
json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1230
1307
|
// Share the /tw proxy's header forwarding so `authorization`/`cookie`
|
|
1231
1308
|
// reach the TW child: in locked-down mode (auth.username configured) the
|
|
1232
1309
|
// /api passthrough used to 401 on every call because it dropped them.
|
|
@@ -1266,12 +1343,22 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1266
1343
|
}
|
|
1267
1344
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1268
1345
|
const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
|
|
1346
|
+
if (isBlockedProxyPath(rest)) {
|
|
1347
|
+
json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
|
|
1348
|
+
return
|
|
1349
|
+
}
|
|
1269
1350
|
try {
|
|
1270
1351
|
const method = (req.method ?? 'GET').toUpperCase()
|
|
1271
1352
|
const headers = forwardHeaders(req.headers)
|
|
1272
1353
|
// TW's CSRF gate requires X-Requested-With on writes; forward it through.
|
|
1273
1354
|
if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
|
|
1274
|
-
|
|
1355
|
+
// Abort the upstream fetch when the CLIENT goes away: without this the TW
|
|
1356
|
+
// child kept streaming a large attachment into the DSH process until the
|
|
1357
|
+
// 30s timeout fired, long after the browser had cancelled (v0.19.3).
|
|
1358
|
+
const abort = new AbortController()
|
|
1359
|
+
const timeout = AbortSignal.timeout(30_000)
|
|
1360
|
+
const signal = typeof AbortSignal.any === 'function' ? AbortSignal.any([abort.signal, timeout]) : timeout
|
|
1361
|
+
const init: RequestInit = { method, headers, signal }
|
|
1275
1362
|
if (method === 'PUT' || method === 'POST') init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES)
|
|
1276
1363
|
const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
|
|
1277
1364
|
const responseHeaders: Record<string, string> = {
|
|
@@ -1300,7 +1387,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1300
1387
|
await new Promise<void>((resolveP, rejectP) => {
|
|
1301
1388
|
body.on('error', rejectP)
|
|
1302
1389
|
res.on('error', rejectP)
|
|
1303
|
-
res.on('close', () =>
|
|
1390
|
+
res.on('close', () => {
|
|
1391
|
+
// Client hung up (aborted download / closed tab): stop pulling from the
|
|
1392
|
+
// TW child instead of draining it until the timeout.
|
|
1393
|
+
try { abort.abort() } catch { /* already aborted */ }
|
|
1394
|
+
try { body.destroy() } catch { /* already closed */ }
|
|
1395
|
+
resolveP()
|
|
1396
|
+
})
|
|
1304
1397
|
res.on('finish', () => resolveP())
|
|
1305
1398
|
body.pipe(res)
|
|
1306
1399
|
})
|
|
@@ -1313,25 +1406,29 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1313
1406
|
}
|
|
1314
1407
|
}
|
|
1315
1408
|
|
|
1409
|
+
// Every handler goes through guardHandler (v0.19.3): a rejection — including a
|
|
1410
|
+
// synchronous throw before the handler's own try, e.g. `new URL(req.url)` —
|
|
1411
|
+
// becomes a 413/500 JSON response instead of an unhandled rejection that
|
|
1412
|
+
// would exit the dsh web process with the request hanging.
|
|
1316
1413
|
const disposers = [
|
|
1317
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (
|
|
1318
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (
|
|
1319
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (
|
|
1320
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: (
|
|
1321
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: (
|
|
1322
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: (
|
|
1323
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: (
|
|
1324
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: (
|
|
1325
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (
|
|
1326
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (
|
|
1327
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (
|
|
1328
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: (
|
|
1329
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: (
|
|
1330
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: (
|
|
1331
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: (
|
|
1332
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: (
|
|
1333
|
-
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (
|
|
1334
|
-
ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: (
|
|
1414
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: guardHandler(handleStatus) }),
|
|
1415
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: guardHandler(handleNote) }),
|
|
1416
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: guardHandler(handleEdit) }),
|
|
1417
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: guardHandler(handleTags) }),
|
|
1418
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: guardHandler(handleRecent) }),
|
|
1419
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: guardHandler(handleGet) }),
|
|
1420
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: guardHandler(handleSearch) }),
|
|
1421
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: guardHandler(handleRender) }),
|
|
1422
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: guardHandler(handleSync) }),
|
|
1423
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: guardHandler(handleUpload) }),
|
|
1424
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: guardHandler(handleRestart) }),
|
|
1425
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: guardHandler(handleSessionSummary) }),
|
|
1426
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: guardHandler(handleAgentSessions) }),
|
|
1427
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: guardHandler(handleAgentModes) }),
|
|
1428
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: guardHandler(handleAgentSend) }),
|
|
1429
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: guardHandler(handleAgentCreate) }),
|
|
1430
|
+
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: guardHandler(handleApiProxy) }),
|
|
1431
|
+
ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: guardHandler(handleTwProxy) }),
|
|
1335
1432
|
]
|
|
1336
1433
|
return () => {
|
|
1337
1434
|
for (const dispose of disposers) dispose()
|
|
@@ -204,7 +204,7 @@ function scanSnapshot(snap: SessionLogSnapshot | undefined, subagent: boolean, c
|
|
|
204
204
|
title: args.newTitle,
|
|
205
205
|
action: 'produced',
|
|
206
206
|
time: t,
|
|
207
|
-
detail: old !== undefined ? `重命名自「${old}」` : undefined,
|
|
207
|
+
detail: old !== undefined && isPlausibleTitle(old) ? `重命名自「${old}」` : undefined,
|
|
208
208
|
subagent,
|
|
209
209
|
})
|
|
210
210
|
}
|
|
@@ -243,12 +243,29 @@ function scanSnapshot(snap: SessionLogSnapshot | undefined, subagent: boolean, c
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* 内联文本净化(v0.19.3):汇总 wikitext 会被 TW 渲染成 HTML 片段再注入 DSH 页面,
|
|
248
|
+
* 而 TW 的解析器**原样透传 HTML**。凡是来自会话日志/请求体的字符串(sessionId、
|
|
249
|
+
* rename 的旧标题、检索词)都要先转义 HTML 元字符并去掉控制字符,别指望上层净化。
|
|
250
|
+
*/
|
|
251
|
+
export function escapeInline(text: string, max = 200): string {
|
|
252
|
+
return text
|
|
253
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
254
|
+
.replace(/&/g, '&')
|
|
255
|
+
.replace(/</g, '<')
|
|
256
|
+
.replace(/>/g, '>')
|
|
257
|
+
.replace(/[[\]|]/g, ' ')
|
|
258
|
+
.slice(0, max)
|
|
259
|
+
}
|
|
260
|
+
|
|
246
261
|
/** 当前状态(存在?标签?修改时间?摘要)。 */
|
|
247
262
|
interface TitleState {
|
|
248
263
|
exists: boolean
|
|
249
264
|
tags: string[]
|
|
250
265
|
modified?: string
|
|
251
266
|
snippet?: string
|
|
267
|
+
/** 查询失败(非 404):状态未知,不能报「已删除/不存在」。 */
|
|
268
|
+
unknown?: boolean
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
/** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
|
|
@@ -266,7 +283,10 @@ async function enrichTitles(client: TiddlyWebClient, titles: string[]): Promise<
|
|
|
266
283
|
snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
|
|
267
284
|
}]
|
|
268
285
|
} catch {
|
|
269
|
-
|
|
286
|
+
// 只有 404 才算「不存在」(tw-api 的 get 只在 404 返回 undefined):
|
|
287
|
+
// 超时/5xx/服务重启中的查询失败必须报「状态未知」,否则一篇还在的笔记
|
|
288
|
+
// 会被标成「已删除」——正是项目里反复强调的「读取失败 ≠ 条目不存在」。
|
|
289
|
+
return [title, { exists: false, tags: [], unknown: true }]
|
|
270
290
|
}
|
|
271
291
|
}
|
|
272
292
|
// Bounded batches: parallel within a batch, insertion order preserved across
|
|
@@ -305,7 +325,7 @@ function buildWikitext(
|
|
|
305
325
|
lines.push('! 会话相关 wiki 汇总')
|
|
306
326
|
lines.push('')
|
|
307
327
|
lines.push(
|
|
308
|
-
`本页列出会话 \`${sessionId}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`,
|
|
328
|
+
`本页列出会话 \`${escapeInline(sessionId, 120)}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`,
|
|
309
329
|
)
|
|
310
330
|
lines.push('')
|
|
311
331
|
const producedCount = producedTitles.length
|
|
@@ -332,16 +352,18 @@ function buildWikitext(
|
|
|
332
352
|
|
|
333
353
|
const entryLine = (title: string, state: TitleState | undefined, entry: NoteEntry): string => {
|
|
334
354
|
const bits: string[] = [`[[${title}]]`]
|
|
335
|
-
if (state
|
|
355
|
+
if (state !== undefined && state.unknown === true) {
|
|
356
|
+
bits.push('⚠️ 状态未知(查询失败)')
|
|
357
|
+
} else if (state === undefined || !state.exists) {
|
|
336
358
|
bits.push('⚠️ 已删除/不存在')
|
|
337
359
|
} else {
|
|
338
|
-
if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).join('、')}${state.tags.length > 6 ? '…' : ''}`)
|
|
360
|
+
if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).map((tag) => escapeInline(tag, 40)).join('、')}${state.tags.length > 6 ? '…' : ''}`)
|
|
339
361
|
const mod = fmtModified(state.modified)
|
|
340
362
|
if (mod.length > 0) bits.push(`修改 ${mod}`)
|
|
341
363
|
}
|
|
342
364
|
const t = fmtTime(entry.time)
|
|
343
365
|
if (t.length > 0) bits.push(`会话内 ${t}`)
|
|
344
|
-
if (entry.detail !== undefined) bits.push(entry.detail)
|
|
366
|
+
if (entry.detail !== undefined) bits.push(escapeInline(entry.detail, 200))
|
|
345
367
|
if (entry.subagent) bits.push('(子代理)')
|
|
346
368
|
return `* ${bits.join(' · ')}`
|
|
347
369
|
}
|
|
@@ -367,8 +389,8 @@ function buildWikitext(
|
|
|
367
389
|
for (const r of collected.searches) {
|
|
368
390
|
const bits: string[] = []
|
|
369
391
|
if (r.kind === 'search') {
|
|
370
|
-
bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? r.query : '(空)'}」`)
|
|
371
|
-
if (r.tags.length > 0) bits.push(`tags ${r.tags.join('、')}`)
|
|
392
|
+
bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? escapeInline(r.query, 120) : '(空)'}」`)
|
|
393
|
+
if (r.tags.length > 0) bits.push(`tags ${r.tags.map((tag) => escapeInline(tag, 40)).join('、')}`)
|
|
372
394
|
} else {
|
|
373
395
|
bits.push('`tiddlywiki_recent`(最近修改)')
|
|
374
396
|
}
|
package/src/index.ts
CHANGED
|
@@ -57,7 +57,8 @@ export {
|
|
|
57
57
|
export { openInTwEditor, registerRoutes } from './host/routes.ts'
|
|
58
58
|
export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './host/routes.ts'
|
|
59
59
|
export type { SessionQueryFace, SessionSummaryResult } from './host/routes.ts'
|
|
60
|
-
export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
|
|
60
|
+
export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes, MASKED_SECRET, maskConfigSecrets, stripMaskedSecrets } from './host/admin.ts'
|
|
61
|
+
export { escapeInline } from './host/session-summary.ts'
|
|
61
62
|
export { seedDocNote, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT } from './host/seed-notes.ts'
|
|
62
63
|
export { seedStarterDocs, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, DSH_DOCS_TAG } from './host/seed-starter-docs.ts'
|
|
63
64
|
export { seedSendToAgent, SEND_TO_AGENT_PLUGIN_TITLE, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_BUNDLE_TEXT } from './host/seed-send-to-agent.ts'
|