This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / book / book.py
46 kB 1159 lines
1"""book: a minimal request-and-approve booking page backed by CalDAV. 2 3Guests pick a free slot and send a request. The request is held in SQLite, 4mirrored into the owner's CalDAV calendar as a TENTATIVE event and mailed to 5the owner with an approve/decline link. Approving turns the event CONFIRMED 6and mails the guest an invitation; declining, cancelling or letting a request 7run past its start time removes the event again. 8 9Free/busy comes from every calendar in the CalDAV user's home collection plus 10the held requests themselves. If the calendar cannot be read, no slots are 11offered: an unreachable calendar must never look like an empty one. 12 13The calendar is also a control surface: every minute the events of bookings 14that have not started yet are read back. Deleting (or cancelling) one cancels 15an approved booking or declines a request, moving one reschedules it, and 16confirming a tentative request approves it -- each with the same mail to the 17guest as the admin page would send. A calendar that cannot be read changes 18nothing. 19 20The only mail a stranger can trigger goes to the owner. Guests receive mail 21only after the owner acted on their request, so the form cannot be abused to 22send mail to arbitrary addresses. 23 24With --busy-token-file, /busy/<token>.ics serves the same busy time (calendar 25plus held requests) as an iCalendar feed of merged intervals that carry 26nothing but their times. 27""" 28 29import argparse 30import base64 31import hashlib 32import json 33import logging 34import os 35import re 36import secrets 37import smtplib 38import sqlite3 39import threading 40import time 41import urllib.error 42import urllib.request 43import xml.etree.ElementTree as ET 44from collections import defaultdict, deque 45from dataclasses import dataclass 46from datetime import date, datetime, timedelta, timezone 47from email.message import EmailMessage 48from email.utils import formataddr, formatdate, make_msgid 49from http import HTTPStatus 50from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 51from pathlib import Path 52from urllib.parse import parse_qs, quote, urlencode, urlsplit 53from zoneinfo import ZoneInfo, available_timezones 54 55import icalendar 56import jinja2 57import recurring_ical_events 58 59log = logging.getLogger("book") 60 61UTC = timezone.utc 62WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") 63TIMEZONES = sorted(available_timezones()) 64SLOT_FORMAT = "%Y%m%dT%H%M%SZ" 65EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") 66TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{20,64}$") 67MAX_BODY = 16 * 1024 68LIMITS = {"name": 100, "email": 254, "note": 2000, "message": 2000} 69# The busy feed covers [now - BUSY_PAST, now + BUSY_AHEAD). 70BUSY_PAST = timedelta(days=7) 71BUSY_AHEAD = timedelta(days=90) 72 73DAV = "DAV:" 74CALDAV = "urn:ietf:params:xml:ns:caldav" 75 76 77# --- configuration ----------------------------------------------------------- 78 79 80@dataclass(frozen=True) 81class EventType: 82 slug: str 83 title: str 84 minutes: int 85 description: str 86 87 88@dataclass(frozen=True) 89class Config: 90 listen_host: str 91 listen_port: int 92 base_url: str 93 owner: str 94 intro: str 95 tz: ZoneInfo 96 event_types: tuple[EventType, ...] 97 hours: dict[int, tuple[tuple[int, int], ...]] # weekday -> (start, end) minutes 98 slot_step: int 99 buffer: int 100 min_notice: timedelta 101 horizon: timedelta 102 caldav_url: str 103 caldav_user: str 104 caldav_password: str 105 caldav_calendar: str 106 smtp_host: str 107 smtp_port: int 108 mail_from: str 109 notify: str 110 db: str 111 templates: str 112 static: str 113 114 def event_type(self, slug: str) -> EventType | None: 115 return next((e for e in self.event_types if e.slug == slug), None) 116 117 118def parse_hhmm(s: str) -> int: 119 h, m = s.split(":") 120 minutes = int(h) * 60 + int(m) 121 if not 0 <= minutes <= 24 * 60: 122 raise ValueError(f"time out of range: {s}") 123 return minutes 124 125 126def load_config(path: str, password_file: str, templates: str, static: str) -> Config: 127 raw = json.loads(Path(path).read_text()) 128 host, _, port = raw["listen"].rpartition(":") 129 hours: dict[int, tuple[tuple[int, int], ...]] = {} 130 for day, ranges in raw["hours"].items(): 131 spans = [] 132 for r in ranges: 133 a, b = (parse_hhmm(x) for x in r.split("-")) 134 if a >= b: 135 raise ValueError(f"empty working-hours range {r} on {day}") 136 spans.append((a, b)) 137 hours[WEEKDAYS.index(day)] = tuple(sorted(spans)) 138 event_types = tuple( 139 EventType(e["slug"], e["title"], int(e["minutes"]), e.get("description", "")) 140 for e in raw["eventTypes"] 141 ) 142 for e in event_types: 143 if not re.fullmatch(r"[a-z0-9-]+", e.slug) or e.slug in ("a", "r"): 144 raise ValueError(f"invalid event type slug: {e.slug}") 145 if not event_types: 146 raise ValueError("no event types configured") 147 caldav = raw["caldav"] 148 smtp = raw["smtp"] 149 return Config( 150 listen_host=host, 151 listen_port=int(port), 152 base_url=raw["baseUrl"].rstrip("/"), 153 owner=raw["owner"], 154 intro=raw.get("intro", ""), 155 tz=ZoneInfo(raw["timezone"]), 156 event_types=event_types, 157 hours=hours, 158 slot_step=int(raw.get("slotStep", 30)), 159 buffer=int(raw.get("bufferMinutes", 0)), 160 min_notice=timedelta(hours=float(raw.get("minNoticeHours", 24))), 161 horizon=timedelta(days=int(raw.get("horizonDays", 28))), 162 caldav_url=caldav["url"].rstrip("/"), 163 caldav_user=caldav["user"], 164 caldav_password=Path(password_file).read_text().strip(), 165 caldav_calendar=caldav.get("calendar", "bookings"), 166 smtp_host=smtp.get("host", "127.0.0.1"), 167 smtp_port=int(smtp.get("port", 25)), 168 mail_from=smtp["from"], 169 notify=smtp["notify"], 170 db=raw["database"], 171 templates=templates, 172 static=static, 173 ) 174 175 176# --- time helpers ------------------------------------------------------------ 177 178 179def now_utc() -> datetime: 180 return datetime.now(UTC).replace(microsecond=0) 181 182 183def to_utc(value: date | datetime, tz: ZoneInfo) -> datetime: 184 """Normalise an iCalendar DATE/DATE-TIME to aware UTC. 185 186 All-day dates start at local midnight; floating times are the owner's. 187 """ 188 if not isinstance(value, datetime): 189 value = datetime(value.year, value.month, value.day) 190 if value.tzinfo is None: 191 value = value.replace(tzinfo=tz) 192 return value.astimezone(UTC) 193 194 195def epoch(dt: datetime) -> int: 196 return int(dt.timestamp()) 197 198 199def from_epoch(ts: int) -> datetime: 200 return datetime.fromtimestamp(ts, UTC) 201 202 203def overlaps(a0: datetime, a1: datetime, intervals: list[tuple[datetime, datetime]]) -> bool: 204 return any(a0 < b1 and b0 < a1 for b0, b1 in intervals) 205 206 207def booking_event(data: bytes, uid: str) -> icalendar.Event: 208 """The booking's VEVENT (not an overridden occurrence) from its calendar object. 209 210 ValueError when the object cannot be read as one: that is never a deletion. 211 """ 212 cal = icalendar.Calendar.from_ical(data) 213 for ev in cal.walk("VEVENT"): 214 if str(ev.get("UID", "")) == uid and "RECURRENCE-ID" not in ev: 215 return ev 216 raise ValueError(f"no VEVENT with UID {uid}") 217 218 219# --- CalDAV ------------------------------------------------------------------ 220 221 222class CalDAVError(Exception): 223 pass 224 225 226class CalDAV: 227 """Just enough CalDAV for one user's home: list, query, get, put, delete.""" 228 229 def __init__(self, cfg: Config): 230 self.base = cfg.caldav_url 231 self.home = f"/{quote(cfg.caldav_user)}/" 232 self.calendar = f"{self.home}{quote(cfg.caldav_calendar)}/" 233 token = base64.b64encode(f"{cfg.caldav_user}:{cfg.caldav_password}".encode()).decode() 234 self.auth = f"Basic {token}" 235 self.tz = cfg.tz 236 self._ensured = False 237 238 def _request( 239 self, 240 method: str, 241 path: str, 242 body: bytes | None = None, 243 headers: dict[str, str] | None = None, 244 accept: tuple[int, ...] = (), 245 ) -> tuple[int, bytes]: 246 req = urllib.request.Request(self.base + path, data=body, method=method) 247 req.add_header("Authorization", self.auth) 248 for k, v in (headers or {}).items(): 249 req.add_header(k, v) 250 try: 251 with urllib.request.urlopen(req, timeout=10) as resp: 252 return resp.status, resp.read() 253 except urllib.error.HTTPError as e: 254 if e.code in accept: 255 return e.code, e.read() 256 raise CalDAVError(f"{method} {path}: HTTP {e.code}") from e 257 except (urllib.error.URLError, TimeoutError, OSError) as e: 258 raise CalDAVError(f"{method} {path}: {e}") from e 259 260 def _xml(self, method: str, path: str, depth: str, body: str, accept=()) -> tuple[int, ET.Element | None]: 261 status, data = self._request( 262 method, 263 path, 264 body.encode(), 265 {"Depth": depth, "Content-Type": "application/xml; charset=utf-8"}, 266 accept, 267 ) 268 if status >= 300 or not data.strip(): 269 return status, None 270 try: 271 return status, ET.fromstring(data) 272 except ET.ParseError as e: 273 raise CalDAVError(f"{method} {path}: unparseable response: {e}") from e 274 275 def ensure_calendar(self) -> None: 276 if self._ensured: 277 return 278 status, _ = self._xml( 279 "PROPFIND", 280 self.calendar, 281 "0", 282 f'<D:propfind xmlns:D="{DAV}"><D:prop><D:resourcetype/></D:prop></D:propfind>', 283 accept=(404,), 284 ) 285 if status == 404: 286 log.info("creating calendar %s", self.calendar) 287 self._xml( 288 "MKCALENDAR", 289 self.calendar, 290 "0", 291 f'<C:mkcalendar xmlns:D="{DAV}" xmlns:C="{CALDAV}"><D:set><D:prop>' 292 "<D:displayname>Bookings</D:displayname>" 293 '<C:supported-calendar-component-set><C:comp name="VEVENT"/>' 294 "</C:supported-calendar-component-set></D:prop></D:set></C:mkcalendar>", 295 ) 296 self._ensured = True 297 298 def calendars(self) -> list[str]: 299 _, root = self._xml( 300 "PROPFIND", 301 self.home, 302 "1", 303 f'<D:propfind xmlns:D="{DAV}" xmlns:C="{CALDAV}"><D:prop><D:resourcetype/>' 304 "<C:supported-calendar-component-set/></D:prop></D:propfind>", 305 ) 306 found = [] 307 for resp in root.iter(f"{{{DAV}}}response") if root is not None else (): 308 href = resp.findtext(f"{{{DAV}}}href") 309 if not href or resp.find(f".//{{{DAV}}}resourcetype/{{{CALDAV}}}calendar") is None: 310 continue 311 comps = [c.get("name") for c in resp.iter(f"{{{CALDAV}}}comp")] 312 if comps and "VEVENT" not in comps: 313 continue 314 found.append(urlsplit(href).path) 315 return found 316 317 def busy(self, start: datetime, end: datetime) -> list[tuple[datetime, datetime]]: 318 """Opaque, non-cancelled event occurrences in [start, end), in UTC.""" 319 span = ( 320 f'<C:time-range start="{start.strftime(SLOT_FORMAT)}" end="{end.strftime(SLOT_FORMAT)}"/>' 321 ) 322 query = ( 323 f'<C:calendar-query xmlns:D="{DAV}" xmlns:C="{CALDAV}"><D:prop><C:calendar-data/></D:prop>' 324 '<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">' 325 f"{span}</C:comp-filter></C:comp-filter></C:filter></C:calendar-query>" 326 ) 327 out: list[tuple[datetime, datetime]] = [] 328 for path in self.calendars(): 329 _, root = self._xml("REPORT", path, "1", query) 330 for data in root.iter(f"{{{CALDAV}}}calendar-data") if root is not None else (): 331 if not data.text: 332 continue 333 try: 334 cal = icalendar.Calendar.from_ical(data.text) 335 occurrences = recurring_ical_events.of(cal, skip_bad_series=True).between(start, end) 336 except Exception: 337 log.exception("unparseable event in %s", path) 338 continue 339 for ev in occurrences: 340 if str(ev.get("TRANSP", "OPAQUE")).upper() == "TRANSPARENT": 341 continue 342 if str(ev.get("STATUS", "")).upper() == "CANCELLED": 343 continue 344 try: 345 out.append((to_utc(ev.start, self.tz), to_utc(ev.end, self.tz))) 346 except Exception: 347 log.exception("event without usable start/end in %s", path) 348 return out 349 350 def put(self, uid: str, ics: bytes) -> None: 351 self.ensure_calendar() 352 self._request( 353 "PUT", 354 f"{self.calendar}{quote(uid)}.ics", 355 ics, 356 {"Content-Type": "text/calendar; charset=utf-8"}, 357 ) 358 359 def delete(self, uid: str) -> None: 360 self._request("DELETE", f"{self.calendar}{quote(uid)}.ics", accept=(404,)) 361 362 def get(self, uid: str) -> bytes | None: 363 """The booking's calendar object, None once it is gone from the calendar.""" 364 status, data = self._request("GET", f"{self.calendar}{quote(uid)}.ics", accept=(404,)) 365 return None if status == 404 else data 366 367 368# --- storage ----------------------------------------------------------------- 369 370 371SCHEMA = """ 372CREATE TABLE IF NOT EXISTS bookings ( 373 id INTEGER PRIMARY KEY, 374 uid TEXT NOT NULL UNIQUE, 375 event TEXT NOT NULL, 376 title TEXT NOT NULL, 377 start INTEGER NOT NULL, 378 end INTEGER NOT NULL, 379 name TEXT NOT NULL, 380 email TEXT NOT NULL, 381 note TEXT NOT NULL, 382 guest_tz TEXT NOT NULL, 383 status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'declined', 'cancelled', 'expired')), 384 guest_token TEXT NOT NULL UNIQUE, 385 admin_token TEXT NOT NULL UNIQUE, 386 sequence INTEGER NOT NULL DEFAULT 0, 387 created INTEGER NOT NULL, 388 updated INTEGER NOT NULL, 389 ip TEXT NOT NULL 390); 391CREATE INDEX IF NOT EXISTS bookings_active ON bookings (status, start); 392""" 393 394ACTIVE = ("pending", "approved") 395 396 397class Store: 398 """One connection behind one lock; traffic here is a handful of requests a day.""" 399 400 def __init__(self, path: str): 401 self.lock = threading.RLock() 402 self.db = sqlite3.connect(path, check_same_thread=False, isolation_level=None) 403 self.db.row_factory = sqlite3.Row 404 self.db.execute("PRAGMA journal_mode=WAL") 405 self.db.executescript(SCHEMA) 406 407 def holds(self, start: datetime, end: datetime) -> list[tuple[datetime, datetime]]: 408 with self.lock: 409 rows = self.db.execute( 410 "SELECT start, end FROM bookings WHERE status IN (?, ?) AND end > ? AND start < ?", 411 (*ACTIVE, epoch(start), epoch(end)), 412 ).fetchall() 413 return [(from_epoch(r["start"]), from_epoch(r["end"])) for r in rows] 414 415 def by_token(self, column: str, token: str) -> sqlite3.Row | None: 416 assert column in ("guest_token", "admin_token") 417 with self.lock: 418 return self.db.execute(f"SELECT * FROM bookings WHERE {column} = ?", (token,)).fetchone() 419 420 def count(self, where: str, args: tuple) -> int: 421 with self.lock: 422 return self.db.execute(f"SELECT count(*) FROM bookings WHERE {where}", args).fetchone()[0] 423 424 def insert(self, **row) -> None: 425 cols = ", ".join(row) 426 marks = ", ".join("?" for _ in row) 427 with self.lock: 428 self.db.execute(f"INSERT INTO bookings ({cols}) VALUES ({marks})", tuple(row.values())) 429 430 def set_status(self, booking_id: int, status: str) -> None: 431 with self.lock: 432 self.db.execute( 433 "UPDATE bookings SET status = ?, sequence = sequence + 1, updated = ? WHERE id = ?", 434 (status, epoch(now_utc()), booking_id), 435 ) 436 437 def get(self, booking_id: int) -> sqlite3.Row: 438 with self.lock: 439 return self.db.execute("SELECT * FROM bookings WHERE id = ?", (booking_id,)).fetchone() 440 441 def overdue(self, now: datetime) -> list[sqlite3.Row]: 442 with self.lock: 443 return self.db.execute( 444 "SELECT * FROM bookings WHERE status = 'pending' AND start <= ?", (epoch(now),) 445 ).fetchall() 446 447 def upcoming(self, now: datetime) -> list[sqlite3.Row]: 448 """Active bookings that have not started yet: the ones the calendar can still change. 449 450 Started ones are left alone, so tidying up a running or past meeting in 451 the calendar never mails the guest. 452 """ 453 with self.lock: 454 return self.db.execute( 455 "SELECT * FROM bookings WHERE status IN (?, ?) AND start > ?", (*ACTIVE, epoch(now)) 456 ).fetchall() 457 458 def reschedule(self, booking_id: int, start: datetime, end: datetime) -> None: 459 with self.lock: 460 self.db.execute( 461 "UPDATE bookings SET start = ?, end = ?, sequence = sequence + 1, updated = ? WHERE id = ?", 462 (epoch(start), epoch(end), epoch(now_utc()), booking_id), 463 ) 464 465 466# --- the application --------------------------------------------------------- 467 468 469class Unavailable(Exception): 470 """The calendar could not be read; refuse to guess.""" 471 472 473class App: 474 def __init__(self, cfg: Config): 475 self.cfg = cfg 476 self.cal = CalDAV(cfg) 477 self.store = Store(cfg.db) 478 self.rate: dict[str, deque[float]] = defaultdict(deque) 479 self.jinja = jinja2.Environment( 480 loader=jinja2.FileSystemLoader(cfg.templates), 481 autoescape=jinja2.select_autoescape(["html.j2"]), 482 undefined=jinja2.StrictUndefined, 483 trim_blocks=True, 484 lstrip_blocks=True, 485 ) 486 487 # availability 488 489 def candidates(self, et: EventType, start: datetime, end: datetime) -> list[datetime]: 490 """Slot starts inside working hours, notice and horizon, ignoring busy times.""" 491 cfg = self.cfg 492 now = now_utc() 493 earliest = max(start, now + cfg.min_notice) 494 latest = min(end, now + cfg.horizon) 495 if earliest >= latest: 496 return [] 497 out = [] 498 day = earliest.astimezone(cfg.tz).date() - timedelta(days=1) 499 last = latest.astimezone(cfg.tz).date() 500 while day <= last: 501 for a, b in cfg.hours.get(day.weekday(), ()): 502 m = a 503 while m + et.minutes <= b: 504 local = datetime(day.year, day.month, day.day) + timedelta(minutes=m) 505 slot = local.replace(tzinfo=cfg.tz).astimezone(UTC) 506 # Skip nonexistent local times (DST gap): they do not round-trip. 507 if slot.astimezone(cfg.tz).replace(tzinfo=None) == local and earliest <= slot < latest: 508 out.append(slot) 509 m += cfg.slot_step 510 day += timedelta(days=1) 511 return sorted(set(out)) 512 513 def free_slots(self, et: EventType, start: datetime, end: datetime) -> list[datetime]: 514 slots = self.candidates(et, start, end) 515 if not slots: 516 return [] 517 pad = timedelta(minutes=self.cfg.buffer) 518 length = timedelta(minutes=et.minutes) 519 window = (slots[0] - pad, slots[-1] + length + pad) 520 try: 521 busy = self.cal.busy(*window) 522 except CalDAVError as e: 523 log.error("cannot read calendar: %s", e) 524 raise Unavailable from e 525 busy += self.store.holds(*window) 526 return [s for s in slots if not overlaps(s - pad, s + length + pad, busy)] 527 528 def busy_feed(self) -> bytes: 529 """Busy time around now as a VCALENDAR without any event details. 530 531 Overlapping and adjacent intervals are merged; the UID is a hash of 532 the interval's times. 533 """ 534 now = now_utc() 535 start, end = now - BUSY_PAST, now + BUSY_AHEAD 536 try: 537 busy = self.cal.busy(start, end) 538 except CalDAVError as e: 539 log.error("cannot read calendar: %s", e) 540 raise Unavailable from e 541 busy += self.store.holds(start, end) 542 merged: list[list[datetime]] = [] 543 for a, b in sorted(busy): 544 if merged and a <= merged[-1][1]: 545 merged[-1][1] = max(merged[-1][1], b) 546 elif a < b: 547 merged.append([a, b]) 548 cal = icalendar.Calendar() 549 cal.add("prodid", "-//lassul.us//book busy//EN") 550 cal.add("version", "2.0") 551 for a, b in merged: 552 ev = icalendar.Event() 553 digest = hashlib.sha256(f"{epoch(a)}-{epoch(b)}".encode()).hexdigest() 554 ev.add("uid", f"{digest[:32]}@busy") 555 ev.add("dtstamp", now) 556 ev.add("dtstart", a) 557 ev.add("dtend", b) 558 ev.add("summary", "Busy") 559 ev.add("transp", "OPAQUE") 560 cal.add_component(ev) 561 return cal.to_ical() 562 563 # mail 564 565 def send( 566 self, 567 to: str, 568 subject: str, 569 body: str, 570 reply_to: str | None = None, 571 ics: bytes | None = None, 572 method: str | None = None, 573 ) -> None: 574 msg = EmailMessage() 575 msg["From"] = formataddr((self.cfg.owner, self.cfg.mail_from)) 576 msg["To"] = to 577 msg["Subject"] = subject 578 msg["Date"] = formatdate(localtime=False) 579 msg["Message-ID"] = make_msgid(domain=self.cfg.mail_from.rpartition("@")[2]) 580 if reply_to: 581 msg["Reply-To"] = reply_to 582 msg.set_content(body) 583 if ics is not None: 584 msg.add_attachment( 585 ics, 586 maintype="text", 587 subtype="calendar", 588 filename="invite.ics", 589 params={"method": method or "PUBLISH"}, 590 ) 591 try: 592 with smtplib.SMTP(self.cfg.smtp_host, self.cfg.smtp_port, timeout=15) as s: 593 s.send_message(msg) 594 except (smtplib.SMTPException, OSError): 595 log.exception("sending mail to %s failed", to) 596 597 def render_text(self, template: str, **ctx) -> str: 598 return self.jinja.get_template(template).render(cfg=self.cfg, **ctx) 599 600 # calendar objects 601 602 def ics(self, b: sqlite3.Row, *, owner_copy: bool, method: str | None = None) -> bytes: 603 cfg = self.cfg 604 cal = icalendar.Calendar() 605 cal.add("prodid", "-//lassul.us//book//EN") 606 cal.add("version", "2.0") 607 if method: 608 cal.add("method", method) 609 ev = icalendar.Event() 610 ev.add("uid", b["uid"]) 611 ev.add("sequence", b["sequence"]) 612 ev.add("dtstamp", now_utc()) 613 ev.add("dtstart", from_epoch(b["start"])) 614 ev.add("dtend", from_epoch(b["end"])) 615 if owner_copy: 616 pending = b["status"] == "pending" 617 ev.add("summary", f"{'? ' if pending else ''}{b['name']}: {b['title']}") 618 ev.add("status", "TENTATIVE" if pending else "CONFIRMED") 619 ev.add( 620 "description", 621 f"{b['name']} <{b['email']}>\n\n{b['note']}\n\n{cfg.base_url}/a/{b['admin_token']}", 622 ) 623 else: 624 ev.add("summary", f"{b['title']} with {cfg.owner}") 625 ev.add("status", "CANCELLED" if method == "CANCEL" else "CONFIRMED") 626 ev.add("description", f"{b['note']}\n\n{cfg.base_url}/r/{b['guest_token']}") 627 ev.add("organizer", f"mailto:{cfg.notify}", parameters={"CN": cfg.owner}) 628 ev.add( 629 "attendee", 630 f"mailto:{b['email']}", 631 parameters={"CN": b["name"], "ROLE": "REQ-PARTICIPANT", "PARTSTAT": "ACCEPTED"}, 632 ) 633 cal.add_component(ev) 634 return cal.to_ical() 635 636 def mirror(self, b: sqlite3.Row) -> None: 637 """Keep the owner's calendar in line with the booking; best effort.""" 638 try: 639 if b["status"] in ACTIVE: 640 self.cal.put(b["uid"], self.ics(b, owner_copy=True)) 641 else: 642 self.cal.delete(b["uid"]) 643 except CalDAVError as e: 644 log.error("calendar mirror for %s failed: %s", b["uid"], e) 645 646 # state transitions 647 648 def request(self, et: EventType, start: datetime, form: dict[str, str], guest_tz: str, ip: str) -> str: 649 cfg = self.cfg 650 with self.store.lock: 651 if start not in self.free_slots(et, start, start + timedelta(seconds=1)): 652 raise ValueError("That time is no longer available.") 653 token = secrets.token_urlsafe(24) 654 self.store.insert( 655 uid=f"{secrets.token_hex(16)}@{urlsplit(cfg.base_url).hostname}", 656 event=et.slug, 657 title=et.title, 658 start=epoch(start), 659 end=epoch(start + timedelta(minutes=et.minutes)), 660 name=form["name"], 661 email=form["email"], 662 note=form["note"], 663 guest_tz=guest_tz, 664 status="pending", 665 guest_token=token, 666 admin_token=secrets.token_urlsafe(24), 667 created=epoch(now_utc()), 668 updated=epoch(now_utc()), 669 ip=ip, 670 ) 671 b = self.store.by_token("guest_token", token) 672 self.mirror(b) 673 self.send( 674 cfg.notify, 675 f"Booking request: {b['name']}, {self.when(b, cfg.tz)}", 676 self.render_text("mail/request.txt", b=b, when=self.when(b, cfg.tz)), 677 reply_to=formataddr((b["name"], b["email"])), 678 ) 679 return token 680 681 def transition(self, b: sqlite3.Row, status: str, message: str = "") -> sqlite3.Row: 682 """Move a booking to `status`, mirror it and mail whoever needs to know.""" 683 cfg = self.cfg 684 with self.store.lock: 685 current = self.store.get(b["id"]) 686 allowed = { 687 "approved": ("pending",), 688 "declined": ("pending",), 689 "cancelled": ACTIVE, 690 "expired": ("pending",), 691 }[status] 692 if current["status"] not in allowed: 693 return current 694 was = current["status"] 695 self.store.set_status(b["id"], status) 696 b = self.store.get(b["id"]) 697 self.mirror(b) 698 guest_when = self.when(b, ZoneInfo(b["guest_tz"])) 699 ctx = {"b": b, "when": guest_when, "message": message} 700 if status == "approved": 701 self.send( 702 b["email"], 703 f"Confirmed: {b['title']} with {cfg.owner}, {guest_when}", 704 self.render_text("mail/approved.txt", **ctx), 705 reply_to=cfg.notify, 706 ics=self.ics(b, owner_copy=False, method="REQUEST"), 707 method="REQUEST", 708 ) 709 elif status == "declined": 710 self.send( 711 b["email"], 712 f"Declined: {b['title']} with {cfg.owner}, {guest_when}", 713 self.render_text("mail/declined.txt", **ctx), 714 reply_to=cfg.notify, 715 ) 716 elif status == "cancelled" and was == "approved": 717 # The guest holds an invitation; withdraw it either way. 718 self.send( 719 b["email"], 720 f"Cancelled: {b['title']} with {cfg.owner}, {guest_when}", 721 self.render_text("mail/cancelled.txt", **ctx), 722 reply_to=cfg.notify, 723 ics=self.ics(b, owner_copy=False, method="CANCEL"), 724 method="CANCEL", 725 ) 726 return b 727 728 def guest_cancel(self, b: sqlite3.Row) -> sqlite3.Row: 729 b = self.transition(b, "cancelled") 730 if b["status"] == "cancelled": 731 self.send( 732 self.cfg.notify, 733 f"Cancelled by guest: {b['name']}, {self.when(b, self.cfg.tz)}", 734 self.render_text("mail/guest-cancelled.txt", b=b, when=self.when(b, self.cfg.tz)), 735 reply_to=formataddr((b["name"], b["email"])), 736 ) 737 return b 738 739 def sweep(self) -> None: 740 for b in self.store.overdue(now_utc()): 741 log.info("expiring unanswered request %s", b["uid"]) 742 self.transition(b, "expired") 743 744 def reschedule(self, b: sqlite3.Row, start: datetime, end: datetime) -> sqlite3.Row: 745 """Move a booking, mirror it and send an approved guest the updated invitation.""" 746 cfg = self.cfg 747 with self.store.lock: 748 current = self.store.get(b["id"]) 749 if current["status"] not in ACTIVE: 750 return current 751 before = self.when(current, ZoneInfo(current["guest_tz"])) 752 self.store.reschedule(b["id"], start, end) 753 b = self.store.get(b["id"]) 754 self.mirror(b) 755 if b["status"] == "approved": 756 guest_when = self.when(b, ZoneInfo(b["guest_tz"])) 757 self.send( 758 b["email"], 759 f"Rescheduled: {b['title']} with {cfg.owner}, {guest_when}", 760 self.render_text("mail/rescheduled.txt", b=b, when=guest_when, before=before), 761 reply_to=cfg.notify, 762 ics=self.ics(b, owner_copy=False, method="REQUEST"), 763 method="REQUEST", 764 ) 765 return b 766 767 def reconcile(self) -> None: 768 """Apply what the owner changed on booking events in the calendar. 769 770 CalDAVError propagates: an unreadable calendar must never be taken for 771 deleted events. 772 """ 773 for b in self.store.upcoming(now_utc()): 774 data = self.cal.get(b["uid"]) 775 if data is None: 776 ev, status = None, "CANCELLED" 777 else: 778 try: 779 ev = booking_event(data, b["uid"]) 780 except ValueError: 781 log.exception("booking %s is unreadable in the calendar; left as is", b["uid"]) 782 continue 783 status = str(ev.get("STATUS", "")).upper() 784 if status == "CANCELLED": 785 log.info("booking %s removed from the calendar", b["uid"]) 786 self.transition(b, "cancelled" if b["status"] == "approved" else "declined") 787 continue 788 try: 789 start, end = to_utc(ev.start, self.cfg.tz), to_utc(ev.end, self.cfg.tz) 790 except Exception: 791 log.exception("booking %s has no usable start/end in the calendar", b["uid"]) 792 continue 793 if end <= start: 794 log.error("booking %s ends before it starts in the calendar; ignored", b["uid"]) 795 continue 796 if (epoch(start), epoch(end)) != (b["start"], b["end"]): 797 log.info("booking %s moved in the calendar", b["uid"]) 798 b = self.reschedule(b, start, end) 799 if b["status"] == "pending" and status == "CONFIRMED": 800 log.info("request %s confirmed in the calendar", b["uid"]) 801 self.transition(b, "approved") 802 803 def rate_limited(self, ip: str, limit: int = 5, window: float = 3600) -> bool: 804 with self.store.lock: 805 q = self.rate[ip] 806 t = time.monotonic() 807 while q and q[0] < t - window: 808 q.popleft() 809 if len(q) >= limit: 810 return True 811 q.append(t) 812 return False 813 814 # formatting 815 816 @staticmethod 817 def when(b: sqlite3.Row, tz: ZoneInfo) -> str: 818 s = from_epoch(b["start"]).astimezone(tz) 819 e = from_epoch(b["end"]).astimezone(tz) 820 return f"{s:%a %d %b %Y, %H:%M}–{e:%H:%M} ({tz.key})" 821 822 823# --- HTTP -------------------------------------------------------------------- 824 825 826STATIC = { 827 "/style.css": ("style.css", "text/css; charset=utf-8"), 828 "/tz.js": ("tz.js", "text/javascript; charset=utf-8"), 829 "/font.woff": ("font.woff", "font/woff"), 830 "/robots.txt": ("robots.txt", "text/plain; charset=utf-8"), 831} 832 833CSP = ( 834 "default-src 'none'; style-src 'self'; script-src 'self'; font-src 'self'; " 835 "form-action 'self'; base-uri 'none'; frame-ancestors 'none'" 836) 837 838 839class HTTPError(Exception): 840 def __init__(self, status: HTTPStatus, message: str): 841 super().__init__(message) 842 self.status = status 843 self.message = message 844 845 846class Handler(BaseHTTPRequestHandler): 847 app: App 848 busy_token: str | None = None 849 server_version = "book" 850 sys_version = "" 851 852 # plumbing 853 854 def log_message(self, fmt: str, *args) -> None: 855 log.info("%s %s", self.client_ip(), fmt % args) 856 857 def client_ip(self) -> str: 858 peer = self.client_address[0] 859 if peer in ("127.0.0.1", "::1"): 860 return self.headers.get("X-Real-IP", peer) 861 return peer 862 863 def respond(self, status: HTTPStatus, body: bytes, ctype: str, headers: dict[str, str] | None = None) -> None: 864 self.send_response(status) 865 self.send_header("Content-Type", ctype) 866 self.send_header("Content-Length", str(len(body))) 867 self.send_header("Content-Security-Policy", CSP) 868 self.send_header("Referrer-Policy", "no-referrer") 869 self.send_header("X-Content-Type-Options", "nosniff") 870 for k, v in (headers or {}).items(): 871 self.send_header(k, v) 872 self.end_headers() 873 if self.command != "HEAD": 874 self.wfile.write(body) 875 876 def page(self, template: str, status: HTTPStatus = HTTPStatus.OK, **ctx) -> None: 877 html = self.app.jinja.get_template(template).render(cfg=self.app.cfg, **ctx) 878 self.respond(status, html.encode(), "text/html; charset=utf-8", {"Cache-Control": "no-store"}) 879 880 def redirect(self, location: str) -> None: 881 self.respond(HTTPStatus.SEE_OTHER, b"", "text/plain", {"Location": location}) 882 883 def query(self) -> dict[str, str]: 884 return {k: v[0] for k, v in parse_qs(urlsplit(self.path).query).items()} 885 886 def form(self) -> dict[str, str]: 887 length = int(self.headers.get("Content-Length") or 0) 888 if length > MAX_BODY: 889 raise HTTPError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request too large.") 890 raw = self.rfile.read(length).decode("utf-8", "replace") 891 return {k: v[0] for k, v in parse_qs(raw, keep_blank_values=True).items()} 892 893 def guest_tz(self, q: dict[str, str]) -> ZoneInfo: 894 tz = q.get("tz", "") 895 return ZoneInfo(tz) if tz in TIMEZONES else self.app.cfg.tz 896 897 def dispatch(self, method: str) -> None: 898 path = urlsplit(self.path).path 899 parts = [p for p in path.split("/") if p] 900 try: 901 if method == "GET" and path in STATIC: 902 return self.static(*STATIC[path]) 903 if not parts: 904 return self.index() if method == "GET" else self.not_found() 905 head, rest = parts[0], parts[1:] 906 if head == "busy": 907 if ( 908 method == "GET" 909 and self.busy_token 910 and len(rest) == 1 911 and secrets.compare_digest(rest[0].encode(), f"{self.busy_token}.ics".encode()) 912 ): 913 return self.respond( 914 HTTPStatus.OK, 915 self.app.busy_feed(), 916 "text/calendar; charset=utf-8", 917 {"Cache-Control": "no-store"}, 918 ) 919 return self.not_found() 920 if head in ("a", "r") and rest and TOKEN_RE.match(rest[0]): 921 column = "admin_token" if head == "a" else "guest_token" 922 b = self.app.store.by_token(column, rest[0]) 923 if b is None: 924 return self.not_found() 925 action = rest[1] if len(rest) > 1 else "" 926 if head == "a": 927 return self.admin(method, b, action) 928 return self.guest(method, b, action) 929 et = self.app.cfg.event_type(head) 930 if et is None or len(rest) > 1: 931 return self.not_found() 932 if not rest: 933 return self.slots(et) if method == "GET" else self.not_found() 934 try: 935 start = datetime.strptime(rest[0], SLOT_FORMAT).replace(tzinfo=UTC) 936 except ValueError: 937 return self.not_found() 938 return self.book(method, et, start) 939 except HTTPError as e: 940 self.page("message.html.j2", e.status, title="Error", message=e.message) 941 except Unavailable: 942 self.page( 943 "message.html.j2", 944 HTTPStatus.SERVICE_UNAVAILABLE, 945 title="Unavailable", 946 message="The calendar cannot be read right now. Please try again later.", 947 ) 948 949 def do_GET(self) -> None: 950 self.dispatch("GET") 951 952 def do_HEAD(self) -> None: 953 self.dispatch("GET") 954 955 def do_POST(self) -> None: 956 self.dispatch("POST") 957 958 def not_found(self) -> None: 959 self.page("message.html.j2", HTTPStatus.NOT_FOUND, title="Not found", message="Nothing here.") 960 961 def static(self, name: str, ctype: str) -> None: 962 try: 963 body = (Path(self.app.cfg.static) / name).read_bytes() 964 except FileNotFoundError: 965 return self.not_found() 966 self.respond(HTTPStatus.OK, body, ctype, {"Cache-Control": "public, max-age=86400"}) 967 968 # public pages 969 970 def index(self) -> None: 971 cfg = self.app.cfg 972 q = self.query() 973 if len(cfg.event_types) == 1: 974 return self.redirect(f"/{cfg.event_types[0].slug}?{urlencode(q)}") 975 # Only pass on an explicit zone: pinning the owner's here would stop 976 # tz.js from detecting the visitor's zone on the next page. 977 tzq = urlencode({"tz": q["tz"]}) if q.get("tz") in TIMEZONES else "" 978 self.page("index.html.j2", tzq=tzq) 979 980 def slots(self, et: EventType) -> None: 981 q = self.query() 982 tz = self.guest_tz(q) 983 today = now_utc().astimezone(tz).date() 984 try: 985 first = date.fromisoformat(q.get("from", "")) 986 except ValueError: 987 first = today 988 first = max(first, today) 989 days = [first + timedelta(days=i) for i in range(7)] 990 start = datetime(first.year, first.month, first.day, tzinfo=tz).astimezone(UTC) 991 end = datetime(days[-1].year, days[-1].month, days[-1].day, tzinfo=tz).astimezone(UTC) + timedelta(days=1) 992 by_day: dict[date, list[datetime]] = {d: [] for d in days} 993 for s in self.app.free_slots(et, start, end): 994 local = s.astimezone(tz) 995 if local.date() in by_day: 996 by_day[local.date()].append(local) 997 horizon = (now_utc() + self.app.cfg.horizon).astimezone(tz).date() 998 prev_first = max(first - timedelta(days=7), today) if first > today else None 999 next_first = first + timedelta(days=7) if first + timedelta(days=7) <= horizon else None 1000 self.page( 1001 "slots.html.j2", 1002 et=et, 1003 tz=tz, 1004 days=by_day, 1005 fmt=SLOT_FORMAT, 1006 utc=UTC, 1007 timezones=TIMEZONES, 1008 prev=prev_first and urlencode({"tz": tz.key, "from": prev_first.isoformat()}), 1009 next=next_first and urlencode({"tz": tz.key, "from": next_first.isoformat()}), 1010 tzq=urlencode({"tz": tz.key}), 1011 tzaware=True, 1012 ) 1013 1014 def book(self, method: str, et: EventType, start: datetime) -> None: 1015 q = self.query() 1016 tz = self.guest_tz(q) 1017 values = {"name": "", "email": "", "note": ""} 1018 error = "" 1019 if method == "POST": 1020 form = self.form() 1021 tz = self.guest_tz(form) 1022 values = {k: form.get(k, "").strip() for k in values} 1023 if form.get("website"): # honeypot: pretend it worked, do nothing 1024 return self.page("message.html.j2", title="Request sent", message="Request sent.") 1025 error = self.validate(values) 1026 if not error: 1027 ip = self.client_ip() 1028 if self.app.rate_limited(ip): 1029 error = "Too many requests from your address. Please try again later." 1030 elif self.app.store.count("status = 'pending'", ()) >= 30: 1031 error = "Too many open requests right now. Please try again later." 1032 elif self.app.store.count("status = 'pending' AND email = ?", (values["email"],)) >= 3: 1033 error = "You already have several open requests." 1034 else: 1035 try: 1036 token = self.app.request(et, start, values, tz.key, ip) 1037 except ValueError as e: 1038 error = str(e) 1039 else: 1040 return self.redirect(f"/r/{token}") 1041 elif start not in self.app.free_slots(et, start, start + timedelta(seconds=1)): 1042 return self.page( 1043 "message.html.j2", 1044 HTTPStatus.GONE, 1045 title="Taken", 1046 message="That time is not available.", 1047 back=f"/{et.slug}?{urlencode({'tz': tz.key})}", 1048 ) 1049 self.page( 1050 "book.html.j2", 1051 HTTPStatus.BAD_REQUEST if error else HTTPStatus.OK, 1052 et=et, 1053 tz=tz, 1054 start=start.astimezone(tz), 1055 end=(start + timedelta(minutes=et.minutes)).astimezone(tz), 1056 values=values, 1057 error=error, 1058 back=f"/{et.slug}?{urlencode({'tz': tz.key})}", 1059 tzaware=True, 1060 ) 1061 1062 @staticmethod 1063 def validate(values: dict[str, str]) -> str: 1064 for k, limit in LIMITS.items(): 1065 if len(values.get(k, "")) > limit: 1066 return f"The {k} is too long." 1067 if not values["name"]: 1068 return "Please tell me your name." 1069 if any(c in values["name"] for c in "\r\n<>\"") or "@" in values["name"]: 1070 return "Please use a plain name." 1071 if not EMAIL_RE.match(values["email"]) or any(c in values["email"] for c in "\r\n<>,\"'"): 1072 return "That email address does not look right." 1073 return "" 1074 1075 # token pages: GET only renders, POST acts, so link scanners cannot click buttons 1076 1077 def guest(self, method: str, b: sqlite3.Row, action: str) -> None: 1078 if action == "invite.ics" and method == "GET" and b["status"] == "approved": 1079 return self.respond( 1080 HTTPStatus.OK, 1081 self.app.ics(b, owner_copy=False, method="PUBLISH"), 1082 "text/calendar; charset=utf-8", 1083 {"Content-Disposition": 'attachment; filename="invite.ics"', "Cache-Control": "no-store"}, 1084 ) 1085 if action == "cancel" and method == "POST": 1086 self.app.guest_cancel(b) 1087 return self.redirect(f"/r/{b['guest_token']}") 1088 if action or method != "GET": 1089 return self.not_found() 1090 tz = ZoneInfo(b["guest_tz"]) 1091 self.page( 1092 "request.html.j2", 1093 b=b, 1094 when=self.app.when(b, tz), 1095 future=from_epoch(b["start"]) > now_utc(), 1096 noindex=True, 1097 ) 1098 1099 def admin(self, method: str, b: sqlite3.Row, action: str) -> None: 1100 if method == "POST" and action in ("approve", "decline", "cancel"): 1101 message = self.form().get("message", "").strip()[: LIMITS["message"]] 1102 status = {"approve": "approved", "decline": "declined", "cancel": "cancelled"}[action] 1103 self.app.transition(b, status, message) 1104 return self.redirect(f"/a/{b['admin_token']}") 1105 if action or method != "GET": 1106 return self.not_found() 1107 self.page( 1108 "admin.html.j2", 1109 b=b, 1110 when=self.app.when(b, self.app.cfg.tz), 1111 guest_when=self.app.when(b, ZoneInfo(b["guest_tz"])), 1112 future=from_epoch(b["start"]) > now_utc(), 1113 noindex=True, 1114 ) 1115 1116 1117def main() -> None: 1118 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 1119 parser.add_argument("--config", required=True, help="JSON configuration file") 1120 parser.add_argument("--caldav-password-file", required=True, help="file holding the CalDAV password") 1121 parser.add_argument("--templates", default=os.environ.get("BOOK_TEMPLATES"), required="BOOK_TEMPLATES" not in os.environ) 1122 parser.add_argument("--static", default=os.environ.get("BOOK_STATIC"), required="BOOK_STATIC" not in os.environ) 1123 parser.add_argument("--busy-token-file", help="file holding the secret of the busy feed /busy/<token>.ics") 1124 args = parser.parse_args() 1125 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") 1126 1127 cfg = load_config(args.config, args.caldav_password_file, args.templates, args.static) 1128 app = App(cfg) 1129 try: 1130 app.cal.ensure_calendar() 1131 except CalDAVError as e: 1132 log.error("calendar not reachable yet: %s", e) 1133 1134 def sweeper() -> None: 1135 while True: 1136 try: 1137 app.sweep() 1138 except Exception: 1139 log.exception("sweep failed") 1140 try: 1141 app.reconcile() 1142 except CalDAVError as e: 1143 log.error("cannot read bookings back from the calendar: %s", e) 1144 except Exception: 1145 log.exception("reconcile failed") 1146 time.sleep(60) 1147 1148 threading.Thread(target=sweeper, daemon=True).start() 1149 Handler.app = app 1150 if args.busy_token_file: 1151 Handler.busy_token = Path(args.busy_token_file).read_text().strip() 1152 server = ThreadingHTTPServer((cfg.listen_host, cfg.listen_port), Handler) 1153 server.daemon_threads = True 1154 log.info("listening on %s:%d", cfg.listen_host, cfg.listen_port) 1155 server.serve_forever() 1156 1157 1158if __name__ == "__main__": 1159 main()