dsh-tiddlywiki 0.19.2 → 0.19.4
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 +8 -2
- package/lib/client.js +2 -2
- package/lib/index.js +398 -163
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/settings-page.ts +2 -2
- package/src/client/tool-views.ts +12 -8
- 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 +184 -62
- package/src/host/session-summary.ts +30 -8
- package/src/host/tools.ts +18 -7
- package/src/host/tw-api.ts +14 -2
- package/src/index.ts +2 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { execFile, spawn } from "node:child_process";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -836,8 +836,18 @@ var TiddlyWebClient = class TiddlyWebClient {
|
|
|
836
836
|
/**
|
|
837
837
|
* Distinct non-system tags with their tiddler counts, most-used first then
|
|
838
838
|
* zh-locale. One skinny listing request (no text payloads).
|
|
839
|
+
*
|
|
840
|
+
* `limit` (v0.19.4) caps the returned array after sorting; the caller loses
|
|
841
|
+
* the tail count unless it also asks `tagStats()`.
|
|
839
842
|
*/
|
|
840
|
-
async listTags() {
|
|
843
|
+
async listTags(limit) {
|
|
844
|
+
const tags = (await this.tagStats()).tags;
|
|
845
|
+
if (limit === void 0) return tags;
|
|
846
|
+
return tags.slice(0, Math.max(1, Math.min(Math.floor(limit), 1e3)));
|
|
847
|
+
}
|
|
848
|
+
/** Full tag vocabulary + its size, so callers that cap can still report the
|
|
849
|
+
* total ("showing 200 of N") — one listing request either way. */
|
|
850
|
+
async tagStats() {
|
|
841
851
|
const items = await this.list(TEXT_LIST_FILTER, false);
|
|
842
852
|
const map = /* @__PURE__ */ new Map();
|
|
843
853
|
for (const t of items) {
|
|
@@ -847,10 +857,14 @@ var TiddlyWebClient = class TiddlyWebClient {
|
|
|
847
857
|
map.set(tag, (map.get(tag) ?? 0) + 1);
|
|
848
858
|
}
|
|
849
859
|
}
|
|
850
|
-
|
|
860
|
+
const tags = [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh")).map(([tag, count]) => ({
|
|
851
861
|
tag,
|
|
852
862
|
count
|
|
853
863
|
}));
|
|
864
|
+
return {
|
|
865
|
+
total: tags.length,
|
|
866
|
+
tags
|
|
867
|
+
};
|
|
854
868
|
}
|
|
855
869
|
};
|
|
856
870
|
/** Clamp a caller-supplied limit into 1…200 (default when absent/invalid). */
|
|
@@ -1315,7 +1329,7 @@ function scanSnapshot(snap, subagent, collected) {
|
|
|
1315
1329
|
title: args.newTitle,
|
|
1316
1330
|
action: "produced",
|
|
1317
1331
|
time: t,
|
|
1318
|
-
detail: old !== void 0 ? `重命名自「${old}」` : void 0,
|
|
1332
|
+
detail: old !== void 0 && isPlausibleTitle(old) ? `重命名自「${old}」` : void 0,
|
|
1319
1333
|
subagent
|
|
1320
1334
|
});
|
|
1321
1335
|
}
|
|
@@ -1362,6 +1376,14 @@ function scanSnapshot(snap, subagent, collected) {
|
|
|
1362
1376
|
}
|
|
1363
1377
|
}
|
|
1364
1378
|
}
|
|
1379
|
+
/**
|
|
1380
|
+
* 内联文本净化(v0.19.3):汇总 wikitext 会被 TW 渲染成 HTML 片段再注入 DSH 页面,
|
|
1381
|
+
* 而 TW 的解析器**原样透传 HTML**。凡是来自会话日志/请求体的字符串(sessionId、
|
|
1382
|
+
* rename 的旧标题、检索词)都要先转义 HTML 元字符并去掉控制字符,别指望上层净化。
|
|
1383
|
+
*/
|
|
1384
|
+
function escapeInline(text, max = 200) {
|
|
1385
|
+
return text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[[\]|]/g, " ").slice(0, max);
|
|
1386
|
+
}
|
|
1365
1387
|
/** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
|
|
1366
1388
|
async function enrichTitles(client, titles) {
|
|
1367
1389
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -1382,7 +1404,8 @@ async function enrichTitles(client, titles) {
|
|
|
1382
1404
|
} catch {
|
|
1383
1405
|
return [title, {
|
|
1384
1406
|
exists: false,
|
|
1385
|
-
tags: []
|
|
1407
|
+
tags: [],
|
|
1408
|
+
unknown: true
|
|
1386
1409
|
}];
|
|
1387
1410
|
}
|
|
1388
1411
|
};
|
|
@@ -1410,7 +1433,7 @@ function buildWikitext(sessionId, collected, producedTitles, readTitles, stateBy
|
|
|
1410
1433
|
const lines = [];
|
|
1411
1434
|
lines.push("! 会话相关 wiki 汇总");
|
|
1412
1435
|
lines.push("");
|
|
1413
|
-
lines.push(`本页列出会话 \`${sessionId}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`);
|
|
1436
|
+
lines.push(`本页列出会话 \`${escapeInline(sessionId, 120)}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`);
|
|
1414
1437
|
lines.push("");
|
|
1415
1438
|
const producedCount = producedTitles.length;
|
|
1416
1439
|
const readCount = readTitles.length;
|
|
@@ -1426,15 +1449,16 @@ function buildWikitext(sessionId, collected, producedTitles, readTitles, stateBy
|
|
|
1426
1449
|
lines.push("");
|
|
1427
1450
|
const entryLine = (title, state, entry) => {
|
|
1428
1451
|
const bits = [`[[${title}]]`];
|
|
1429
|
-
if (state
|
|
1452
|
+
if (state !== void 0 && state.unknown === true) bits.push("⚠️ 状态未知(查询失败)");
|
|
1453
|
+
else if (state === void 0 || !state.exists) bits.push("⚠️ 已删除/不存在");
|
|
1430
1454
|
else {
|
|
1431
|
-
if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).join("、")}${state.tags.length > 6 ? "…" : ""}`);
|
|
1455
|
+
if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).map((tag) => escapeInline(tag, 40)).join("、")}${state.tags.length > 6 ? "…" : ""}`);
|
|
1432
1456
|
const mod = fmtModified(state.modified);
|
|
1433
1457
|
if (mod.length > 0) bits.push(`修改 ${mod}`);
|
|
1434
1458
|
}
|
|
1435
1459
|
const t = fmtTime(entry.time);
|
|
1436
1460
|
if (t.length > 0) bits.push(`会话内 ${t}`);
|
|
1437
|
-
if (entry.detail !== void 0) bits.push(entry.detail);
|
|
1461
|
+
if (entry.detail !== void 0) bits.push(escapeInline(entry.detail, 200));
|
|
1438
1462
|
if (entry.subagent) bits.push("(子代理)");
|
|
1439
1463
|
return `* ${bits.join(" · ")}`;
|
|
1440
1464
|
};
|
|
@@ -1459,8 +1483,8 @@ function buildWikitext(sessionId, collected, producedTitles, readTitles, stateBy
|
|
|
1459
1483
|
for (const r of collected.searches) {
|
|
1460
1484
|
const bits = [];
|
|
1461
1485
|
if (r.kind === "search") {
|
|
1462
|
-
bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? r.query : "(空)"}」`);
|
|
1463
|
-
if (r.tags.length > 0) bits.push(`tags ${r.tags.join("、")}`);
|
|
1486
|
+
bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? escapeInline(r.query, 120) : "(空)"}」`);
|
|
1487
|
+
if (r.tags.length > 0) bits.push(`tags ${r.tags.map((tag) => escapeInline(tag, 40)).join("、")}`);
|
|
1464
1488
|
} else bits.push("`tiddlywiki_recent`(最近修改)");
|
|
1465
1489
|
const t = fmtTime(r.time);
|
|
1466
1490
|
if (t.length > 0) bits.push(t);
|
|
@@ -1575,6 +1599,39 @@ function json(res, payload, status = 200) {
|
|
|
1575
1599
|
res.end(JSON.stringify(payload));
|
|
1576
1600
|
}
|
|
1577
1601
|
/**
|
|
1602
|
+
* Map a thrown error to an HTTP status. Oversized bodies are a client problem
|
|
1603
|
+
* (413), everything else is ours (500) — the routes used to answer 500 for
|
|
1604
|
+
* `/note` and `/admin/*` but 413 for `/upload` for the identical condition.
|
|
1605
|
+
*/
|
|
1606
|
+
function errorStatus(err) {
|
|
1607
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1608
|
+
return /body too large/i.test(message) ? 413 : 500;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Wrap an async route handler so a rejected promise can never become an
|
|
1612
|
+
* unhandled rejection (v0.19.3).
|
|
1613
|
+
*
|
|
1614
|
+
* WHY: every registration used to be `void handleX(req, res)`, and the host
|
|
1615
|
+
* installs no `unhandledRejection` handler — Node's default policy then EXITS
|
|
1616
|
+
* the whole dsh web process, while the client request hangs with no response.
|
|
1617
|
+
* A handler that throws (including a sync throw before its own try block, e.g.
|
|
1618
|
+
* `new URL(req.url)` outside the try in the proxies) now always ends in a
|
|
1619
|
+
* response: 413/500 JSON, or a bare `end()` when headers are already sent.
|
|
1620
|
+
*/
|
|
1621
|
+
function guardHandler(handler) {
|
|
1622
|
+
return (req, res) => {
|
|
1623
|
+
handler(req, res).catch((err) => {
|
|
1624
|
+
try {
|
|
1625
|
+
if (!res.headersSent) json(res, {
|
|
1626
|
+
ok: false,
|
|
1627
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1628
|
+
}, errorStatus(err));
|
|
1629
|
+
else res.end();
|
|
1630
|
+
} catch {}
|
|
1631
|
+
});
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1578
1635
|
* True when a NON-GET/HEAD/OPTIONS request is a cross-site (CSRF) request.
|
|
1579
1636
|
*
|
|
1580
1637
|
* Every legitimate caller of these routes is same-origin: the DSH GUI, the
|
|
@@ -3667,6 +3724,8 @@ function sanitizeUploadName(input) {
|
|
|
3667
3724
|
if (typeof input !== "string") return "";
|
|
3668
3725
|
let name = basename(input.trim().replace(/[\\/]+/g, "/")).replace(/[\u0000-\u001f\u007f]/g, "").replace(/[<>:"|?*]/g, "_").replace(/^\.+/, "").trim();
|
|
3669
3726
|
if (name.length === 0 || name === "." || name === "..") return "";
|
|
3727
|
+
name = name.replace(/[. ]+$/, "");
|
|
3728
|
+
if (name.length === 0) return "";
|
|
3670
3729
|
if (WINDOWS_RESERVED_NAMES.test(name)) name = `_${name}`;
|
|
3671
3730
|
if (name.length > 160) name = name.slice(0, 160);
|
|
3672
3731
|
return name;
|
|
@@ -3702,6 +3761,24 @@ function snippetOf$1(text, max = 120) {
|
|
|
3702
3761
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
3703
3762
|
return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
|
|
3704
3763
|
}
|
|
3764
|
+
/** Max `limit` accepted by the list routes (bounded payloads, v0.19.4). */
|
|
3765
|
+
const MAX_LIST_LIMIT = 200;
|
|
3766
|
+
/** Max `limit` accepted by `/tags` — the tag list is a small wrapper around one
|
|
3767
|
+
* full listing, so a larger cap is fine while still bounding the payload. */
|
|
3768
|
+
const MAX_TAGS_LIMIT = 500;
|
|
3769
|
+
/** Clamp a `limit` query param. `fallback` covers absent/unparsable values. */
|
|
3770
|
+
function readLimit(url, fallback, max = MAX_LIST_LIMIT) {
|
|
3771
|
+
const raw = Number(url.searchParams.get("limit") ?? fallback);
|
|
3772
|
+
return Number.isFinite(raw) ? Math.max(1, Math.min(Math.floor(raw), max)) : fallback;
|
|
3773
|
+
}
|
|
3774
|
+
/** Optional `limit` query param: `undefined` when absent/unparsable (= no cap). */
|
|
3775
|
+
function readOptionalLimit(url, max) {
|
|
3776
|
+
const raw = url.searchParams.get("limit");
|
|
3777
|
+
if (raw === null || raw.trim().length === 0) return void 0;
|
|
3778
|
+
const parsed = Number(raw);
|
|
3779
|
+
if (!Number.isFinite(parsed)) return void 0;
|
|
3780
|
+
return Math.max(1, Math.min(Math.floor(parsed), max));
|
|
3781
|
+
}
|
|
3705
3782
|
/** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
|
|
3706
3783
|
function timestampTitle(date = /* @__PURE__ */ new Date()) {
|
|
3707
3784
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
@@ -3868,6 +3945,13 @@ function registerRoutes(ctx, deps) {
|
|
|
3868
3945
|
}, 400);
|
|
3869
3946
|
return;
|
|
3870
3947
|
}
|
|
3948
|
+
if (!/^[A-Za-z0-9._:-]{1,120}$/.test(session)) {
|
|
3949
|
+
json(res, {
|
|
3950
|
+
ok: false,
|
|
3951
|
+
error: "session id has an unsupported format"
|
|
3952
|
+
}, 400);
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3871
3955
|
const client = deps.getClient();
|
|
3872
3956
|
if (client === void 0) {
|
|
3873
3957
|
json(res, {
|
|
@@ -3893,7 +3977,7 @@ function registerRoutes(ctx, deps) {
|
|
|
3893
3977
|
json(res, {
|
|
3894
3978
|
ok: false,
|
|
3895
3979
|
error: err instanceof Error ? err.message : String(err)
|
|
3896
|
-
},
|
|
3980
|
+
}, errorStatus(err));
|
|
3897
3981
|
}
|
|
3898
3982
|
};
|
|
3899
3983
|
/**
|
|
@@ -3937,7 +4021,7 @@ function registerRoutes(ctx, deps) {
|
|
|
3937
4021
|
json(res, {
|
|
3938
4022
|
ok: false,
|
|
3939
4023
|
error: err instanceof Error ? err.message : String(err)
|
|
3940
|
-
},
|
|
4024
|
+
}, errorStatus(err));
|
|
3941
4025
|
}
|
|
3942
4026
|
};
|
|
3943
4027
|
/**
|
|
@@ -4015,7 +4099,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4015
4099
|
json(res, {
|
|
4016
4100
|
ok: false,
|
|
4017
4101
|
error: err instanceof Error ? err.message : String(err)
|
|
4018
|
-
},
|
|
4102
|
+
}, errorStatus(err));
|
|
4019
4103
|
}
|
|
4020
4104
|
};
|
|
4021
4105
|
/**
|
|
@@ -4073,7 +4157,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4073
4157
|
json(res, {
|
|
4074
4158
|
ok: false,
|
|
4075
4159
|
error: err instanceof Error ? err.message : String(err)
|
|
4076
|
-
},
|
|
4160
|
+
}, errorStatus(err));
|
|
4077
4161
|
}
|
|
4078
4162
|
};
|
|
4079
4163
|
/**
|
|
@@ -4129,6 +4213,32 @@ function registerRoutes(ctx, deps) {
|
|
|
4129
4213
|
}, 503);
|
|
4130
4214
|
return;
|
|
4131
4215
|
}
|
|
4216
|
+
if (mode !== void 0) {
|
|
4217
|
+
const ap = deps.getAgentPresets();
|
|
4218
|
+
if (ap === void 0) {
|
|
4219
|
+
json(res, {
|
|
4220
|
+
ok: false,
|
|
4221
|
+
error: "agent presets service unavailable"
|
|
4222
|
+
}, 503);
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
try {
|
|
4226
|
+
const presets = await ap.list();
|
|
4227
|
+
if (!presets.some((preset) => preset.id === mode)) {
|
|
4228
|
+
json(res, {
|
|
4229
|
+
ok: false,
|
|
4230
|
+
error: `unknown agent preset "${mode}" (available: ${presets.map((preset) => preset.id).join(", ")})`
|
|
4231
|
+
}, 400);
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
} catch (err) {
|
|
4235
|
+
json(res, {
|
|
4236
|
+
ok: false,
|
|
4237
|
+
error: `cannot validate agent preset: ${err instanceof Error ? err.message : String(err)}`
|
|
4238
|
+
}, 503);
|
|
4239
|
+
return;
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4132
4242
|
const ws = deps.getWorkspaceRegistry();
|
|
4133
4243
|
if (cwd.length > 0) {
|
|
4134
4244
|
if (!isAbsolute(cwd)) {
|
|
@@ -4186,7 +4296,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4186
4296
|
json(res, {
|
|
4187
4297
|
ok: false,
|
|
4188
4298
|
error: err instanceof Error ? err.message : String(err)
|
|
4189
|
-
},
|
|
4299
|
+
}, errorStatus(err));
|
|
4190
4300
|
}
|
|
4191
4301
|
};
|
|
4192
4302
|
const handleNote = async (req, res) => {
|
|
@@ -4250,7 +4360,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4250
4360
|
json(res, {
|
|
4251
4361
|
ok: false,
|
|
4252
4362
|
error: err instanceof Error ? err.message : String(err)
|
|
4253
|
-
},
|
|
4363
|
+
}, errorStatus(err));
|
|
4254
4364
|
}
|
|
4255
4365
|
};
|
|
4256
4366
|
const handleEdit = async (req, res) => {
|
|
@@ -4296,13 +4406,18 @@ function registerRoutes(ctx, deps) {
|
|
|
4296
4406
|
json(res, {
|
|
4297
4407
|
ok: false,
|
|
4298
4408
|
error: err instanceof Error ? err.message : String(err)
|
|
4299
|
-
},
|
|
4409
|
+
}, errorStatus(err));
|
|
4300
4410
|
}
|
|
4301
4411
|
};
|
|
4302
4412
|
/** Distinct non-system tags for the quick-note tag autocomplete. The flat
|
|
4303
4413
|
* `tags` array feeds note-widget's chip autocomplete; the parallel `items`
|
|
4304
4414
|
* (tag → tiddler count) feeds the reply-stream `tiddlywiki_list_tags` tool
|
|
4305
|
-
* card, so both consumers share one endpoint.
|
|
4415
|
+
* card, so both consumers share one endpoint.
|
|
4416
|
+
*
|
|
4417
|
+
* Query params (v0.19.4): `limit` caps the payload (absent = every tag, so
|
|
4418
|
+
* the autocomplete keeps its full vocabulary), `sort=count` orders by usage
|
|
4419
|
+
* (default `alpha`). `tags`/`items` are always the same set in the same
|
|
4420
|
+
* order, plus `total`/`truncated` so a capped caller can say "N of M". */
|
|
4306
4421
|
const handleTags = async (req, res) => {
|
|
4307
4422
|
if (rejectNonRead(req, res)) return;
|
|
4308
4423
|
const client = deps.getClient();
|
|
@@ -4314,23 +4429,24 @@ function registerRoutes(ctx, deps) {
|
|
|
4314
4429
|
return;
|
|
4315
4430
|
}
|
|
4316
4431
|
try {
|
|
4317
|
-
const
|
|
4318
|
-
const
|
|
4319
|
-
|
|
4320
|
-
const tags
|
|
4432
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
4433
|
+
const limit = readOptionalLimit(url, MAX_TAGS_LIMIT);
|
|
4434
|
+
const byCount = (url.searchParams.get("sort") ?? "alpha") === "count";
|
|
4435
|
+
const { total, tags: all } = await client.tagStats();
|
|
4436
|
+
const ordered = byCount ? all : [...all].sort((a, b) => a.tag.localeCompare(b.tag, "zh"));
|
|
4437
|
+
const picked = limit === void 0 ? ordered : ordered.slice(0, limit);
|
|
4321
4438
|
json(res, {
|
|
4322
4439
|
ok: true,
|
|
4323
|
-
tags,
|
|
4324
|
-
items:
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
}))
|
|
4440
|
+
tags: picked.map((t) => t.tag),
|
|
4441
|
+
items: picked,
|
|
4442
|
+
total,
|
|
4443
|
+
truncated: picked.length < total
|
|
4328
4444
|
});
|
|
4329
4445
|
} catch (err) {
|
|
4330
4446
|
json(res, {
|
|
4331
4447
|
ok: false,
|
|
4332
4448
|
error: err instanceof Error ? err.message : String(err)
|
|
4333
|
-
},
|
|
4449
|
+
}, errorStatus(err));
|
|
4334
4450
|
}
|
|
4335
4451
|
};
|
|
4336
4452
|
/** Recent non-system tiddlers for the quick-note "最近" picker (newest first). */
|
|
@@ -4345,9 +4461,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4345
4461
|
return;
|
|
4346
4462
|
}
|
|
4347
4463
|
try {
|
|
4348
|
-
const
|
|
4349
|
-
const limitRaw = Number(url.searchParams.get("limit") ?? 15);
|
|
4350
|
-
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 15;
|
|
4464
|
+
const limit = readLimit(new URL(req.url ?? "/", "http://127.0.0.1"), 15);
|
|
4351
4465
|
json(res, {
|
|
4352
4466
|
ok: true,
|
|
4353
4467
|
limit,
|
|
@@ -4362,7 +4476,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4362
4476
|
json(res, {
|
|
4363
4477
|
ok: false,
|
|
4364
4478
|
error: err instanceof Error ? err.message : String(err)
|
|
4365
|
-
},
|
|
4479
|
+
}, errorStatus(err));
|
|
4366
4480
|
}
|
|
4367
4481
|
};
|
|
4368
4482
|
/** Full tiddler for the quick-note "最近" picker (load into the editor). */
|
|
@@ -4418,7 +4532,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4418
4532
|
json(res, {
|
|
4419
4533
|
ok: false,
|
|
4420
4534
|
error: err instanceof Error ? err.message : String(err)
|
|
4421
|
-
},
|
|
4535
|
+
}, errorStatus(err));
|
|
4422
4536
|
}
|
|
4423
4537
|
};
|
|
4424
4538
|
/**
|
|
@@ -4444,8 +4558,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4444
4558
|
const tag = url.searchParams.get("tag") ?? void 0;
|
|
4445
4559
|
const since = url.searchParams.get("since") ?? void 0;
|
|
4446
4560
|
const type = url.searchParams.get("type") ?? void 0;
|
|
4447
|
-
const
|
|
4448
|
-
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 30;
|
|
4561
|
+
const limit = readLimit(url, 30);
|
|
4449
4562
|
const { items, total } = await client.search(query, {
|
|
4450
4563
|
tags,
|
|
4451
4564
|
tag,
|
|
@@ -4473,7 +4586,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4473
4586
|
json(res, {
|
|
4474
4587
|
ok: false,
|
|
4475
4588
|
error: err instanceof Error ? err.message : String(err)
|
|
4476
|
-
},
|
|
4589
|
+
}, errorStatus(err));
|
|
4477
4590
|
}
|
|
4478
4591
|
};
|
|
4479
4592
|
const handleRestart = async (req, res) => {
|
|
@@ -4499,7 +4612,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4499
4612
|
json(res, {
|
|
4500
4613
|
ok: false,
|
|
4501
4614
|
error: err instanceof Error ? err.message : String(err)
|
|
4502
|
-
},
|
|
4615
|
+
}, errorStatus(err));
|
|
4503
4616
|
}
|
|
4504
4617
|
};
|
|
4505
4618
|
/** One-click git sync for the floating button / settings page: pull →
|
|
@@ -4568,7 +4681,7 @@ function registerRoutes(ctx, deps) {
|
|
|
4568
4681
|
json(res, {
|
|
4569
4682
|
ok: false,
|
|
4570
4683
|
error: err instanceof Error ? err.message : String(err)
|
|
4571
|
-
},
|
|
4684
|
+
}, errorStatus(err));
|
|
4572
4685
|
} finally {
|
|
4573
4686
|
endMutation();
|
|
4574
4687
|
}
|
|
@@ -4612,16 +4725,24 @@ function registerRoutes(ctx, deps) {
|
|
|
4612
4725
|
await mkdir(filesDir, { recursive: true });
|
|
4613
4726
|
const ext = extname(name);
|
|
4614
4727
|
const stem = ext.length > 0 ? name.slice(0, -ext.length) : name;
|
|
4615
|
-
let candidate =
|
|
4616
|
-
|
|
4728
|
+
let candidate = "";
|
|
4729
|
+
let wrote = false;
|
|
4730
|
+
for (let i = 0; i <= 1e4 && !wrote; i++) {
|
|
4731
|
+
candidate = i === 0 ? name : `${stem}-${i}${ext}`;
|
|
4617
4732
|
try {
|
|
4618
|
-
await
|
|
4619
|
-
|
|
4620
|
-
|
|
4733
|
+
await writeFile(join(filesDir, candidate), buf, { flag: "wx" });
|
|
4734
|
+
wrote = true;
|
|
4735
|
+
} catch (err) {
|
|
4736
|
+
if (err.code !== "EEXIST") throw err;
|
|
4621
4737
|
}
|
|
4622
|
-
candidate = `${stem}-${i}${ext}`;
|
|
4623
4738
|
}
|
|
4624
|
-
|
|
4739
|
+
if (!wrote) {
|
|
4740
|
+
json(res, {
|
|
4741
|
+
ok: false,
|
|
4742
|
+
error: "同名文件过多,请换一个文件名"
|
|
4743
|
+
}, 409);
|
|
4744
|
+
return;
|
|
4745
|
+
}
|
|
4625
4746
|
deps.autoCommit();
|
|
4626
4747
|
invalidateGitStatus();
|
|
4627
4748
|
json(res, {
|
|
@@ -4714,6 +4835,29 @@ function registerRoutes(ctx, deps) {
|
|
|
4714
4835
|
}, 502);
|
|
4715
4836
|
}
|
|
4716
4837
|
};
|
|
4838
|
+
/**
|
|
4839
|
+
* Titles the browser-facing TW proxies must never serve (v0.19.3).
|
|
4840
|
+
*
|
|
4841
|
+
* `$:/plugins/dsh-tiddlywiki/config` holds the shared tokens and the git
|
|
4842
|
+
* remote (possibly with a PAT); both `/tw` and `/api` forward ANY path to the
|
|
4843
|
+
* loopback TW child, whose TiddlyWeb REST answers for `$:/…` titles — a
|
|
4844
|
+
* route-level guard on `/get` was therefore trivially bypassed by
|
|
4845
|
+
* `GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2F…`
|
|
4846
|
+
* (verified). The whole plugin namespace is blocked: nothing under it is
|
|
4847
|
+
* needed by the TW frontend, and the host itself talks to TW directly.
|
|
4848
|
+
*/
|
|
4849
|
+
const BLOCKED_PROXY_TITLE_PREFIXES = ["$:/plugins/dsh-tiddlywiki/"];
|
|
4850
|
+
/** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
|
|
4851
|
+
const isBlockedProxyPath = (pathname) => {
|
|
4852
|
+
const at = pathname.indexOf("/tiddlers/");
|
|
4853
|
+
if (at < 0) return false;
|
|
4854
|
+
const raw = pathname.slice(at + 10);
|
|
4855
|
+
let title = raw;
|
|
4856
|
+
try {
|
|
4857
|
+
title = decodeURIComponent(raw);
|
|
4858
|
+
} catch {}
|
|
4859
|
+
return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix));
|
|
4860
|
+
};
|
|
4717
4861
|
/** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
|
|
4718
4862
|
const handleApiProxy = async (req, res) => {
|
|
4719
4863
|
if (rejectCrossSiteWrite(req, res)) return;
|
|
@@ -4724,9 +4868,16 @@ function registerRoutes(ctx, deps) {
|
|
|
4724
4868
|
}, 503);
|
|
4725
4869
|
return;
|
|
4726
4870
|
}
|
|
4727
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
4728
|
-
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, "") || "/";
|
|
4729
4871
|
try {
|
|
4872
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
4873
|
+
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, "") || "/";
|
|
4874
|
+
if (isBlockedProxyPath(rest)) {
|
|
4875
|
+
json(res, {
|
|
4876
|
+
ok: false,
|
|
4877
|
+
error: "system tiddler not exposed"
|
|
4878
|
+
}, 403);
|
|
4879
|
+
return;
|
|
4880
|
+
}
|
|
4730
4881
|
const headers = forwardHeaders(req.headers);
|
|
4731
4882
|
const method = (req.method ?? "GET").toUpperCase();
|
|
4732
4883
|
if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
|
|
@@ -4770,14 +4921,23 @@ function registerRoutes(ctx, deps) {
|
|
|
4770
4921
|
}
|
|
4771
4922
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
4772
4923
|
const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), "") || "/";
|
|
4924
|
+
if (isBlockedProxyPath(rest)) {
|
|
4925
|
+
json(res, {
|
|
4926
|
+
ok: false,
|
|
4927
|
+
error: "system tiddler not exposed"
|
|
4928
|
+
}, 403);
|
|
4929
|
+
return;
|
|
4930
|
+
}
|
|
4773
4931
|
try {
|
|
4774
4932
|
const method = (req.method ?? "GET").toUpperCase();
|
|
4775
4933
|
const headers = forwardHeaders(req.headers);
|
|
4776
4934
|
if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
|
|
4935
|
+
const abort = new AbortController();
|
|
4936
|
+
const timeout = AbortSignal.timeout(3e4);
|
|
4777
4937
|
const init = {
|
|
4778
4938
|
method,
|
|
4779
4939
|
headers,
|
|
4780
|
-
signal: AbortSignal.timeout
|
|
4940
|
+
signal: typeof AbortSignal.any === "function" ? AbortSignal.any([abort.signal, timeout]) : timeout
|
|
4781
4941
|
};
|
|
4782
4942
|
if (method === "PUT" || method === "POST") init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES);
|
|
4783
4943
|
const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
|
|
@@ -4805,7 +4965,15 @@ function registerRoutes(ctx, deps) {
|
|
|
4805
4965
|
await new Promise((resolveP, rejectP) => {
|
|
4806
4966
|
body.on("error", rejectP);
|
|
4807
4967
|
res.on("error", rejectP);
|
|
4808
|
-
res.on("close", () =>
|
|
4968
|
+
res.on("close", () => {
|
|
4969
|
+
try {
|
|
4970
|
+
abort.abort();
|
|
4971
|
+
} catch {}
|
|
4972
|
+
try {
|
|
4973
|
+
body.destroy();
|
|
4974
|
+
} catch {}
|
|
4975
|
+
resolveP();
|
|
4976
|
+
});
|
|
4809
4977
|
res.on("finish", () => resolveP());
|
|
4810
4978
|
body.pipe(res);
|
|
4811
4979
|
});
|
|
@@ -4824,128 +4992,92 @@ function registerRoutes(ctx, deps) {
|
|
|
4824
4992
|
ctx.webServer.register({
|
|
4825
4993
|
kind: "exact",
|
|
4826
4994
|
path: `${ROUTE_PREFIX}/status`,
|
|
4827
|
-
handler: (
|
|
4828
|
-
handleStatus(req, res);
|
|
4829
|
-
}
|
|
4995
|
+
handler: guardHandler(handleStatus)
|
|
4830
4996
|
}),
|
|
4831
4997
|
ctx.webServer.register({
|
|
4832
4998
|
kind: "exact",
|
|
4833
4999
|
path: `${ROUTE_PREFIX}/note`,
|
|
4834
|
-
handler: (
|
|
4835
|
-
handleNote(req, res);
|
|
4836
|
-
}
|
|
5000
|
+
handler: guardHandler(handleNote)
|
|
4837
5001
|
}),
|
|
4838
5002
|
ctx.webServer.register({
|
|
4839
5003
|
kind: "exact",
|
|
4840
5004
|
path: `${ROUTE_PREFIX}/edit`,
|
|
4841
|
-
handler: (
|
|
4842
|
-
handleEdit(req, res);
|
|
4843
|
-
}
|
|
5005
|
+
handler: guardHandler(handleEdit)
|
|
4844
5006
|
}),
|
|
4845
5007
|
ctx.webServer.register({
|
|
4846
5008
|
kind: "exact",
|
|
4847
5009
|
path: `${ROUTE_PREFIX}/tags`,
|
|
4848
|
-
handler: (
|
|
4849
|
-
handleTags(req, res);
|
|
4850
|
-
}
|
|
5010
|
+
handler: guardHandler(handleTags)
|
|
4851
5011
|
}),
|
|
4852
5012
|
ctx.webServer.register({
|
|
4853
5013
|
kind: "exact",
|
|
4854
5014
|
path: `${ROUTE_PREFIX}/recent`,
|
|
4855
|
-
handler: (
|
|
4856
|
-
handleRecent(req, res);
|
|
4857
|
-
}
|
|
5015
|
+
handler: guardHandler(handleRecent)
|
|
4858
5016
|
}),
|
|
4859
5017
|
ctx.webServer.register({
|
|
4860
5018
|
kind: "exact",
|
|
4861
5019
|
path: `${ROUTE_PREFIX}/get`,
|
|
4862
|
-
handler: (
|
|
4863
|
-
handleGet(req, res);
|
|
4864
|
-
}
|
|
5020
|
+
handler: guardHandler(handleGet)
|
|
4865
5021
|
}),
|
|
4866
5022
|
ctx.webServer.register({
|
|
4867
5023
|
kind: "exact",
|
|
4868
5024
|
path: `${ROUTE_PREFIX}/search`,
|
|
4869
|
-
handler: (
|
|
4870
|
-
handleSearch(req, res);
|
|
4871
|
-
}
|
|
5025
|
+
handler: guardHandler(handleSearch)
|
|
4872
5026
|
}),
|
|
4873
5027
|
ctx.webServer.register({
|
|
4874
5028
|
kind: "exact",
|
|
4875
5029
|
path: `${ROUTE_PREFIX}/render`,
|
|
4876
|
-
handler: (
|
|
4877
|
-
handleRender(req, res);
|
|
4878
|
-
}
|
|
5030
|
+
handler: guardHandler(handleRender)
|
|
4879
5031
|
}),
|
|
4880
5032
|
ctx.webServer.register({
|
|
4881
5033
|
kind: "exact",
|
|
4882
5034
|
path: `${ROUTE_PREFIX}/sync`,
|
|
4883
|
-
handler: (
|
|
4884
|
-
handleSync(req, res);
|
|
4885
|
-
}
|
|
5035
|
+
handler: guardHandler(handleSync)
|
|
4886
5036
|
}),
|
|
4887
5037
|
ctx.webServer.register({
|
|
4888
5038
|
kind: "exact",
|
|
4889
5039
|
path: `${ROUTE_PREFIX}/upload`,
|
|
4890
|
-
handler: (
|
|
4891
|
-
handleUpload(req, res);
|
|
4892
|
-
}
|
|
5040
|
+
handler: guardHandler(handleUpload)
|
|
4893
5041
|
}),
|
|
4894
5042
|
ctx.webServer.register({
|
|
4895
5043
|
kind: "exact",
|
|
4896
5044
|
path: `${ROUTE_PREFIX}/restart`,
|
|
4897
|
-
handler: (
|
|
4898
|
-
handleRestart(req, res);
|
|
4899
|
-
}
|
|
5045
|
+
handler: guardHandler(handleRestart)
|
|
4900
5046
|
}),
|
|
4901
5047
|
ctx.webServer.register({
|
|
4902
5048
|
kind: "exact",
|
|
4903
5049
|
path: `${ROUTE_PREFIX}/session/summary`,
|
|
4904
|
-
handler: (
|
|
4905
|
-
handleSessionSummary(req, res);
|
|
4906
|
-
}
|
|
5050
|
+
handler: guardHandler(handleSessionSummary)
|
|
4907
5051
|
}),
|
|
4908
5052
|
ctx.webServer.register({
|
|
4909
5053
|
kind: "exact",
|
|
4910
5054
|
path: `${ROUTE_PREFIX}/agent/sessions`,
|
|
4911
|
-
handler: (
|
|
4912
|
-
handleAgentSessions(req, res);
|
|
4913
|
-
}
|
|
5055
|
+
handler: guardHandler(handleAgentSessions)
|
|
4914
5056
|
}),
|
|
4915
5057
|
ctx.webServer.register({
|
|
4916
5058
|
kind: "exact",
|
|
4917
5059
|
path: `${ROUTE_PREFIX}/agent/modes`,
|
|
4918
|
-
handler: (
|
|
4919
|
-
handleAgentModes(req, res);
|
|
4920
|
-
}
|
|
5060
|
+
handler: guardHandler(handleAgentModes)
|
|
4921
5061
|
}),
|
|
4922
5062
|
ctx.webServer.register({
|
|
4923
5063
|
kind: "exact",
|
|
4924
5064
|
path: `${ROUTE_PREFIX}/agent/send`,
|
|
4925
|
-
handler: (
|
|
4926
|
-
handleAgentSend(req, res);
|
|
4927
|
-
}
|
|
5065
|
+
handler: guardHandler(handleAgentSend)
|
|
4928
5066
|
}),
|
|
4929
5067
|
ctx.webServer.register({
|
|
4930
5068
|
kind: "exact",
|
|
4931
5069
|
path: `${ROUTE_PREFIX}/agent/create`,
|
|
4932
|
-
handler: (
|
|
4933
|
-
handleAgentCreate(req, res);
|
|
4934
|
-
}
|
|
5070
|
+
handler: guardHandler(handleAgentCreate)
|
|
4935
5071
|
}),
|
|
4936
5072
|
ctx.webServer.register({
|
|
4937
5073
|
kind: "prefix",
|
|
4938
5074
|
path: `${ROUTE_PREFIX}/api`,
|
|
4939
|
-
handler: (
|
|
4940
|
-
handleApiProxy(req, res);
|
|
4941
|
-
}
|
|
5075
|
+
handler: guardHandler(handleApiProxy)
|
|
4942
5076
|
}),
|
|
4943
5077
|
ctx.webServer.register({
|
|
4944
5078
|
kind: "prefix",
|
|
4945
5079
|
path: `${TW_PROXY_PREFIX}`,
|
|
4946
|
-
handler: (
|
|
4947
|
-
handleTwProxy(req, res);
|
|
4948
|
-
}
|
|
5080
|
+
handler: guardHandler(handleTwProxy)
|
|
4949
5081
|
})
|
|
4950
5082
|
];
|
|
4951
5083
|
return () => {
|
|
@@ -5005,12 +5137,14 @@ async function readWikiInfo(wikiPath) {
|
|
|
5005
5137
|
} catch {
|
|
5006
5138
|
throw new Error("tiddlywiki.info 不是合法 JSON;请先修复该文件再保存设置(已保留原文件)");
|
|
5007
5139
|
}
|
|
5140
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("tiddlywiki.info 顶层不是对象;请先修复该文件再保存设置(已保留原文件)");
|
|
5141
|
+
const obj = parsed;
|
|
5008
5142
|
return {
|
|
5009
|
-
...
|
|
5010
|
-
description:
|
|
5011
|
-
plugins: Array.isArray(
|
|
5012
|
-
themes: Array.isArray(
|
|
5013
|
-
languages: Array.isArray(
|
|
5143
|
+
...obj,
|
|
5144
|
+
description: obj.description,
|
|
5145
|
+
plugins: Array.isArray(obj.plugins) ? obj.plugins : [],
|
|
5146
|
+
themes: Array.isArray(obj.themes) ? obj.themes : [],
|
|
5147
|
+
languages: Array.isArray(obj.languages) ? obj.languages : []
|
|
5014
5148
|
};
|
|
5015
5149
|
}
|
|
5016
5150
|
/**
|
|
@@ -5164,6 +5298,73 @@ async function ensureLanguage(wikiPath, twRoot, lang) {
|
|
|
5164
5298
|
await writeWikiInfo(wikiPath, info);
|
|
5165
5299
|
return true;
|
|
5166
5300
|
}
|
|
5301
|
+
/**
|
|
5302
|
+
* Sentinel returned instead of a stored secret (`bridge.token` /
|
|
5303
|
+
* `ui.sendToAgent.token`) by `GET /admin/state` and by `POST /admin/config`'s
|
|
5304
|
+
* echo. `/admin/*` is an unauthenticated surface (only CSRF-hardened), and the
|
|
5305
|
+
* config tiddler legitimately carries shared tokens — handing them to any
|
|
5306
|
+
* caller of the loopback/LAN-reachable GUI is a real leak (v0.19.3). The
|
|
5307
|
+
* settings page renders the sentinel verbatim; posting it back is a no-op
|
|
5308
|
+
* (`stripMaskedSecrets` drops it), so the real value never leaves the host.
|
|
5309
|
+
*/
|
|
5310
|
+
const MASKED_SECRET = "********";
|
|
5311
|
+
/** Mask secrets (and credentials inside the git remote URL) for an HTTP caller. */
|
|
5312
|
+
function maskConfigSecrets(config) {
|
|
5313
|
+
const bridge = config.bridge ?? {};
|
|
5314
|
+
const ui = config.ui ?? {};
|
|
5315
|
+
const sendToAgent = ui.sendToAgent ?? {};
|
|
5316
|
+
const git = config.git ?? {};
|
|
5317
|
+
const maskToken = (value) => typeof value === "string" && value.length > 0 ? MASKED_SECRET : "";
|
|
5318
|
+
return {
|
|
5319
|
+
...config,
|
|
5320
|
+
git: {
|
|
5321
|
+
...git,
|
|
5322
|
+
remote: typeof git.remote === "string" ? redactRemoteUrl(git.remote) : git.remote
|
|
5323
|
+
},
|
|
5324
|
+
bridge: {
|
|
5325
|
+
...bridge,
|
|
5326
|
+
token: maskToken(bridge.token),
|
|
5327
|
+
tokenSet: typeof bridge.token === "string" && bridge.token.length > 0
|
|
5328
|
+
},
|
|
5329
|
+
ui: {
|
|
5330
|
+
...ui,
|
|
5331
|
+
sendToAgent: {
|
|
5332
|
+
...sendToAgent,
|
|
5333
|
+
token: maskToken(sendToAgent.token),
|
|
5334
|
+
tokenSet: typeof sendToAgent.token === "string" && sendToAgent.token.length > 0
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
};
|
|
5338
|
+
}
|
|
5339
|
+
/**
|
|
5340
|
+
* Drop the masked placeholders from an incoming config patch so saving the
|
|
5341
|
+
* settings page never overwrites a stored secret with `********` (or the
|
|
5342
|
+
* redacted git remote with `https://***@…`). `bridge.token`/`ui.sendToAgent.token`
|
|
5343
|
+
* are cleared only when the caller actually sends an empty string.
|
|
5344
|
+
*/
|
|
5345
|
+
function stripMaskedSecrets(patch, current) {
|
|
5346
|
+
const copy = { ...patch };
|
|
5347
|
+
const cleanToken = (container) => {
|
|
5348
|
+
if (typeof container !== "object" || container === null) return container;
|
|
5349
|
+
const obj = { ...container };
|
|
5350
|
+
if (obj.token === "********") delete obj.token;
|
|
5351
|
+
delete obj.tokenSet;
|
|
5352
|
+
return obj;
|
|
5353
|
+
};
|
|
5354
|
+
if (copy.bridge !== void 0) copy.bridge = cleanToken(copy.bridge);
|
|
5355
|
+
if (copy.ui !== void 0) {
|
|
5356
|
+
const ui = typeof copy.ui === "object" && copy.ui !== null ? { ...copy.ui } : copy.ui;
|
|
5357
|
+
if (typeof ui === "object" && ui !== null && ui.sendToAgent !== void 0) ui.sendToAgent = cleanToken(ui.sendToAgent);
|
|
5358
|
+
copy.ui = ui;
|
|
5359
|
+
}
|
|
5360
|
+
if (copy.git !== void 0 && typeof copy.git === "object" && copy.git !== null) {
|
|
5361
|
+
const git = { ...copy.git };
|
|
5362
|
+
const storedRemote = typeof current.git?.remote === "string" ? current.git.remote : "";
|
|
5363
|
+
if (typeof git.remote === "string" && storedRemote.length > 0 && git.remote === redactRemoteUrl(storedRemote)) delete git.remote;
|
|
5364
|
+
copy.git = git;
|
|
5365
|
+
}
|
|
5366
|
+
return copy;
|
|
5367
|
+
}
|
|
5167
5368
|
function registerAdminRoutes(ctx, deps) {
|
|
5168
5369
|
const handleState = async (req, res) => {
|
|
5169
5370
|
try {
|
|
@@ -5193,7 +5394,7 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5193
5394
|
languages: info.languages ?? []
|
|
5194
5395
|
},
|
|
5195
5396
|
catalog,
|
|
5196
|
-
config: deps.config.get(),
|
|
5397
|
+
config: maskConfigSecrets(deps.config.get()),
|
|
5197
5398
|
git
|
|
5198
5399
|
});
|
|
5199
5400
|
} catch (err) {
|
|
@@ -5221,6 +5422,10 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5221
5422
|
const catalog = await bundledCatalog(deps.twRoot());
|
|
5222
5423
|
const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name));
|
|
5223
5424
|
const knownLangs = new Set(catalog.languages.map((c) => c.name));
|
|
5425
|
+
/** Snapshot for the no-op guard below (JSON compare is enough here). */
|
|
5426
|
+
const beforeInfo = JSON.stringify(info);
|
|
5427
|
+
/** Set only when the request carried a themes array with a resolvable active pick. */
|
|
5428
|
+
let activatedTheme;
|
|
5224
5429
|
const applyList = (field, raw) => {
|
|
5225
5430
|
if (!Array.isArray(raw)) return info[field];
|
|
5226
5431
|
const next = [];
|
|
@@ -5241,33 +5446,45 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5241
5446
|
}
|
|
5242
5447
|
return next;
|
|
5243
5448
|
};
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5449
|
+
try {
|
|
5450
|
+
info.plugins = applyList("plugins", body.plugins);
|
|
5451
|
+
if (Array.isArray(body.themes)) {
|
|
5452
|
+
const themeDeps = {};
|
|
5453
|
+
for (const theme of catalog.themes) if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents;
|
|
5454
|
+
const selected = applyList("themes", body.themes);
|
|
5455
|
+
if (typeof body.themeActive === "string" && body.themeActive.length > 0) {
|
|
5456
|
+
const activeName = body.themeActive;
|
|
5457
|
+
if (known.has(activeName) || info.themes.includes(activeName)) {
|
|
5458
|
+
if (!selected.includes(activeName)) selected.push(activeName);
|
|
5459
|
+
activatedTheme = activeName;
|
|
5460
|
+
}
|
|
5255
5461
|
}
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5462
|
+
info.themes = normalizeThemes(selected, themeDeps);
|
|
5463
|
+
if (activatedTheme === void 0 && info.themes.length > 0) activatedTheme = info.themes[info.themes.length - 1];
|
|
5464
|
+
} else info.themes = applyList("themes", body.themes);
|
|
5465
|
+
if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages);
|
|
5466
|
+
} catch (err) {
|
|
5467
|
+
json(res, {
|
|
5468
|
+
ok: false,
|
|
5469
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5470
|
+
}, 400);
|
|
5471
|
+
return;
|
|
5472
|
+
}
|
|
5473
|
+
const changed = JSON.stringify(info) !== beforeInfo;
|
|
5474
|
+
if (changed) {
|
|
5475
|
+
await writeWikiInfo(wikiPath, info);
|
|
5476
|
+
await deps.server.restart();
|
|
5477
|
+
}
|
|
5478
|
+
if (Array.isArray(body.themes) && activatedTheme !== void 0) {
|
|
5264
5479
|
const client = deps.getClient();
|
|
5265
5480
|
if (client !== void 0) await client.put({
|
|
5266
5481
|
title: "$:/theme",
|
|
5267
5482
|
text: `$:/themes/${activatedTheme}`,
|
|
5268
5483
|
type: "text/vnd.tiddlywiki",
|
|
5269
5484
|
tags: []
|
|
5270
|
-
}).catch(() =>
|
|
5485
|
+
}).catch((err) => {
|
|
5486
|
+
console.warn("[dsh-tiddlywiki] activating theme failed:", err);
|
|
5487
|
+
});
|
|
5271
5488
|
}
|
|
5272
5489
|
if (Array.isArray(body.languages)) {
|
|
5273
5490
|
const client = deps.getClient();
|
|
@@ -5279,13 +5496,16 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5279
5496
|
text: active,
|
|
5280
5497
|
type: "text/plain",
|
|
5281
5498
|
tags: []
|
|
5282
|
-
}).catch(() =>
|
|
5499
|
+
}).catch((err) => {
|
|
5500
|
+
console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
|
|
5501
|
+
});
|
|
5283
5502
|
const hint = langs.length > 0 ? langs[0] : "";
|
|
5284
5503
|
if ((deps.config.get().uiLanguage ?? "") !== hint) await deps.config.set(client, { uiLanguage: hint }).catch(() => void 0);
|
|
5285
5504
|
}
|
|
5286
5505
|
}
|
|
5287
5506
|
json(res, {
|
|
5288
5507
|
ok: true,
|
|
5508
|
+
changed,
|
|
5289
5509
|
info: {
|
|
5290
5510
|
plugins: info.plugins,
|
|
5291
5511
|
themes: info.themes,
|
|
@@ -5296,7 +5516,7 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5296
5516
|
json(res, {
|
|
5297
5517
|
ok: false,
|
|
5298
5518
|
error: err instanceof Error ? err.message : String(err)
|
|
5299
|
-
},
|
|
5519
|
+
}, 500);
|
|
5300
5520
|
}
|
|
5301
5521
|
};
|
|
5302
5522
|
const handleConfig = async (req, res) => {
|
|
@@ -5311,10 +5531,10 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5311
5531
|
}, 503);
|
|
5312
5532
|
return;
|
|
5313
5533
|
}
|
|
5314
|
-
await deps.config.set(client, body);
|
|
5534
|
+
await deps.config.set(client, stripMaskedSecrets(body, deps.config.get()));
|
|
5315
5535
|
json(res, {
|
|
5316
5536
|
ok: true,
|
|
5317
|
-
config: deps.config.get()
|
|
5537
|
+
config: maskConfigSecrets(deps.config.get())
|
|
5318
5538
|
});
|
|
5319
5539
|
} catch (err) {
|
|
5320
5540
|
json(res, {
|
|
@@ -5447,51 +5667,37 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
5447
5667
|
ctx.webServer.register({
|
|
5448
5668
|
kind: "exact",
|
|
5449
5669
|
path: `${ROUTE_PREFIX}/admin/state`,
|
|
5450
|
-
handler: (
|
|
5451
|
-
handleState(req, res);
|
|
5452
|
-
}
|
|
5670
|
+
handler: guardHandler(handleState)
|
|
5453
5671
|
}),
|
|
5454
5672
|
ctx.webServer.register({
|
|
5455
5673
|
kind: "exact",
|
|
5456
5674
|
path: `${ROUTE_PREFIX}/admin/info`,
|
|
5457
|
-
handler: (
|
|
5458
|
-
handleInfo(req, res);
|
|
5459
|
-
}
|
|
5675
|
+
handler: guardHandler(handleInfo)
|
|
5460
5676
|
}),
|
|
5461
5677
|
ctx.webServer.register({
|
|
5462
5678
|
kind: "exact",
|
|
5463
5679
|
path: `${ROUTE_PREFIX}/admin/config`,
|
|
5464
|
-
handler: (
|
|
5465
|
-
handleConfig(req, res);
|
|
5466
|
-
}
|
|
5680
|
+
handler: guardHandler(handleConfig)
|
|
5467
5681
|
}),
|
|
5468
5682
|
ctx.webServer.register({
|
|
5469
5683
|
kind: "exact",
|
|
5470
5684
|
path: `${ROUTE_PREFIX}/admin/restart`,
|
|
5471
|
-
handler: (
|
|
5472
|
-
handleRestart(req, res);
|
|
5473
|
-
}
|
|
5685
|
+
handler: guardHandler(handleRestart)
|
|
5474
5686
|
}),
|
|
5475
5687
|
ctx.webServer.register({
|
|
5476
5688
|
kind: "exact",
|
|
5477
5689
|
path: `${ROUTE_PREFIX}/admin/seeds`,
|
|
5478
|
-
handler: (
|
|
5479
|
-
handleSeeds(req, res);
|
|
5480
|
-
}
|
|
5690
|
+
handler: guardHandler(handleSeeds)
|
|
5481
5691
|
}),
|
|
5482
5692
|
ctx.webServer.register({
|
|
5483
5693
|
kind: "exact",
|
|
5484
5694
|
path: `${ROUTE_PREFIX}/admin/seeds/run`,
|
|
5485
|
-
handler: (
|
|
5486
|
-
handleSeedsRun(req, res);
|
|
5487
|
-
}
|
|
5695
|
+
handler: guardHandler(handleSeedsRun)
|
|
5488
5696
|
}),
|
|
5489
5697
|
ctx.webServer.register({
|
|
5490
5698
|
kind: "exact",
|
|
5491
5699
|
path: `${ROUTE_PREFIX}/admin/seeds/remove`,
|
|
5492
|
-
handler: (
|
|
5493
|
-
handleSeedsRemove(req, res);
|
|
5494
|
-
}
|
|
5700
|
+
handler: guardHandler(handleSeedsRemove)
|
|
5495
5701
|
})
|
|
5496
5702
|
];
|
|
5497
5703
|
return () => {
|
|
@@ -5565,8 +5771,16 @@ const PRIVATE_V4_PATTERNS = [
|
|
|
5565
5771
|
/^169\.254\./,
|
|
5566
5772
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
5567
5773
|
/^192\.0\.0\./,
|
|
5774
|
+
/^192\.0\.2\./,
|
|
5775
|
+
/^192\.88\.99\./,
|
|
5568
5776
|
/^192\.168\./,
|
|
5569
|
-
/^198\.1[89]
|
|
5777
|
+
/^198\.1[89]\./,
|
|
5778
|
+
/^198\.51\.100\./,
|
|
5779
|
+
/^203\.0\.113\./,
|
|
5780
|
+
/^22[4-9]\./,
|
|
5781
|
+
/^23\d\./,
|
|
5782
|
+
/^24\d\./,
|
|
5783
|
+
/^25[0-5]\./
|
|
5570
5784
|
];
|
|
5571
5785
|
/** Parse a dotted-quad IPv4 into 4 bytes, or null. */
|
|
5572
5786
|
function parseIpv4(input) {
|
|
@@ -6045,6 +6259,9 @@ var ClipBridge = class {
|
|
|
6045
6259
|
};
|
|
6046
6260
|
const onListening = () => {
|
|
6047
6261
|
server.removeListener("error", onError);
|
|
6262
|
+
server.on("error", (err) => {
|
|
6263
|
+
this.deps.log?.(`server error after listen: ${err.message}`);
|
|
6264
|
+
});
|
|
6048
6265
|
const addr = server.address();
|
|
6049
6266
|
this.boundPort = addr.port;
|
|
6050
6267
|
resolve();
|
|
@@ -6061,7 +6278,17 @@ var ClipBridge = class {
|
|
|
6061
6278
|
this.boundPort = 0;
|
|
6062
6279
|
if (server === void 0) return Promise.resolve();
|
|
6063
6280
|
return new Promise((resolve) => {
|
|
6064
|
-
|
|
6281
|
+
let done = false;
|
|
6282
|
+
const finish = () => {
|
|
6283
|
+
if (done) return;
|
|
6284
|
+
done = true;
|
|
6285
|
+
resolve();
|
|
6286
|
+
};
|
|
6287
|
+
try {
|
|
6288
|
+
server.closeAllConnections?.();
|
|
6289
|
+
} catch {}
|
|
6290
|
+
server.close(() => finish());
|
|
6291
|
+
setTimeout(finish, 1e3).unref?.();
|
|
6065
6292
|
});
|
|
6066
6293
|
}
|
|
6067
6294
|
respond(res, payload, status = 200) {
|
|
@@ -6721,8 +6948,11 @@ function registerTiddlywikiTools(ctx, deps) {
|
|
|
6721
6948
|
}));
|
|
6722
6949
|
register(defineTool({
|
|
6723
6950
|
name: "tiddlywiki_list_tags",
|
|
6724
|
-
description: "列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。",
|
|
6725
|
-
parameters: {
|
|
6951
|
+
description: "列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。默认最多返回 200 个(`limit` 可调,上限 1000),被截断时结果里会带 total/truncated。",
|
|
6952
|
+
parameters: { limit: {
|
|
6953
|
+
type: "integer",
|
|
6954
|
+
description: "可选:最多返回多少个标签(按使用次数降序),默认 200,上限 1000。"
|
|
6955
|
+
} },
|
|
6726
6956
|
output: {
|
|
6727
6957
|
schema: { type: "json" },
|
|
6728
6958
|
render: (_args, value) => {
|
|
@@ -6730,18 +6960,23 @@ function registerTiddlywikiTools(ctx, deps) {
|
|
|
6730
6960
|
type: "text",
|
|
6731
6961
|
text: "知识库暂无标签。"
|
|
6732
6962
|
}];
|
|
6733
|
-
const lines = [
|
|
6963
|
+
const lines = [value.truncated ? `现有标签(共 ${value.total} 个,仅列出使用最多的 ${value.tags.length} 个,按使用次数降序):` : `现有标签(${value.total} 个,按使用次数降序):`];
|
|
6734
6964
|
for (const t of value.tags) lines.push(`- ${t.tag} × ${t.count}`);
|
|
6965
|
+
if (value.truncated) lines.push(`(其余 ${value.total - value.tags.length} 个较少使用的标签未列出;需要时可提高 limit 重试)`);
|
|
6735
6966
|
return [{
|
|
6736
6967
|
type: "text",
|
|
6737
6968
|
text: lines.join("\n")
|
|
6738
6969
|
}];
|
|
6739
6970
|
}
|
|
6740
6971
|
},
|
|
6741
|
-
execute: async () => {
|
|
6742
|
-
const
|
|
6972
|
+
execute: async (args) => {
|
|
6973
|
+
const stats = await requireWiki().tagStats();
|
|
6974
|
+
const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.max(1, Math.min(Math.floor(args.limit), 1e3)) : 200;
|
|
6975
|
+
const tags = stats.tags.slice(0, limit);
|
|
6743
6976
|
return {
|
|
6744
6977
|
count: tags.length,
|
|
6978
|
+
total: stats.total,
|
|
6979
|
+
truncated: tags.length < stats.total,
|
|
6745
6980
|
tags
|
|
6746
6981
|
};
|
|
6747
6982
|
}
|
|
@@ -8318,6 +8553,6 @@ function apply(ctx, rawConfig = {}) {
|
|
|
8318
8553
|
}, "dsh-tiddlywiki: host teardown");
|
|
8319
8554
|
}
|
|
8320
8555
|
//#endregion
|
|
8321
|
-
export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DEFAULT_NOTE_TYPE, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, MARKDOWN_PLUGIN, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, SEED_DEFS, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildWriteTiddler, bundledCatalog, checkAllSeeds, cleanTiddler, deepMerge, defineTool, downloadClipImage, dshHomePath, ensureLanguage, ensurePlugin, ensureTwWebHost, flattenTiddlerFields, flushPendingWrites, hostAllowed, imageExtensionForMime, inject, isBinaryType, isPrivateAddress, isSafeUrl, name, needsRestartAfterSeeds, normalizeThemes, openInTwEditor, parseClipPayload, pickImageMime, readWikiInfo, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, unseedClipBridge, waitForFileWrite, writeSessionSummary, writeWikiInfo };
|
|
8556
|
+
export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DEFAULT_NOTE_TYPE, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, MARKDOWN_PLUGIN, MASKED_SECRET, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, SEED_DEFS, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildWriteTiddler, bundledCatalog, checkAllSeeds, cleanTiddler, deepMerge, defineTool, downloadClipImage, dshHomePath, ensureLanguage, ensurePlugin, ensureTwWebHost, escapeInline, flattenTiddlerFields, flushPendingWrites, hostAllowed, imageExtensionForMime, inject, isBinaryType, isPrivateAddress, isSafeUrl, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeThemes, openInTwEditor, parseClipPayload, pickImageMime, readWikiInfo, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, stripMaskedSecrets, unseedClipBridge, waitForFileWrite, writeSessionSummary, writeWikiInfo };
|
|
8322
8557
|
|
|
8323
8558
|
//# sourceMappingURL=index.js.map
|