fix(workspace): open rendered preview links correctly
This commit is contained in:
parent
f1586daa3b
commit
99c886c199
4 changed files with 140 additions and 4 deletions
|
|
@ -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 = '<base target="_blank">'
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
if re.search(r"<head(?:\s[^>]*)?>", text, flags=re.IGNORECASE):
|
||||
text = re.sub(r"(<head\b[^>]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE)
|
||||
elif re.search(r"<!doctype[^>]*>", text, flags=re.IGNORECASE):
|
||||
text = re.sub(
|
||||
r"(<!doctype[^>]*>)",
|
||||
r"\1<head>" + base + "</head>",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
else:
|
||||
text = "<head>" + base + "</head>" + 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1327,7 +1327,7 @@
|
|||
</div>
|
||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
||||
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
|
||||
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts" title="HTML preview"></iframe>
|
||||
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="HTML preview"></iframe>
|
||||
</div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
|
||||
t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);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 `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${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 <a> tags first to avoid re-linking already-linked URLs.
|
||||
const _a_stash=[];
|
||||
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
|
||||
s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||||
s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||||
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
|
||||
// Restore raw <pre> 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;
|
||||
|
|
|
|||
88
tests/test_issue2768_workspace_links.py
Normal file
88
tests/test_issue2768_workspace_links.py
Normal file
|
|
@ -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 '<a href="mailto:foo@example.test" target="_blank" rel="noopener">email</a>' in html
|
||||
assert '<a href="tel:+15551212" target="_blank" rel="noopener">phone</a>' in html
|
||||
|
||||
|
||||
def test_workspace_html_iframe_allows_links_to_escape_sandbox():
|
||||
iframe = re.search(r'<iframe[^>]+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 '<base target="_blank">' in ROUTES_PY
|
||||
assert "_serve_inline_html_preview" in raw_handler
|
||||
assert "html_inline_ok" in raw_handler
|
||||
Loading…
Reference in a new issue