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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ repos:
- id: ruff-format
- id: ruff-check

- repo: git@github.com:pre-commit/pre-commit-hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.4.0
hooks:
- id: no-commit-to-branch
Expand Down
2 changes: 1 addition & 1 deletion apps/villages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
villages = Blueprint("villages", __name__)


def load_village(year, village_id, require_admin=False):
def load_village(year: int, village_id: int, require_admin: bool = False) -> Village:
"""Helper to return village or 404"""
if year != event_year():
abort(404)
Expand Down
3 changes: 3 additions & 0 deletions apps/villages/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
class VillageForm(Form):
name = StringField("Village Name", [Length(2, 25)])
description = TextAreaField("Description", [Optional()])
long_description = TextAreaField("Long Description", [Optional()])
url = StringField("URL", [URL(), Optional()])

num_attendees = IntegerField("Number of People", [Optional()])
Expand Down Expand Up @@ -46,6 +47,7 @@ class VillageForm(Form):
def populate(self, village: Village) -> None:
self.name.data = village.name
self.description.data = village.description
self.long_description.data = village.long_description
self.url.data = village.url

requirements = village.requirements
Expand All @@ -60,6 +62,7 @@ def populate_obj(self, village: Village) -> None:
assert self.name.data is not None
village.name = self.name.data
village.description = self.description.data
village.long_description = self.long_description.data
village.url = self.url.data

if village.requirements is None:
Expand Down
86 changes: 83 additions & 3 deletions apps/villages/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import html

import markdown
import nh3
from flask import abort, flash, redirect, render_template, request, url_for
from flask.typing import ResponseValue
from flask_login import current_user, login_required
from markupsafe import Markup
from sqlalchemy import exists, select

from main import db
Expand Down Expand Up @@ -41,7 +46,7 @@ def register() -> ResponseValue:
db.session.commit()

flash("Your village registration has been received, thanks! You can edit it below.")
return redirect(url_for(".edit", year=event_year(), village_id=village.id))
return redirect(url_for(".view", year=event_year(), village_id=village.id))

return render_template("villages/register.html", form=form)

Expand All @@ -65,6 +70,81 @@ def main(year: int) -> ResponseValue:
)


@villages.route("/<int:year>/<int:village_id>")
def view(year: int, village_id: int) -> ResponseValue:
village = load_village(year, village_id)
show_edit = (
current_user.village
and current_user.village.id == village_id
and current_user.village_membership.admin
)

return render_template(
"villages/view.html",
village=village,
show_edit=show_edit,
village_long_description_html=(
render_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"]
contentHtml = nh3.clean(
markdown.markdown(markdown_text, extensions=extensions),
tags=(nh3.ALLOWED_TAGS - {"img"}),
link_rel="noopener nofollow", # default includes noreferrer but not nofollow
)
innerHtml = render_template("sandboxed-iframe.html", body=Markup(contentHtml))
iFrameHtml = f'<iframe sandbox="allow-scripts" class="embedded-content" srcdoc="{html.escape(innerHtml, True)}" onload="javascript:window.listenForFrameResizedMessages(this);"></iframe>'
return Markup(iFrameHtml)


@villages.route("/<int:year>/<int:village_id>/view2")
def view2(year: int, village_id: int) -> ResponseValue:
village = load_village(year, village_id)
rendered_long_description = (
render_markdown2(village.long_description) if village.long_description else None
)

return render_template(
"villages/view2.html",
village=village,
village_long_description_html=rendered_long_description,
)


def render_markdown2(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"]
contentHtml = nh3.clean(
markdown.markdown(markdown_text, extensions=extensions),
tags=(nh3.ALLOWED_TAGS - {"img"}),
link_rel="noopener nofollow", # default includes noreferrer but not nofollow
)
innerHtml = f"""
<link rel="stylesheet" href="/static/css/main.css">
<div id="emf-container" style="min-height: 100%;">
<div class="emf-row">
<div class="emf-col" role="main">
{Markup(contentHtml)}
</div>
</div>
</div>"""
iFrameHtml = f'<iframe sandbox class="embedded-content" srcdoc="{html.escape(innerHtml, True)}"></iframe>'
return Markup(iFrameHtml)


@villages.route("/<int:year>/<int:village_id>/edit", methods=["GET", "POST"])
@login_required
def edit(year: int, village_id: int) -> ResponseValue:
Expand All @@ -84,13 +164,13 @@ def edit(year: int, village_id: int) -> ResponseValue:
else:
# All good, update DB
for venue in village.venues:
if venue.name == village.name:
if venue.name == village.name and form.name.data is not None:
# Rename a village venue if it exists and has the old name.
venue.name = form.name.data

form.populate_obj(village)
db.session.commit()
flash("Your village registration has been updated.")
return redirect(url_for(".edit", year=year, village_id=village_id))
return redirect(url_for(".view", year=year, village_id=village_id))

return render_template("villages/edit.html", form=form, village=village)
16 changes: 16 additions & 0 deletions css/_village.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.village-form {
textarea#description {
height: 100px;
}

textarea#long_description {
height: 200px;
}
}

.embedded-content {
width: 100%;
height: 450px; // default height, recalculated by javascript.
border: none;
overflow: hidden;
}
3 changes: 2 additions & 1 deletion css/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@use "./_tickets.scss";
@use "./_responsive_table.scss";
@use "./_sponsorship.scss";
@use "./_village.scss";
@use "./volunteer_schedule.scss";

@font-face {
Expand All @@ -40,4 +41,4 @@
src: local(""),
url("../static/fonts/raleway-v22-latin-ext_latin-700.woff2") format("woff2"),
url("../static/fonts/raleway-v22-latin-ext_latin-700.woff") format("woff");
}
}
24 changes: 24 additions & 0 deletions js/sandboxed-iframe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
function sendFrameResizedMessage() {
//postMessage to set iframe height
window.parent.postMessage({ "type": "frame-resized", "value": document.body.parentElement.scrollHeight }, '*');
}

function listenForFrameResizedMessages(iFrameEle) {
window.addEventListener('message', receiveMessage, false);

function receiveMessage(evt) {
console.log("Got message: " + JSON.stringify(evt.data) + " from origin: " + evt.origin);
// Do we trust the sender of this message?
// origin of sandboxed iframes is null but is this a useful check?
// if (evt.origin !== null) {
// return;
// }

if (evt.data.type === "frame-resized") {
iFrameEle.style.height = evt.data.value + "px";
}
}
}

window.listenForFrameResizedMessages = listenForFrameResizedMessages;
window.sendFrameResizedMessage = sendFrameResizedMessage;
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ def send_security_headers(response):
"'unsafe-hashes'",
"'sha256-2rvfFrggTCtyF5WOiTri1gDS8Boibj4Njn0e+VCBmDI='", # return false;
"'sha256-gC0PN/M+TSxp9oNdolzpqpAA+ZRrv9qe1EnAbUuDmk8='", # return modelActions.execute('notify');
"'sha256-GtgSCbDPm83G2B75TQfbv5TR/nqHF4WnrnN+njVmQFU='", # javascript:window.listenForFrameResizedMessages(this);
"'sha256-dW+ze6eYWjNQB4tjHLKsdbtI4AqFRK/FpdEu/ZCxmLc='", # javascript:window.sendFrameResizedMessage()
]

if app.config.get("DEBUG_TB_ENABLED"):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Add long_description to village

Revision ID: 8c17cee05585
Revises: 53220373bfde
Create Date: 2025-10-25 09:40:24.997019

"""

# revision identifiers, used by Alembic.
revision = '8c17cee05585'
down_revision = '53220373bfde'

from alembic import op
import sqlalchemy as sa


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('village', schema=None) as batch_op:
batch_op.add_column(sa.Column('long_description', sa.String(), nullable=True))

with op.batch_alter_table('village_version', schema=None) as batch_op:
batch_op.add_column(sa.Column('long_description', sa.String(), autoincrement=False, nullable=True))

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('village_version', schema=None) as batch_op:
batch_op.drop_column('long_description')

with op.batch_alter_table('village', schema=None) as batch_op:
batch_op.drop_column('long_description')

# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions models/village.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Village(BaseModel):

name: Mapped[str] = mapped_column(unique=True)
description: Mapped[str | None]
long_description: Mapped[str | None]
url: Mapped[str | None]
location: Mapped[WKBElement | None] = mapped_column(Geometry("POINT", srid=4326, spatial_index=False))

Expand Down Expand Up @@ -98,6 +99,7 @@ def get_export_data(cls):
v.id: {
"name": v.name,
"description": v.description,
"long_description": v.long_description,
"url": v.url,
"location": v.latlon,
}
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"logging_tree~=1.9",
"lxml>=6.0.0",
"markdown~=3.1",
"nh3>=0.3.1",
"pendulum~=3.1",
"pillow<12.0",
"playwright>=1.43.0,<2",
Expand Down
1 change: 1 addition & 0 deletions rsbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default defineConfig({
"volunteer_schedule.js": './js/volunteer-schedule.js',
"event_tickets.js": './js/event-tickets.js',
"arrivals.js": './js/arrivals.js',
"sandboxed-iframe.js": './js/sandboxed-iframe.js',

"admin.scss": './css/admin.scss',
"arrivals.scss": './css/arrivals.scss',
Expand Down
2 changes: 1 addition & 1 deletion templates/about/villages.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
title: Villages
---
# Villages
A village is a group of friends, like-minded people, colleagues or even families camping together at EMF. Maybe your village is a collection of sub villages, or maybe it’s just your hackerspace. A village can be based around themes, shared passions and existing communities - great places in the past have been the Maths village, the HAB Village and groups like Milliways and the Scottish Consulate. You can find the list of currently registered villages for EMF 2024 at [emfcamp.org/villages/{{ event_year }}](/villages/{{ event_year }}).
A village is a group of friends, like-minded people, colleagues or even families camping together at EMF. Maybe your village is a collection of sub villages, or maybe it’s just your hackerspace. A village can be based around themes, shared passions and existing communities - great places in the past have been the Maths village, the HAB Village and groups like Milliways and the Scottish Consulate. You can find the list of currently registered villages for EMF {{ event_year }} at [emfcamp.org/villages/{{ event_year }}](/villages/{{ event_year }}).

If there is a village based around something you are interested in, then you should definitely visit that village during the event to see what's going on. Or you could even join that village to help make more things go on. If there's not a village based around your interests, then you can start your own village and others will probably join you.

Expand Down
2 changes: 1 addition & 1 deletion templates/account/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ <h3 class="panel-title">Villages</h3>
</div>
<div class="panel-body">
{% if current_user.village %}
<p>You're a member of the <strong>{{current_user.village.name}}</strong> village.</p>
<p>You're a member of the <strong><a href="{{url_for('villages.view', year=event_year, village_id=current_user.village.id)}}">{{current_user.village.name}}</a></strong> village.</p>
{% if current_user.village_membership.admin %}
<a class="btn btn-primary" href="{{url_for('villages.edit', year=event_year, village_id=current_user.village.id)}}">
Edit village details</a>
Expand Down
27 changes: 27 additions & 0 deletions templates/sandboxed-iframe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{# The source of an iFrame used to sandbox user-controlled HTML #}
{# Once loaded, this emits an event with it's content's size which the parent page can listen for to adapt the iFrame's height #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
{% block css -%}
<link rel="stylesheet" href="{{ static_url_for('static', filename="css/main.css") }}">
{% endblock -%}
{% block head -%}{% endblock -%}
</head>
<body itemscope itemtype="http://schema.org/WebPage" {% block body_class %}{% endblock %} onload="javascript:window.sendFrameResizedMessage()" style="overflow: hidden;">
{% block document %}
<div id="emf-container" style="min-height: 100%;">
<div class="emf-row">
<div class="emf-col {{ main_class }}" role="main" {% if self.content_scope() -%}
itemscope itemtype="{% block content_scope %}{% endblock %}"
{%- endif %}>
{% block body -%} {{ body }} {% endblock -%}
</div>
</div>
</div>
{% endblock %}
<script src="{{static_url_for('static', filename="js/sandboxed-iframe.js")}}"></script>
{% block foot -%}{% endblock -%}
</body>
</html>
8 changes: 6 additions & 2 deletions templates/villages/_form.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div class="well">
<form method="post" class="form-horizontal" role="form">
<form method="post" class="form-horizontal village-form" role="form">
{{ form.hidden_tag() }}
<fieldset>
<legend>Basic Details</legend>
Expand All @@ -8,7 +8,11 @@
{{ render_field(form.name, horizontal=9, placeholder="My Village") }}
{{ render_field(form.url, horizontal=9, placeholder="") }}
{{ render_field(form.description, horizontal=9,
placeholder="A description of your village. Who are you, what cool things will you be doing?") }}
placeholder="A short description of your village. Who are you, what cool things will you be doing?") }}
{% call render_field(form.long_description, horizontal=9,
placeholder="A longer description of your village using Markdown. Project details etc.") %}
<a href="https://www.markdown-cheatsheet.com">Standard Markdown</a> supported <em>apart from</em> images.
{% endcall %}
</fieldset>
<fieldset>
<legend>Requirements</legend>
Expand Down
1 change: 1 addition & 0 deletions templates/villages/admin/info.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ <h3>Village Info: {{village.name}}</h3>

{{ render_field(form.name) }}
{{ render_field(form.description) }}
{{ render_field(form.long_description) }}
{{ render_field(form.url) }}
{{ render_field(form.lat) }}
{{ render_field(form.lon) }}
Expand Down
Loading
Loading