diff --git a/apps/common/__init__.py b/apps/common/__init__.py
index e2c326506..26f688b98 100644
--- a/apps/common/__init__.py
+++ b/apps/common/__init__.py
@@ -1,3 +1,4 @@
+import html
import json
import logging
import re
@@ -8,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 (
@@ -422,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))
diff --git a/apps/villages/views.py b/apps/villages/views.py
index 81c2d6d45..cc1dd090d 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_untrusted_markdown
from ..config import config
from . import load_village, villages
from .forms import VillageForm
@@ -89,28 +85,11 @@ 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
),
)
-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..0efbe40a8
--- /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
diff --git a/apps/wiki/forms.py b/apps/wiki/forms.py
new file mode 100644
index 000000000..06eee1d21
--- /dev/null
+++ b/apps/wiki/forms.py
@@ -0,0 +1,32 @@
+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()])
+ 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 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
new file mode 100644
index 000000000..b00808058
--- /dev/null
+++ b/apps/wiki/views.py
@@ -0,0 +1,203 @@
+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 merge3 import Merge3
+from sqlalchemy_continuum.utils import version_class
+
+from main import db
+from models.wiki import WikiPage
+
+from ..common import render_untrusted_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) # type: ignore[attr-defined]
+ )
+ if versions:
+ return str(versions[0].transaction_id)
+ return "0"
+
+
+@wiki.route("/")
+def list_pages() -> ResponseReturnValue:
+ pages = WikiPage.all_pages()
+ WikiPageVersion = version_class(WikiPage)
+ latest_versions = {
+ page.id: (
+ page.versions.order_by(None) # type: ignore[attr-defined]
+ .order_by(WikiPageVersion.transaction_id.desc())
+ .first()
+ )
+ for page in pages
+ }
+ return render_template("wiki/list.html", pages=pages, latest_versions=latest_versions)
+
+
+@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 "",
+ )
+ 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_untrusted_markdown(page.content) if page.content else None
+ WikiPageVersion = version_class(WikiPage)
+ latest_version = (
+ page.versions.order_by(None) # type: ignore[attr-defined]
+ .order_by(WikiPageVersion.transaction_id.desc())
+ .first()
+ )
+ return render_template(
+ "wiki/view.html", page=page, content_html=content_html, latest_version=latest_version
+ )
+
+
+@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)
+
+ # Non-admins cannot change the title; fill it in so validation passes
+ if not current_user.has_permission("admin"):
+ form.title.data = page.title
+
+ if not form.validate_on_submit():
+ return render_template("wiki/edit.html", form=form, page=page, creating=False)
+
+ # Conflict detection: 3-way merge against the base version the user started from
+ saved_token = form.version_token.data or "0"
+ current_token = _current_version_token(page)
+
+ if saved_token != current_token:
+ # Look up base content (what the user started editing from)
+ WikiPageVersion = version_class(WikiPage)
+ if saved_token == "0":
+ base_content = ""
+ else:
+ base_ver = page.versions.filter( # type: ignore[attr-defined]
+ WikiPageVersion.transaction_id == int(saved_token)
+ ).first()
+ base_content = (base_ver.content or "") if base_ver else ""
+
+ base_lines = base_content.splitlines(keepends=True)
+ current_lines = page.content.splitlines(keepends=True)
+ our_lines = (form.content.data or "").splitlines(keepends=True)
+
+ merged_lines = list(
+ Merge3(base_lines, current_lines, our_lines).merge_lines(
+ name_a="current version", name_b="your edit"
+ )
+ )
+ has_conflict = any(line.startswith("<<<<<<<") for line in merged_lines)
+
+ if not has_conflict:
+ # Clean 3-way merge — save automatically
+ assert form.title.data is not None
+ page.title = form.title.data
+ page.content = "".join(merged_lines)
+ db.session.commit()
+ flash("Page saved (your changes were automatically merged with a concurrent edit).")
+ return redirect(url_for(".view", slug=slug))
+
+ # True conflict — pre-fill textarea with merged content including conflict markers
+ form.content.data = "".join(merged_lines)
+ form.version_token.data = current_token
+ return render_template(
+ "wiki/edit.html",
+ form=form,
+ page=page,
+ creating=False,
+ conflict=True,
+ )
+
+ assert form.title.data is not None
+ if current_user.has_permission("admin"):
+ page.title = form.title.data
+ page.content = form.content.data or ""
+ 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())) # type: ignore[attr-defined]
+ 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() # 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_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..8fc20742c 100644
--- a/main.py
+++ b/main.py
@@ -386,6 +386,7 @@ def shell_imports():
from apps.volunteer import volunteer
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)
@@ -400,6 +401,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/3b07c4eea8ea_add_wiki_page_table.py b/migrations/versions/3b07c4eea8ea_add_wiki_page_table.py
new file mode 100644
index 000000000..df7ce8076
--- /dev/null
+++ b/migrations/versions/3b07c4eea8ea_add_wiki_page_table.py
@@ -0,0 +1,58 @@
+"""add wiki_page table
+
+Revision ID: 3b07c4eea8ea
+Revises: f361662d6dee
+Create Date: 2026-05-06 18:04:36.747725
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '3b07c4eea8ea'
+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',
+ 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.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)
+
+ 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('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)
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ 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')
+ 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')
+ # ### end Alembic commands ###
diff --git a/models/__init__.py b/models/__init__.py
index e37527565..6d9e14d7d 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -191,5 +191,6 @@ def get_pk(row):
from .user import * # noqa: F403
from .village import * # noqa: F403
from .volunteer import * # noqa: F403
+from .wiki import * # noqa: F403
db.configure_mappers()
diff --git a/models/wiki.py b/models/wiki.py
new file mode 100644
index 000000000..d6912639c
--- /dev/null
+++ b/models/wiki.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from sqlalchemy import Text, select
+from sqlalchemy.orm import Mapped, mapped_column
+
+from main import db
+from models import BaseModel
+
+__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="")
+
+ @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/pyproject.toml b/pyproject.toml
index 099b0391b..3539279cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
"iso8601",
"logging_tree~=1.9",
"markdown~=3.1",
+ "merge3~=0.0",
"nh3>=0.3.1",
"pendulum~=3.1",
"pillow<13.0",
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..f9a1eb941
--- /dev/null
+++ b/templates/wiki/edit.html
@@ -0,0 +1,61 @@
+{% 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 and your changes could not be automatically merged.
+ The content below has been pre-filled with the merged result — look for
+
<<<<<<< markers to find the conflicting sections,
+ resolve them manually, then save.
+ Or
discard your edit
+ and start again from the current version.
+
+{% endif %}
+
+
+{% endblock %}
diff --git a/templates/wiki/history.html b/templates/wiki/history.html
new file mode 100644
index 000000000..032c94bf1
--- /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..ce1377cd2
--- /dev/null
+++ b/templates/wiki/list.html
@@ -0,0 +1,38 @@
+{% 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 |
+ {% if current_user.is_authenticated and current_user.has_permission('admin') %}By | {% endif %}
+
+
+
+ {% for page in pages %}
+ {% set v = latest_versions[page.id] %}
+
+ | {{ page.title }} |
+ {{ v.transaction.issued_at.strftime('%Y-%m-%d %H:%M') if v else '—' }} |
+ {% if current_user.is_authenticated and current_user.has_permission('admin') %}
+ {{ v.transaction.user.name if v and v.transaction.user else '—' }} |
+ {% endif %}
+
+ {% 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..ee679ed93
--- /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 %}
+
+
+
+ {% if latest_version %}
+ Last updated {{ latest_version.transaction.issued_at.strftime('%Y-%m-%d %H:%M') }}
+ {% if latest_version.transaction.user and current_user.is_authenticated and current_user.has_permission('admin') %}by {{ latest_version.transaction.user.name }}{% endif %}.
+ {% endif %}
+ View history
+
+Content on this page is provided by attendees and is not the responsibility of EMF.
+{% endblock %}
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 '