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