From 64af1f516550e0f7d4b6cb2b8651b400fb936f2b Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Fri, 4 Sep 2026 22:24:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(export):=20=E4=BF=AE=E5=A4=8D=20PR=20#17=20?= =?UTF-8?q?=E5=AE=A1=E9=98=85=E9=97=AE=E9=A2=98=EF=BC=881=20P1=20+=205=20P?= =?UTF-8?q?2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 链接/图片 URL 协议白名单校验,危险协议降级为纯文本 + warning - P2 图片 AST 字段映射(src=attrs.url,alt 取 children 文本) - P2 原始 HTML 块转义保留,正文不丢失 + warning - P2 过期/淘汰/重启清理导出产物文件 - P2 解析与渲染移入 asyncio.to_thread,运行中取消生效 - P2 function-plot 围栏别名补全 - 回归测试覆盖全部修复 Co-Authored-By: Claude Code --- backend/app/export/exporters/html.py | 51 ++++++++++-- backend/app/export/markdown.py | 27 +++++-- backend/app/export/service.py | 48 ++++++++++-- backend/app/main.py | 3 + backend/tests/test_export.py | 111 +++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 19 deletions(-) diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py index 0bcad16..ca8436f 100644 --- a/backend/app/export/exporters/html.py +++ b/backend/app/export/exporters/html.py @@ -8,12 +8,28 @@ from __future__ import annotations import html from datetime import datetime +from urllib.parse import urlparse from app.contracts import ExportOptions from app.export.document import Document, DocumentNode, ExportResult _MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块" _FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块" +_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留" + +# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级 +_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"}) + + +def _safe_url(url: str) -> str | None: + """校验 URL 协议;安全返回原串,不安全返回 None。""" + url = url.strip() + if not url: + return None + scheme = urlparse(url).scheme.lower() + if scheme and scheme not in _ALLOWED_URL_SCHEMES: + return None + return url _BASE_CSS = """ body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; } @@ -46,7 +62,8 @@ hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; } class HtmlExporter: """实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。""" - async def export(self, document: Document, options: ExportOptions) -> ExportResult: + def render(self, document: Document, options: ExportOptions) -> ExportResult: + """同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。""" self._options = options warnings: list[str] = [] body = self._render_children(document.children, warnings) @@ -55,6 +72,10 @@ class HtmlExporter: content=content.encode("utf-8"), mime_type="text/html", warnings=warnings ) + async def export(self, document: Document, options: ExportOptions) -> ExportResult: + """契约要求的 async 接口;渲染本身同步,直接转发到 render。""" + return self.render(document, options) + def _assemble( self, document: Document, options: ExportOptions, body: str, warnings: list[str] ) -> str: @@ -179,6 +200,11 @@ class HtmlExporter: def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str: return f'
$${html.escape(node.text)}$$
' + def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str: + # 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险 + warnings.append(_RAW_HTML_WARNING) + return f'
{html.escape(node.text)}
' + # --- 行内 --- def _render_text(self, node: DocumentNode, warnings: list[str]) -> str: return html.escape(node.text) @@ -190,21 +216,32 @@ class HtmlExporter: return f"{self._render_children(node.children, warnings)}" def _render_link(self, node: DocumentNode, warnings: list[str]) -> str: - href = html.escape(str(node.attributes.get("href") or "")) + inner = self._render_children(node.children, warnings) + href = str(node.attributes.get("href") or "") + safe_href = _safe_url(href) + if safe_href is None: + # 危险协议(如 javascript:)降级为纯文本,不输出可点击链接 + warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}") + return inner title = str(node.attributes.get("title") or "") - attrs = [f'href="{href}"'] + attrs = [f'href="{html.escape(safe_href)}"'] if title: attrs.append(f'title="{html.escape(title)}"') - return f"{self._render_children(node.children, warnings)}" + return f"{inner}" def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str: return f"{html.escape(node.text)}" def _render_image(self, node: DocumentNode, warnings: list[str]) -> str: - src = html.escape(str(node.attributes.get("src") or "")) - alt = html.escape(str(node.attributes.get("alt") or "")) + src = str(node.attributes.get("src") or "") + alt = str(node.attributes.get("alt") or "") + safe_src = _safe_url(src) + if safe_src is None: + # 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本 + warnings.append(f"图片地址不安全,已跳过:{src!r}") + return html.escape(alt) if alt else "" title = str(node.attributes.get("title") or "") - attrs = [f'src="{src}"', f'alt="{alt}"'] + attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(alt)}"'] if title: attrs.append(f'title="{html.escape(title)}"') return f"" diff --git a/backend/app/export/markdown.py b/backend/app/export/markdown.py index 181d190..bf9934d 100644 --- a/backend/app/export/markdown.py +++ b/backend/app/export/markdown.py @@ -15,7 +15,7 @@ _PLUGINS = ["table", "math", "url", "task_lists"] # fenced code 语言分流:命中则转为专用节点,其余按普通代码块 _MERMAID_LANG = "mermaid" -_FUNCTION_PLOT_LANGS = {"function_plot", "functionplot"} +_FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"} def parse_document(markdown: str) -> Document: @@ -85,10 +85,19 @@ class _AstMapper: return DocumentNode(type="thematic_break", node_id=self.next_id()) if kind == "blank_line": return None - # 未知块级 token(如 block_html)保守保留原文,避免静默丢失 + if kind == "block_html": + # 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文 + return DocumentNode( + type="html_block", node_id=self.next_id(), text=token.get("raw", "") + ) + # 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃 raw = token.get("raw", "") if raw: - return DocumentNode(type="paragraph", node_id=self.next_id(), text=raw) + return DocumentNode( + type="paragraph", + node_id=self.next_id(), + children=[DocumentNode(type="text", node_id=self.next_id(), text=raw)], + ) return None def map_list_item(self, token: dict) -> DocumentNode: @@ -144,10 +153,16 @@ class _AstMapper: if kind == "codespan": return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", "")) if kind == "image": + # mistune 图片 token:src 在 attrs.url,alt 来自 children 的文本,title 在 attrs.title attrs = token.get("attrs", {}) - attributes = {"src": attrs.get("src", "")} - if attrs.get("alt"): - attributes["alt"] = attrs["alt"] + alt = "".join( + child.get("raw", "") + for child in token.get("children", []) + if child.get("type") == "text" + ) + attributes = {"src": attrs.get("url", "")} + if alt: + attributes["alt"] = alt if attrs.get("title"): attributes["title"] = attrs["title"] return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes) diff --git a/backend/app/export/service.py b/backend/app/export/service.py index 73361ad..6730246 100644 --- a/backend/app/export/service.py +++ b/backend/app/export/service.py @@ -28,7 +28,7 @@ from app.contracts import ( ExportStatus, ) from app.errors import ApiError -from app.export.document import Document +from app.export.document import Document, ExportResult from app.export.exporters.html import HtmlExporter from app.export.markdown import parse_document from app.services import note_service @@ -61,10 +61,44 @@ def _safe_download_name(title: str) -> str: return name[:80] +def _export_path(job_id: str) -> Path: + return get_settings().exports_path / f"{job_id}.html" + + +def _delete_file(job_id: str) -> None: + """删除导出产物文件;文件不存在时忽略。""" + try: + _export_path(job_id).unlink(missing_ok=True) + except OSError: + logger.warning("Failed to delete export file: %s", job_id) + + +def cleanup_orphan_files() -> int: + """清理 exports 目录下无对应内存任务的孤立产物(服务重启后调用)。""" + exports_dir = get_settings().exports_path + if not exports_dir.is_dir(): + return 0 + removed = 0 + for path in exports_dir.glob("*.html"): + if path.stem not in _jobs: + try: + path.unlink() + removed += 1 + except OSError: + logger.warning("Failed to delete orphan export file: %s", path) + return removed + + +def _render_document(document: Document, options: ExportOptions) -> ExportResult: + """同步渲染辅助,供 asyncio.to_thread 调用;每次新建实例避免跨线程复用。""" + return HtmlExporter().render(document, options) + + def _forget(job_id: str) -> None: _jobs.pop(job_id, None) _tasks.pop(job_id, None) _cancel_flags.pop(job_id, None) + _delete_file(job_id) def _evict_terminal() -> bool: @@ -166,19 +200,20 @@ async def _execute( if cancel_event.is_set(): raise ExportCancelled() - document = parse_document(markdown) + # 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环, + # 使运行中的取消能在渲染边界生效;写文件前再次检查取消。 + document = await asyncio.to_thread(parse_document, markdown) document.attributes["title"] = title if metadata: document.attributes["metadata"] = metadata - exporter = HtmlExporter() - result = await exporter.export(document, options) + result = await asyncio.to_thread(_render_document, document, options) if cancel_event.is_set(): raise ExportCancelled() out_dir = get_settings().exports_path out_dir.mkdir(parents=True, exist_ok=True) - path = out_dir / f"{job_id}.html" + path = _export_path(job_id) path.write_bytes(result.content) completed_at = _now() @@ -260,8 +295,9 @@ def get_export_file(job_id: str) -> Path: 404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id} ) if job.file.expires_at <= _now(): + _forget(job_id) # 过期即清理内存记录与产物文件 raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id}) - return get_settings().exports_path / f"{job_id}.html" + return _export_path(job_id) async def wait_for_export(job_id: str) -> ExportJob | None: diff --git a/backend/app/main.py b/backend/app/main.py index bfaaf91..0ed0bba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException from app.config import get_settings from app.container import container from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler +from app.export import service as export_service from app.routes import router as api_router from app.schemas import HealthResponse, ServiceStatusResponse @@ -16,6 +17,8 @@ settings = get_settings() @asynccontextmanager async def lifespan(_: FastAPI): + # 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。 + export_service.cleanup_orphan_files() yield # 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。 container.plugins.shutdown() diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index 9bbad58..38fda15 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -16,6 +16,7 @@ from pydantic import ValidationError from app.config import get_settings from app.contracts import ( ExportFormat, + ExportJob, ExportOptions, ExportRequest, ExportSource, @@ -133,6 +134,21 @@ def test_parse_document_table_and_math() -> None: assert "math_block" in kinds +def test_parse_document_image_maps_src_alt_title() -> None: + doc = parse_document('![替代文本](https://a.b/img.png "标题")') + img = doc.children[0].children[0] + assert img.type == "image" + assert img.attributes["src"] == "https://a.b/img.png" + assert img.attributes["alt"] == "替代文本" + assert img.attributes["title"] == "标题" + + +def test_parse_document_function_plot_dash_alias() -> None: + doc = parse_document("```function-plot\ny = x^2\n```") + assert doc.children[0].type == "function_plot" + assert doc.children[0].text == "y = x^2" + + # --------------------------------------------------------------------------- # # HtmlExporter # --------------------------------------------------------------------------- # @@ -162,6 +178,38 @@ def test_html_exporter_marks_mermaid_and_function_plot() -> None: assert any("mermaid" in w for w in result.warnings) +def test_html_exporter_rejects_unsafe_link_protocol() -> None: + result = asyncio.run( + HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions()) + ) + html = result.content.decode("utf-8") + assert "javascript:" not in html + assert "点我" in html + assert any("不安全" in w for w in result.warnings) + + +def test_html_exporter_rejects_unsafe_image_protocol() -> None: + result = asyncio.run( + HtmlExporter().export(parse_document("![alt](data:text/html,