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.
This commit is contained in:
2026-09-04 17:56:55 -04:00
parent c458b8e64d
commit e52cc60fcf
4 changed files with 411 additions and 0 deletions
+103
View File
@@ -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)):
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
"</div></body></html>" % (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
+249
View File
@@ -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)