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 %} +

+ 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..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 %} + +
+
+ {{ form.hidden_tag() }} + {{ form.version_token() }} + +
+ {% if creating %} + {{ render_field(form.slug, horizontal=9, placeholder="wiki-page-name") }} + {% endif %} + {% if creating or current_user.has_permission('admin') %} + {{ render_field(form.title, horizontal=9) }} + {% else %} +
+ +

{{ page.title }}

+
+ {% endif %} + {% call render_field(form.content, horizontal=9, rows=20) %} + Standard Markdown + supported, apart from images. + {% endcall %} +
+ +
+
+ {{ form.submit(class="btn btn-primary") }} + {% 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..032c94bf1 --- /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. #} + + + + + + {% if current_user.is_authenticated and current_user.has_permission('admin') %}{% endif %} + + + + {% for version in versions %} + + + + + {% if current_user.is_authenticated and current_user.has_permission('admin') %} + + {% endif %} + + {% endfor %} + +
CompareDateEditor
+ {% 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 %} +
+
+{% 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 %} + + + + + + {% if current_user.is_authenticated and current_user.has_permission('admin') %}{% endif %} + + + + {% for page in pages %} + {% set v = latest_versions[page.id] %} + + + + {% if current_user.is_authenticated and current_user.has_permission('admin') %} + + {% endif %} + + {% endfor %} + +
TitleLast updatedBy
{{ page.title }}{{ v.transaction.issued_at.strftime('%Y-%m-%d %H:%M') if v else '—' }}{{ v.transaction.user.name if v and v.transaction.user 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..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 '