From 0ffbef0f4d18de7afcfb40bfe600e8c3adf90bd2 Mon Sep 17 00:00:00 2001 From: Alexander Baxevanis Date: Wed, 22 Apr 2026 10:28:43 +0000 Subject: [PATCH 01/12] Initial wiki implementation --- apps/common/__init__.py | 19 ++ apps/villages/views.py | 23 +-- apps/wiki/__init__.py | 11 ++ apps/wiki/forms.py | 35 ++++ apps/wiki/views.py | 164 ++++++++++++++++++ main.py | 3 + .../8d6cdb63ce01_add_wiki_page_table.py | 68 ++++++++ models/wiki.py | 43 +++++ templates/wiki/diff.html | 53 ++++++ templates/wiki/edit.html | 64 +++++++ templates/wiki/history.html | 61 +++++++ templates/wiki/list.html | 35 ++++ templates/wiki/view.html | 27 +++ 13 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 apps/wiki/__init__.py create mode 100644 apps/wiki/forms.py create mode 100644 apps/wiki/views.py create mode 100644 migrations/versions/8d6cdb63ce01_add_wiki_page_table.py create mode 100644 models/wiki.py create mode 100644 templates/wiki/diff.html create mode 100644 templates/wiki/edit.html create mode 100644 templates/wiki/history.html create mode 100644 templates/wiki/list.html create mode 100644 templates/wiki/view.html diff --git a/apps/common/__init__.py b/apps/common/__init__.py index e2c326506..225528562 100644 --- a/apps/common/__init__.py +++ b/apps/common/__init__.py @@ -1,3 +1,4 @@ +import html import json import logging import re @@ -25,6 +26,7 @@ from flask_login import current_user, login_user from jinja2.utils import urlize from markdown import markdown +import nh3 from markupsafe import Markup from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Response @@ -449,6 +451,23 @@ def get_next_url(default=None): return default +def render_markdown(markdown_text: str) -> Markup: + """Render untrusted user-supplied markdown safely. + + Sanitises HTML via nh3 and wraps output in a sandboxed iframe so that + arbitrary scripts and navigation from user content cannot affect the page. + """ + extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] + content_html = nh3.clean( + markdown(markdown_text, extensions=extensions), + tags=(nh3.ALLOWED_TAGS - {"img"}), + link_rel="noopener nofollow", + ) + inner_html = render_template("sandboxed-iframe.html", body=Markup(content_html)) + iframe_html = f'' + return Markup(iframe_html) + + def tidy_workshop_cost(participant_cost: str) -> str: # Some people put in a string, some just put in a £ amount try: diff --git a/apps/villages/views.py b/apps/villages/views.py index 81c2d6d45..68e2f00d3 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -1,17 +1,13 @@ -import html - -import markdown -import nh3 from flask import abort, flash, redirect, render_template, request, url_for from flask.typing import ResponseReturnValue from flask_login import current_user, login_required -from markupsafe import Markup from sqlalchemy import exists, select from main import db from models.content import Venue from models.village import Village, VillageMember +from ..common import render_markdown from ..config import config from . import load_village, villages from .forms import VillageForm @@ -94,23 +90,6 @@ def view(year: int, village_id: int) -> ResponseReturnValue: ) -def render_markdown(markdown_text: str) -> Markup: - """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"] - content_html = nh3.clean( - markdown.markdown(markdown_text, extensions=extensions), - tags=(nh3.ALLOWED_TAGS - {"img"}), - link_rel="noopener nofollow", # default includes noreferrer but not nofollow - ) - inner_html = render_template("sandboxed-iframe.html", body=Markup(content_html)) - iFrame_html = f'' - return Markup(iFrame_html) - - @villages.route("///edit", methods=["GET", "POST"]) @login_required def edit(year: int, village_id: int) -> ResponseReturnValue: diff --git a/apps/wiki/__init__.py b/apps/wiki/__init__.py new file mode 100644 index 000000000..d918d2ab2 --- /dev/null +++ b/apps/wiki/__init__.py @@ -0,0 +1,11 @@ +""" +Wiki App + +Collaboratively editable wiki pages with version history. +""" + +from flask import Blueprint + +wiki = Blueprint("wiki", __name__) + +from . import views # noqa: F401, E402 diff --git a/apps/wiki/forms.py b/apps/wiki/forms.py new file mode 100644 index 000000000..33761715c --- /dev/null +++ b/apps/wiki/forms.py @@ -0,0 +1,35 @@ +import re + +from wtforms import HiddenField, StringField, SubmitField, TextAreaField +from wtforms.validators import InputRequired, Length, Optional, Regexp, ValidationError + +from models.wiki import WikiPage + +from ..common.forms import Form + + +class WikiPageForm(Form): + title = StringField("Title", [InputRequired(), Length(1, 200)]) + content = TextAreaField("Content", [Optional()]) + edit_note = StringField("Edit summary", [Optional(), Length(max=200)]) + version_token = HiddenField() + submit = SubmitField("Save") + + +class CreateWikiPageForm(WikiPageForm): + slug = StringField( + "URL slug", + [ + InputRequired(), + Length(1, 100), + Regexp( + r"^[a-z0-9]+(?:-[a-z0-9]+)*$", + message="Slug must be lowercase letters, digits and hyphens only (e.g. ride-share)", + ), + ], + ) + submit = SubmitField("Create page") + + def validate_slug(self, field: StringField) -> None: + if WikiPage.get_by_slug(field.data): + raise ValidationError("A wiki page with this slug already exists.") diff --git a/apps/wiki/views.py b/apps/wiki/views.py new file mode 100644 index 000000000..2d3e3c76e --- /dev/null +++ b/apps/wiki/views.py @@ -0,0 +1,164 @@ +import difflib + +from flask import abort, flash, redirect, render_template, request, url_for +from flask.typing import ResponseReturnValue +from flask_login import current_user, login_required +from sqlalchemy_continuum.utils import version_class + +from main import db +from models import naive_utcnow +from models.wiki import WikiPage + +from ..common import render_markdown, require_permission +from . import wiki +from .forms import CreateWikiPageForm, WikiPageForm + + +def _current_version_token(page: WikiPage) -> str: + """Return a string token representing the page's current version. + + Used for optimistic-concurrency conflict detection. The token is the + latest transaction_id, or "0" for a page that has never been saved. + """ + versions = list(page.versions.order_by(None).order_by(version_class(WikiPage).transaction_id.desc()).limit(1)) + if versions: + return str(versions[0].transaction_id) + return "0" + + +@wiki.route("/") +def list_pages() -> ResponseReturnValue: + pages = WikiPage.all_pages() + return render_template("wiki/list.html", pages=pages) + + +@wiki.route("/new", methods=["GET", "POST"]) +@require_permission("wiki") +def new_page() -> ResponseReturnValue: + form = CreateWikiPageForm() + if form.validate_on_submit(): + page = WikiPage( + slug=form.slug.data, + title=form.title.data, + content=form.content.data or "", + created_by_id=current_user.id, + updated_by_id=current_user.id, + ) + db.session.add(page) + db.session.commit() + flash(f"Page '{page.title}' created.") + return redirect(url_for(".view", slug=page.slug)) + return render_template("wiki/edit.html", form=form, page=None, creating=True) + + +@wiki.route("/") +def view(slug: str) -> ResponseReturnValue: + page = WikiPage.get_by_slug(slug) + if page is None: + abort(404) + content_html = render_markdown(page.content) if page.content else None + return render_template("wiki/view.html", page=page, content_html=content_html) + + +@wiki.route("//edit", methods=["GET", "POST"]) +@login_required +def edit(slug: str) -> ResponseReturnValue: + page = WikiPage.get_by_slug(slug) + if page is None: + abort(404) + + form = WikiPageForm() + + if request.method == "GET": + form.title.data = page.title + form.content.data = page.content + form.version_token.data = _current_version_token(page) + return render_template("wiki/edit.html", form=form, page=page, creating=False) + + if not form.validate_on_submit(): + return render_template("wiki/edit.html", form=form, page=page, creating=False) + + # Conflict detection: compare stored token against current state + saved_token = form.version_token.data or "0" + current_token = _current_version_token(page) + force = request.form.get("force") == "1" + + if saved_token != current_token and not force: + # Someone else saved between when this user opened the edit form + # and now. Show the diff so the user can decide. + conflict_diff = list( + difflib.unified_diff( + page.content.splitlines(keepends=True), + (form.content.data or "").splitlines(keepends=True), + fromfile="current version", + tofile="your edit", + lineterm="", + ) + ) + return render_template( + "wiki/edit.html", + form=form, + page=page, + creating=False, + conflict=True, + conflict_diff=conflict_diff, + ) + + page.title = form.title.data + page.content = form.content.data or "" + page.updated_by_id = current_user.id + page.updated_at = naive_utcnow() + db.session.commit() + + flash("Page saved.") + return redirect(url_for(".view", slug=slug)) + + +@wiki.route("//history") +def history(slug: str) -> ResponseReturnValue: + page = WikiPage.get_by_slug(slug) + if page is None: + abort(404) + + WikiPageVersion = version_class(WikiPage) + versions = list( + page.versions.order_by(None).order_by(WikiPageVersion.transaction_id.desc()) + ) + return render_template("wiki/history.html", page=page, versions=versions) + + +@wiki.route("//diff//") +def diff(slug: str, from_txn: int, to_txn: int) -> ResponseReturnValue: + page = WikiPage.get_by_slug(slug) + if page is None: + abort(404) + + WikiPageVersion = version_class(WikiPage) + from_ver = page.versions.filter(WikiPageVersion.transaction_id == from_txn).first() + to_ver = page.versions.filter(WikiPageVersion.transaction_id == to_txn).first() + + if from_ver is None or to_ver is None: + abort(404) + + from_lines = (from_ver.content or "").splitlines(keepends=True) + to_lines = (to_ver.content or "").splitlines(keepends=True) + + diff_lines = list( + difflib.unified_diff( + from_lines, + to_lines, + fromfile=f"Version #{from_txn} ({from_ver.transaction.issued_at.strftime('%Y-%m-%d %H:%M')})", + tofile=f"Version #{to_txn} ({to_ver.transaction.issued_at.strftime('%Y-%m-%d %H:%M')})", + lineterm="", + ) + ) + + return render_template( + "wiki/diff.html", + page=page, + diff_lines=diff_lines, + from_txn=from_txn, + to_txn=to_txn, + from_ver=from_ver, + to_ver=to_ver, + ) diff --git a/main.py b/main.py index c7af69074..7fe971e48 100644 --- a/main.py +++ b/main.py @@ -219,6 +219,7 @@ def after_request(response): from models import feature_flag, site_state from models.user import User, load_anonymous_user + from models.wiki import WikiPage # noqa: F401 — registers WikiPage with SQLAlchemy Continuum @login_manager.user_loader def load_user(userid: str) -> User | None: @@ -384,6 +385,7 @@ def shell_imports(): from apps.users import users from apps.villages import villages from apps.volunteer import volunteer + from apps.wiki import wiki from apps.volunteer.admin import volunteer_admin from apps.volunteer.admin.notify import notify @@ -400,6 +402,7 @@ def shell_imports(): app.register_blueprint(arrivals, url_prefix="/arrivals") app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(villages, url_prefix="/villages") + app.register_blueprint(wiki, url_prefix="/wiki") app.register_blueprint(admin, url_prefix="/admin") app.register_blueprint(volunteer, url_prefix="/volunteer") app.register_blueprint(notify, url_prefix="/volunteer/admin/notify") diff --git a/migrations/versions/8d6cdb63ce01_add_wiki_page_table.py b/migrations/versions/8d6cdb63ce01_add_wiki_page_table.py new file mode 100644 index 000000000..041188861 --- /dev/null +++ b/migrations/versions/8d6cdb63ce01_add_wiki_page_table.py @@ -0,0 +1,68 @@ +"""add wiki_page table + +Revision ID: 8d6cdb63ce01 +Revises: f361662d6dee +Create Date: 2026-04-22 09:45:26.734992 + +""" + +# revision identifiers, used by Alembic. +revision = '8d6cdb63ce01' +down_revision = 'f361662d6dee' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('wiki_page_version', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('slug', sa.String(), autoincrement=False, nullable=True), + sa.Column('title', sa.String(), autoincrement=False, nullable=True), + sa.Column('content', sa.Text(), autoincrement=False, nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('updated_at', sa.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('created_by_id', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('updated_by_id', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column('operation_type', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('id', 'transaction_id', name=op.f('pk_wiki_page_version')) + ) + with op.batch_alter_table('wiki_page_version', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_wiki_page_version_operation_type'), ['operation_type'], unique=False) + batch_op.create_index(batch_op.f('ix_wiki_page_version_slug'), ['slug'], unique=False) + batch_op.create_index(batch_op.f('ix_wiki_page_version_transaction_id'), ['transaction_id'], unique=False) + + op.create_table('wiki_page', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('slug', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], name=op.f('fk_wiki_page_created_by_id_user')), + sa.ForeignKeyConstraint(['updated_by_id'], ['user.id'], name=op.f('fk_wiki_page_updated_by_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_wiki_page')) + ) + with op.batch_alter_table('wiki_page', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_wiki_page_slug'), ['slug'], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('wiki_page', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_wiki_page_slug')) + + op.drop_table('wiki_page') + with op.batch_alter_table('wiki_page_version', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_wiki_page_version_transaction_id')) + batch_op.drop_index(batch_op.f('ix_wiki_page_version_slug')) + batch_op.drop_index(batch_op.f('ix_wiki_page_version_operation_type')) + + op.drop_table('wiki_page_version') + # ### end Alembic commands ### diff --git a/models/wiki.py b/models/wiki.py new file mode 100644 index 000000000..d0e51de29 --- /dev/null +++ b/models/wiki.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Text, select +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from main import NaiveDT, db +from models import BaseModel, naive_utcnow + +if TYPE_CHECKING: + from models.user import User + +__all__ = ["WikiPage"] + + +class WikiPage(BaseModel): + __tablename__ = "wiki_page" + __versioned__: dict[str, str] = {} + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(unique=True, index=True) + title: Mapped[str] + content: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[NaiveDT] = mapped_column(default=naive_utcnow) + updated_at: Mapped[NaiveDT] = mapped_column(default=naive_utcnow, onupdate=naive_utcnow) + + created_by_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), nullable=True) + updated_by_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), nullable=True) + + created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_id]) + updated_by: Mapped[User | None] = relationship(foreign_keys=[updated_by_id]) + + @classmethod + def get_by_slug(cls, slug: str) -> WikiPage | None: + return db.session.execute(select(cls).where(cls.slug == slug)).scalar_one_or_none() + + @classmethod + def all_pages(cls) -> list[WikiPage]: + return list(db.session.execute(select(cls).order_by(cls.title)).scalars()) + + def __repr__(self) -> str: + return f"" diff --git a/templates/wiki/diff.html b/templates/wiki/diff.html new file mode 100644 index 000000000..2cd5bed96 --- /dev/null +++ b/templates/wiki/diff.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Diff: {{ page.title }} — Wiki{% endblock %} +{% block head %} + +{% endblock %} +{% block body %} +

+ Diff: {{ page.title }} +

+ +
+
+
From
+
+ Version #{{ from_txn }} + — {{ from_ver.transaction.issued_at.strftime('%Y-%m-%d %H:%M') }} + {% if from_ver.transaction.user %}by {{ from_ver.transaction.user.name }}{% endif %} +
+
To
+
+ Version #{{ to_txn }} + — {{ to_ver.transaction.issued_at.strftime('%Y-%m-%d %H:%M') }} + {% if to_ver.transaction.user %}by {{ to_ver.transaction.user.name }}{% endif %} +
+
+
+ +{% if diff_lines %} +
+{% for line in diff_lines %} +{% if line.startswith('---') or line.startswith('+++') %}{{ line }} +{% elif line.startswith('+') %}{{ line }} +{% elif line.startswith('-') %}{{ line }} +{% elif line.startswith('@@') %}{{ line }} +{% else %}{{ line }} +{% endif %} +{% endfor %} +
+{% else %} +

No differences between these versions.

+{% endif %} + View current page +

+{% endblock %} diff --git a/templates/wiki/edit.html b/templates/wiki/edit.html new file mode 100644 index 000000000..487668ede --- /dev/null +++ b/templates/wiki/edit.html @@ -0,0 +1,64 @@ +{% from "_formhelpers.html" import render_field %} +{% extends "base.html" %} + +{% block title %}{% if creating %}New Wiki Page{% elif page %}Edit: {{ page.title }} — Wiki{% else %}Edit — Wiki{% endif %}{% endblock %} + +{% block body %} + +{% if creating %} +

New wiki page

+{% else %} +

Edit: {{ page.title }}

+{% endif %} + +{% if conflict %} +
+ Edit conflict! + The page was changed by someone else after you opened the editor. + Your changes are shown below against the current version. + You can force save to overwrite those changes, or + discard your edit + and start again from the current version. + +
{% for line in conflict_diff %}{{ line }}
+{% endfor %}
+
+{% endif %} + +
+
+ {{ form.hidden_tag() }} + {{ form.version_token() }} + {% if conflict %} + + {% endif %} + +
+ {% if creating %} + {{ render_field(form.slug, horizontal=9, placeholder="wiki-page-name") }} + {% endif %} + {{ render_field(form.title, horizontal=9) }} + {% call render_field(form.content, horizontal=9, rows=20) %} + Standard Markdown + supported, apart from images. + {% endcall %} + {{ render_field(form.edit_note, horizontal=9, placeholder="Briefly describe what you changed (optional)") }} +
+ +
+
+ {% if conflict %} + {{ form.submit(class="btn btn-danger", value="Force save") }} + {% else %} + {{ form.submit(class="btn btn-primary") }} + {% endif %} + {% if page %} + Cancel + {% else %} + Cancel + {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/wiki/history.html b/templates/wiki/history.html new file mode 100644 index 000000000..932b648a5 --- /dev/null +++ b/templates/wiki/history.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}History: {{ page.title }} — Wiki{% endblock %} +{% block body %} +

+ History: {{ page.title }} +

+ +{% if versions %} +

Select any two versions below to compare them.

+
+ {# The action is overridden by JS; plain links are the fallback. #} + + + + + + + + + + + {% for version in versions %} + + + + + + + + {% endfor %} + +
CompareDateEditorEdit summary
+ {% if not loop.last %} + + older → this + + {% endif %} + + {% if not loop.first %} + + this → newer + + {% endif %} + {{ version.transaction.issued_at.strftime('%Y-%m-%d %H:%M') }} + {% if version.transaction.user %} + {{ version.transaction.user.name }} + {% else %} + unknown + {% endif %} + {{ version.edit_note or '' }}
+
+{% else %} +

No version history yet.

+{% endif %} + +

← Back to page

+{% endblock %} diff --git a/templates/wiki/list.html b/templates/wiki/list.html new file mode 100644 index 000000000..521a6de68 --- /dev/null +++ b/templates/wiki/list.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Wiki{% endblock %} +{% block body %} +

+ Wiki + {% if current_user.is_authenticated and current_user.has_permission('wiki') %} + New page + {% endif %} +

+ +

Collaboratively maintained pages for attendees. Anyone with a login can edit these pages.

+ +{% if pages %} + + + + + + + + + + {% for page in pages %} + + + + + + {% endfor %} + +
TitleLast updatedBy
{{ page.title }}{{ page.updated_at.strftime('%Y-%m-%d %H:%M') }}{{ page.updated_by.name if page.updated_by else '—' }}
+{% else %} +

No wiki pages yet.

+{% endif %} +{% endblock %} diff --git a/templates/wiki/view.html b/templates/wiki/view.html new file mode 100644 index 000000000..a4c8d3f7b --- /dev/null +++ b/templates/wiki/view.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }} — Wiki{% endblock %} +{% block body %} +

+ {{ page.title }} + {% if current_user.is_authenticated %} + Edit + {% endif %} +

+ +{% if content_html %} + +{{ content_html }} +{% else %} +

This page has no content yet.

+{% endif %} + +
+

+ Last updated {{ page.updated_at.strftime('%Y-%m-%d %H:%M') }} + {% if page.updated_by %}by {{ page.updated_by.name }}{% endif %}. + View history + · + All wiki pages +

+

Content on this page is provided by attendees and is not the responsibility of EMF.

+{% endblock %} From 605c9546386a40c1e21f1bd433bddbca9af9a10d Mon Sep 17 00:00:00 2001 From: Alexander Baxevanis Date: Wed, 22 Apr 2026 11:21:27 +0000 Subject: [PATCH 02/12] Fix ruff formatting in apps/wiki/views.py --- apps/wiki/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/wiki/views.py b/apps/wiki/views.py index 2d3e3c76e..9c625e726 100644 --- a/apps/wiki/views.py +++ b/apps/wiki/views.py @@ -20,7 +20,9 @@ def _current_version_token(page: WikiPage) -> str: Used for optimistic-concurrency conflict detection. The token is the latest transaction_id, or "0" for a page that has never been saved. """ - versions = list(page.versions.order_by(None).order_by(version_class(WikiPage).transaction_id.desc()).limit(1)) + versions = list( + page.versions.order_by(None).order_by(version_class(WikiPage).transaction_id.desc()).limit(1) + ) if versions: return str(versions[0].transaction_id) return "0" @@ -121,9 +123,7 @@ def history(slug: str) -> ResponseReturnValue: abort(404) WikiPageVersion = version_class(WikiPage) - versions = list( - page.versions.order_by(None).order_by(WikiPageVersion.transaction_id.desc()) - ) + versions = list(page.versions.order_by(None).order_by(WikiPageVersion.transaction_id.desc())) return render_template("wiki/history.html", page=page, versions=versions) From 317db44222e7c902881e4d225ea93f3c8c9856fe Mon Sep 17 00:00:00 2001 From: Alexander Baxevanis Date: Wed, 22 Apr 2026 12:01:07 +0000 Subject: [PATCH 03/12] Fix linter errors --- apps/common/__init__.py | 2 +- apps/wiki/__init__.py | 2 +- apps/wiki/forms.py | 2 -- main.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/common/__init__.py b/apps/common/__init__.py index 225528562..5124e52c1 100644 --- a/apps/common/__init__.py +++ b/apps/common/__init__.py @@ -9,6 +9,7 @@ from typing import Any, cast, overload from urllib.parse import urljoin, urlparse, urlunparse +import nh3 import pendulum from decorator import decorator from flask import ( @@ -26,7 +27,6 @@ from flask_login import current_user, login_user from jinja2.utils import urlize from markdown import markdown -import nh3 from markupsafe import Markup from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Response diff --git a/apps/wiki/__init__.py b/apps/wiki/__init__.py index d918d2ab2..0efbe40a8 100644 --- a/apps/wiki/__init__.py +++ b/apps/wiki/__init__.py @@ -8,4 +8,4 @@ wiki = Blueprint("wiki", __name__) -from . import views # noqa: F401, E402 +from . import views # noqa: F401 diff --git a/apps/wiki/forms.py b/apps/wiki/forms.py index 33761715c..10bc2d3f7 100644 --- a/apps/wiki/forms.py +++ b/apps/wiki/forms.py @@ -1,5 +1,3 @@ -import re - from wtforms import HiddenField, StringField, SubmitField, TextAreaField from wtforms.validators import InputRequired, Length, Optional, Regexp, ValidationError diff --git a/main.py b/main.py index 7fe971e48..63ae78748 100644 --- a/main.py +++ b/main.py @@ -385,9 +385,9 @@ def shell_imports(): from apps.users import users from apps.villages import villages from apps.volunteer import volunteer - from apps.wiki import wiki from apps.volunteer.admin import volunteer_admin from apps.volunteer.admin.notify import notify + from apps.wiki import wiki app.register_blueprint(base) app.register_blueprint(users) From 627f1278f9380e61e16daa40d8c2eb8f35396018 Mon Sep 17 00:00:00 2001 From: Alexander Baxevanis Date: Wed, 22 Apr 2026 12:08:19 +0000 Subject: [PATCH 04/12] Fix remaining code quality issues --- apps/wiki/forms.py | 2 +- apps/wiki/views.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/wiki/forms.py b/apps/wiki/forms.py index 10bc2d3f7..e037e820e 100644 --- a/apps/wiki/forms.py +++ b/apps/wiki/forms.py @@ -29,5 +29,5 @@ class CreateWikiPageForm(WikiPageForm): submit = SubmitField("Create page") def validate_slug(self, field: StringField) -> None: - if WikiPage.get_by_slug(field.data): + if field.data is not None and WikiPage.get_by_slug(field.data): raise ValidationError("A wiki page with this slug already exists.") diff --git a/apps/wiki/views.py b/apps/wiki/views.py index 9c625e726..d46cdb919 100644 --- a/apps/wiki/views.py +++ b/apps/wiki/views.py @@ -21,7 +21,7 @@ def _current_version_token(page: WikiPage) -> str: latest transaction_id, or "0" for a page that has never been saved. """ versions = list( - page.versions.order_by(None).order_by(version_class(WikiPage).transaction_id.desc()).limit(1) + page.versions.order_by(None).order_by(version_class(WikiPage).transaction_id.desc()).limit(1) # type: ignore[attr-defined] ) if versions: return str(versions[0].transaction_id) @@ -106,6 +106,7 @@ def edit(slug: str) -> ResponseReturnValue: conflict_diff=conflict_diff, ) + assert form.title.data is not None page.title = form.title.data page.content = form.content.data or "" page.updated_by_id = current_user.id @@ -123,7 +124,7 @@ def history(slug: str) -> ResponseReturnValue: abort(404) WikiPageVersion = version_class(WikiPage) - versions = list(page.versions.order_by(None).order_by(WikiPageVersion.transaction_id.desc())) + versions = list(page.versions.order_by(None).order_by(WikiPageVersion.transaction_id.desc())) # type: ignore[attr-defined] return render_template("wiki/history.html", page=page, versions=versions) @@ -134,8 +135,8 @@ def diff(slug: str, from_txn: int, to_txn: int) -> ResponseReturnValue: abort(404) WikiPageVersion = version_class(WikiPage) - from_ver = page.versions.filter(WikiPageVersion.transaction_id == from_txn).first() - to_ver = page.versions.filter(WikiPageVersion.transaction_id == to_txn).first() + from_ver = page.versions.filter(WikiPageVersion.transaction_id == from_txn).first() # type: ignore[attr-defined] + to_ver = page.versions.filter(WikiPageVersion.transaction_id == to_txn).first() # type: ignore[attr-defined] if from_ver is None or to_ver is None: abort(404) From 64f8f914a36d15dace2ffb096836f155700ac156 Mon Sep 17 00:00:00 2001 From: Alexander Baxevanis Date: Wed, 6 May 2026 17:40:44 +0000 Subject: [PATCH 05/12] `render_markdown` - better name and placement Co-authored-by: Copilot --- apps/common/__init__.py | 34 ++++++++++++++++----------------- apps/villages/views.py | 4 ++-- apps/wiki/views.py | 4 ++-- tests/test_village_rendering.py | 4 ++-- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/common/__init__.py b/apps/common/__init__.py index 5124e52c1..26f688b98 100644 --- a/apps/common/__init__.py +++ b/apps/common/__init__.py @@ -424,6 +424,23 @@ def render_template_markdown(filename: str, template: str = "about/template.html return render_template(page_template(metadata, template), **context) +def render_untrusted_markdown(markdown_text: str) -> Markup: + """Render untrusted user-supplied markdown safely. + + Sanitises HTML via nh3 and wraps output in a sandboxed iframe so that + arbitrary scripts and navigation from user content cannot affect the page. + """ + extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] + content_html = nh3.clean( + markdown(markdown_text, extensions=extensions), + tags=(nh3.ALLOWED_TAGS - {"img"}), + link_rel="noopener nofollow", + ) + inner_html = render_template("sandboxed-iframe.html", body=Markup(content_html)) + iframe_html = f'' + return Markup(iframe_html) + + def make_safe_url(target: str) -> str | None: ref_url = urlparse(request.host_url) test_url = urlparse(urljoin(request.host_url, target)) @@ -451,23 +468,6 @@ def get_next_url(default=None): return default -def render_markdown(markdown_text: str) -> Markup: - """Render untrusted user-supplied markdown safely. - - Sanitises HTML via nh3 and wraps output in a sandboxed iframe so that - arbitrary scripts and navigation from user content cannot affect the page. - """ - extensions = ["markdown.extensions.nl2br", "markdown.extensions.smarty", "tables"] - content_html = nh3.clean( - markdown(markdown_text, extensions=extensions), - tags=(nh3.ALLOWED_TAGS - {"img"}), - link_rel="noopener nofollow", - ) - inner_html = render_template("sandboxed-iframe.html", body=Markup(content_html)) - iframe_html = f'' - return Markup(iframe_html) - - def tidy_workshop_cost(participant_cost: str) -> str: # Some people put in a string, some just put in a £ amount try: diff --git a/apps/villages/views.py b/apps/villages/views.py index 68e2f00d3..cc1dd090d 100644 --- a/apps/villages/views.py +++ b/apps/villages/views.py @@ -7,7 +7,7 @@ from models.content import Venue from models.village import Village, VillageMember -from ..common import render_markdown +from ..common import render_untrusted_markdown from ..config import config from . import load_village, villages from .forms import VillageForm @@ -85,7 +85,7 @@ def view(year: int, village_id: int) -> ResponseReturnValue: village=village, show_edit=show_edit, village_long_description_html=( - render_markdown(village.long_description) if village.long_description else None + render_untrusted_markdown(village.long_description) if village.long_description else None ), ) diff --git a/apps/wiki/views.py b/apps/wiki/views.py index d46cdb919..db86c6e5a 100644 --- a/apps/wiki/views.py +++ b/apps/wiki/views.py @@ -9,7 +9,7 @@ from models import naive_utcnow from models.wiki import WikiPage -from ..common import render_markdown, require_permission +from ..common import render_untrusted_markdown, require_permission from . import wiki from .forms import CreateWikiPageForm, WikiPageForm @@ -58,7 +58,7 @@ def view(slug: str) -> ResponseReturnValue: page = WikiPage.get_by_slug(slug) if page is None: abort(404) - content_html = render_markdown(page.content) if page.content else None + content_html = render_untrusted_markdown(page.content) if page.content else None return render_template("wiki/view.html", page=page, content_html=content_html) diff --git a/tests/test_village_rendering.py b/tests/test_village_rendering.py index b5b4fba6e..36e839579 100644 --- a/tests/test_village_rendering.py +++ b/tests/test_village_rendering.py @@ -6,7 +6,7 @@ def test_render_simple(request_context): - rendered = views.render_markdown("Hi *you*. Welcome to [EMF](https://www.emfcamp.org/)") + rendered = views.render_untrusted_markdown("Hi *you*. Welcome to [EMF](https://www.emfcamp.org/)") assert '