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:
@@ -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)
|
||||
Reference in New Issue
Block a user