Files
API/docs/calendar-ical.md
T
Paddy a574e93359 Escape and fold the iCal export, serve it as text/calendar, add subscription links
- iCal export: RFC 5545 text escaping for SUMMARY, DESCRIPTION and LOCATION,
  line folding at 75 octets (UTF-8 aware), CRLF line endings, and
  Content-Type text/calendar instead of the inferred text/html. A line break
  in a description used to end the property early; the new calendar
  frontend has a multi-line description field.
- formatDate no longer shifts the event's end date in place.
- GET /calendar/events/subscriptions: the iCal URL of every calendar, with
  its shared password where needed, for the calendar app's "Abonnieren"
  dialog. Editors only (requireAppAccess('calendar')), the shared password
  is not accepted there, never cached; calendars without a configured
  credential are left out.
- Tests for both; documented in docs/calendar-ical.md.

Backwards compatible with the current Angular calendar app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:15:59 +02:00

3.4 KiB

Calendar iCal export and subscriptions

How GET /calendar/events/{calendar}/ical builds its file, what changed on 2026-09-28, and the endpoint the calendar app uses to hand editors ready-made subscription URLs.

The export

icalgenerator.service.ts turns a calendar's PUBLIC events into one VCALENDAR:

  • Timed events: DTSTART;TZID=Europe/Berlin: / DTEND;TZID=Europe/Berlin: formatted with the API process's local getHours() etc. The times are therefore only right if the process runs in Berlin time (TZ=Europe/Berlin, or a server in that zone). Nothing enforces this. Inferred from the code; not checked against the production server.
  • Whole-day events: VALUE=DATE, and the stored end date is treated as the inclusive last day - the generator adds one day for DTEND. The calendar app stores whole-day events as 00:00 to 23:59 Berlin time for this reason.
  • repeatFrequency is written verbatim as RRULE:FREQ=<value> (no interval, count or end).
  • public needs no credential; the other calendars take ?password= because a calendar client cannot send the session cookie (see calendar-auth-migration.md).

RFC 5545 conformance (2026-09-28)

Before this date the generator wrote text values raw. A line break in a description ended the DESCRIPTION property early and the rest was read as a garbage property; unescaped , and ; are invalid in text values. The new calendar frontend has a multi-line description field, so this went from theoretical to certain. Now:

  • SUMMARY, DESCRIPTION and LOCATION go through escapeText (\ ; , and line breaks, backslash first).
  • URL is a URI value and is not escaped; line breaks are stripped from it.
  • Every content line is folded at 75 octets (UTF-8 aware - a multi-byte character is never split) and the file uses CRLF line endings throughout (toContentLines).
  • formatDate no longer shifts the caller's Date when it adds the whole-day end's extra day.
  • The response is Content-Type: text/calendar; charset=utf-8. Before, Express inferred text/html from the string body; calendar apps coped, but it was wrong.

Pinned by test/calendar/icalgenerator.service.test.ts.

Not changed, worth knowing: ORGANIZER is written as a bare display name (ORGANIZER:Anna), while RFC 5545 expects a cal-address (ORGANIZER;CN=Anna:mailto:…). Calendar apps have tolerated it so far.

GET /calendar/events/subscriptions

For the calendar app's "Abonnieren" dialog. Behind requireAppAccess('calendar'); the shared password is not accepted here (one calendar's password must not reveal the others).

Returns [{calendar, icalUrl}] in calendarNames order, built from API_BASE_URL:

  • public without a password;
  • every other calendar with ?password=<its shared password>, URL-encoded;
  • a calendar whose credential env var is unset is left out rather than listed with a URL that cannot work.

choir and birthdays share CHOIR_CREDENTIAL, so rotating it changes both URLs - and breaks every existing subscription to either. The response is sent with Cache-Control: no-store because it carries the passwords.

Signed-in editors can read every event of every calendar anyway, so handing them the passwords exposes nothing they could not already see.

Found while doing this

  • Only the admin pool reads DB_PORT; Calendar.db.ts (and the feedback/tickets pools) always connect to 3306. A local test database for the calendar has to listen there.