From 99c886c1992c8ec633c9259db28824c7afc3d046 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 24 May 2026 18:07:19 +0800 Subject: [PATCH] fix(workspace): open rendered preview links correctly --- api/routes.py | 49 +++++++++++++- static/index.html | 2 +- static/ui.js | 5 +- tests/test_issue2768_workspace_links.py | 88 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue2768_workspace_links.py diff --git a/api/routes.py b/api/routes.py index 32ba020a..edef7ed5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6908,6 +6908,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ return True +def _html_preview_with_blank_base(raw: bytes) -> bytes: + base = '' + text = raw.decode("utf-8", errors="replace") + if re.search(r"]*)?>", text, flags=re.IGNORECASE): + text = re.sub(r"(]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE) + elif re.search(r"]*>", text, flags=re.IGNORECASE): + text = re.sub( + r"(]*>)", + r"\1" + base + "", + text, + count=1, + flags=re.IGNORECASE, + ) + else: + text = "" + base + "" + text + return text.encode("utf-8") + + +def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str): + """Serve sandboxed workspace HTML preview with links targeting a new tab.""" + try: + body = _html_preview_with_blank_base(target.read_bytes()) + except PermissionError: + return bad(handler, "Permission denied", 403) + except Exception: + return bad(handler, "Could not read file", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Accept-Ranges", "none") + handler.send_header("Cache-Control", cache_control) + handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name)) + handler.send_header("Content-Security-Policy", csp) + handler.send_header("X-Content-Type-Options", "nosniff") + handler.send_header("Referrer-Policy", "same-origin") + handler.send_header( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=(), clipboard-write=(self)", + ) + handler.end_headers() + handler.wfile.write(body) + return True + + def _handle_media(handler, parsed): """Serve a local file by absolute path for inline display in the chat. @@ -7213,8 +7258,10 @@ def _handle_file_raw(handler, parsed): # CSP sandbox directive applies the same isolation server-side: without # allow-same-origin, the document is treated as a unique opaque origin and # cannot read WebUI cookies, localStorage, or postMessage to the parent. - csp = "sandbox allow-scripts" if html_inline_ok else None + csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None # _serve_file_bytes sends Content-Security-Policy when csp is set. + if html_inline_ok: + return _serve_inline_html_preview(handler, target, "no-store", csp=csp) return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp) diff --git a/static/index.html b/static/index.html index fe54a92c..e5518943 100644 --- a/static/index.html +++ b/static/index.html @@ -1327,7 +1327,7 @@ diff --git a/static/ui.js b/static/ui.js index c6a5cd2a..6679c87a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2889,7 +2889,7 @@ function renderMd(raw){ t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; - t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); @@ -2982,7 +2982,7 @@ function renderMd(raw){ // Stash existing tags first to avoid re-linking already-linked URLs. const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); - s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Restore raw
 only after markdown rewrites so literal preformatted
   // content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3016,6 +3016,7 @@ function renderMd(raw){
     if(!compact) return false;
     if(/^(javascript|data|vbscript):/i.test(compact)) return false;
     if(/^https?:\/\//i.test(raw)) return true;
+    if(/^(mailto:|tel:)/i.test(raw)) return true;
     if(img && /^api\//i.test(raw)) return true;
     if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
     return false;
diff --git a/tests/test_issue2768_workspace_links.py b/tests/test_issue2768_workspace_links.py
new file mode 100644
index 00000000..0733e200
--- /dev/null
+++ b/tests/test_issue2768_workspace_links.py
@@ -0,0 +1,88 @@
+import json
+import pathlib
+import re
+import subprocess
+import textwrap
+
+
+REPO = pathlib.Path(__file__).resolve().parents[1]
+UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
+
+
+def _extract_function(src: str, name: str) -> str:
+    marker = f"function {name}("
+    start = src.index(marker)
+    brace = src.index("{", start)
+    depth = 1
+    pos = brace + 1
+    while depth and pos < len(src):
+        ch = src[pos]
+        if ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+        pos += 1
+    assert depth == 0, f"could not extract {name}()"
+    return src[start:pos]
+
+
+def _render(markdown: str) -> str:
+    js = textwrap.dedent(
+        r'''
+        const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+        const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
+        const _PDF_EXTS=/\.pdf$/i;
+        const _SVG_EXTS=/\.svg$/i;
+        const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
+        const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
+        function t(k){ return k; }
+        function _mediaPlayerHtml(){ return ''; }
+        global.document={baseURI:'http://example.test/'};
+        '''
+    )
+    js += "\n" + _extract_function(UI_JS, "_matchBacktickFenceLine")
+    js += "\n" + _extract_function(UI_JS, "_isBacktickFenceClose")
+    js += "\n" + _extract_function(UI_JS, "renderMd")
+    js += textwrap.dedent(
+        r'''
+        const input=process.argv[1];
+        process.stdout.write(JSON.stringify(renderMd(input)));
+        '''
+    )
+    proc = subprocess.run(
+        ["node", "-e", js, markdown],
+        cwd=REPO,
+        text=True,
+        capture_output=True,
+        timeout=30,
+        check=True,
+    )
+    return json.loads(proc.stdout)
+
+
+def test_workspace_markdown_renders_mailto_and_tel_links():
+    html = _render("[email](mailto:foo@example.test) and [phone](tel:+15551212)")
+
+    assert 'email' in html
+    assert 'phone' in html
+
+
+def test_workspace_html_iframe_allows_links_to_escape_sandbox():
+    iframe = re.search(r']+id="previewHtmlIframe"[^>]*>', INDEX_HTML)
+
+    assert iframe, "previewHtmlIframe iframe not found"
+    sandbox = re.search(r'sandbox="([^"]+)"', iframe.group(0))
+    assert sandbox, "previewHtmlIframe must keep an explicit sandbox"
+    assert "allow-scripts" in sandbox.group(1)
+    assert "allow-popups" in sandbox.group(1)
+    assert "allow-popups-to-escape-sandbox" in sandbox.group(1)
+
+
+def test_file_raw_inline_html_preview_injects_base_target_blank():
+    raw_handler = ROUTES_PY[ROUTES_PY.index("def _handle_file_raw") :]
+
+    assert '' in ROUTES_PY
+    assert "_serve_inline_html_preview" in raw_handler
+    assert "html_inline_ok" in raw_handler