From e52cc60fcf54b423d4b9cee687c07d4c9a7cd7da Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 17:56:55 -0400 Subject: [PATCH] P4: calendar write-back to Radicale, site-owned UIDs only The site becomes a second writer to scouts/site73, as the scoped scoutsite principal (rw on that one collection, denied everywhere else by the rights file). Two rules enforced in calendar_write.py, not left to callers: the site owns only UIDs ending @site73.greenlanescouts73.org and refuses any other before a network call, the same shape as band-cal-sync and @band.us; and with no RADICALE_* configuration every write is a 503, never a silent no-op. The VEVENT layout matches seed.py exactly so the feed reverses it into the row shape the public pages already render: all-day DTEND exclusive, TZID + VTIMEZONE on timed events, 90-minute default for a timed one-day event, noon on the end date for a timed multi-day one, CATEGORIES for the unit, X-SCOUT73-BADGE, 75-octet folding. Reads come from the scout-calendar feed (now carrying uid and recurring); rows the site created and that are not part of a series are marked mine. Writes are logged to auth_events and bust the page cache so a leader sees their event within the feed's minute. DELETE really deletes - the calendar's history is Radicale's git log. Endpoints under calendar:write. tests/smoke_admin.py 78 -> 102. Proven against the real store on a 2036 probe (outside the feed window): create, read back byte-for-byte, replace, delete, second delete 404, foreign UID 403 with no store call, unconfigured 503. --- app/admin_api.py | 103 +++++++++++++++++ app/calendar_write.py | 249 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 5 + tests/smoke_admin.py | 54 +++++++++ 4 files changed, 411 insertions(+) create mode 100644 app/calendar_write.py diff --git a/app/admin_api.py b/app/admin_api.py index 892ad09..d566128 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -48,6 +48,7 @@ from fastapi import APIRouter, Body, Header, HTTPException, Query, Request from fastapi.responses import HTMLResponse import auth +import calendar_write import identity import store @@ -498,3 +499,105 @@ def api_docs(request: Request, x_admin_token: str = Header(None)): "%s
MethodPathNeedsQuery / path paramsNotes
" "" % (mine, rows)) return HTMLResponse(body) + + +# ---------------------------------------------------------------------------- +# Calendar write-back (P4). The site becomes a second writer to Radicale, +# owning only UIDs ending calendar_write.UID_SUFFIX. Reads come from the +# scout-calendar feed (fetched fresh here, not from the page cache), so the +# list is the same rows the public site renders plus uid and ownership. +# ---------------------------------------------------------------------------- + +def _feed_rows(): + import app as main_app + try: + rows = main_app._fetch_feed() + except Exception as e: + raise HTTPException(502, "calendar feed unreachable: %s" % e) + for r in rows: + r["mine"] = calendar_write.owns(r.get("uid")) and not r.get("recurring") + return rows + + +@router.get("/calendar") +def list_calendar(request: Request, x_admin_token: str = Header(None)): + """Every event the public calendar shows, newest first is NOT the order: + the feed's own date order. `mine` marks rows the site created and may + edit or delete; everything else is read-only here and edited in a + CalDAV client. `configured` says whether writes are possible at all.""" + _auth(request, x_admin_token, "calendar:write") + return {"events": _feed_rows(), "configured": calendar_write.configured(), + "uid_suffix": calendar_write.UID_SUFFIX} + + +@router.post("/calendar", status_code=201) +def create_event(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)): + """Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both), + optional end, time (HH:MM 24h), end_time, location, badge, description. + No time = all-day. A timed one-day event with no end_time lasts 90 min.""" + actor = _auth(request, x_admin_token, "calendar:write") + try: + ev = calendar_write.clean(payload) + uid = calendar_write.new_uid() + calendar_write.put_event(uid, ev) + except calendar_write.CalendarRejected as e: + raise HTTPException(e.status, e.detail) + identity.log_event("calendar.created", email=actor if "@" in actor else None, + detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"])) + _bust_site_cache() + return {"uid": uid, "event": _serial(ev)} + + +@router.put("/calendar/{uid}") +def replace_event(request: Request, uid: str, payload: dict = Body(...), x_admin_token: str = Header(None)): + """Replace one site-owned event in full (same body as POST). A UID the + site does not own is 403 before anything is sent to the store.""" + actor = _auth(request, x_admin_token, "calendar:write") + if not calendar_write.owns(uid): + raise HTTPException(403, "the site only manages events it created") + try: + ev = calendar_write.clean(payload) + calendar_write.put_event(uid, ev) + except calendar_write.CalendarRejected as e: + raise HTTPException(e.status, e.detail) + identity.log_event("calendar.updated", email=actor if "@" in actor else None, + detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"])) + _bust_site_cache() + return {"uid": uid, "event": _serial(ev)} + + +@router.delete("/calendar/{uid}") +def delete_event(request: Request, uid: str, x_admin_token: str = Header(None)): + """Remove one site-owned event from the store. Unlike everything else on + this API this really deletes: the calendar's history is Radicale's git + log, not a revoked_at column.""" + actor = _auth(request, x_admin_token, "calendar:write") + if not calendar_write.owns(uid): + raise HTTPException(403, "the site only manages events it created") + try: + status = calendar_write.delete_event(uid) + except calendar_write.CalendarRejected as e: + raise HTTPException(e.status, e.detail) + if status == 404: + raise HTTPException(404, "no such event in the store") + identity.log_event("calendar.deleted", email=actor if "@" in actor else None, detail=uid) + _bust_site_cache() + return {"uid": uid, "deleted": True} + + +def _serial(ev): + out = dict(ev) + out["date"] = ev["date"].isoformat() + out["end"] = ev["end"].isoformat() if ev["end"] else None + return out + + +def _bust_site_cache(): + """The public pages cache the feed for five minutes; a leader who just + posted an event should not wait that long to see it. The feed itself + refreshes within a minute.""" + try: + import app as main_app + main_app._feed_state["fetched"] = 0.0 + except Exception: + pass diff --git a/app/calendar_write.py b/app/calendar_write.py new file mode 100644 index 0000000..5306f54 --- /dev/null +++ b/app/calendar_write.py @@ -0,0 +1,249 @@ +""" +calendar_write.py - the site's write-back to the public calendar (P4). + +Radicale collection scouts/site73 is the source of truth for the public +calendar; the scout-calendar feed converts it to the JSON the site reads. +This module is the one place the site WRITES to that collection, over +CalDAV, as the scoped `scoutsite` principal. Two rules, both enforced here +and not left to callers: + + 1. The site owns only UIDs ending UID_SUFFIX and touches nothing else. + band-cal-sync owns @band.us the same way; the seeded events and anything + Mike adds in a CalDAV client stay outside the site's reach. A wrong + UID is a 403 before any network call. + 2. Fail closed. With no RADICALE_* configuration every write is a 503, + never a silent no-op that reads as success. + +The VEVENT layout matches projects/scout-calendar/seed.py exactly (all-day +DTEND exclusive, TZID + VTIMEZONE on timed events, CATEGORIES for the unit, +X-SCOUT73-BADGE, 75-octet folding) so the feed reverses it into the same +row shape the site already renders. Stdlib only, like the rest of the app. +""" + +import base64 +import datetime +import hashlib +import os +import urllib.error +import urllib.request +import uuid + +UID_SUFFIX = "@site73.greenlanescouts73.org" +TZID = "America/New_York" +UNITS = ("pack", "troop", "both") +DEFAULT_TIMED_MINUTES = 90 +TIMEOUT = 8 + +RADICALE_URL = (os.environ.get("RADICALE_URL") or "").strip() +RADICALE_USER = (os.environ.get("RADICALE_USER") or "").strip() +RADICALE_PASS = os.environ.get("RADICALE_PASS") or "" + +VTIMEZONE_NY = """BEGIN:VTIMEZONE +TZID:America/New_York +X-LIC-LOCATION:America/New_York +BEGIN:DAYLIGHT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE""".split("\n") + + +class CalendarRejected(Exception): + def __init__(self, status, detail): + super().__init__(detail) + self.status = status + self.detail = detail + + +def configured(): + return bool(RADICALE_URL and RADICALE_USER and RADICALE_PASS) + + +def owns(uid): + return bool(uid) and str(uid).endswith(UID_SUFFIX) + + +def new_uid(): + return str(uuid.uuid4()) + UID_SUFFIX + + +def slug(uid): + """Stable object name per UID. The site- prefix marks ownership on disk + the way band- does for the BAND mirror.""" + return "site-" + hashlib.sha1(uid.encode("utf-8")).hexdigest()[:16] + ".ics" + + +# --------------------------------------------------------------------------- +# Building the object +# --------------------------------------------------------------------------- + +def esc(v): + return (str(v).replace("\\", "\\\\").replace(";", "\\;") + .replace(",", "\\,").replace("\n", "\\n")) + + +def fold(line): + """RFC 5545 folding at 75 OCTETS.""" + enc = line.encode("utf-8") + if len(enc) <= 75: + return [line] + out, cur, size = [], "", 0 + for ch in line: + w = len(ch.encode("utf-8")) + if size + w > 75: + out.append(cur) + cur, size = " " + ch, 1 + w + else: + cur += ch + size += w + if cur: + out.append(cur) + return out + + +def _date(v, what): + try: + return datetime.date.fromisoformat(str(v).strip()) + except (TypeError, ValueError): + raise CalendarRejected(422, "%s must be a YYYY-MM-DD date" % what) + + +def _clock(v, what): + try: + t = datetime.datetime.strptime(str(v).strip(), "%H:%M") + except (TypeError, ValueError): + raise CalendarRejected(422, '%s must be 24h "HH:MM"' % what) + return t.hour, t.minute + + +def clean(fields): + """Validate and normalise an event payload. Returns the dict the builder + uses. Blank strings are treated as absent.""" + f = {k: (v.strip() if isinstance(v, str) else v) for k, v in (fields or {}).items()} + f = {k: (None if v == "" else v) for k, v in f.items()} + title = f.get("title") + if not title: + raise CalendarRejected(422, "title is required") + if len(title) > 120: + raise CalendarRejected(422, "title is over 120 characters") + unit = (f.get("unit") or "pack").lower() + if unit not in UNITS: + raise CalendarRejected(422, "unit must be one of %s" % (UNITS,)) + start = _date(f.get("date"), "date") + end = _date(f["end"], "end") if f.get("end") else None + if end is not None and end < start: + raise CalendarRejected(422, "end must be on or after date") + if end == start: + end = None + time_ = f.get("time") + end_time = f.get("end_time") + if end_time and not time_: + raise CalendarRejected(422, "end_time needs a start time") + if time_: + h, m = _clock(time_, "time") + time_ = "%02d:%02d" % (h, m) + if end_time: + eh, em = _clock(end_time, "end_time") + end_time = "%02d:%02d" % (eh, em) + if end is None and (eh, em) <= (h, m): + raise CalendarRejected(422, "end_time must be after time on a one-day event") + for k, cap in (("location", 160), ("badge", 40), ("description", 2000)): + if f.get(k) and len(str(f[k])) > cap: + raise CalendarRejected(422, "%s is over %d characters" % (k, cap)) + return {"title": title, "unit": unit, "date": start, "end": end, "time": time_, + "end_time": end_time, "location": f.get("location"), "badge": f.get("badge"), + "description": f.get("description")} + + +def build_ics(uid, ev, stamp=None): + """The complete text/calendar object for one event. `ev` is clean()'s + output. Returns bytes with CRLF line ends.""" + stamp = stamp or datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + start, end = ev["date"], ev["end"] + lines = ["BEGIN:VEVENT", "UID:%s" % uid, "DTSTAMP:%s" % stamp, "SUMMARY:%s" % esc(ev["title"])] + timed = bool(ev["time"]) + if timed: + h, m = _clock(ev["time"], "time") + lines.append("DTSTART;TZID=%s:%s" % (TZID, "%sT%02d%02d00" % (start.strftime("%Y%m%d"), h, m))) + if end is not None: + if ev["end_time"]: + eh, em = _clock(ev["end_time"], "end_time") + lines.append("DTEND;TZID=%s:%sT%02d%02d00" % (TZID, end.strftime("%Y%m%d"), eh, em)) + else: + lines.append("DTEND;TZID=%s:%sT120000" % (TZID, end.strftime("%Y%m%d"))) + else: + if ev["end_time"]: + eh, em = _clock(ev["end_time"], "end_time") + dt_end = datetime.datetime.combine(start, datetime.time(eh, em)) + else: + dt_end = (datetime.datetime.combine(start, datetime.time(h, m)) + + datetime.timedelta(minutes=DEFAULT_TIMED_MINUTES)) + lines.append("DTEND;TZID=%s:%s" % (TZID, dt_end.strftime("%Y%m%dT%H%M00"))) + else: + lines.append("DTSTART;VALUE=DATE:%s" % start.strftime("%Y%m%d")) + last = end or start + lines.append("DTEND;VALUE=DATE:%s" % (last + datetime.timedelta(days=1)).strftime("%Y%m%d")) + if ev.get("location"): + lines.append("LOCATION:%s" % esc(ev["location"])) + if ev.get("description"): + lines.append("DESCRIPTION:%s" % esc(ev["description"])) + lines.append("CATEGORIES:%s" % esc(ev["unit"])) + if ev.get("badge"): + lines.append("X-SCOUT73-BADGE:%s" % esc(ev["badge"])) + lines.append("END:VEVENT") + body = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//greenlanescouts73.org//site-calendar-write//EN"] + if timed: + body += VTIMEZONE_NY + body += lines + ["END:VCALENDAR"] + out = [] + for ln in body: + out.extend(fold(ln)) + return ("\r\n".join(out) + "\r\n").encode("utf-8") + + +# --------------------------------------------------------------------------- +# CalDAV +# --------------------------------------------------------------------------- + +def _request(method, uid, data=None): + if not configured(): + raise CalendarRejected(503, "calendar write-back is not configured (RADICALE_URL/USER/PASS)") + if not owns(uid): + raise CalendarRejected(403, "the site only manages events it created (UIDs ending %s)" % UID_SUFFIX) + url = RADICALE_URL.rstrip("/") + "/" + slug(uid) + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Basic " + base64.b64encode( + ("%s:%s" % (RADICALE_USER, RADICALE_PASS)).encode()).decode()) + if data is not None: + req.add_header("Content-Type", "text/calendar; charset=utf-8") + try: + with _urlopen(req, timeout=TIMEOUT) as resp: + return resp.status + except urllib.error.HTTPError as e: + if method == "DELETE" and e.code == 404: + return 404 + raise CalendarRejected(502, "calendar store answered %d on %s" % (e.code, method)) + except Exception as e: + raise CalendarRejected(502, "calendar store unreachable: %s" % e) + + +# Seam for tests. +_urlopen = urllib.request.urlopen + + +def put_event(uid, ev): + return _request("PUT", uid, build_ics(uid, ev)) + + +def delete_event(uid): + return _request("DELETE", uid) diff --git a/docker-compose.yml b/docker-compose.yml index 34198f9..415b819 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,11 @@ services: # forwarded here; adding it to the stack alone is not enough. - ADMIN_BOOTSTRAP_EMAIL=${ADMIN_BOOTSTRAP_EMAIL} - SITE_BASE_URL=${SITE_BASE_URL} + # P4 calendar write-back. The scoped `scoutsite` Radicale principal, rw + # on scouts/site73 only. Unset = every calendar write is a 503. + - RADICALE_URL=${RADICALE_URL} + - RADICALE_USER=${RADICALE_USER} + - RADICALE_PASS=${RADICALE_PASS} volumes: - /srv/scout-website/data:/data - /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 508992e..05910e6 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -208,6 +208,60 @@ def _tok(req): check("admin token from the LAN passes", _tok(_Req("10.0.0.55")) == 200) check("admin token from outside is 403, regardless of the setting", _tok(_Req("108.36.248.87")) == 403) +print("\ncalendar write-back") +import calendar_write as C +raises("title required", 422, C.CalendarRejected, C.clean, {"date": "2026-10-03"}) +raises("date required", 422, C.CalendarRejected, C.clean, {"title": "x"}) +raises("bad unit", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "unit": "den"}) +raises("end before start", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "end": "2026-10-02"}) +raises("bad time", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "time": "1pm"}) +raises("end_time without time", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "end_time": "14:00"}) +raises("end_time before time same day", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "time": "14:00", "end_time": "13:00"}) +ev = C.clean({"title": "Pack Hike; again", "unit": "Pack", "date": "2026-10-03", "end": "2026-10-03", "time": "13:00", + "location": "Foy Park", "badge": "", "description": "Bring water,\nand a hat"}) +check("clean normalises: unit lower, same-day end dropped, blank badge absent", + ev["unit"] == "pack" and ev["end"] is None and ev["badge"] is None and ev["time"] == "13:00") +uid = "t1" + C.UID_SUFFIX +ics = C.build_ics(uid, ev, stamp="20260904T000000Z").decode() +check("timed event carries VTIMEZONE and TZID start, 90-minute default end", + "BEGIN:VTIMEZONE" in ics and "DTSTART;TZID=America/New_York:20261003T130000" in ics + and "DTEND;TZID=America/New_York:20261003T143000" in ics) +check("text is escaped and newlines encoded", "SUMMARY:Pack Hike\\; again" in ics and "DESCRIPTION:Bring water\\,\\nand a hat" in ics) +check("unit is CATEGORIES, crlf line ends", "CATEGORIES:pack" in ics and ics.endswith("END:VCALENDAR\r\n") + and ics.count("\n") == ics.count("\r\n")) +allday = C.build_ics(uid, C.clean({"title": "Fall Campout", "unit": "both", "date": "2026-10-17", "end": "2026-10-18", "badge": "OVERNIGHT"}), stamp="20260904T000000Z").decode() +check("all-day multi-day: VALUE=DATE with exclusive end, badge, no VTIMEZONE", + "DTSTART;VALUE=DATE:20261017" in allday and "DTEND;VALUE=DATE:20261019" in allday + and "X-SCOUT73-BADGE:OVERNIGHT" in allday and "VTIMEZONE" not in allday) +multi = C.build_ics(uid, C.clean({"title": "x", "date": "2026-10-17", "end": "2026-10-18", "time": "16:00"}), stamp="20260904T000000Z").decode() +check("timed multi-day with no end_time ends at noon on the end date, as seed.py did", + "DTEND;TZID=America/New_York:20261018T120000" in multi) +longt = C.build_ics(uid, C.clean({"title": "A" * 120, "date": "2026-10-03"}), stamp="20260904T000000Z").decode() +check("long lines are folded at 75 octets", all(len(l.encode()) <= 75 for l in longt.split("\r\n"))) +check("ownership by suffix", C.owns("x" + C.UID_SUFFIX) and not C.owns("site73-abc@greenlanescouts73.org") + and not C.owns("x@band.us") and not C.owns(None)) +check("new uids are owned and unique", C.owns(C.new_uid()) and C.new_uid() != C.new_uid()) +check("slug is stable and marked", C.slug("a" + C.UID_SUFFIX).startswith("site-") and C.slug("a" + C.UID_SUFFIX) == C.slug("a" + C.UID_SUFFIX)) +# fail closed and never touch a foreign uid, with the transport watched +calls = [] +class _Resp: + status = 201 + def __enter__(self): return self + def __exit__(self, *a): return False +def _fake(req, timeout=None): + calls.append((req.get_method(), req.full_url, req.get_header("Authorization") is not None)); return _Resp() +C._urlopen = _fake +C.RADICALE_URL = ""; C.RADICALE_USER = ""; C.RADICALE_PASS = "" +raises("unconfigured put is 503", 503, C.CalendarRejected, C.put_event, uid, ev) +check("and nothing was sent", calls == []) +C.RADICALE_URL = "http://radicale.test/scouts/site73/"; C.RADICALE_USER = "scoutsite"; C.RADICALE_PASS = "p" +raises("foreign uid put is 403", 403, C.CalendarRejected, C.put_event, "site73-abc@greenlanescouts73.org", ev) +raises("foreign uid delete is 403", 403, C.CalendarRejected, C.delete_event, "x@band.us") +check("still nothing sent for foreign uids", calls == []) +check("owned put goes to the slug with auth", C.put_event(uid, ev) == 201 and calls[-1][0] == "PUT" + and calls[-1][1] == "http://radicale.test/scouts/site73/" + C.slug(uid) and calls[-1][2]) +check("owned delete", C.delete_event(uid) == 201 and calls[-1][0] == "DELETE") + print("\napi docs registry") import admin_api as A reg = A.describe_routes()