Render News.body as Markdown over the public API

Club staff author body as Markdown in the control panel (help_text
now explains the syntax); the public API renders it to sanitized
HTML on the way out via news/services.py -- markdown for the
conversion, nh3 (Rust/ammonia) to strip anything staff's raw
Markdown source might smuggle through (script tags, event handler
attributes, javascript: URLs) before it reaches someone else's
public website. The control panel's own preview is untouched and
still shows the raw source.

Excerpt is now derived from the rendered HTML's plain text rather
than the raw Markdown source, so syntax like ** or [text](url)
doesn't leak into what's meant to be a short teaser.
This commit is contained in:
2026-08-07 22:19:46 +02:00
parent 3d03ff644c
commit 34bad16b19
7 changed files with 167 additions and 4 deletions

View File

@@ -11,13 +11,13 @@ import uuid
from datetime import datetime
from django.utils import timezone
from django.utils.text import Truncator
from ninja import Router, Schema
from ninja.errors import HttpError
from api.errors import require_club
from .models import News
from .services import render_body_excerpt, render_body_html
router = Router(tags=["news"])
@@ -68,8 +68,8 @@ def _to_news_item_out(item, request) -> NewsItemOut:
id=item.pk,
title=item.title,
slug=item.slug,
excerpt=Truncator(item.body).words(EXCERPT_WORDS, truncate=""),
body=item.body,
excerpt=render_body_excerpt(item.body, words=EXCERPT_WORDS),
body=render_body_html(item.body),
published_at=item.published_at,
teams=[team.name for team in item.teams.all()],
photos=[NewsPhotoOut(url=request.build_absolute_uri(photo.image.url), is_main=photo.is_main, ordering=photo.ordering) for photo in item.photos.all()],

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-07 20:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('news', '0002_backfill_slugs'),
]
operations = [
migrations.AlterField(
model_name='news',
name='body',
field=models.TextField(help_text='Supports Markdown: **bold**, *italic*, [link text](https://example.com), # heading, - list item, > quote. Rendered to HTML for the public website; shown as plain text here in the control panel.', verbose_name='body'),
),
]

View File

@@ -26,7 +26,14 @@ class News(ClubScopedModel):
slug = models.SlugField(_("slug"), max_length=255, blank=True)
slug_source = "title"
body = models.TextField(_("body"))
body = models.TextField(
_("body"),
help_text=_(
"Supports Markdown: **bold**, *italic*, [link text](https://example.com), "
"# heading, - list item, > quote. Rendered to HTML for the public website; "
"shown as plain text here in the control panel."
),
)
teams = models.ManyToManyField(Team, related_name="news_items", blank=True, verbose_name=_("teams"), help_text=_("Leave empty for club-wide news."))
visibility = models.CharField(_("visibility"), max_length=10, choices=Visibility.choices, default=Visibility.INTERNAL)

41
news/services.py Normal file
View File

@@ -0,0 +1,41 @@
"""Markdown rendering for News.body.
Club staff author `body` as Markdown (see NewsForm's help text) -- the public
API (news/api.py) renders it to HTML on the way out; the control panel's own
preview shows the raw source as-authored, unrendered.
`nh3` (Rust/ammonia bindings) sanitizes the result: markdown.markdown() will
happily pass through raw HTML embedded in the source, and body is authored by
club staff, who aren't a fully trusted boundary for content served straight
into someone else's public website.
"""
import markdown as _markdown
import nh3
from django.utils.html import strip_tags
from django.utils.text import Truncator
_EXTENSIONS = [
"nl2br", # staff type in a plain textarea -- a single Enter should break the line,
# not require a blank line like standard Markdown paragraphs do.
"sane_lists",
"fenced_code",
]
_ALLOWED_TAGS = {"p", "br", "strong", "em", "b", "i", "u", "a", "ul", "ol", "li", "blockquote", "code", "pre", "h2", "h3", "h4", "img", "hr"}
_ALLOWED_ATTRIBUTES = {"a": {"href", "title"}, "img": {"src", "alt", "title"}}
_ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
def render_body_html(body: str) -> str:
"""Markdown source -> sanitized HTML."""
html = _markdown.markdown(body, extensions=_EXTENSIONS)
return nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES, url_schemes=_ALLOWED_URL_SCHEMES)
def render_body_excerpt(body: str, *, words: int) -> str:
"""Plain-text excerpt, derived from the rendered HTML rather than the raw
Markdown source -- otherwise syntax like `**`/`#`/`[text](url)` shows up
verbatim in what's meant to be a short teaser."""
plain_text = strip_tags(render_body_html(body))
return Truncator(plain_text).words(words, truncate="")