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:
@@ -0,0 +1,48 @@
|
||||
"""A tiny, safe Markdown subset → HTML, for admin feedback replies.
|
||||
|
||||
Everything is HTML-escaped FIRST, then a small whitelist of structures is
|
||||
introduced: bold (**…**), bullet lists (- / *), headings (#/##/###),
|
||||
paragraphs, and line breaks. No links, no attributes, no inline styles, no raw
|
||||
HTML passthrough — so the output is safe to render and to email. Authored only
|
||||
by admins, but sanitized on principle regardless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
_BULLET_RE = re.compile(r"^\s*[-*]\s+")
|
||||
_HEADING_RE = re.compile(r"^\s*(#{1,3})\s+")
|
||||
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
||||
_HEADING_TAG = {1: "h3", 2: "h4", 3: "h5"}
|
||||
|
||||
|
||||
def _inline(s: str) -> str:
|
||||
"""Escape, then apply the only inline transform we allow: **bold**."""
|
||||
s = html.escape(s, quote=False)
|
||||
return _BOLD_RE.sub(r"<strong>\1</strong>", s)
|
||||
|
||||
|
||||
def render_reply_html(text: str) -> str:
|
||||
"""Render the supported Markdown subset to sanitized HTML (or '' if empty)."""
|
||||
text = (text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
if not text:
|
||||
return ""
|
||||
out: list[str] = []
|
||||
for block in re.split(r"\n\s*\n", text):
|
||||
lines = [ln for ln in block.split("\n")]
|
||||
nonempty = [ln for ln in lines if ln.strip()]
|
||||
if not nonempty:
|
||||
continue
|
||||
if all(_BULLET_RE.match(ln) for ln in nonempty):
|
||||
items = "".join("<li>" + _inline(_BULLET_RE.sub("", ln)) + "</li>" for ln in nonempty)
|
||||
out.append("<ul>" + items + "</ul>")
|
||||
elif len(nonempty) == 1 and _HEADING_RE.match(nonempty[0]):
|
||||
level = len(_HEADING_RE.match(nonempty[0]).group(1))
|
||||
content = _HEADING_RE.sub("", nonempty[0])
|
||||
tag = _HEADING_TAG[level]
|
||||
out.append(f"<{tag}>" + _inline(content) + f"</{tag}>")
|
||||
else:
|
||||
out.append("<p>" + "<br>".join(_inline(ln) for ln in nonempty) + "</p>")
|
||||
return "".join(out)
|
||||
Reference in New Issue
Block a user