Feedback reply: admin-only WYSIWYG editor (server stays the adult)

Replace the Markdown composer with a small contenteditable WYSIWYG (Codex
greenlit for this narrow, admin-only surface).

* markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text.
  Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and
  span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong,
  i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles
  (content kept as escaped text) and discards script/style content entirely.
* API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html,
  derives plain text → stored message + the email text/plain part. Unchanged:
  multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422.
* Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List),
  execCommand with styleWithCSS=false for semantic tags, font size wraps the
  selection in a fixed-px span, paste intercepted as plain text. No links yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 09:10:57 -04:00
parent 9deca522b4
commit a5cea7cd74
5 changed files with 254 additions and 114 deletions
+79 -49
View File
@@ -136,52 +136,58 @@
} }
} }
// In-site reply (Markdown-ish: textarea + toolbar; server renders sanitized HTML). // In-site reply: small admin-only WYSIWYG (contenteditable + execCommand).
// The editor's HTML is sanitized SERVER-SIDE; the client just collects it.
let replyingId = $state(null); let replyingId = $state(null);
let replyText = $state('');
let replyBusy = $state(false); let replyBusy = $state(false);
let replyErr = $state(''); let replyErr = $state('');
let replyTextarea = $state(null); let replyEmpty = $state(true);
function openReply(f) { replyingId = f.id; replyText = ''; replyErr = ''; } let replyEditor = $state(null);
function cancelReply() { replyingId = null; replyText = ''; replyErr = ''; } const SIZE_PX = { small: 13, normal: 15, large: 18, xlarge: 22 };
// Wrap the current selection (e.g. **bold**), restoring focus + selection. function openReply(f) { replyingId = f.id; replyErr = ''; replyEmpty = true; }
function mdWrap(token) { function cancelReply() { replyingId = null; replyErr = ''; }
const el = replyTextarea; function focusEditor(node) { queueMicrotask(() => node.focus()); }
if (!el) return; function onEditorInput() { replyEmpty = !(replyEditor?.textContent || '').trim(); }
const s = el.selectionStart, e = el.selectionEnd; // Paste as plain text — never let rich/pasted HTML into the editor.
const sel = replyText.slice(s, e) || 'text'; function onPaste(e) {
replyText = replyText.slice(0, s) + token + sel + token + replyText.slice(e); e.preventDefault();
queueMicrotask(() => { const text = (e.clipboardData || window.clipboardData).getData('text/plain');
el.focus(); document.execCommand('insertText', false, text);
el.selectionStart = s + token.length;
el.selectionEnd = s + token.length + sel.length;
});
} }
// Prefix each selected line (e.g. "- " or "## "). // Bold/italic/lists via execCommand; styleWithCSS=false → semantic <b>/<i>/<ul>.
function mdPrefix(prefix) { function cmd(c) {
const el = replyTextarea; replyEditor?.focus();
if (!el) return; try { document.execCommand('styleWithCSS', false, false); } catch { /* not all browsers */ }
const s = el.selectionStart, e = el.selectionEnd; document.execCommand(c, false, null);
const start = replyText.lastIndexOf('\n', s - 1) + 1; onEditorInput();
let end = replyText.indexOf('\n', e); }
if (end === -1) end = replyText.length; // Font size: wrap the selection in a span with a fixed, whitelisted px.
const block = replyText.slice(start, end) || 'item'; function setSize(key) {
const prefixed = block.split('\n').map((ln) => prefix + ln).join('\n'); const px = SIZE_PX[key];
replyText = replyText.slice(0, start) + prefixed + replyText.slice(end); if (!px) return;
queueMicrotask(() => el.focus()); replyEditor?.focus();
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
if (range.collapsed) return; // need a selection to size
const span = document.createElement('span');
span.style.fontSize = px + 'px';
try { span.appendChild(range.extractContents()); range.insertNode(span); } catch { /* ignore */ }
sel.removeAllRanges();
onEditorInput();
} }
async function sendReply(f) { async function sendReply(f) {
const msg = replyText.trim(); if (replyEmpty || replyBusy) return;
if (!msg || replyBusy) return; const html = replyEditor?.innerHTML || '';
replyBusy = true; replyErr = ''; replyBusy = true; replyErr = '';
try { try {
const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { message: msg }); const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { html });
f.replies = [...(f.replies || []), res.reply]; f.replies = [...(f.replies || []), res.reply];
f.read_at = f.read_at || res.reply.sent_at; // server marked it read f.read_at = f.read_at || res.reply.sent_at; // server marked it read
replyingId = null; replyText = ''; replyingId = null;
} catch (e) { } catch (e) {
replyErr = e?.message || 'Could not send — the draft is kept.'; replyErr = e?.message || 'Could not send — your draft is kept.';
} finally { } finally {
replyBusy = false; replyBusy = false;
} }
@@ -475,15 +481,33 @@
{#if replyingId === f.id} {#if replyingId === f.id}
<div class="composer"> <div class="composer">
<div class="mdbar"> <div class="rtbar">
<button type="button" class="mdbtn" title="Bold (**…**)" onclick={() => mdWrap('**')}><b>B</b></button> <button type="button" class="rtbtn" title="Bold" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('bold')}><b>B</b></button>
<button type="button" class="mdbtn" title="Bullet list" onclick={() => mdPrefix('- ')}> List</button> <button type="button" class="rtbtn" title="Italic" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('italic')}><i>I</i></button>
<button type="button" class="mdbtn" title="Heading" onclick={() => mdPrefix('## ')}>H</button> <select class="szsel" title="Text size" onmousedown={(e) => e.stopPropagation()} onchange={(e) => { setSize(e.currentTarget.value); e.currentTarget.value = ''; }}>
<option value="">Size</option>
<option value="small">Small</option>
<option value="normal">Normal</option>
<option value="large">Large</option>
<option value="xlarge">X-Large</option>
</select>
<button type="button" class="rtbtn" title="Bullet list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertUnorderedList')}>• List</button>
<button type="button" class="rtbtn" title="Numbered list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertOrderedList')}>1. List</button>
</div> </div>
<textarea bind:this={replyTextarea} bind:value={replyText} rows="4" placeholder="Write a reply… **bold**, - bullets, ## heading"></textarea> <div
class="rtedit"
contenteditable="true"
bind:this={replyEditor}
use:focusEditor
oninput={onEditorInput}
onpaste={onPaste}
role="textbox"
aria-multiline="true"
aria-label="Write a reply"
></div>
{#if replyErr}<p class="cerr">{replyErr}</p>{/if} {#if replyErr}<p class="cerr">{replyErr}</p>{/if}
<div class="cbtns"> <div class="cbtns">
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || !replyText.trim()}> <button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || replyEmpty}>
{replyBusy ? 'Sending…' : 'Send reply'} {replyBusy ? 'Sending…' : 'Send reply'}
</button> </button>
<button class="act" onclick={cancelReply}>Cancel</button> <button class="act" onclick={cancelReply}>Cancel</button>
@@ -669,18 +693,24 @@
.rep .repmsg :global(h4), .rep .repmsg :global(h4),
.rep .repmsg :global(h5) { margin: 6px 0 4px; font-size: 0.95rem; } .rep .repmsg :global(h5) { margin: 6px 0 4px; font-size: 0.95rem; }
.composer { margin: 10px 0 0; } .composer { margin: 10px 0 0; }
.mdbar { display: flex; gap: 6px; margin-bottom: 6px; } .rtbar { display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; align-items: center; }
.mdbtn { .rtbtn {
font: inherit; font-size: 0.78rem; background: var(--surface); border: 1px solid var(--line); font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
color: var(--ink); border-radius: 7px; padding: 3px 9px; cursor: pointer; line-height: 1.4; color: var(--ink); border-radius: 7px; padding: 3px 10px; cursor: pointer; line-height: 1.4; min-width: 30px;
} }
.mdbtn:hover { border-color: var(--accent); color: var(--accent-deep); } .rtbtn:hover { border-color: var(--accent); color: var(--accent-deep); }
.composer textarea { .szsel {
width: 100%; box-sizing: border-box; font: inherit; font-size: 0.9rem; font: inherit; font-size: 0.8rem; background: var(--surface); border: 1px solid var(--line);
color: var(--ink); border-radius: 7px; padding: 3px 6px; cursor: pointer;
}
.rtedit {
min-height: 90px; box-sizing: border-box; font-size: 0.92rem; line-height: 1.5;
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
background: var(--bg); color: var(--ink); resize: vertical; background: var(--bg); color: var(--ink);
} }
.composer textarea:focus { outline: none; border-color: var(--accent); } .rtedit:focus { outline: none; border-color: var(--accent); }
.rtedit :global(ul), .rtedit :global(ol) { margin: 4px 0; padding-left: 22px; }
.rtedit :global(p) { margin: 0 0 6px; }
.cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; } .cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; }
.cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; } .cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.csend { .csend {
+8 -7
View File
@@ -32,7 +32,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from . import auth, email_send, feeds, oauth_google, queries, share, summarize from . import auth, email_send, feeds, oauth_google, queries, share, summarize
from .markup import render_reply_html from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect from .db import connect
from .filters import filter_articles, prefs_from_json from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead from .hero import safe_to_lead
@@ -383,7 +383,7 @@ class FeedbackReadBody(BaseModel):
class FeedbackReplyBody(BaseModel): class FeedbackReplyBody(BaseModel):
message: str = "" html: str = "" # raw editor HTML — sanitized server-side before use
class SourceActiveBody(BaseModel): class SourceActiveBody(BaseModel):
@@ -843,8 +843,10 @@ def create_app() -> FastAPI:
@app.post("/api/admin/feedback/{fid}/reply") @app.post("/api/admin/feedback/{fid}/reply")
def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict: def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict:
message = (body.message or "").strip()[:4000] # Sanitize the editor HTML to our allowlist; derive the plain-text fallback.
if not message: reply_html = sanitize_reply_html(body.html)[:8000]
reply_text = reply_html_to_text(reply_html)
if not reply_text:
raise HTTPException(status_code=422, detail="Reply message is required.") raise HTTPException(status_code=422, detail="Reply message is required.")
# 1. Validate + gather, then release the DB connection — SMTP can take # 1. Validate + gather, then release the DB connection — SMTP can take
# ~20s and shouldn't keep a connection open. # ~20s and shouldn't keep a connection open.
@@ -856,11 +858,10 @@ def create_app() -> FastAPI:
if not fb["contact_email"]: if not fb["contact_email"]:
raise HTTPException(status_code=400, detail="No reply address for this feedback.") raise HTTPException(status_code=400, detail="No reply address for this feedback.")
admin_id, to, original = admin["id"], fb["contact_email"], fb["message"] admin_id, to, original = admin["id"], fb["contact_email"], fb["message"]
reply_html = render_reply_html(message) # tiny safe Markdown subset
# 2. Send with no DB connection held; only record a reply that actually # 2. Send with no DB connection held; only record a reply that actually
# went out, so the UI can keep the draft on failure. # went out, so the UI can keep the draft on failure.
try: try:
email_send.send_feedback_reply(to, message, reply_html, original) email_send.send_feedback_reply(to, reply_text, reply_html, original)
except Exception as exc: # noqa: BLE001 — surface any SMTP failure to the admin except Exception as exc: # noqa: BLE001 — surface any SMTP failure to the admin
raise HTTPException(status_code=502, detail=f"Could not send the reply: {exc}") raise HTTPException(status_code=502, detail=f"Could not send the reply: {exc}")
# 3. Record the sent reply + mark the item read. # 3. Record the sent reply + mark the item read.
@@ -868,7 +869,7 @@ def create_app() -> FastAPI:
cur = conn.execute( cur = conn.execute(
"INSERT INTO feedback_replies (feedback_id, user_id, message, message_html, sent_to) " "INSERT INTO feedback_replies (feedback_id, user_id, message, message_html, sent_to) "
"VALUES (?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?)",
(fid, admin_id, message, reply_html, to), (fid, admin_id, reply_text, reply_html, to),
) )
conn.execute( conn.execute(
"UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,) "UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,)
+127 -37
View File
@@ -1,48 +1,138 @@
"""A tiny, safe Markdown subset → HTML, for admin feedback replies. """Server-side sanitizing for admin feedback replies.
Everything is HTML-escaped FIRST, then a small whitelist of structures is The reply composer is a small admin-only WYSIWYG (contenteditable + execCommand).
introduced: bold (**…**), bullet lists (- / *), headings (#/##/###), Browsers emit inconsistent HTML, so the server is the adult in the room: it
paragraphs, and line breaks. No links, no attributes, no inline styles, no raw takes whatever the editor produced and rebuilds it from a strict allowlist —
HTML passthrough — so the output is safe to render and to email. Authored only
by admins, but sanitized on principle regardless. strong, em, p, br, ul, ol, li, and span ONLY with a whitelisted font-size.
Everything else (links, images, scripts, arbitrary styles, colors, fonts) is
dropped; disallowed-tag *content* is kept as escaped text, except script/style
whose content is discarded entirely. No raw HTML is ever trusted downstream —
we store and send only this sanitized output.
""" """
from __future__ import annotations from __future__ import annotations
import html import html as _html
import re import re
from html.parser import HTMLParser
_BULLET_RE = re.compile(r"^\s*[-*]\s+") # Canonicalise the tags we keep (b→strong, i→em, div→p); anything not here is dropped.
_HEADING_RE = re.compile(r"^\s*(#{1,3})\s+") _TAG_MAP = {
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*") "b": "strong", "strong": "strong",
_HEADING_TAG = {1: "h3", 2: "h4", 3: "h5"} "i": "em", "em": "em",
"p": "p", "div": "p",
"br": "br",
"ul": "ul", "ol": "ol", "li": "li",
"span": "span",
}
_ALLOWED_SIZES = {"13px", "15px", "18px", "22px"}
_DROP_CONTENT = {"script", "style"}
_FONT_SIZE_RE = re.compile(r"font-size:\s*(\d+px)", re.I)
# execCommand fontSize emits <font size="1..7">; map the common ones to our scale.
_FONT_TAG_SIZE = {"1": "13px", "2": "13px", "3": "15px", "4": "18px", "5": "22px", "6": "22px", "7": "22px"}
def _inline(s: str) -> str: class _Sanitizer(HTMLParser):
"""Escape, then apply the only inline transform we allow: **bold**.""" def __init__(self) -> None:
s = html.escape(s, quote=False) super().__init__(convert_charrefs=True)
return _BOLD_RE.sub(r"<strong>\1</strong>", s) self.out: list[str] = []
self.open: list[str | None] = [] # emitted canonical tag, or None for a dropped wrapper
self.skip = 0 # depth inside script/style (content discarded)
def handle_starttag(self, tag, attrs):
def render_reply_html(text: str) -> str: t = tag.lower()
"""Render the supported Markdown subset to sanitized HTML (or '' if empty).""" if t in _DROP_CONTENT:
text = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip() self.skip += 1
if not text: self.open.append(None)
return "" return
out: list[str] = [] if self.skip:
for block in re.split(r"\n\s*\n", text): return
lines = [ln for ln in block.split("\n")] if t == "br":
nonempty = [ln for ln in lines if ln.strip()] self.out.append("<br>")
if not nonempty: return # void — no stack entry
continue size = self._size(t, attrs)
if all(_BULLET_RE.match(ln) for ln in nonempty): if t == "span":
items = "".join("<li>" + _inline(_BULLET_RE.sub("", ln)) + "</li>" for ln in nonempty) if size:
out.append("<ul>" + items + "</ul>") self.out.append(f'<span style="font-size:{size}">')
elif len(nonempty) == 1 and _HEADING_RE.match(nonempty[0]): self.open.append("span")
level = len(_HEADING_RE.match(nonempty[0]).group(1)) else:
content = _HEADING_RE.sub("", nonempty[0]) self.open.append(None) # unstyled/!whitelisted span → drop wrapper, keep text
tag = _HEADING_TAG[level] return
out.append(f"<{tag}>" + _inline(content) + f"</{tag}>") if t == "font":
if size:
self.out.append(f'<span style="font-size:{size}">')
self.open.append("span") # normalise <font size> → safe span
else:
self.open.append(None)
return
canon = _TAG_MAP.get(t)
if canon:
self.out.append(f"<{canon}>")
self.open.append(canon)
else: else:
out.append("<p>" + "<br>".join(_inline(ln) for ln in nonempty) + "</p>") self.open.append(None) # disallowed tag — drop tag, keep children
return "".join(out)
def handle_startendtag(self, tag, attrs):
if tag.lower() == "br" and not self.skip:
self.out.append("<br>")
def handle_endtag(self, tag):
t = tag.lower()
if t in _DROP_CONTENT:
if self.skip:
self.skip -= 1
if self.open:
self.open.pop()
return
if self.skip or t == "br":
return
if not self.open:
return
canon = self.open.pop()
if canon:
self.out.append(f"</{canon}>")
def handle_data(self, data):
if not self.skip:
self.out.append(_html.escape(data, quote=False))
def _size(self, tag, attrs):
for k, v in attrs:
if not v:
continue
if k.lower() == "style":
m = _FONT_SIZE_RE.search(v)
if m and m.group(1) in _ALLOWED_SIZES:
return m.group(1)
elif tag == "font" and k.lower() == "size":
return _FONT_TAG_SIZE.get(v.strip())
return None
def sanitize_reply_html(raw: str) -> str:
"""Rebuild editor HTML from the strict allowlist (or '' if it has no content)."""
if not raw:
return ""
p = _Sanitizer()
p.feed(raw)
p.close()
html = "".join(p.out)
# If nothing but markup/whitespace survived, treat as empty.
if not re.sub(r"<[^>]+>", "", html).strip():
return ""
return html.strip()
def reply_html_to_text(clean_html: str) -> str:
"""Plain-text fallback derived from the already-sanitized HTML."""
if not clean_html:
return ""
s = re.sub(r"</li>", "\n", clean_html)
s = re.sub(r"<li>", "- ", s)
s = re.sub(r"</p>", "\n\n", s)
s = re.sub(r"<br\s*/?>", "\n", s)
s = re.sub(r"<[^>]+>", "", s)
s = _html.unescape(s)
return re.sub(r"\n{3,}", "\n\n", s).strip()
+6 -7
View File
@@ -88,7 +88,7 @@ def test_feedback_admin_actions_gated(tmp_path, monkeypatch):
anon = TestClient(app) anon = TestClient(app)
assert anon.post("/api/admin/feedback/1/read", json={"read": True}).status_code == 401 assert anon.post("/api/admin/feedback/1/read", json={"read": True}).status_code == 401
assert anon.delete("/api/admin/feedback/1").status_code == 401 assert anon.delete("/api/admin/feedback/1").status_code == 401
assert anon.post("/api/admin/feedback/1/reply", json={"message": "hi"}).status_code == 401 assert anon.post("/api/admin/feedback/1/reply", json={"html": "hi"}).status_code == 401
def test_feedback_actions_404_on_missing_id(tmp_path, monkeypatch): def test_feedback_actions_404_on_missing_id(tmp_path, monkeypatch):
@@ -109,10 +109,10 @@ def test_feedback_reply_sends_stores_marks_read(tmp_path, monkeypatch):
tc.post("/api/auth/email/start", json={"email": "boss@x.com"}) tc.post("/api/auth/email/start", json={"email": "boss@x.com"})
tc.post("/api/auth/email/verify", json={"token": link["l"].split("token=")[1]}) tc.post("/api/auth/email/verify", json={"token": link["l"].split("token=")[1]})
fid = tc.get("/api/admin/feedback").json()[0]["id"] fid = tc.get("/api/admin/feedback").json()[0]["id"]
r = tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": "Yes — a **companion app** is coming."}) r = tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": "Yes — a <b>companion app</b> is coming."})
assert r.status_code == 200 assert r.status_code == 200
assert sent["to"] == "reader@x.com" and "companion app" in sent["msg"] and "is there an app?" in sent["orig"] assert sent["to"] == "reader@x.com" and "companion app" in sent["msg"] and "is there an app?" in sent["orig"]
assert "<strong>companion app</strong>" in sent["html"] # rendered markdown sent as HTML assert "<strong>companion app</strong>" in sent["html"] # sanitized editor HTML (b→strong)
fb = tc.get("/api/admin/feedback").json()[0] fb = tc.get("/api/admin/feedback").json()[0]
assert fb["read_at"] is not None # marked read on reply assert fb["read_at"] is not None # marked read on reply
rep = fb["replies"][0] rep = fb["replies"][0]
@@ -125,7 +125,6 @@ def test_feedback_reply_requires_address_and_message(tmp_path, monkeypatch):
monkeypatch.setattr("goodnews.email_send.send_feedback_reply", monkeypatch.setattr("goodnews.email_send.send_feedback_reply",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send"))) lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send")))
fid = tc.get("/api/admin/feedback").json()[0]["id"] fid = tc.get("/api/admin/feedback").json()[0]["id"]
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": ""}).status_code == 422 assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": " "}).status_code == 422
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": "hi"}).status_code == 400 # no address assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": "hi"}).status_code == 400 # no address
assert tc.post("/api/admin/feedback/9999/reply", json={"message": "hi"}).status_code == 404 assert tc.post("/api/admin/feedback/9999/reply", json={"html": "hi"}).status_code == 404
import goodnews.api as api # already reloaded by _admin_client's app
+34 -14
View File
@@ -1,23 +1,43 @@
from goodnews.markup import render_reply_html as r from goodnews.markup import sanitize_reply_html as s, reply_html_to_text as t2
def test_escapes_before_formatting(): def test_keeps_allowed_formatting_and_normalizes_tags():
out = r("<script>alert(1)</script> and **bold**") out = s("<b>bold</b> and <i>it</i> and <strong>x</strong> and <em>y</em>")
assert "<script>" not in out and "&lt;script&gt;" in out assert out == "<strong>bold</strong> and <em>it</em> and <strong>x</strong> and <em>y</em>"
assert "<strong>bold</strong>" in out
def test_bullets_and_heading_and_paragraphs(): def test_lists_and_paragraphs():
assert r("- one\n- two") == "<ul><li>one</li><li>two</li></ul>" assert s("<ul><li>a</li><li>b</li></ul>") == "<ul><li>a</li><li>b</li></ul>"
assert r("## Update") == "<h4>Update</h4>" assert s("<ol><li>one</li></ol>") == "<ol><li>one</li></ol>"
assert r("a\nb\n\nc") == "<p>a<br>b</p><p>c</p>" assert s("<div>line</div>") == "<p>line</p>" # div canonicalised to p
assert s("a<br>b") == "a<br>b"
def test_no_links_or_attrs_pass_through(): def test_font_size_whitelist():
out = r('[click](http://x) <a href="x" onclick="y">hi</a>') assert s('<span style="font-size:18px">big</span>') == '<span style="font-size:18px">big</span>'
assert "<a" not in out # no anchor tag produced or passed through (escaped to &lt;a) # off-whitelist size → span dropped, text kept
assert "[click]" in out # markdown links unsupported → left as literal text assert s('<span style="font-size:40px">huge</span>') == "huge"
# <font size> normalised to a safe span
assert s('<font size="5">x</font>') == '<span style="font-size:22px">x</span>'
def test_strips_dangerous_and_arbitrary():
# script content discarded entirely
assert "alert" not in s("<script>alert(1)</script>hi") and s("<script>alert(1)</script>hi") == "hi"
# links dropped, text kept; no href/onclick survive
out = s('<a href="http://x" onclick="y()">click</a>')
assert out == "click"
# arbitrary styles/colors stripped, content kept
assert s('<span style="color:red;font-weight:bold">z</span>') == "z"
# escapes stray angle brackets in text
assert s("2 < 3 & 4 > 1") == "2 &lt; 3 &amp; 4 &gt; 1"
def test_empty(): def test_empty():
assert r(" ") == "" and r(None) == "" assert s("") == "" and s("<p></p>") == "" and s("<br>") == ""
def test_html_to_text():
assert t2("<p>hi <strong>there</strong></p>") == "hi there"
assert t2("<ul><li>a</li><li>b</li></ul>") == "- a\n- b"
assert t2('<span style="font-size:18px">big</span>') == "big"