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:
2026-09-28 22:15:59 +02:00
parent d522f35663
commit a574e93359
6 changed files with 355 additions and 8 deletions
+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);
});
});