This repository has no description
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()