From db14f3c67b75976bff93d490858fcadf2c5f9160 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 2 Oct 2025 20:22:11 +0000 Subject: [PATCH 1/9] use https instead of ssh as ssh isn't on the dev container --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86fd96a72..b545850eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: - id: ruff-format - id: ruff-check - - repo: git@github.com:pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - id: no-commit-to-branch From 92856ae631b6b7b0f35f75b2fe84c8d5fa76e03f Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Thu, 2 Oct 2025 20:22:59 +0000 Subject: [PATCH 2/9] Add a view page for an individual village --- apps/villages/views.py | 7 +++++++ templates/about/villages.md | 2 +- templates/villages/view.html | 21 +++++++++++++++++++++ templates/villages/villages.html | 6 +----- 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 templates/villages/view.html diff --git a/apps/villages/views.py b/apps/villages/views.py index 9dfc04757..fcb0db660 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -65,6 +65,13 @@ def main(year: int) -> ResponseValue: ) +@villages.route("//") +def view(year: int, village_id: int) -> ResponseValue: + village = load_village(year, village_id) + + return render_template("villages/view.html", village=village) + + @villages.route("///edit", methods=["GET", "POST"]) @login_required def edit(year: int, village_id: int) -> ResponseValue: diff --git a/templates/about/villages.md b/templates/about/villages.md index 8175e6363..8c575d927 100644 --- a/templates/about/villages.md +++ b/templates/about/villages.md @@ -1,7 +1,7 @@ title: Villages --- # Villages -A village is a group of friends, like-minded people, colleagues or even families camping together at EMF. Maybe your village is a collection of sub villages, or maybe it’s just your hackerspace. A village can be based around themes, shared passions and existing communities - great places in the past have been the Maths village, the HAB Village and groups like Milliways and the Scottish Consulate. You can find the list of currently registered villages for EMF 2024 at [emfcamp.org/villages/{{ event_year }}](/villages/{{ event_year }}). +A village is a group of friends, like-minded people, colleagues or even families camping together at EMF. Maybe your village is a collection of sub villages, or maybe it’s just your hackerspace. A village can be based around themes, shared passions and existing communities - great places in the past have been the Maths village, the HAB Village and groups like Milliways and the Scottish Consulate. You can find the list of currently registered villages for EMF {{ event_year }} at [emfcamp.org/villages/{{ event_year }}](/villages/{{ event_year }}). If there is a village based around something you are interested in, then you should definitely visit that village during the event to see what's going on. Or you could even join that village to help make more things go on. If there's not a village based around your interests, then you can start your own village and others will probably join you. diff --git a/templates/villages/view.html b/templates/villages/view.html new file mode 100644 index 000000000..0848cddf6 --- /dev/null +++ b/templates/villages/view.html @@ -0,0 +1,21 @@ +{% from "_formhelpers.html" import render_field %} +{% extends "base.html" %} +{% block title %}Village: {{village.name}}{% endblock %} +{% block body %} +{% if village.url %} +

{{village.name}}

+{% else %} +

{{village.name}}

+{% endif %} + +{% if village.description %} +

{{village.description}}

+{% endif %} + +{# TODO: embed a map somehow? #} +{% if village.map_link %} +πŸ“ Map +{% endif %} + +{# TODO: Can we show what's on at the village venues here? #} +{% endblock %} diff --git a/templates/villages/villages.html b/templates/villages/villages.html index b8ec8c881..ba0e50462 100644 --- a/templates/villages/villages.html +++ b/templates/villages/villages.html @@ -23,11 +23,7 @@

List of Villages

{% for village in villages %} - {% if village.url %} - {{village.name}} - {% else %} - {{village.name}} - {% endif %} + {{village.name}} {{village.description}} {% if any_village_located %} From c2a991d99f99e03c901cf2249bafdc81ee4f8ad5 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Sun, 19 Oct 2025 20:49:21 +0000 Subject: [PATCH 3/9] Add a long description and a couple of ways of rendering --- apps/villages/forms.py | 3 + apps/villages/views.py | 60 ++++++++++++++++++- css/_village.scss | 16 +++++ css/main.scss | 3 +- js/sandboxed-iframe.js | 24 ++++++++ main.py | 2 + ...e329427_add_long_description_to_village.py | 36 +++++++++++ models/village.py | 2 + pyproject.toml | 1 + rsbuild.config.mjs | 1 + templates/sandboxed-iframe.html | 27 +++++++++ templates/villages/_form.html | 6 +- templates/villages/admin/info.html | 1 + templates/villages/view.html | 6 +- templates/villages/view2.html | 24 ++++++++ uv.lock | 35 +++++++++++ 16 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 css/_village.scss create mode 100644 js/sandboxed-iframe.js create mode 100644 migrations/versions/6646de329427_add_long_description_to_village.py create mode 100644 templates/sandboxed-iframe.html create mode 100644 templates/villages/view2.html diff --git a/apps/villages/forms.py b/apps/villages/forms.py index 38c536847..11bfc1465 100644 --- a/apps/villages/forms.py +++ b/apps/villages/forms.py @@ -19,6 +19,7 @@ class VillageForm(Form): name = StringField("Village Name", [Length(2, 25)]) description = TextAreaField("Description", [Optional()]) + long_description = TextAreaField("Long Description", [Optional()]) url = StringField("URL", [URL(), Optional()]) num_attendees = IntegerField("Number of People", [Optional()]) @@ -46,6 +47,7 @@ class VillageForm(Form): def populate(self, village: Village) -> None: self.name.data = village.name self.description.data = village.description + self.long_description.data = village.long_description self.url.data = village.url requirements = village.requirements @@ -60,6 +62,7 @@ def populate_obj(self, village: Village) -> None: assert self.name.data is not None village.name = self.name.data village.description = self.description.data + village.long_description = self.long_description.data village.url = self.url.data if village.requirements is None: diff --git a/apps/villages/views.py b/apps/villages/views.py index fcb0db660..0af280fa3 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -1,6 +1,11 @@ +import html + +import markdown +import nh3 from flask import abort, flash, redirect, render_template, request, url_for from flask.typing import ResponseValue from flask_login import current_user, login_required +from markupsafe import Markup from sqlalchemy import exists, select from main import db @@ -69,7 +74,60 @@ def main(year: int) -> ResponseValue: def view(year: int, village_id: int) -> ResponseValue: village = load_village(year, village_id) - return render_template("villages/view.html", village=village) + return render_template( + "villages/view.html", + village=village, + village_long_description_html=render_markdown(village.long_description), + ) + + +def render_markdown(markdown_text): + """Render untrusted markdown + + This doesn't have access to any templating unlike email markdown + which is from a trusted user so is pre-processed with jinja. + """ + extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] + contentHtml = nh3.clean( + markdown.markdown(markdown_text, extensions=extensions), tags=(nh3.ALLOWED_TAGS - {"img"}) + ) + innerHtml = render_template("sandboxed-iframe.html", body=Markup(contentHtml)) + iFrameHtml = f'' + return Markup(iFrameHtml) + + +@villages.route("///view2") +def view2(year: int, village_id: int) -> ResponseValue: + village = load_village(year, village_id) + + return render_template( + "villages/view2.html", + village=village, + village_long_description_html=render_markdown2(village.long_description), + ) + + +def render_markdown2(markdown_text): + """Render untrusted markdown + + This doesn't have access to any templating unlike email markdown + which is from a trusted user so is pre-processed with jinja. + """ + extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] + contentHtml = nh3.clean( + markdown.markdown(markdown_text, extensions=extensions), tags=(nh3.ALLOWED_TAGS - {"img"}) + ) + innerHtml = f""" + +
+
+
+ {Markup(contentHtml)} +
+
+
""" + iFrameHtml = f'' + return Markup(iFrameHtml) @villages.route("///edit", methods=["GET", "POST"]) diff --git a/css/_village.scss b/css/_village.scss new file mode 100644 index 000000000..5b07289ed --- /dev/null +++ b/css/_village.scss @@ -0,0 +1,16 @@ +.village-form { + textarea#description { + height: 100px; + } + + textarea#long_description { + height: 200px; + } +} + +.embedded-content { + width: 100%; + height: 450px; // default height, recalculated by javascript. + border: none; + overflow: hidden; +} \ No newline at end of file diff --git a/css/main.scss b/css/main.scss index 3f27400c2..cfddd01d7 100644 --- a/css/main.scss +++ b/css/main.scss @@ -20,6 +20,7 @@ @use "./_tickets.scss"; @use "./_responsive_table.scss"; @use "./_sponsorship.scss"; +@use "./_village.scss"; @use "./volunteer_schedule.scss"; @font-face { @@ -40,4 +41,4 @@ src: local(""), url("../static/fonts/raleway-v22-latin-ext_latin-700.woff2") format("woff2"), url("../static/fonts/raleway-v22-latin-ext_latin-700.woff") format("woff"); -} +} \ No newline at end of file diff --git a/js/sandboxed-iframe.js b/js/sandboxed-iframe.js new file mode 100644 index 000000000..01ad2b439 --- /dev/null +++ b/js/sandboxed-iframe.js @@ -0,0 +1,24 @@ +function sendFrameResizedMessage() { + //postMessage to set iframe height + window.parent.postMessage({ "type": "frame-resized", "value": document.body.parentElement.scrollHeight }, '*'); +} + +function listenForFrameResizedMessages(iFrameEle) { + window.addEventListener('message', receiveMessage, false); + + function receiveMessage(evt) { + console.log("Got message: " + JSON.stringify(evt.data) + " from origin: " + evt.origin); + // Do we trust the sender of this message? + // origin of sandboxed iframes is null but is this a useful check? + // if (evt.origin !== null) { + // return; + // } + + if (evt.data.type === "frame-resized") { + iFrameEle.style.height = evt.data.value + "px"; + } + } +} + +window.listenForFrameResizedMessages = listenForFrameResizedMessages; +window.sendFrameResizedMessage = sendFrameResizedMessage; \ No newline at end of file diff --git a/main.py b/main.py index a7feb87a6..b9a092184 100644 --- a/main.py +++ b/main.py @@ -284,6 +284,8 @@ def send_security_headers(response): "'unsafe-hashes'", "'sha256-2rvfFrggTCtyF5WOiTri1gDS8Boibj4Njn0e+VCBmDI='", # return false; "'sha256-gC0PN/M+TSxp9oNdolzpqpAA+ZRrv9qe1EnAbUuDmk8='", # return modelActions.execute('notify'); + "'sha256-GtgSCbDPm83G2B75TQfbv5TR/nqHF4WnrnN+njVmQFU='", # javascript:window.listenForFrameResizedMessages(this); + "'sha256-dW+ze6eYWjNQB4tjHLKsdbtI4AqFRK/FpdEu/ZCxmLc='", # javascript:window.sendFrameResizedMessage() ] if app.config.get("DEBUG_TB_ENABLED"): diff --git a/migrations/versions/6646de329427_add_long_description_to_village.py b/migrations/versions/6646de329427_add_long_description_to_village.py new file mode 100644 index 000000000..7bda2a076 --- /dev/null +++ b/migrations/versions/6646de329427_add_long_description_to_village.py @@ -0,0 +1,36 @@ +"""Add long_description to village + +Revision ID: 6646de329427 +Revises: 5062a9a72efc +Create Date: 2025-10-02 20:34:55.746599 + +""" + +# revision identifiers, used by Alembic. +revision = '6646de329427' +down_revision = '5062a9a72efc' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('village', schema=None) as batch_op: + batch_op.add_column(sa.Column('long_description', sa.String(), nullable=True)) + + with op.batch_alter_table('village_version', schema=None) as batch_op: + batch_op.add_column(sa.Column('long_description', sa.String(), autoincrement=False, nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('village_version', schema=None) as batch_op: + batch_op.drop_column('long_description') + + with op.batch_alter_table('village', schema=None) as batch_op: + batch_op.drop_column('long_description') + + # ### end Alembic commands ### diff --git a/models/village.py b/models/village.py index 0343505b3..8fdd37f1a 100644 --- a/models/village.py +++ b/models/village.py @@ -31,6 +31,7 @@ class Village(BaseModel): name: Mapped[str] = mapped_column(unique=True) description: Mapped[str | None] + long_description: Mapped[str | None] url: Mapped[str | None] location: Mapped[WKBElement | None] = mapped_column(Geometry("POINT", srid=4326, spatial_index=False)) @@ -98,6 +99,7 @@ def get_export_data(cls): v.id: { "name": v.name, "description": v.description, + "long_description": v.long_description, "url": v.url, "location": v.latlon, } diff --git a/pyproject.toml b/pyproject.toml index 9c4430d0d..2d2b32ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "logging_tree~=1.9", "lxml>=6.0.0", "markdown~=3.1", + "nh3>=0.3.1", "pendulum~=3.1", "pillow<12.0", "playwright>=1.43.0,<2", diff --git a/rsbuild.config.mjs b/rsbuild.config.mjs index 55e8ad438..cb698682b 100644 --- a/rsbuild.config.mjs +++ b/rsbuild.config.mjs @@ -69,6 +69,7 @@ export default defineConfig({ "volunteer_schedule.js": './js/volunteer-schedule.js', "event_tickets.js": './js/event-tickets.js', "arrivals.js": './js/arrivals.js', + "sandboxed-iframe.js": './js/sandboxed-iframe.js', "admin.scss": './css/admin.scss', "arrivals.scss": './css/arrivals.scss', diff --git a/templates/sandboxed-iframe.html b/templates/sandboxed-iframe.html new file mode 100644 index 000000000..753c9adbf --- /dev/null +++ b/templates/sandboxed-iframe.html @@ -0,0 +1,27 @@ +{# The source of an iFrame used to sandbox user-controlled HTML #} +{# Once loaded, this emits an event with it's content's size which the parent page can listen for to adapt the iFrame's height #} + + + + + {% block css -%} + + {% endblock -%} + {% block head -%}{% endblock -%} + + +{% block document %} +
+
+
+ {% block body -%} {{ body }} {% endblock -%} +
+
+
+{% endblock %} + +{% block foot -%}{% endblock -%} + + diff --git a/templates/villages/_form.html b/templates/villages/_form.html index a07267b82..856ec2318 100644 --- a/templates/villages/_form.html +++ b/templates/villages/_form.html @@ -1,5 +1,5 @@
-
+ {{ form.hidden_tag() }}
Basic Details @@ -8,7 +8,9 @@ {{ render_field(form.name, horizontal=9, placeholder="My Village") }} {{ render_field(form.url, horizontal=9, placeholder="") }} {{ render_field(form.description, horizontal=9, - placeholder="A description of your village. Who are you, what cool things will you be doing?") }} + placeholder="A short description of your village. Who are you, what cool things will you be doing?") }} + {{ render_field(form.long_description, horizontal=9, + placeholder="A longer description of your village using Markdown. Project details etc.") }}
Requirements diff --git a/templates/villages/admin/info.html b/templates/villages/admin/info.html index 2dbec741f..6c2a43c7f 100644 --- a/templates/villages/admin/info.html +++ b/templates/villages/admin/info.html @@ -15,6 +15,7 @@

Village Info: {{village.name}}

{{ render_field(form.name) }} {{ render_field(form.description) }} + {{ render_field(form.long_description) }} {{ render_field(form.url) }} {{ render_field(form.lat) }} {{ render_field(form.lon) }} diff --git a/templates/villages/view.html b/templates/villages/view.html index 0848cddf6..149fad108 100644 --- a/templates/villages/view.html +++ b/templates/villages/view.html @@ -1,6 +1,6 @@ -{% from "_formhelpers.html" import render_field %} {% extends "base.html" %} {% block title %}Village: {{village.name}}{% endblock %} +{% block foot %}{% endblock%} {% block body %} {% if village.url %}

{{village.name}}

@@ -12,6 +12,10 @@

{{village.name}}

{{village.description}}

{% endif %} +{% if village_long_description_html %} +

{{village_long_description_html}}

+{% endif %} + {# TODO: embed a map somehow? #} {% if village.map_link %} πŸ“ Map diff --git a/templates/villages/view2.html b/templates/villages/view2.html new file mode 100644 index 000000000..b0556a818 --- /dev/null +++ b/templates/villages/view2.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Village: {{village.name}}{% endblock %} +{% block body %} +{% if village.url %} +

{{village.name}}

+{% else %} +

{{village.name}}

+{% endif %} + +{% if village.description %} +

{{village.description}}

+{% endif %} + +{% if village_long_description_html %} +

{{village_long_description_html}}

+{% endif %} + +{# TODO: embed a map somehow? #} +{% if village.map_link %} +πŸ“ Map +{% endif %} + +{# TODO: Can we show what's on at the village venues here? #} +{% endblock %} diff --git a/uv.lock b/uv.lock index b6e7d3b5b..306eb2b3c 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,6 +1098,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nh3" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a6/c6e942fc8dcadab08645f57a6d01d63e97114a30ded5f269dc58e05d4741/nh3-0.3.1.tar.gz", hash = "sha256:6a854480058683d60bdc7f0456105092dae17bef1f300642856d74bd4201da93", size = 18590, upload-time = "2025-10-07T03:27:58.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/24/4becaa61e066ff694c37627f5ef7528901115ffa17f7a6693c40da52accd/nh3-0.3.1-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:80dc7563a2a3b980e44b221f69848e3645bbf163ab53e3d1add4f47b26120355", size = 1420887, upload-time = "2025-10-07T03:27:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/94/49/16a6ec9098bb9bdf0fb9f09d6464865a3a48858d8d96e779a998ec3bdce0/nh3-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f600ad86114df21efc4a3592faa6b1d099c0eebc7e018efebb1c133376097da", size = 791700, upload-time = "2025-10-07T03:27:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cc/1c024d7c23ad031dfe82ad59581736abcc403b006abb0d2785bffa768b54/nh3-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:669a908706cd28203d9cfce2f567575686e364a1bc6074d413d88d456066f743", size = 830225, upload-time = "2025-10-07T03:27:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/89/08/4a87f9212373bd77bba01c1fd515220e0d263316f448d9c8e4b09732a645/nh3-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a5721f59afa0ab3dcaa0d47e58af33a5fcd254882e1900ee4a8968692a40f79d", size = 999112, upload-time = "2025-10-07T03:27:29.782Z" }, + { url = "https://files.pythonhosted.org/packages/19/cf/94783911eb966881a440ba9641944c27152662a253c917a794a368b92a3c/nh3-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2cb6d9e192fbe0d451c7cb1350dadedbeae286207dbf101a28210193d019752e", size = 1070424, upload-time = "2025-10-07T03:27:31.2Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/efb57b44e86a3de528561b49ed53803e5d42cd0441dcfd29b89422160266/nh3-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:474b176124c1b495ccfa1c20f61b7eb83ead5ecccb79ab29f602c148e8378489", size = 996129, upload-time = "2025-10-07T03:27:32.595Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d3/87c39ea076510e57ee99a27fa4c2335e9e5738172b3963ee7c744a32726c/nh3-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a2434668f4eef4eab17c128e565ce6bea42113ce10c40b928e42c578d401800", size = 980310, upload-time = "2025-10-07T03:27:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/bc/30/00cfbd2a4d268e8d3bda9d1542ba4f7a20fbed37ad1e8e51beeee3f6fdae/nh3-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:0f454ba4c6aabafcaae964ae6f0a96cecef970216a57335fabd229a265fbe007", size = 584439, upload-time = "2025-10-07T03:27:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/80/fa/39d27a62a2f39eb88c2bd50d9fee365a3645e456f3ec483c945a49c74f47/nh3-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:22b9e9c9eda497b02b7273b79f7d29e1f1170d2b741624c1b8c566aef28b1f48", size = 592388, upload-time = "2025-10-07T03:27:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/7df1c4ee13ef65ee06255df8101141793e97b4326e8509afbce5deada2b5/nh3-0.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:42e426f36e167ed29669b77ae3c4b9e185e4a1b130a86d7c3249194738a1d7b2", size = 579337, upload-time = "2025-10-07T03:27:38.055Z" }, + { url = "https://files.pythonhosted.org/packages/e1/28/a387fed70438d2810c8ac866e7b24bf1a5b6f30ae65316dfe4de191afa52/nh3-0.3.1-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1de5c1a35bed19a1b1286bab3c3abfe42e990a8a6c4ce9bb9ab4bde49107ea3b", size = 1433666, upload-time = "2025-10-07T03:27:39.118Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f9/500310c1f19cc80770a81aac3c94a0c6b4acdd46489e34019173b2b15a50/nh3-0.3.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaba26591867f697cffdbc539faddeb1d75a36273f5bfe957eb421d3f87d7da1", size = 819897, upload-time = "2025-10-07T03:27:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d4/ebb0965d767cba943793fa8f7b59d7f141bd322c86387a5e9485ad49754a/nh3-0.3.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:489ca5ecd58555c2865701e65f614b17555179e71ecc76d483b6f3886b813a9b", size = 803562, upload-time = "2025-10-07T03:27:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9c/df037a13f0513283ecee1cf99f723b18e5f87f20e480582466b1f8e3a7db/nh3-0.3.1-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a25662b392b06f251da6004a1f8a828dca7f429cd94ac07d8a98ba94d644438", size = 1050854, upload-time = "2025-10-07T03:27:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/488fce56029de430e30380ec21f29cfaddaf0774f63b6aa2bf094c8b4c27/nh3-0.3.1-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38b4872499ab15b17c5c6e9f091143d070d75ddad4a4d1ce388d043ca556629c", size = 1002152, upload-time = "2025-10-07T03:27:44.358Z" }, + { url = "https://files.pythonhosted.org/packages/da/4a/24b0118de34d34093bf03acdeca3a9556f8631d4028814a72b9cc5216382/nh3-0.3.1-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48425995d37880281b467f7cf2b3218c1f4750c55bcb1ff4f47f2320a2bb159c", size = 912333, upload-time = "2025-10-07T03:27:45.757Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/16b3886858b3953ef836dea25b951f3ab0c5b5a431da03f675c0e999afb8/nh3-0.3.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94292dd1bd2a2e142fa5bb94c0ee1d84433a5d9034640710132da7e0376fca3a", size = 796945, upload-time = "2025-10-07T03:27:47.169Z" }, + { url = "https://files.pythonhosted.org/packages/87/bb/aac139cf6796f2e0fec026b07843cea36099864ec104f865e2d802a25a30/nh3-0.3.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dd6d1be301123a9af3263739726eeeb208197e5e78fc4f522408c50de77a5354", size = 837257, upload-time = "2025-10-07T03:27:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d7/1d770876a288a3f5369fd6c816363a5f9d3a071dba24889458fdeb4f7a49/nh3-0.3.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b74bbd047b361c0f21d827250c865ff0895684d9fcf85ea86131a78cfa0b835b", size = 1004142, upload-time = "2025-10-07T03:27:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/c4259e8b94c2f4ba10a7560e0889a6b7d2f70dce7f3e93f6153716aaae47/nh3-0.3.1-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b222c05ae5139320da6caa1c5aed36dd0ee36e39831541d9b56e048a63b4d701", size = 1075896, upload-time = "2025-10-07T03:27:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/06/b15ba9fea4773741acb3382dcf982f81e55f6053e8a6e72a97ac91928b1d/nh3-0.3.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b0d6c834d3c07366ecbdcecc1f4804c5ce0a77fa52ee4653a2a26d2d909980ea", size = 1003235, upload-time = "2025-10-07T03:27:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/1d/13/74707f99221bbe0392d18611b51125d45f8bd5c6be077ef85575eb7a38b1/nh3-0.3.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:670f18b09f75c86c3865f79543bf5acd4bbe2a5a4475672eef2399dd8cdb69d2", size = 987308, upload-time = "2025-10-07T03:27:53.003Z" }, + { url = "https://files.pythonhosted.org/packages/ee/81/24bf41a5ce7648d7e954de40391bb1bcc4b7731214238c7138c2420f962c/nh3-0.3.1-cp38-abi3-win32.whl", hash = "sha256:d7431b2a39431017f19cd03144005b6c014201b3e73927c05eab6ca37bb1d98c", size = 591695, upload-time = "2025-10-07T03:27:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/263eb96b6d32c61a92c1e5480b7f599b60db7d7fbbc0d944be7532d0ac42/nh3-0.3.1-cp38-abi3-win_amd64.whl", hash = "sha256:c0acef923a1c3a2df3ee5825ea79c149b6748c6449781c53ab6923dc75e87d26", size = 600564, upload-time = "2025-10-07T03:27:55.966Z" }, + { url = "https://files.pythonhosted.org/packages/34/67/d5e07efd38194f52b59b8af25a029b46c0643e9af68204ee263022924c27/nh3-0.3.1-cp38-abi3-win_arm64.whl", hash = "sha256:a3e810a92fb192373204456cac2834694440af73d749565b4348e30235da7f0b", size = 586369, upload-time = "2025-10-07T03:27:57.234Z" }, +] + [[package]] name = "numpy" version = "2.3.2" @@ -2163,6 +2196,7 @@ dependencies = [ { name = "logging-tree" }, { name = "lxml" }, { name = "markdown" }, + { name = "nh3" }, { name = "pendulum" }, { name = "pillow" }, { name = "playwright" }, @@ -2250,6 +2284,7 @@ requires-dist = [ { name = "logging-tree", specifier = "~=1.9" }, { name = "lxml", specifier = ">=6.0.0" }, { name = "markdown", specifier = "~=3.1" }, + { name = "nh3", specifier = ">=0.3.1" }, { name = "pendulum", specifier = "~=3.1" }, { name = "pillow", specifier = "<12.0" }, { name = "playwright", specifier = ">=1.43.0,<2" }, From bde1755e16e64bf1e348bdadcd3720750799e314 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Sat, 25 Oct 2025 15:19:54 +0000 Subject: [PATCH 4/9] Rebase migration --- ...=> 8c17cee05585_add_long_description_to_village.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename migrations/versions/{6646de329427_add_long_description_to_village.py => 8c17cee05585_add_long_description_to_village.py} (86%) diff --git a/migrations/versions/6646de329427_add_long_description_to_village.py b/migrations/versions/8c17cee05585_add_long_description_to_village.py similarity index 86% rename from migrations/versions/6646de329427_add_long_description_to_village.py rename to migrations/versions/8c17cee05585_add_long_description_to_village.py index 7bda2a076..e103fed3d 100644 --- a/migrations/versions/6646de329427_add_long_description_to_village.py +++ b/migrations/versions/8c17cee05585_add_long_description_to_village.py @@ -1,14 +1,14 @@ """Add long_description to village -Revision ID: 6646de329427 -Revises: 5062a9a72efc -Create Date: 2025-10-02 20:34:55.746599 +Revision ID: 8c17cee05585 +Revises: 53220373bfde +Create Date: 2025-10-25 09:40:24.997019 """ # revision identifiers, used by Alembic. -revision = '6646de329427' -down_revision = '5062a9a72efc' +revision = '8c17cee05585' +down_revision = '53220373bfde' from alembic import op import sqlalchemy as sa From c6ce6d1a830d2d3bbaa4c5a4ab6861b791c9572a Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Fri, 31 Oct 2025 09:08:34 +0000 Subject: [PATCH 5/9] Add more type annotations and fix 2 None issues --- apps/villages/__init__.py | 2 +- apps/villages/views.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/villages/__init__.py b/apps/villages/__init__.py index 16c2414e8..86a181a8b 100644 --- a/apps/villages/__init__.py +++ b/apps/villages/__init__.py @@ -13,7 +13,7 @@ villages = Blueprint("villages", __name__) -def load_village(year, village_id, require_admin=False): +def load_village(year: int, village_id: int, require_admin: bool = False) -> Village: """Helper to return village or 404""" if year != event_year(): abort(404) diff --git a/apps/villages/views.py b/apps/villages/views.py index 0af280fa3..e7b325a63 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -77,11 +77,13 @@ def view(year: int, village_id: int) -> ResponseValue: return render_template( "villages/view.html", village=village, - village_long_description_html=render_markdown(village.long_description), + village_long_description_html=( + render_markdown(village.long_description) if village.long_description else None + ), ) -def render_markdown(markdown_text): +def render_markdown(markdown_text: str) -> Markup: """Render untrusted markdown This doesn't have access to any templating unlike email markdown @@ -99,15 +101,18 @@ def render_markdown(markdown_text): @villages.route("///view2") def view2(year: int, village_id: int) -> ResponseValue: village = load_village(year, village_id) + rendered_long_description = ( + render_markdown2(village.long_description) if village.long_description else None + ) return render_template( "villages/view2.html", village=village, - village_long_description_html=render_markdown2(village.long_description), + village_long_description_html=rendered_long_description, ) -def render_markdown2(markdown_text): +def render_markdown2(markdown_text: str) -> Markup: """Render untrusted markdown This doesn't have access to any templating unlike email markdown @@ -149,7 +154,7 @@ def edit(year: int, village_id: int) -> ResponseValue: else: # All good, update DB for venue in village.venues: - if venue.name == village.name: + if venue.name == village.name and form.name.data is not None: # Rename a village venue if it exists and has the old name. venue.name = form.name.data From 3ad70e82b05b2066fd3a8993b3b41182d303250d Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Tue, 9 Dec 2025 20:46:11 +0000 Subject: [PATCH 6/9] Add test for rendering and tweak link's rel --- apps/villages/views.py | 8 +++- tests/test_village_rendering.py | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/test_village_rendering.py diff --git a/apps/villages/views.py b/apps/villages/views.py index e7b325a63..2a9083065 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -91,7 +91,9 @@ def render_markdown(markdown_text: str) -> Markup: """ extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] contentHtml = nh3.clean( - markdown.markdown(markdown_text, extensions=extensions), tags=(nh3.ALLOWED_TAGS - {"img"}) + markdown.markdown(markdown_text, extensions=extensions), + tags=(nh3.ALLOWED_TAGS - {"img"}), + link_rel="noopener nofollow", # default includes noreferrer but not nofollow ) innerHtml = render_template("sandboxed-iframe.html", body=Markup(contentHtml)) iFrameHtml = f'' @@ -120,7 +122,9 @@ def render_markdown2(markdown_text: str) -> Markup: """ extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] contentHtml = nh3.clean( - markdown.markdown(markdown_text, extensions=extensions), tags=(nh3.ALLOWED_TAGS - {"img"}) + markdown.markdown(markdown_text, extensions=extensions), + tags=(nh3.ALLOWED_TAGS - {"img"}), + link_rel="noopener nofollow", # default includes noreferrer but not nofollow ) innerHtml = f""" diff --git a/tests/test_village_rendering.py b/tests/test_village_rendering.py new file mode 100644 index 000000000..0fdb83bb2 --- /dev/null +++ b/tests/test_village_rendering.py @@ -0,0 +1,70 @@ +import html + +import markdown + +from apps.villages import views + + +def test_render_simple(request_context): + rendered = views.render_markdown("Hi *you*. Welcome to [EMF](https://www.emfcamp.org/)") + + assert '