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:
+8
-7
@@ -32,7 +32,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
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 .filters import filter_articles, prefs_from_json
|
||||
from .hero import safe_to_lead
|
||||
@@ -383,7 +383,7 @@ class FeedbackReadBody(BaseModel):
|
||||
|
||||
|
||||
class FeedbackReplyBody(BaseModel):
|
||||
message: str = ""
|
||||
html: str = "" # raw editor HTML — sanitized server-side before use
|
||||
|
||||
|
||||
class SourceActiveBody(BaseModel):
|
||||
@@ -843,8 +843,10 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/admin/feedback/{fid}/reply")
|
||||
def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict:
|
||||
message = (body.message or "").strip()[:4000]
|
||||
if not message:
|
||||
# Sanitize the editor HTML to our allowlist; derive the plain-text fallback.
|
||||
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.")
|
||||
# 1. Validate + gather, then release the DB connection — SMTP can take
|
||||
# ~20s and shouldn't keep a connection open.
|
||||
@@ -856,11 +858,10 @@ def create_app() -> FastAPI:
|
||||
if not fb["contact_email"]:
|
||||
raise HTTPException(status_code=400, detail="No reply address for this feedback.")
|
||||
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
|
||||
# went out, so the UI can keep the draft on failure.
|
||||
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
|
||||
raise HTTPException(status_code=502, detail=f"Could not send the reply: {exc}")
|
||||
# 3. Record the sent reply + mark the item read.
|
||||
@@ -868,7 +869,7 @@ def create_app() -> FastAPI:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO feedback_replies (feedback_id, user_id, message, message_html, sent_to) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(fid, admin_id, message, reply_html, to),
|
||||
(fid, admin_id, reply_text, reply_html, to),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,)
|
||||
|
||||
+127
-37
@@ -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
|
||||
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.
|
||||
The reply composer is a small admin-only WYSIWYG (contenteditable + execCommand).
|
||||
Browsers emit inconsistent HTML, so the server is the adult in the room: it
|
||||
takes whatever the editor produced and rebuilds it from a strict allowlist —
|
||||
|
||||
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
|
||||
|
||||
import html
|
||||
import html as _html
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
_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"}
|
||||
# Canonicalise the tags we keep (b→strong, i→em, div→p); anything not here is dropped.
|
||||
_TAG_MAP = {
|
||||
"b": "strong", "strong": "strong",
|
||||
"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:
|
||||
"""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)
|
||||
class _Sanitizer(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
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 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}>")
|
||||
def handle_starttag(self, tag, attrs):
|
||||
t = tag.lower()
|
||||
if t in _DROP_CONTENT:
|
||||
self.skip += 1
|
||||
self.open.append(None)
|
||||
return
|
||||
if self.skip:
|
||||
return
|
||||
if t == "br":
|
||||
self.out.append("<br>")
|
||||
return # void — no stack entry
|
||||
size = self._size(t, attrs)
|
||||
if t == "span":
|
||||
if size:
|
||||
self.out.append(f'<span style="font-size:{size}">')
|
||||
self.open.append("span")
|
||||
else:
|
||||
self.open.append(None) # unstyled/!whitelisted span → drop wrapper, keep text
|
||||
return
|
||||
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:
|
||||
out.append("<p>" + "<br>".join(_inline(ln) for ln in nonempty) + "</p>")
|
||||
return "".join(out)
|
||||
self.open.append(None) # disallowed tag — drop tag, keep children
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user