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
57 changes: 57 additions & 0 deletions backend/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,62 @@ It does not return a `pending_approval` flag in this spec. Pending
approval is represented only by pending registrations, not by a partially live
user state.

## Committee Collections

Lists of items a committee manages from its own page, such as the cards in
Het Fotoboek (`/leden/fotos`), edited by FOCUS.

Each collection is its own table with **one row per item**: key = item id,
value = the item's JSON without the id. Items are saved and deleted one at a
time, so editing one item never touches the others.

Who may do what is fixed in code, in `COLLECTIONS`
(`data/features/committee_collections.py`):

| Collection | Table | Read | Write | Item shape |
|---|---|---|---|---|
| `fotos` | `collection_fotos` | `member` | `focus` | `validate_foto_card` in `data/features/fotos.py` |

Admins can always read and write every collection. Admins configure nothing
per collection: granting a member the committee's permission tag is enough.

**Security boundary:** a committee permission only grants writing to the
tables of the collections that name it as `write_role`. The collection routes
read and write nothing but the requested collection's own table, and a
committee permission unlocks no other route. So a committee can't change
users, registrations, permissions, other committees' data or anything else.

### Routes

The same three routes serve every collection; the body names it.

- `POST /members/collections/list/` `{collection}`: returns `{items}`, each
item as `{id, created_at, ...fields}`. Needs the read role or `admin`.
- `POST /members/collections/save/` `{collection, item}`: creates the item, or
replaces the item with the same `item.id`. Without an id the backend
generates one. Returns `{item}`. Needs the write role or `admin`.
- `id` and `created_at` are owned by the backend: `created_at` (unix
seconds) is set when the item is created and kept when it is replaced; a
`created_at` sent by the client is ignored
- 400 if the item fails the collection's shape check or the id is invalid
(1-64 letters, digits, `-`, `_`)
- 413 if the encoded item exceeds the collection's `max_item_bytes`
- `POST /members/collections/delete/` `{collection, id}`: needs the write role
or `admin`. 404 if no item has that id.

All three return 404 for an unknown collection and 403 without the right
permission. They accept the primary or the secondary session cookie.

### Adding a collection for another committee

1. Write a validator for one item's shape (like `validate_foto_card`).
2. Add an entry to `COLLECTIONS`: table name, read role, write role,
validator, max item size. The table is created automatically because
`COLLECTION_TABLES` is part of `DB_TABLES`.
3. Build the page in the frontend on the three routes above.

Concurrent edits of the **same** item are last-write-wins.

## Email Notifications

There are two categories of email.
Expand Down Expand Up @@ -1364,4 +1420,5 @@ window.
| `session_cache` | `session_token` | session JSON | Cache of validated Faroe sessions |
| `birthdays` | `user_id` | birthday JSON | Dodeka-owned projection rebuilt from applied Volta data |
| `private` | string key | `{"role": permission, "value": JSON}` | Permission-gated private key-value data |
| `collection_<name>` | item id | item JSON | One table per committee collection, see "Committee Collections" |
| `tokens` | `{kind}:{email}` | auxiliary token data | Local testing/tooling mirror for verification codes |
35 changes: 35 additions & 0 deletions backend/src/apiserver/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
)
from apiserver.handlers.acceptance import start_outbox_dispatcher
from apiserver.handlers.features.birthdays import birthdays_handler
from apiserver.handlers.features.committee_collections import (
delete_collection_item_handler,
list_collection_handler,
save_collection_item_handler,
)
from apiserver.handlers.features.private_kv import (
admin_get_private_handler,
admin_list_private_handler,
Expand Down Expand Up @@ -185,6 +190,15 @@ def h_birthdays():
def h_get_private():
return get_private_handler(req, headers, auth_client, store_queue)

def h_list_collection():
return list_collection_handler(req, headers, auth_client, store_queue)

def h_save_collection_item():
return save_collection_item_handler(req, headers, auth_client, store_queue)

def h_delete_collection_item():
return delete_collection_item_handler(req, headers, auth_client, store_queue)

def h_admin_get_private():
return admin_get_private_handler(req, store_queue)

Expand Down Expand Up @@ -275,6 +289,27 @@ def h_admin_list_private():
requires_credentials=True,
)
},
# Committee collections (fotoboek, ...): the handlers check the
# collection's read/write role from COLLECTIONS themselves.
"/members/collections/list/": {
"POST": RouteEntry(
h_list_collection, PermissionConfig.public(), requires_credentials=True
)
},
"/members/collections/save/": {
"POST": RouteEntry(
h_save_collection_item,
PermissionConfig.public(),
requires_credentials=True,
)
},
"/members/collections/delete/": {
"POST": RouteEntry(
h_delete_collection_item,
PermissionConfig.public(),
requires_credentials=True,
)
},
"/admin/private_kv/get/": {
"POST": RouteEntry(h_admin_get_private, PermissionConfig.require("admin"))
},
Expand Down
3 changes: 3 additions & 0 deletions backend/src/apiserver/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from apiserver.data.features.birthdays import BIRTHDAYS_TABLE
from apiserver.data.features.committee_collections import COLLECTION_TABLES
from apiserver.data.features.private_kv import PRIVATE_TABLE
from apiserver.data.outbox import OUTBOX_TABLE
from apiserver.data.registrations import (
Expand Down Expand Up @@ -34,4 +35,6 @@
PRIVATE_TABLE,
SYSTEM_USERS_TABLE,
CODES_TABLE,
# One table per committee collection (fotos, ...)
*COLLECTION_TABLES,
]
108 changes: 108 additions & 0 deletions backend/src/apiserver/data/features/committee_collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Committee collections: committees write their own data to the backend.

A collection is a list of items one committee manages from its own page, e.g.
the cards of Het Fotoboek (FOCUS). Each collection is its own table with one
row per item (key = item id, value = the item's JSON).

Security boundary: a committee permission (``focus``, ``comcom``, ...) only
ever grants writing to the tables of the collections that name it as
``write_role``. The collection routes touch nothing but the requested
collection's own table, so a committee can't reach users, registrations,
other committees' data or anything else. Who may read and write is fixed here
in code; admins only grant members the committee's tag.

To add a collection for another committee:

1. write a ``validate_<thing>`` function describing one item's shape
(see ``fotos.py``),
2. add an entry to ``COLLECTIONS`` below,
3. build the page in the frontend; it uses the generic
``/members/collections/*`` routes with ``{"collection": "<name>"}``.

The table is created automatically (``COLLECTION_TABLES`` is part of
``DB_TABLES``). No route or admin setup is needed.
"""

import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from freetser import Storage

from apiserver.data.features.fotos import validate_foto_card


@dataclass(frozen=True)
class Collection:
table: str
"""freetser table holding this collection, one row per item."""
read_role: str
"""Permission needed to list the items. Admins can always read."""
write_role: str
"""Permission needed to create, change or delete items. Admins always can."""
validate: Callable[[dict[str, Any]], list[str]]
"""Returns the problems with one item (without its id); empty = valid."""
max_item_bytes: int
"""Upper bound on one encoded item, so a single row can't grow unbounded."""


COLLECTIONS: dict[str, Collection] = {
# Het Fotoboek (/leden/fotos): every member sees the cards, FOCUS edits.
"fotos": Collection(
table="collection_fotos",
read_role="member",
write_role="focus",
validate=validate_foto_card,
max_item_bytes=1_000_000,
),
}

COLLECTION_TABLES = [c.table for c in COLLECTIONS.values()]

# Fields the backend owns: clients can't set them, they are added on read.
SERVER_FIELDS = frozenset({"id", "created_at"})


def list_items(store: Storage, collection: Collection) -> list[dict[str, Any]]:
"""All items as ``{"id": ..., "created_at": ..., **fields}``, sorted by id."""
items: list[dict[str, Any]] = []
for item_id in sorted(store.list_keys(collection.table)):
result = store.get(collection.table, item_id)
if result is None:
continue
value_bytes, _ = result
items.append({"id": item_id, **json.loads(value_bytes.decode("utf-8"))})
return items


def item_exists(store: Storage, collection: Collection, item_id: str) -> bool:
return store.get(collection.table, item_id) is not None


def put_item(
store: Storage,
collection: Collection,
item_id: str,
fields: dict[str, Any],
timestamp: int,
) -> int:
"""Create the item, or replace it if the id already exists.

Stores ``created_at`` (unix seconds) next to the fields: ``timestamp`` for
a new item, the original value when replacing one. Returns it.
"""
created_at = timestamp
existing = store.get(collection.table, item_id)
if existing is not None:
stored_created_at = json.loads(existing[0].decode("utf-8")).get("created_at")
if isinstance(stored_created_at, int):
created_at = stored_created_at
item_bytes = json.dumps({**fields, "created_at": created_at}).encode("utf-8")
store.overwrite(collection.table, item_id, item_bytes, expires_at=0)
return created_at


def delete_item(store: Storage, collection: Collection, item_id: str) -> bool:
"""Delete the item; False if it did not exist."""
return store.delete(collection.table, item_id)
147 changes: 147 additions & 0 deletions backend/src/apiserver/data/features/fotos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Shape of a card in Het Fotoboek (/leden/fotos), edited by FOCUS.

One card is one event with a cover image and links to its photo albums. The
frontend counterpart is ``PhotoEvent`` in
``dodekafrontend/src/pages/leden/fotos/fotos.tsx``; keep the two in sync.
"""

import re
from datetime import date
from typing import Any

EVENT_TYPES = frozenset({"Wedstrijd", "Gezelligheid", "Training", "Reis", "Overig"})

MAX_TYPES = 3
MAX_NAME_LENGTH = 200
MAX_LINKS = 20
MAX_LINK_LABEL_LENGTH = 50
MAX_URL_LENGTH = 2000

ALLOWED_FIELDS = frozenset(
{"name", "date", "end_date", "types", "in_progress", "seizoen", "image", "links"}
)
SEASON_PATTERN = re.compile(r"^\d{2}/\d{2}$")


def parse_iso_date(value: Any) -> date | None:
"""The date for a ``YYYY-MM-DD`` string; None if it isn't one."""
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None


def is_web_url(value: str) -> bool:
return value.startswith(("https://", "http://")) and len(value) <= MAX_URL_LENGTH


def check_name(item: dict[str, Any]) -> list[str]:
name = item.get("name")
if not isinstance(name, str) or not name.strip():
return ["name is required"]
if len(name) > MAX_NAME_LENGTH:
return [f"name is longer than {MAX_NAME_LENGTH} characters"]
return []


def check_dates(item: dict[str, Any]) -> list[str]:
"""``date`` is the (first) day of the event. ``end_date`` is optional, for
events spanning several days (e.g. a trainingsweekend), and can't be
earlier than ``date``."""
problems: list[str] = []
start = parse_iso_date(item.get("date"))
if start is None:
problems.append("date must be YYYY-MM-DD")
if "end_date" in item:
end = parse_iso_date(item["end_date"])
if end is None:
problems.append("end_date must be YYYY-MM-DD")
elif start is not None and end < start:
problems.append("end_date can't be before date")
return problems


def check_types(item: dict[str, Any]) -> list[str]:
"""An event can be several kinds at once, e.g. Wedstrijd + Gezelligheid."""
types = item.get("types")
if (
isinstance(types, list)
and 1 <= len(types) <= MAX_TYPES
and len(set(types)) == len(types)
and all(t in EVENT_TYPES for t in types)
):
return []
return [
f"types must be 1-{MAX_TYPES} different values from "
f"{', '.join(sorted(EVENT_TYPES))}"
]


def check_flags(item: dict[str, Any]) -> list[str]:
"""``in_progress`` ("Wordt aan gewerkt"): FOCUS is still editing the photos.
``seizoen`` overrides the season derived from the date."""
problems: list[str] = []
if "in_progress" in item and not isinstance(item["in_progress"], bool):
problems.append("in_progress must be true or false")
seizoen = item.get("seizoen")
if seizoen is not None and (
not isinstance(seizoen, str) or not SEASON_PATTERN.match(seizoen)
):
problems.append("seizoen must look like 25/26")
return problems


def check_image(item: dict[str, Any]) -> list[str]:
"""Images are resized in the browser and stored inline as a data URL; the
collection's max item size keeps them small."""
image = item.get("image")
if image is not None and (
not isinstance(image, str)
or not (image.startswith("data:image/") or is_web_url(image))
):
return ["image must be an image data URL or a web URL"]
return []


def is_valid_link(link: Any) -> bool:
return (
isinstance(link, dict)
and set(link) == {"label", "url"}
and isinstance(link["label"], str)
and bool(link["label"].strip())
and len(link["label"]) <= MAX_LINK_LABEL_LENGTH
and isinstance(link["url"], str)
and is_web_url(link["url"])
)


def check_links(item: dict[str, Any]) -> list[str]:
links = item.get("links", [])
if not isinstance(links, list) or len(links) > MAX_LINKS:
return [f"links must be a list of at most {MAX_LINKS} links"]
if not all(is_valid_link(link) for link in links):
return ["each link needs a short label and a web url"]
return []


def validate_foto_card(item: dict[str, Any]) -> list[str]:
"""Return the problems with a card's fields; an empty list means valid.

``item`` is the card without the server-owned ``id`` and ``created_at``.
"""
problems: list[str] = []
unknown = set(item) - ALLOWED_FIELDS
if unknown:
problems.append(f"unknown fields: {', '.join(sorted(unknown))}")
for check in (
check_name,
check_dates,
check_types,
check_flags,
check_image,
check_links,
):
problems.extend(check(item))
return problems
Loading
Loading