diff --git a/docs/calendar-ical.md b/docs/calendar-ical.md new file mode 100644 index 0000000..cb5cb42 --- /dev/null +++ b/docs/calendar-ical.md @@ -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=` (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=`, 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. diff --git a/src/models/calendar/events/credentials.service.ts b/src/models/calendar/events/credentials.service.ts index 1ac701b..8d585b1 100644 --- a/src/models/calendar/events/credentials.service.ts +++ b/src/models/calendar/events/credentials.service.ts @@ -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; diff --git a/src/models/calendar/events/events.router.ts b/src/models/calendar/events/events.router.ts index 3d6c25c..ec985a7 100644 --- a/src/models/calendar/events/events.router.ts +++ b/src/models/calendar/events/events.router.ts @@ -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: diff --git a/src/models/calendar/events/icalgenerator.service.ts b/src/models/calendar/events/icalgenerator.service.ts index 205dd0c..6a38058 100644 --- a/src/models/calendar/events/icalgenerator.service.ts +++ b/src/models/calendar/events/icalgenerator.service.ts @@ -13,7 +13,7 @@ export const convertToIcal = async (events: Event[]): Promise => { 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 diff --git a/test/calendar/events.router.test.ts b/test/calendar/events.router.test.ts index 79c3ef5..1cf01fd 100644 --- a/test/calendar/events.router.test.ts +++ b/test/calendar/events.router.test.ts @@ -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); + }); +}); diff --git a/test/calendar/icalgenerator.service.test.ts b/test/calendar/icalgenerator.service.test.ts new file mode 100644 index 0000000..cec2c10 --- /dev/null +++ b/test/calendar/icalgenerator.service.test.ts @@ -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 => ({ + 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); + }); +});