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
+6 -7
View File
@@ -88,7 +88,7 @@ def test_feedback_admin_actions_gated(tmp_path, monkeypatch):
anon = TestClient(app)
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.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):
@@ -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/verify", json={"token": link["l"].split("token=")[1]})
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 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]
assert fb["read_at"] is not None # marked read on reply
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",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send")))
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={"message": "hi"}).status_code == 400 # no address
assert tc.post("/api/admin/feedback/9999/reply", json={"message": "hi"}).status_code == 404
import goodnews.api as api # already reloaded by _admin_client's app
assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"html": " "}).status_code == 422
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={"html": "hi"}).status_code == 404
+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():
out = r("<script>alert(1)</script> and **bold**")
assert "<script>" not in out and "&lt;script&gt;" in out
assert "<strong>bold</strong>" in out
def test_keeps_allowed_formatting_and_normalizes_tags():
out = s("<b>bold</b> and <i>it</i> and <strong>x</strong> and <em>y</em>")
assert out == "<strong>bold</strong> and <em>it</em> and <strong>x</strong> and <em>y</em>"
def test_bullets_and_heading_and_paragraphs():
assert r("- one\n- two") == "<ul><li>one</li><li>two</li></ul>"
assert r("## Update") == "<h4>Update</h4>"
assert r("a\nb\n\nc") == "<p>a<br>b</p><p>c</p>"
def test_lists_and_paragraphs():
assert s("<ul><li>a</li><li>b</li></ul>") == "<ul><li>a</li><li>b</li></ul>"
assert s("<ol><li>one</li></ol>") == "<ol><li>one</li></ol>"
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():
out = r('[click](http://x) <a href="x" onclick="y">hi</a>')
assert "<a" not in out # no anchor tag produced or passed through (escaped to &lt;a)
assert "[click]" in out # markdown links unsupported → left as literal text
def test_font_size_whitelist():
assert s('<span style="font-size:18px">big</span>') == '<span style="font-size:18px">big</span>'
# off-whitelist size → span dropped, text kept
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():
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"