diff --git a/devplacepy/rendering.py b/devplacepy/rendering.py index daf055bb..753bf3ba 100644 --- a/devplacepy/rendering.py +++ b/devplacepy/rendering.py @@ -79,6 +79,7 @@ _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b") _EMAIL_KEEP_DOMAIN = "molodetz.nl" _MEDIA_SKIP_TAGS = {"a", "code", "pre"} +_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}\"']+$") _TITLE_INLINE_TAGS = { "b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br", } @@ -139,6 +140,11 @@ def _alt_from_url(url: str) -> str: def _embed_url(url: str) -> str: + trail = "" + punct_match = _TRAILING_PUNCT_RE.search(url) + if punct_match: + trail = punct_match.group() + url = url[: punct_match.start()] youtube = _YOUTUBE_RE.search(url) if youtube: video_id = youtube.group(1) @@ -146,19 +152,19 @@ def _embed_url(url: str) -> str: f'
" + f"{trail}" ) escaped = html.escape(url, quote=True) if _IMAGE_RE.search(url): alt = html.escape(_alt_from_url(url), quote=True) - return f'{alt}' + return f'{alt}{trail}' if _VIDEO_RE.search(url): - return f'' + return f'{trail}' if _AUDIO_RE.search(url): - return f'' + return f'{trail}' return ( f'' - f"{html.escape(url)}" + f"{html.escape(url)}{trail}" ) diff --git a/devplacepy/static/js/ContentRenderer.js b/devplacepy/static/js/ContentRenderer.js index 67282015..1a4eca35 100644 --- a/devplacepy/static/js/ContentRenderer.js +++ b/devplacepy/static/js/ContentRenderer.js @@ -188,17 +188,21 @@ export class ContentRenderer { a.textContent = "@" + part.username; fragment.appendChild(a); } else { - const el = this.urlToEmbed(part.value); + const { cleaned, trail } = this.stripTrailingPunct(part.value); + const el = this.urlToEmbed(cleaned); if (el) { fragment.appendChild(el); } else { const a = document.createElement("a"); - a.href = part.value; + a.href = cleaned; a.target = "_blank"; a.rel = "noopener noreferrer"; - a.textContent = part.value; + a.textContent = cleaned; fragment.appendChild(a); } + if (trail) { + fragment.appendChild(document.createTextNode(trail)); + } } } @@ -282,6 +286,14 @@ export class ContentRenderer { return null; } + stripTrailingPunct(url) { + const m = url.match(/[.,;:!?)\]}\"']+$/); + if (m) { + return { cleaned: url.slice(0, m.index), trail: m[0] }; + } + return { cleaned: url, trail: "" }; + } + walkNodes(root, callback) { const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]); const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); diff --git a/tests/unit/rendering.py b/tests/unit/rendering.py index d5ba187b..a4c1d06c 100644 --- a/tests/unit/rendering.py +++ b/tests/unit/rendering.py @@ -263,3 +263,46 @@ def test_xss_legitimate_link_survives_audit(): out = str(render_content("see https://example.com/page ok")) assert 'href="https://example.com/page"' in out assert_no_executable_html(out) + + +def test_bare_url_with_trailing_period(): + out = str(render_content("see https://example.com/page.")) + assert 'href="https://example.com/page"' in out + assert "." in out + assert "https://example.com/page." not in out + + +def test_bare_url_with_trailing_comma(): + out = str(render_content("check https://example.com/page,")) + assert 'href="https://example.com/page"' in out + assert "," in out + + +def test_bare_url_with_trailing_exclamation(): + out = str(render_content("look https://example.com/page!")) + assert 'href="https://example.com/page"' in out + assert "!" in out + + +def test_bare_url_with_trailing_question_mark(): + out = str(render_content("did you see https://example.com/page?")) + assert 'href="https://example.com/page"' in out + assert "?" in out + + +def test_bare_url_with_trailing_paren(): + out = str(render_content("see (https://example.com/page)")) + assert 'href="https://example.com/page"' in out + assert ")" in out + + +def test_bare_url_with_trailing_multiple_punctuation(): + out = str(render_content("visit https://example.com/page...")) + assert 'href="https://example.com/page"' in out + assert "..." in out + + +def test_bare_url_without_trailing_punctuation_unchanged(): + out = str(render_content("see https://example.com/page ok")) + assert 'href="https://example.com/page"' in out + assert "https://example.com/page " in out