This repository has no description
0

Configure Feed

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

tools.book: serve a busy-only iCalendar feed on cal.lassul.us

For when.lassul.us (timeful) to show lass's availability without event
details, at https://cal.lassul.us/busy/<token>.ics.

With --busy-token-file, GET /busy/<token>.ics returns the busy time book
itself uses for free slots (CalDAV.busy plus held requests), so the feed
and the booking page agree on what blocks time: recurrences expanded,
all-day events from local midnight, TRANSPARENT and CANCELLED events
left out, TENTATIVE events (own ones and pending booking requests) kept
as busy, since book holds those slots too.

Intervals are merged so overlapping events cannot be counted, and each
VEVENT carries only DTSTART/DTEND in UTC, DTSTAMP (required by RFC
5545), SUMMARY "Busy", TRANSP OPAQUE and a UID hashed from its times.
The window runs from 7 days back to 90 days ahead: book's own 28 day
horizon is too short for polls planned weeks ahead, and the past week
keeps polls that are still open showing sensible data.

The feed is generated per request, so it is as fresh as the calendar;
an unreadable calendar gives 503, never an empty feed. Any other path
under /busy/ gives 404; the token is compared in constant time.

configs/book.nix: the token comes from the prompt-less vars generator
busy-feed and reaches book as a systemd credential, so it never lands in
the Nix store; nginx proxies all of cal.lassul.us/busy/ to book.
book.lassul.us returns 404 for /busy/ so the feed has a single URL.

author
Hermes
committer
lassulus
date (Oct 17, 2026, 11:42 AM CEST) commit 53e7ff6f parent c3fad322
+91 -3
+27 -3
configs/book.nix
··· 71 71 (pkgs.writeText "book.json" (builtins.toJSON settings)) 72 72 "--caldav-password-file" 73 73 "%d/caldav-password" 74 + "--busy-token-file" 75 + "%d/busy-token" 74 76 ]; 75 - LoadCredential = "caldav-password:${ 76 - config.clan.core.vars.generators.radicale-lass.files."password".path 77 - }"; 77 + LoadCredential = [ 78 + "caldav-password:${config.clan.core.vars.generators.radicale-lass.files."password".path}" 79 + "busy-token:${config.clan.core.vars.generators.busy-feed.files."token".path}" 80 + ]; 78 81 DynamicUser = true; 79 82 StateDirectory = "book"; 80 83 Restart = "on-failure"; ··· 98 101 }; 99 102 }; 100 103 104 + # Secret of the busy-only feed https://cal.lassul.us/busy/<token>.ics, for 105 + # the availability polls on when.lassul.us (configs/timeful.nix). 106 + clan.core.vars.generators.busy-feed = { 107 + files."token" = { }; 108 + runtimeInputs = [ pkgs.coreutils ]; 109 + script = '' 110 + head -c 24 /dev/urandom | basenc --base64url | tr -d '\n' > "$out/token" 111 + ''; 112 + }; 113 + 114 + # book answers 404 for every /busy/ path but the one with the token. 115 + services.nginx.virtualHosts."cal.lassul.us".locations."/busy/" = { 116 + proxyPass = "http://127.0.0.1:${toString port}"; 117 + extraConfig = '' 118 + proxy_set_header Host $host; 119 + proxy_set_header X-Real-IP $remote_addr; 120 + ''; 121 + }; 122 + 101 123 services.nginx.virtualHosts.${domain} = { 102 124 enableACME = true; 103 125 forceSSL = true; ··· 111 133 client_max_body_size 16k; 112 134 ''; 113 135 }; 136 + # The busy feed is served on cal.lassul.us only. 137 + locations."/busy/".return = "404"; 114 138 }; 115 139 }
+61
tools/book/book.py
··· 20 20 The only mail a stranger can trigger goes to the owner. Guests receive mail 21 21 only after the owner acted on their request, so the form cannot be abused to 22 22 send mail to arbitrary addresses. 23 + 24 + With --busy-token-file, /busy/<token>.ics serves the same busy time (calendar 25 + plus held requests) as an iCalendar feed of merged intervals that carry 26 + nothing but their times. 23 27 """ 24 28 25 29 import argparse 26 30 import base64 31 + import hashlib 27 32 import json 28 33 import logging 29 34 import os ··· 61 66 TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{20,64}$") 62 67 MAX_BODY = 16 * 1024 63 68 LIMITS = {"name": 100, "email": 254, "note": 2000, "message": 2000} 69 + # The busy feed covers [now - BUSY_PAST, now + BUSY_AHEAD). 70 + BUSY_PAST = timedelta(days=7) 71 + BUSY_AHEAD = timedelta(days=90) 64 72 65 73 DAV = "DAV:" 66 74 CALDAV = "urn:ietf:params:xml:ns:caldav" ··· 517 525 busy += self.store.holds(*window) 518 526 return [s for s in slots if not overlaps(s - pad, s + length + pad, busy)] 519 527 528 + def busy_feed(self) -> bytes: 529 + """Busy time around now as a VCALENDAR without any event details. 530 + 531 + Overlapping and adjacent intervals are merged; the UID is a hash of 532 + the interval's times. 533 + """ 534 + now = now_utc() 535 + start, end = now - BUSY_PAST, now + BUSY_AHEAD 536 + try: 537 + busy = self.cal.busy(start, end) 538 + except CalDAVError as e: 539 + log.error("cannot read calendar: %s", e) 540 + raise Unavailable from e 541 + busy += self.store.holds(start, end) 542 + merged: list[list[datetime]] = [] 543 + for a, b in sorted(busy): 544 + if merged and a <= merged[-1][1]: 545 + merged[-1][1] = max(merged[-1][1], b) 546 + elif a < b: 547 + merged.append([a, b]) 548 + cal = icalendar.Calendar() 549 + cal.add("prodid", "-//lassul.us//book busy//EN") 550 + cal.add("version", "2.0") 551 + for a, b in merged: 552 + ev = icalendar.Event() 553 + digest = hashlib.sha256(f"{epoch(a)}-{epoch(b)}".encode()).hexdigest() 554 + ev.add("uid", f"{digest[:32]}@busy") 555 + ev.add("dtstamp", now) 556 + ev.add("dtstart", a) 557 + ev.add("dtend", b) 558 + ev.add("summary", "Busy") 559 + ev.add("transp", "OPAQUE") 560 + cal.add_component(ev) 561 + return cal.to_ical() 562 + 520 563 # mail 521 564 522 565 def send( ··· 802 845 803 846 class Handler(BaseHTTPRequestHandler): 804 847 app: App 848 + busy_token: str | None = None 805 849 server_version = "book" 806 850 sys_version = "" 807 851 ··· 859 903 if not parts: 860 904 return self.index() if method == "GET" else self.not_found() 861 905 head, rest = parts[0], parts[1:] 906 + if head == "busy": 907 + if ( 908 + method == "GET" 909 + and self.busy_token 910 + and len(rest) == 1 911 + and secrets.compare_digest(rest[0].encode(), f"{self.busy_token}.ics".encode()) 912 + ): 913 + return self.respond( 914 + HTTPStatus.OK, 915 + self.app.busy_feed(), 916 + "text/calendar; charset=utf-8", 917 + {"Cache-Control": "no-store"}, 918 + ) 919 + return self.not_found() 862 920 if head in ("a", "r") and rest and TOKEN_RE.match(rest[0]): 863 921 column = "admin_token" if head == "a" else "guest_token" 864 922 b = self.app.store.by_token(column, rest[0]) ··· 1062 1120 parser.add_argument("--caldav-password-file", required=True, help="file holding the CalDAV password") 1063 1121 parser.add_argument("--templates", default=os.environ.get("BOOK_TEMPLATES"), required="BOOK_TEMPLATES" not in os.environ) 1064 1122 parser.add_argument("--static", default=os.environ.get("BOOK_STATIC"), required="BOOK_STATIC" not in os.environ) 1123 + parser.add_argument("--busy-token-file", help="file holding the secret of the busy feed /busy/<token>.ics") 1065 1124 args = parser.parse_args() 1066 1125 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") 1067 1126 ··· 1088 1147 1089 1148 threading.Thread(target=sweeper, daemon=True).start() 1090 1149 Handler.app = app 1150 + if args.busy_token_file: 1151 + Handler.busy_token = Path(args.busy_token_file).read_text().strip() 1091 1152 server = ThreadingHTTPServer((cfg.listen_host, cfg.listen_port), Handler) 1092 1153 server.daemon_threads = True 1093 1154 log.info("listening on %s:%d", cfg.listen_host, cfg.listen_port)
+3
tools/book/usage.kdl
··· 7 7 flag "--caldav-password-file" help="File holding the CalDAV user's password" required=#true { 8 8 arg "<file>" 9 9 } 10 + flag "--busy-token-file" help="File holding the secret token of the busy-only iCalendar feed /busy/<token>.ics (no feed without it)" { 11 + arg "<file>" 12 + } 10 13 flag "--templates" help="Template directory (default: $BOOK_TEMPLATES, set by the package)" { 11 14 arg "<dir>" 12 15 }