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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user