From a131a8cc450fbd0b91c22643a2f52a8d382cf4ea Mon Sep 17 00:00:00 2001 From: hectorqin Date: Tue, 22 Sep 2026 15:56:22 +0800 Subject: [PATCH] feat(plugins): add bounded diagnostic output --- docs/plugin-extensions.md | 4 ++-- server/src/sources/extensions.ts | 9 +++++++++ server/test/plugin-extensions.test.ts | 8 ++++++++ web/src/api/sources.ts | 1 + web/src/styles/reader.css | 4 ++++ web/src/ui/plugin-page-screen.tsx | 6 +++++- web/test/plugin-page.test.ts | 18 ++++++++++++++++++ 7 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/plugin-extensions.md b/docs/plugin-extensions.md index 7797357..a62df9f 100644 --- a/docs/plugin-extensions.md +++ b/docs/plugin-extensions.md @@ -16,7 +16,7 @@ GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourceType,context:{instance,userId},pageId,action,values}`。宿主根据 sourceId 从数据库解析插件与实例,客户端不能伪造上下文;暂停的来源仍可管理,停用插件则不可访问页面。普通读者无页面读取及写入权限。 -返回 Page DTO:`title/description/notice/forms/sections/tabs/activeTab`。每个 Tab 包含 `id/title/description/forms/sections`,页面公共内容和当前 Tab 同时渲染。Form 包含 `id/title/submit/fields/values`,可选 `layout: "inline"` 和 `confirm` 声明紧凑布局及行内二次确认,fields 支持 text、textarea、number、boolean、select;values 携带不透明行 ID。Field 可选 `placeholder/min/max`,Page 可选 `noticeKind: "info" | "error"`;所有字段由宿主验证。section 支持 `emptyText`,item 支持 `collapsible`。一次动作返回新页面;可选 activeTab 请求切换到指定 Tab,否则保留当前选择。纯 Tab 切换保留输入草稿。提交后保留其它声明未变化的表单草稿;服务端改变表单声明或主动刷新时以新页面为准。 +返回 Page DTO:`title/description/notice/forms/sections/tabs/activeTab`。每个 Tab 包含 `id/title/description/forms/sections/outputs`,页面公共内容和当前 Tab 同时渲染。Form 包含 `id/title/submit/fields/values`,可选 `layout: "inline"` 和 `confirm` 声明紧凑布局及行内二次确认,fields 支持 text、textarea、number、boolean、select;values 携带不透明行 ID。Field 可选 `placeholder/min/max`,Page 可选 `noticeKind: "info" | "error"`;所有字段由宿主验证。section 支持 `emptyText`,item 支持 `collapsible`。`outputs` 用于调试日志、JSON 或普通文本,格式为 `{title,text,format:"text"|"log"|"json"}`,宿主以纯文本块渲染并限制数量与大小,不执行内容。一次动作返回新页面;可选 activeTab 请求切换到指定 Tab,否则保留当前选择。纯 Tab 切换保留输入草稿。提交后保留其它声明未变化的表单草稿;服务端改变表单声明或主动刷新时以新页面为准。 宿主校验声明、页面结构、选项和输入配额;未声明页面拒绝访问。渲染器只呈现文本,不接受 HTML、JS、iframe 或任意前端代码。授权在 HTTP 层执行,隐藏入口不代替鉴权。 @@ -42,7 +42,7 @@ GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourc ## 验证方式 -生产 bundle 的 Chrome 验证脚本覆盖 390px 手机和 1280px 桌面布局、从自定义来源入口进入、三个 Tab、规则展开与编辑、订阅启停、搜索筛选和目录换源。运行 `node web/tools/ui-review/plugin-extensions.mjs`(仓库根目录,预先构建 Web;CHROME_PATH 可指定浏览器),截图输出到 docs/ui-review/plugin-*.png。 +生产 bundle 的 Chrome 验证脚本覆盖 390px 手机和 1280px 桌面布局、从自定义来源入口进入、四个 Tab、规则展开与编辑、订阅启停、书源调试、搜索筛选和目录换源。运行 `node web/tools/ui-review/plugin-extensions.mjs`(仓库根目录,预先构建 Web;CHROME_PATH 可指定浏览器),截图输出到 docs/ui-review/plugin-*.png。 ## 本次验证(2026-09-21) diff --git a/server/src/sources/extensions.ts b/server/src/sources/extensions.ts index 1f273aa..1729c08 100644 --- a/server/src/sources/extensions.ts +++ b/server/src/sources/extensions.ts @@ -13,6 +13,7 @@ export interface ExtensionForm { } export interface ExtensionContent { forms: ExtensionForm[]; + outputs?: Array<{ title: string; text: string; format: 'text' | 'log' | 'json' }>; sections?: Array<{ title: string; emptyText?: string; items: Array<{ title: string; description?: string; collapsible?: boolean; forms?: ExtensionForm[] }> }>; } export interface ExtensionPage extends ExtensionContent { @@ -85,6 +86,14 @@ export function extensionPage(input: unknown): ExtensionPage { }; const content = (input: Record) => { forms(input.forms); + if (input.outputs !== undefined) { + check(Array.isArray(input.outputs) && input.outputs.length <= 8); + for (const output of input.outputs) { + record(output); label(output.title); + check(typeof output.text === 'string' && output.text.length <= 65536); + check(['text', 'log', 'json'].includes(String(output.format))); + } + } if (input.sections !== undefined) { check(Array.isArray(input.sections) && input.sections.length <= 16); for (const section of input.sections) { diff --git a/server/test/plugin-extensions.test.ts b/server/test/plugin-extensions.test.ts index 427f90d..fa1ac2e 100644 --- a/server/test/plugin-extensions.test.ts +++ b/server/test/plugin-extensions.test.ts @@ -31,3 +31,11 @@ test('extension UX hints are validated without allowing executable or unbounded assert.throws(() => extensionPage({ title: 'x', forms: [], noticeKind: 'html' })); assert.throws(() => extensionPage({ title: 'x', forms: [{ id: 'save', title: '', submit: 'Save', fields: [], confirm: {} }] })); }); + +test('read-only extension outputs are text-only and bounded', () => { + const output = { title: 'Log', text: '\n200 OK', format: 'log' }; + assert.equal(extensionPage({ title: 'Debug', forms: [], outputs: [output] }).outputs?.[0]?.text, output.text); + for (const outputs of [[{ ...output, format: 'html' }], [{ ...output, text: {} }], [{ ...output, text: 'x'.repeat(65537) }], Array(9).fill(output)]) { + assert.throws(() => extensionPage({ title: 'Debug', forms: [], outputs })); + } +}); diff --git a/web/src/api/sources.ts b/web/src/api/sources.ts index 479ef1e..b6cf9c4 100644 --- a/web/src/api/sources.ts +++ b/web/src/api/sources.ts @@ -42,6 +42,7 @@ export interface ExtensionForm { layout?: 'inline'; confirm?: string; id: string; title: string; submit: string; fields: ExtensionField[]; values?: Record } export interface ExtensionContent { forms: ExtensionForm[]; + outputs?: Array<{ title: string; text: string; format: 'text' | 'log' | 'json' }>; sections?: Array<{ title: string; emptyText?: string; items: Array<{ title: string; description?: string; collapsible?: boolean; forms?: ExtensionForm[] }> }>; } export interface ExtensionPage extends ExtensionContent { diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 5385f33..5a3a8c0 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -3849,6 +3849,10 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin } [data-theme='dark'] .extension-toolbar .extension-error { color: #ffaaa0; } .extension-tabs button:disabled { opacity: .55; cursor: default; } +.extension-output { min-width: 0; } +.extension-output summary { font-weight: 650; cursor: pointer; padding-block: .35rem; } +.extension-output pre { max-height: 26rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: .75rem 0 0; padding: 1rem; background: var(--reader-surface); border: 1px solid var(--reader-line); border-radius: .5rem; font: .8125rem/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; } +.extension-output pre:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } /* Source hub: navigation tabs, one primary action per row, secondary settings on demand. */ .sources-hub .sources-tabs { flex: none; flex-wrap: nowrap; gap: 0; padding: 0 1rem; width: 100%; max-width: 66rem; margin: 0 auto 1rem; } diff --git a/web/src/ui/plugin-page-screen.tsx b/web/src/ui/plugin-page-screen.tsx index 2e9826f..057c467 100644 --- a/web/src/ui/plugin-page-screen.tsx +++ b/web/src/ui/plugin-page-screen.tsx @@ -61,7 +61,7 @@ export class PluginPageScreen { {form.fields.map(field =>