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>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user