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