This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.book: init

+1440
+991
tools/book/book.py
··· 1 + """book: a minimal request-and-approve booking page backed by CalDAV. 2 + 3 + Guests pick a free slot and send a request. The request is held in SQLite, 4 + mirrored into the owner's CalDAV calendar as a TENTATIVE event and mailed to 5 + the owner with an approve/decline link. Approving turns the event CONFIRMED 6 + and mails the guest an invitation; declining, cancelling or letting a request 7 + run past its start time removes the event again. 8 + 9 + Free/busy comes from every calendar in the CalDAV user's home collection plus 10 + the held requests themselves. If the calendar cannot be read, no slots are 11 + offered: an unreachable calendar must never look like an empty one. 12 + 13 + The only mail a stranger can trigger goes to the owner. Guests receive mail 14 + only after the owner acted on their request, so the form cannot be abused to 15 + send mail to arbitrary addresses. 16 + """ 17 + 18 + import argparse 19 + import base64 20 + import json 21 + import logging 22 + import os 23 + import re 24 + import secrets 25 + import smtplib 26 + import sqlite3 27 + import threading 28 + import time 29 + import urllib.error 30 + import urllib.request 31 + import xml.etree.ElementTree as ET 32 + from collections import defaultdict, deque 33 + from dataclasses import dataclass 34 + from datetime import date, datetime, timedelta, timezone 35 + from email.message import EmailMessage 36 + from email.utils import formataddr, formatdate, make_msgid 37 + from http import HTTPStatus 38 + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 39 + from pathlib import Path 40 + from urllib.parse import parse_qs, quote, urlencode, urlsplit 41 + from zoneinfo import ZoneInfo, available_timezones 42 + 43 + import icalendar 44 + import jinja2 45 + import recurring_ical_events 46 + 47 + log = logging.getLogger("book") 48 + 49 + UTC = timezone.utc 50 + WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") 51 + TIMEZONES = sorted(available_timezones()) 52 + SLOT_FORMAT = "%Y%m%dT%H%M%SZ" 53 + EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") 54 + TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{20,64}$") 55 + MAX_BODY = 16 * 1024 56 + LIMITS = {"name": 100, "email": 254, "note": 2000, "message": 2000} 57 + 58 + DAV = "DAV:" 59 + CALDAV = "urn:ietf:params:xml:ns:caldav" 60 + 61 + 62 + # --- configuration ----------------------------------------------------------- 63 + 64 + 65 + @dataclass(frozen=True) 66 + class EventType: 67 + slug: str 68 + title: str 69 + minutes: int 70 + description: str 71 + 72 + 73 + @dataclass(frozen=True) 74 + class 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 + 103 + def 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 + 111 + def 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 + 164 + def now_utc() -> datetime: 165 + return datetime.now(UTC).replace(microsecond=0) 166 + 167 + 168 + def 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 + 180 + def epoch(dt: datetime) -> int: 181 + return int(dt.timestamp()) 182 + 183 + 184 + def from_epoch(ts: int) -> datetime: 185 + return datetime.fromtimestamp(ts, UTC) 186 + 187 + 188 + def 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 + 195 + class CalDAVError(Exception): 196 + pass 197 + 198 + 199 + class 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 + 339 + SCHEMA = """ 340 + CREATE 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 + ); 359 + CREATE INDEX IF NOT EXISTS bookings_active ON bookings (status, start); 360 + """ 361 + 362 + ACTIVE = ("pending", "approved") 363 + 364 + 365 + class 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 + 419 + class Unavailable(Exception): 420 + """The calendar could not be read; refuse to guess.""" 421 + 422 + 423 + class 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 + 682 + STATIC = { 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 + 689 + CSP = ( 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 + 695 + class HTTPError(Exception): 696 + def __init__(self, status: HTTPStatus, message: str): 697 + super().__init__(message) 698 + self.status = status 699 + self.message = message 700 + 701 + 702 + class 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 + 958 + def 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 + 990 + if __name__ == "__main__": 991 + main()
+32
tools/book/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { pkgs, ... }: 5 + let 6 + python = pkgs.python3.withPackages (ps: [ 7 + ps.icalendar 8 + ps.jinja2 9 + ps.recurring-ical-events 10 + ]); 11 + # The VGA font of https://lassul.us, so the booking page matches it. 12 + static = pkgs.runCommand "book-static" { } '' 13 + cp -r ${./static} $out 14 + chmod u+w $out 15 + cp ${../../configs/websites/lassul.us/Web437_IBM_VGA_8x16.woff} $out/font.woff 16 + ''; 17 + in 18 + { 19 + # Request-and-approve booking page backed by CalDAV (book.lassul.us, 20 + # configs/book.nix). 21 + packages.book = 22 + (pkgs.writeShellApplication { 23 + name = "book"; 24 + text = '' 25 + export BOOK_TEMPLATES=${./templates} 26 + export BOOK_STATIC=${static} 27 + exec ${python}/bin/python3 ${./book.py} "$@" 28 + ''; 29 + }).overrideAttrs 30 + { passthru.usage = builtins.readFile ./usage.kdl; }; 31 + }; 32 + }
+15
tools/book/usage.kdl
··· 1 + bin "book" 2 + about "Serve a request-and-approve booking page whose free/busy and bookings live in a CalDAV calendar" 3 + 4 + flag "--config" help="JSON configuration file (see configs/book.nix)" required=#true { 5 + arg "<file>" 6 + } 7 + flag "--caldav-password-file" help="File holding the CalDAV user's password" required=#true { 8 + arg "<file>" 9 + } 10 + flag "--templates" help="Template directory (default: $BOOK_TEMPLATES, set by the package)" { 11 + arg "<dir>" 12 + } 13 + flag "--static" help="Static file directory (default: $BOOK_STATIC, set by the package)" { 14 + arg "<dir>" 15 + }
+3
tools/book/static/robots.txt
··· 1 + User-agent: * 2 + Disallow: /a/ 3 + Disallow: /r/
+164
tools/book/static/style.css
··· 1 + /* Same palette and VGA font as https://lassul.us */ 2 + @font-face { 3 + font-family: "Web437 IBM VGA 8x16"; 4 + src: url("/font.woff") format("woff"); 5 + } 6 + :root { 7 + color-scheme: dark light; 8 + --bg: #000; 9 + --fg: #ccc; 10 + --hi: #7c7; 11 + --warn: #c96; 12 + --dim: #888; 13 + } 14 + @media (prefers-color-scheme: light) { 15 + :root { 16 + --bg: #f2efe6; 17 + --fg: #333; 18 + --hi: #171; 19 + --warn: #941; 20 + --dim: #777; 21 + } 22 + } 23 + html { 24 + background: var(--bg); 25 + } 26 + body { 27 + margin: 0; 28 + color: var(--fg); 29 + font: 30 + 16px/1.25 "Web437 IBM VGA 8x16", 31 + monospace; 32 + } 33 + main { 34 + max-width: 40em; 35 + margin: 0 auto; 36 + padding: 1em 1em 3em; 37 + } 38 + header { 39 + margin-bottom: 2em; 40 + } 41 + h1 { 42 + font-size: inherit; 43 + font-weight: normal; 44 + color: var(--hi); 45 + margin: 0 0 1em; 46 + } 47 + a { 48 + color: var(--hi); 49 + text-decoration: underline dotted; 50 + } 51 + a:hover, 52 + a:focus { 53 + background: var(--hi); 54 + color: var(--bg); 55 + text-decoration: none; 56 + } 57 + header a { 58 + color: inherit; 59 + } 60 + .dim { 61 + color: var(--dim); 62 + } 63 + .error { 64 + color: var(--warn); 65 + } 66 + .ok { 67 + color: var(--hi); 68 + } 69 + ul.types { 70 + list-style: none; 71 + padding: 0; 72 + } 73 + ul.types li { 74 + margin-bottom: 1em; 75 + } 76 + ul.types li::before { 77 + content: "> "; 78 + color: var(--dim); 79 + } 80 + nav.weeks { 81 + display: flex; 82 + justify-content: space-between; 83 + margin: 1.5em 0 0.5em; 84 + } 85 + .day { 86 + display: flex; 87 + gap: 2ch; 88 + padding: 0.25em 0; 89 + border-top: 1px dotted var(--dim); 90 + } 91 + .day .date { 92 + flex: none; 93 + width: 10ch; 94 + } 95 + .day .times { 96 + display: flex; 97 + flex-wrap: wrap; 98 + gap: 0.25em 2ch; 99 + } 100 + details.tz { 101 + margin-top: 1.5em; 102 + color: var(--dim); 103 + } 104 + details.tz summary { 105 + cursor: pointer; 106 + } 107 + details.tz form { 108 + display: flex; 109 + gap: 1ch; 110 + margin-top: 0.5em; 111 + } 112 + label { 113 + display: block; 114 + margin: 1em 0 0.25em; 115 + color: var(--dim); 116 + } 117 + input, 118 + textarea, 119 + button { 120 + font: inherit; 121 + color: inherit; 122 + background: transparent; 123 + border: 1px solid var(--dim); 124 + border-radius: 0; 125 + padding: 0.25em 0.5ch; 126 + } 127 + input, 128 + textarea { 129 + width: 100%; 130 + box-sizing: border-box; 131 + } 132 + details.tz input { 133 + width: auto; 134 + flex: 1; 135 + } 136 + input:focus, 137 + textarea:focus { 138 + outline: none; 139 + border-color: var(--hi); 140 + } 141 + button { 142 + color: var(--hi); 143 + border-color: var(--hi); 144 + cursor: pointer; 145 + } 146 + button:hover, 147 + button:focus { 148 + background: var(--hi); 149 + color: var(--bg); 150 + } 151 + .actions { 152 + display: flex; 153 + gap: 2ch; 154 + } 155 + pre.note { 156 + font: inherit; 157 + white-space: pre-wrap; 158 + border-left: 1px solid var(--dim); 159 + padding-left: 1ch; 160 + } 161 + .hp { 162 + position: absolute; 163 + left: -10000px; 164 + }
+15
tools/book/static/tz.js
··· 1 + // Show times in the visitor's zone: add ?tz= once; every link carries it on. 2 + (function () { 3 + if (!document.body.dataset.tzaware) return; 4 + var url = new URL(location.href); 5 + if (url.searchParams.has("tz")) return; 6 + var tz; 7 + try { 8 + tz = Intl.DateTimeFormat().resolvedOptions().timeZone; 9 + } catch (e) { 10 + return; 11 + } 12 + if (!tz) return; 13 + url.searchParams.set("tz", tz); 14 + location.replace(url.toString()); 15 + })();
+28
tools/book/templates/admin.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% block title %}{{ b.name }}{% endblock %} 3 + {% block body %} 4 + <h1>{{ b.title }} <span class="dim">· {{ b.status }}</span></h1> 5 + <p> 6 + {{ when }}<br /> 7 + <span class="dim">guest sees {{ guest_when }}</span> 8 + </p> 9 + <p>{{ b.name }} &lt;<a href="mailto:{{ b.email }}">{{ b.email }}</a>&gt;</p> 10 + {% if b.note %} 11 + <pre class="note">{{ b.note }}</pre> 12 + {% endif %} 13 + 14 + {% if future and b.status in ("pending", "approved") %} 15 + <form method="post"> 16 + <label for="message">message to {{ b.name }} (optional)</label> 17 + <textarea id="message" name="message" rows="3" maxlength="2000"></textarea> 18 + <p class="actions"> 19 + {% if b.status == "pending" %} 20 + <button formaction="/a/{{ b.admin_token }}/approve">approve</button> 21 + <button formaction="/a/{{ b.admin_token }}/decline">decline</button> 22 + {% else %} 23 + <button formaction="/a/{{ b.admin_token }}/cancel">cancel meeting</button> 24 + {% endif %} 25 + </p> 26 + </form> 27 + {% endif %} 28 + {% endblock %}
+19
tools/book/templates/base.html.j2
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + {% if noindex is defined %} 7 + <meta name="robots" content="noindex" /> 8 + {% endif %} 9 + <title>{% block title %}book{% endblock %} · {{ cfg.owner }}</title> 10 + <link rel="stylesheet" href="/style.css" /> 11 + <script src="/tz.js" defer></script> 12 + </head> 13 + <body{% if tzaware is defined %} data-tzaware="1"{% endif %}> 14 + <main> 15 + <header><a href="/">{{ cfg.owner }}</a> <span class="dim">/ book</span></header> 16 + {% block body %}{% endblock %} 17 + </main> 18 + </body> 19 + </html>
+29
tools/book/templates/book.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% block title %}{{ et.title }}{% endblock %} 3 + {% block body %} 4 + <h1>{{ et.title }} <span class="dim">· {{ et.minutes }} min</span></h1> 5 + <p> 6 + {{ start.strftime("%A %d %B %Y") }}<br /> 7 + {{ start.strftime("%H:%M") }}–{{ end.strftime("%H:%M") }} <span class="dim">{{ tz.key }}</span> 8 + </p> 9 + <p><a href="{{ back }}">‹ other time</a></p> 10 + 11 + {% if error %} 12 + <p class="error">{{ error }}</p> 13 + {% endif %} 14 + 15 + <form method="post"> 16 + <input type="hidden" name="tz" value="{{ tz.key }}" /> 17 + <label for="name">name</label> 18 + <input id="name" name="name" value="{{ values.name }}" maxlength="100" required autocomplete="name" /> 19 + <label for="email">email</label> 20 + <input id="email" name="email" type="email" value="{{ values.email }}" maxlength="254" required autocomplete="email" /> 21 + <label for="note">what is it about? where do we meet?</label> 22 + <textarea id="note" name="note" rows="5" maxlength="2000">{{ values.note }}</textarea> 23 + <div class="hp" aria-hidden="true"> 24 + <label for="website">leave this empty</label> 25 + <input id="website" name="website" tabindex="-1" autocomplete="off" /> 26 + </div> 27 + <p><button>send request</button></p> 28 + </form> 29 + {% endblock %}
+14
tools/book/templates/index.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% block body %} 3 + {% if cfg.intro %} 4 + <p>{{ cfg.intro }}</p> 5 + {% endif %} 6 + <ul class="types"> 7 + {% for et in cfg.event_types %} 8 + <li> 9 + <a href="/{{ et.slug }}?{{ tzq }}">{{ et.title }}</a> <span class="dim">{{ et.minutes }} min</span> 10 + {% if et.description %}<br /><span class="dim">{{ et.description }}</span>{% endif %} 11 + </li> 12 + {% endfor %} 13 + </ul> 14 + {% endblock %}
+6
tools/book/templates/message.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% block title %}{{ title }}{% endblock %} 3 + {% block body %} 4 + <p>{{ message }}</p> 5 + <p><a href="{{ back if back is defined else '/' }}">‹ back</a></p> 6 + {% endblock %}
+24
tools/book/templates/request.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% block title %}{{ b.title }}{% endblock %} 3 + {% block body %} 4 + <h1>{{ b.title }}</h1> 5 + <p>{{ when }}</p> 6 + 7 + {% if b.status == "pending" %} 8 + <p>Request sent. You get a mail once I confirm or decline it. Keep this page's address to cancel.</p> 9 + {% elif b.status == "approved" %} 10 + <p class="ok">Confirmed. The invitation is in your mail; <a href="/r/{{ b.guest_token }}/invite.ics">download it again</a>.</p> 11 + {% elif b.status == "declined" %} 12 + <p>Declined. <a href="/">Pick another time</a>.</p> 13 + {% elif b.status == "cancelled" %} 14 + <p>Cancelled.</p> 15 + {% elif b.status == "expired" %} 16 + <p>This request was not answered in time. <a href="/">Pick another time</a>.</p> 17 + {% endif %} 18 + 19 + {% if b.status in ("pending", "approved") and future %} 20 + <form method="post" action="/r/{{ b.guest_token }}/cancel"> 21 + <button>cancel {{ "request" if b.status == "pending" else "meeting" }}</button> 22 + </form> 23 + {% endif %} 24 + {% endblock %}
+44
tools/book/templates/slots.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% block title %}{{ et.title }}{% endblock %} 3 + {% block body %} 4 + <h1>{{ et.title }} <span class="dim">· {{ et.minutes }} min</span></h1> 5 + {% if et.description %} 6 + <p>{{ et.description }}</p> 7 + {% elif cfg.intro and cfg.event_types | length == 1 %} 8 + <p>{{ cfg.intro }}</p> 9 + {% endif %} 10 + <p class="dim">Pick a time. I confirm requests by mail.</p> 11 + 12 + <nav class="weeks"> 13 + {% if prev %}<a href="?{{ prev }}">‹ earlier</a>{% else %}<span></span>{% endif %} 14 + {% if next %}<a href="?{{ next }}">later ›</a>{% endif %} 15 + </nav> 16 + 17 + <div class="days"> 18 + {% for day, slots in days.items() %} 19 + <div class="day"> 20 + <span class="date">{{ day.strftime("%a %d %b") }}</span> 21 + <span class="times"> 22 + {% for s in slots %} 23 + <a href="/{{ et.slug }}/{{ s.astimezone(utc).strftime(fmt) }}?{{ tzq }}">{{ s.strftime("%H:%M") }}</a> 24 + {% else %} 25 + <span class="dim">-</span> 26 + {% endfor %} 27 + </span> 28 + </div> 29 + {% endfor %} 30 + </div> 31 + 32 + <details class="tz"> 33 + <summary>times in {{ tz.key }}</summary> 34 + <form method="get"> 35 + <input name="tz" list="timezones" value="{{ tz.key }}" aria-label="time zone" /> 36 + <datalist id="timezones"> 37 + {% for name in timezones %} 38 + <option value="{{ name }}"></option> 39 + {% endfor %} 40 + </datalist> 41 + <button>set</button> 42 + </form> 43 + </details> 44 + {% endblock %}
+15
tools/book/templates/mail/approved.txt
··· 1 + Hi {{ b.name }}, 2 + 3 + your request is confirmed: 4 + 5 + {{ b.title }} with {{ cfg.owner }} 6 + {{ when }} 7 + {% if message %} 8 + 9 + {{ message }} 10 + {% endif %} 11 + 12 + The invitation is attached. To cancel: 13 + {{ cfg.base_url }}/r/{{ b.guest_token }} 14 + 15 + {{ cfg.owner }}
+14
tools/book/templates/mail/cancelled.txt
··· 1 + Hi {{ b.name }}, 2 + 3 + this meeting is cancelled: 4 + 5 + {{ b.title }} with {{ cfg.owner }} 6 + {{ when }} 7 + {% if message %} 8 + 9 + {{ message }} 10 + {% endif %} 11 + 12 + The attached cancellation removes it from your calendar. 13 + 14 + {{ cfg.owner }}
+14
tools/book/templates/mail/declined.txt
··· 1 + Hi {{ b.name }}, 2 + 3 + sorry, I can't make this one: 4 + 5 + {{ b.title }} 6 + {{ when }} 7 + {% if message %} 8 + 9 + {{ message }} 10 + {% endif %} 11 + 12 + Feel free to pick another time: {{ cfg.base_url }}/ 13 + 14 + {{ cfg.owner }}
+4
tools/book/templates/mail/guest-cancelled.txt
··· 1 + {{ b.name }} <{{ b.email }}> cancelled: {{ b.title }} 2 + {{ when }} 3 + 4 + {{ cfg.base_url }}/a/{{ b.admin_token }}
+9
tools/book/templates/mail/request.txt
··· 1 + {{ b.name }} <{{ b.email }}> asks for: {{ b.title }} 2 + {{ when }} 3 + 4 + {{ b.note or "(no note)" }} 5 + 6 + Approve or decline: 7 + {{ cfg.base_url }}/a/{{ b.admin_token }} 8 + 9 + Replying to this mail goes to {{ b.name }}.