1 Commits

Author SHA1 Message Date
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
6 changed files with 355 additions and 8 deletions
+65
View File
@@ -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.
@@ -21,7 +21,13 @@ dotenv.config();
* test/calendar/credentials.service.test.ts.
*/
const credentialFor = (calendarName: string): string | undefined => {
/**
* The shared password for a calendar, or undefined for `public` (which needs
* none), an unknown calendar, or one whose credential is not configured.
* Exported for the subscriptions endpoint, which hands signed-in editors the
* ready-made iCal URLs - see GET /calendar/events/subscriptions.
*/
export const credentialFor = (calendarName: string): string | undefined => {
switch (calendarName) {
case 'members':
return process.env.MEMBER_CREDENTIAL;
+60 -1
View File
@@ -8,6 +8,7 @@ import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js';
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {API_BASE_URL} from '../../admin/admin.config.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
@@ -423,7 +424,12 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let file = await iCalService.convertToIcal(events);
// Send the ical file back
res.set({'Content-Disposition': 'attachment; filename=' + fileName + '.ics'});
// text/calendar, not the text/html that send() infers from a string - calendar apps
// coped with the wrong type, but it was wrong (RFC 5545 §8.1).
res.set({
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename=' + fileName + '.ics'
});
res.status(200).send(file);
} catch (e: any) {
let errorGuid = Guid.create().toString();
@@ -436,6 +442,59 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
}
});
/**
* @swagger
* /calendar/events/subscriptions:
* get:
* summary: The iCal subscription URL of every calendar
* description: >
* Ready-to-use iCal URLs for each calendar, with the calendar's shared password
* already in the query string where it needs one - for the calendar app's
* "Abonnieren" dialog. A calendar whose password is not configured is left out
* rather than listed with a URL that cannot work. Requires a signed-in account
* with the calendar permission: those editors can read every event anyway, so the
* passwords tell them nothing new. Never cached.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* calendar:
* type: string
* example: members
* icalUrl:
* type: string
* example: https://api.nachklang.art/calendar/events/members/ical?password=…
* 401:
* description: Unauthorized - not signed in
* 403:
* description: Forbidden - the account lacks the calendar permission
*/
eventsRouter.get('/subscriptions', requireCalendarAccess, (req: Request, res: Response) => {
const base = API_BASE_URL.replace(/\/+$/, '');
const subscriptions = Array.from(calendarNames.keys()).flatMap((calendar) => {
const url = `${base}/calendar/events/${calendar}/ical`;
if (calendar === 'public') {
return [{calendar, icalUrl: url}];
}
const password = CredentialService.credentialFor(calendar);
return password ? [{calendar, icalUrl: `${url}?password=${encodeURIComponent(password)}`}] : [];
});
// These URLs carry the shared passwords; keep them out of every cache on the way.
res.set('Cache-Control', 'no-store');
res.status(200).send(subscriptions);
});
/**
* @swagger
* /calendar/events:
@@ -13,7 +13,7 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
addEventToFile(ical, event);
}
return serializeIcalFile(ical);
return toContentLines(serializeIcalFile(ical));
} catch (err) {
throw err;
}
@@ -116,9 +116,11 @@ const addEventToFile = (ical: iCalFile, event: Event) => {
* @param event
*/
const createIcalEvent = (event: Event): iCalEvent => {
let description = event.description ? event.description + '\n' : '';
let location = event.location ? event.location + '\n' : '';
let url = event.url ? event.url + '\n' : '';
// Free text is escaped (see escapeText); the URL is a URI value, which has
// no escapes - it only must not break the line.
let description = event.description ? escapeText(event.description) + '\n' : '';
let location = event.location ? escapeText(event.location) + '\n' : '';
let url = event.url ? event.url.replace(/[\r\n]+/g, '') + '\n' : '';
return {
header: 'BEGIN:VEVENT\n',
@@ -128,7 +130,7 @@ const createIcalEvent = (event: Event): iCalEvent => {
start: formatDate(event.startDateTime, event.wholeDay) + '\n',
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
summary: event.name + '\n',
summary: escapeText(event.name) + '\n',
description: description,
location: location,
url: url,
@@ -143,8 +145,11 @@ const createIcalEvent = (event: Event): iCalEvent => {
* @param wholeDayFormat
* @param isEndDate
*/
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
const formatDate = (input: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
let returnString = '';
// A copy: this used to shift the caller's Date in place, so the event object
// came out of the export with its end a day later than it went in.
const date = new Date(input);
// We need to do this for whole day events as otherwise the event ends one day too early
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
@@ -184,6 +189,58 @@ export interface iCalEvent {
footer: string;
}
/**
* RFC 5545 §3.3.11 TEXT escaping. Without it a description with a line break
* ended the DESCRIPTION property early and the rest of the text was read as a
* new (garbage) property, and an unescaped `,` or `;` is invalid in the value.
* The backslash goes first, or the escapes added after it would be doubled.
*/
export const escapeText = (value: string): string =>
value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
const MAX_LINE_OCTETS = 75;
/**
* RFC 5545 §3.1 line folding: no content line longer than 75 octets, a longer
* one continues on the next line after a single leading space. Counts UTF-8
* octets, not characters, and never splits inside a character - "ü" is two
* octets and a fold between them corrupts it.
*/
export const foldLine = (line: string): string => {
const parts: string[] = [];
let current = '';
let octets = 0;
for (const char of line) {
const size = Buffer.byteLength(char, 'utf8');
// A continuation line's leading space counts towards its 75.
const limit = parts.length === 0 ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;
if (octets + size > limit) {
parts.push(current);
current = '';
octets = 0;
}
current += char;
octets += size;
}
parts.push(current);
return parts.join('\r\n ');
};
/**
* The generator builds the file with bare `\n` line ends; the format wants
* every content line folded and terminated by CRLF (RFC 5545 §3.1).
*/
const toContentLines = (ical: string): string =>
ical
.split('\n')
.filter((line) => line !== '')
.map(foldLine)
.join('\r\n') + '\r\n';
/**
* Checks if a given string is null, undefined or blank
* @param str The string to check
+49
View File
@@ -68,6 +68,8 @@ const validEvent = {
beforeEach(() => {
vi.clearAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir & more';
process.env.MANAGEMENT_CREDENTIAL = '';
(EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
@@ -164,6 +166,13 @@ describe('reading', () => {
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('serves the iCal export as text/calendar', async () => {
const res = await request(app).get('/calendar/events/public/ical').expect(200);
expect(res.headers['content-type']).toBe('text/calendar; charset=utf-8');
expect(res.headers['content-disposition']).toBe('attachment; filename=Nachklang_calendar.ics');
});
it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]);
@@ -222,3 +231,43 @@ describe('writing', () => {
.expect(401);
});
});
describe('subscriptions', () => {
it('is not for anyone signed out, or signed in without the calendar permission', async () => {
await request(app).get('/calendar/events/subscriptions').expect(401);
signedInAs(['tickets']);
await request(app).get('/calendar/events/subscriptions').expect(403);
});
it('refuses the shared password as a way in', async () => {
// Otherwise one calendar's password would unlock all the others.
await request(app).get('/calendar/events/subscriptions').query({password: 'member-secret'}).expect(401);
});
it('lists a ready-made iCal URL per configured calendar, never cached', async () => {
signedInAs(['calendar']);
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual([
{calendar: 'public', icalUrl: 'http://localhost:3000/calendar/events/public/ical'},
{calendar: 'members', icalUrl: 'http://localhost:3000/calendar/events/members/ical?password=member-secret'},
// URL-encoded, so a password with & or spaces survives.
{calendar: 'choir', icalUrl: 'http://localhost:3000/calendar/events/choir/ical?password=choir%20%26%20more'},
// management is missing: its credential is unset, and a URL without one could never work.
{calendar: 'birthdays', icalUrl: 'http://localhost:3000/calendar/events/birthdays/ical?password=choir%20%26%20more'}
]);
});
it('hands out URLs that actually open the calendar', async () => {
signedInAs(['calendar']);
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
const members = res.body.find((s: {calendar: string}) => s.calendar === 'members');
signedOut();
const path = new URL(members.icalUrl);
await request(app).get(path.pathname + path.search).expect(200);
});
});
+111
View File
@@ -0,0 +1,111 @@
import {describe, expect, it} from 'vitest';
import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js';
import {Event} from '../../src/models/calendar/events/event.interface.js';
/**
* The iCal export is read by every subscribed calendar app, so a malformed
* file is an outage nobody on our side sees. These pin RFC 5545's text
* escaping and line folding, which the generator did not do before: a line
* break in a description ended the DESCRIPTION property early, and the new
* calendar frontend's multi-line description field would have produced
* exactly that.
*/
const event = (overrides: Partial<Event> = {}): Event => ({
eventId: 1,
calendarId: 1,
uuid: 'uuid-1',
name: 'Konzert',
description: '',
startDateTime: new Date('2026-10-03T17:00:00Z'),
endDateTime: new Date('2026-10-03T19:00:00Z'),
createdDate: new Date('2026-01-01T12:00:00Z'),
location: '',
createdBy: 'Anna',
url: '',
wholeDay: false,
repeatFrequency: '',
...overrides
});
/** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */
const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean);
describe('escapeText', () => {
it('escapes backslash, semicolon, comma and line breaks', () => {
expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d');
expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4');
});
it('escapes the backslash first, so the others are not doubled', () => {
expect(escapeText(';')).toBe('\\;');
});
});
describe('foldLine', () => {
it('leaves a short line alone', () => {
expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert');
});
it('keeps every physical line within 75 octets and unfolds back to the original', () => {
const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6);
const folded = foldLine(line);
for (const physical of folded.split('\r\n')) {
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
}
expect(folded.replace(/\r\n /g, '')).toBe(line);
});
it('never splits a multi-byte character', () => {
const line = 'SUMMARY:' + 'ü'.repeat(80);
for (const physical of foldLine(line).split('\r\n')) {
expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/);
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
}
});
});
describe('convertToIcal', () => {
it('terminates every line with CRLF', async () => {
const ical = await convertToIcal([event()]);
expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ical.replace(/\r\n/g, '')).not.toContain('\n');
});
it('keeps a multi-line description inside one DESCRIPTION property', async () => {
const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]);
const lines = unfold(ical);
expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen');
// Nothing leaked out as a line of its own.
expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false);
});
it('escapes the title and the location too', async () => {
const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})]));
expect(lines).toContain('SUMMARY:Konzert\\, Teil 2');
expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe');
});
it('does not escape the URL, but keeps it on one line', async () => {
const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})]));
expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2');
});
it('still writes the yearly rule for birthdays', async () => {
const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})]));
expect(lines).toContain('RRULE:FREQ=YEARLY');
});
it('leaves the event\'s own dates untouched', async () => {
// formatDate used to add the whole-day end's extra day to the caller's Date in place.
const whole = event({
wholeDay: true,
startDateTime: new Date('2026-10-03T00:00:00'),
endDateTime: new Date('2026-10-03T23:59:00')
});
const endBefore = whole.endDateTime.getTime();
await convertToIcal([whole]);
expect(whole.endDateTime.getTime()).toBe(endBefore);
});
});