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

66 lines
3.4 KiB
Markdown

# 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.