Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/common/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import html
import json
import logging
import re
Expand All @@ -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 (
Expand Down Expand Up @@ -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'<iframe sandbox="allow-scripts allow-top-navigation-by-user-activation" class="embedded-content" srcdoc="{html.escape(inner_html, True)}"></iframe>'
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))
Expand Down
25 changes: 2 additions & 23 deletions apps/villages/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'<iframe sandbox="allow-scripts allow-top-navigation-by-user-activation" class="embedded-content" srcdoc="{html.escape(inner_html, True)}"></iframe>'
return Markup(iFrame_html)


@villages.route("/<int:year>/<int:village_id>/edit", methods=["GET", "POST"])
@login_required
def edit(year: int, village_id: int) -> ResponseReturnValue:
Expand Down
11 changes: 11 additions & 0 deletions apps/wiki/__init__.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions apps/wiki/forms.py
Original file line number Diff line number Diff line change
@@ -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.")
203 changes: 203 additions & 0 deletions apps/wiki/views.py
Original file line number Diff line number Diff line change
@@ -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("/<slug>")
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("/<slug>/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("/<slug>/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("/<slug>/diff/<int:from_txn>/<int:to_txn>")
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,
)
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
Loading
Loading