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