Feedback reply: light Markdown formatting (bold / bullets / heading)
Per Codex: a constrained Markdown-ish composer rather than contenteditable. * goodnews/markup.render_reply_html — escapes everything first, then introduces only a tiny whitelist (**bold**, - bullets, #/##/### headings, paragraphs, line breaks). No links, attributes, inline styles, or raw HTML passthrough. * feedback_replies.message_html column (+ live migration); replies store both the Markdown text and the rendered HTML. * email_send.send_feedback_reply now sends multipart text/plain + text/html (the sanitized render, wrapped in a trusted email template). * Frontend: textarea + a small toolbar (Bold / • List / H) that inserts Markdown; the reply thread renders the server-sanitized HTML. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,19 +102,22 @@ def test_feedback_reply_sends_stores_marks_read(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None)
|
||||
sent = {}
|
||||
monkeypatch.setattr(api.email_send, "send_feedback_reply",
|
||||
lambda to, msg, orig: sent.update(to=to, msg=msg, orig=orig))
|
||||
lambda to, msg, html, orig: sent.update(to=to, msg=msg, html=html, orig=orig))
|
||||
TestClient(app).post("/api/feedback", json={"message": "is there an app?", "email": "reader@x.com", "visitor": "v"})
|
||||
tc = TestClient(app); link = {}
|
||||
monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, l: link.update(l=l))
|
||||
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={"message": "Yes — a **companion app** 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
|
||||
fb = tc.get("/api/admin/feedback").json()[0]
|
||||
assert fb["read_at"] is not None # marked read on reply
|
||||
assert len(fb["replies"]) == 1 and fb["replies"][0]["sent_to"] == "reader@x.com"
|
||||
rep = fb["replies"][0]
|
||||
assert len(fb["replies"]) == 1 and rep["sent_to"] == "reader@x.com"
|
||||
assert "<strong>companion app</strong>" in rep["message_html"] # stored HTML
|
||||
|
||||
|
||||
def test_feedback_reply_requires_address_and_message(tmp_path, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from goodnews.markup import render_reply_html as r
|
||||
|
||||
|
||||
def test_escapes_before_formatting():
|
||||
out = r("<script>alert(1)</script> and **bold**")
|
||||
assert "<script>" not in out and "<script>" in out
|
||||
assert "<strong>bold</strong>" in out
|
||||
|
||||
|
||||
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_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 <a)
|
||||
assert "[click]" in out # markdown links unsupported → left as literal text
|
||||
|
||||
|
||||
def test_empty():
|
||||
assert r(" ") == "" and r(None) == ""
|
||||
Reference in New Issue
Block a user