From 34bad16b190c34fd5cba15e58402a1d0add10728 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Fri, 7 Aug 2026 22:19:46 +0200 Subject: [PATCH] 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. --- api/tests.py | 48 +++++++++++++++++++++++++ news/api.py | 6 ++-- news/migrations/0003_alter_news_body.py | 18 ++++++++++ news/models.py | 9 ++++- news/services.py | 41 +++++++++++++++++++++ pyproject.toml | 2 ++ uv.lock | 47 ++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 news/migrations/0003_alter_news_body.py create mode 100644 news/services.py diff --git a/api/tests.py b/api/tests.py index 520943b..b24b9e4 100644 --- a/api/tests.py +++ b/api/tests.py @@ -141,6 +141,54 @@ class NewsApiTests(ApiTestBase): self.assertEqual(response.status_code, 404) + def test_body_markdown_is_rendered_to_html(self): + self.make_news(body="## Big win\n\nWe beat **Rivals FC** 4-2. [Full report](https://example.com).") + + body = self.api_get("/news/").json()["results"][0]["body"] + + self.assertIn("

Big win

", body) + self.assertIn("Rivals FC", body) + self.assertIn('href="https://example.com"', body) + self.assertIn(">Full report", body) + + def test_body_markdown_a_single_newline_becomes_a_line_break(self): + self.make_news(body="Line one\nLine two") + + body = self.api_get("/news/").json()["results"][0]["body"] + + self.assertIn("Line one 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()], diff --git a/news/migrations/0003_alter_news_body.py b/news/migrations/0003_alter_news_body.py new file mode 100644 index 0000000..c8e504a --- /dev/null +++ b/news/migrations/0003_alter_news_body.py @@ -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'), + ), + ] diff --git a/news/models.py b/news/models.py index c3db193..54c033a 100644 --- a/news/models.py +++ b/news/models.py @@ -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) diff --git a/news/services.py b/news/services.py new file mode 100644 index 0000000..6f909c4 --- /dev/null +++ b/news/services.py @@ -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=" …") diff --git a/pyproject.toml b/pyproject.toml index 28f933b..9ec2c4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "django-storages[s3]>=1.14.6", "django-waffle>=5.0.0", "gunicorn>=26.0.0", + "markdown>=3.10.3", + "nh3>=0.3.6", "openpyxl>=3.1.5", "pillow>=12.3.0", "psycopg[binary]>=3.3.4", diff --git a/uv.lock b/uv.lock index 087f760..860e816 100644 --- a/uv.lock +++ b/uv.lock @@ -545,6 +545,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", size = 1421679, upload-time = "2026-06-22T00:46:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", size = 792570, upload-time = "2026-06-22T00:46:22.179Z" }, + { url = "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", size = 842243, upload-time = "2026-06-22T00:46:23.801Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", size = 1001468, upload-time = "2026-06-22T00:46:25.481Z" }, + { url = "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", size = 1082933, upload-time = "2026-06-22T00:46:27.15Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", size = 1043120, upload-time = "2026-06-22T00:46:28.89Z" }, + { url = "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", size = 1023824, upload-time = "2026-06-22T00:46:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", size = 599253, upload-time = "2026-06-22T00:46:32.072Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", size = 612553, upload-time = "2026-06-22T00:46:33.53Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", size = 595151, upload-time = "2026-06-22T00:46:34.878Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" }, + { url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" }, + { url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" }, + { url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" }, + { url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" @@ -817,6 +860,8 @@ dependencies = [ { name = "django-storages", extra = ["s3"] }, { name = "django-waffle" }, { name = "gunicorn" }, + { name = "markdown" }, + { name = "nh3" }, { name = "openpyxl" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, @@ -848,6 +893,8 @@ requires-dist = [ { name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" }, { name = "django-waffle", specifier = ">=5.0.0" }, { name = "gunicorn", specifier = ">=26.0.0" }, + { name = "markdown", specifier = ">=3.10.3" }, + { name = "nh3", specifier = ">=0.3.6" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },