forked from
lass.pds.lassul.us/superconfig
This repository has no description
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()