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 %}
+
+
+
+
+ - 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 %}
+
+
+{% 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 %}
+
+
+{% if versions %}
+Select any two versions below to compare them.
+
+{% 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 %}
+
+
+
+ | Title |
+ Last updated |
+ By |
+
+
+
+ {% for page in pages %}
+
+ | {{ page.title }} |
+ {{ page.updated_at.strftime('%Y-%m-%d %H:%M') }} |
+ {{ page.updated_by.name if page.updated_by else '—' }} |
+
+ {% endfor %}
+
+
+{% 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 '