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