From ce80e52923fc0caa2de8661fc09dfcc546b94c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Tollk=C3=BChn?= Date: Thu, 22 Oct 2026 17:09:16 +0200 Subject: [PATCH] - fix escaping in ICS generation - add CRLF line endings in ICS - add line folding in ICS - fix downloadICS() --- assets/launch.js | 141 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 109 insertions(+), 32 deletions(-) diff --git a/assets/launch.js b/assets/launch.js index fdaf642..e73df4e 100644 --- a/assets/launch.js +++ b/assets/launch.js @@ -889,6 +889,66 @@ function createRoomName(meetingName) { * ============================================================ */ +const ICS_LINE_LIMIT = 75; + +/* + * UTF-8 byte length of a string. + */ +function byteLength(text) { + let length = 0; + + for (const char of text) { + const code = char.codePointAt(0); + + length += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4; + } + + return length; +} + +/* + * Escape a text value per RFC 5545: backslash first, + * then semicolon and comma, newlines as literal "\n". + */ +function escapeICS(text) { + return text + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r?\n/g, "\\n"); +} + +/* + * Fold lines longer than 75 octets into CRLF + space + * continuations (RFC 5545). + */ +function foldICS(content) { + return content + .split("\r\n") + .map((line) => { + if (byteLength(line) <= ICS_LINE_LIMIT) { + return line; + } + + const parts = []; + let current = ""; + + for (const char of line) { + if (byteLength(current + char) > ICS_LINE_LIMIT) { + parts.push(current); + current = " " + char; + } else { + current += char; + } + } + + parts.push(current); + + return parts.join("\r\n"); + }) + .join("\r\n"); +} + function generateICS(meetingName, description, startTime, endTime, organizer) { /* Generate a unique room name from the meeting name */ const roomName = createRoomName(meetingName); @@ -911,32 +971,39 @@ function generateICS(meetingName, description, startTime, endTime, organizer) { const dtStart = formatICSDate(startTime); const dtEnd = formatICSDate(endTime); - /* Build ICS content */ - const icsContent = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//BNVB//Jitsi Meeting//EN -CALSCALE:GREGORIAN -METHOD:PUBLISH -BEGIN:VEVENT -UID:${uid} -DTSTAMP:${dtStamp} -DTSTART:${dtStart} -DTEND:${dtEnd} -SUMMARY:${meetingName.replace(/,/g, '\\,')} -DESCRIPTION:${description.replace(/,/g, '\\,').replace(/\n/g, '\\n')} -LOCATION:${meetingUrl} -ORGANIZER:MAILTO:${organizer} -STATUS:CONFIRMED -URL:${meetingUrl} -SEQUENCE:0 -TRANSP:OPAQUE -BEGIN:VALARM -ACTION:DISPLAY -DESCRIPTION:Reminder -TRIGGER:-PT15M -END:VALARM -END:VEVENT -END:VCALENDAR`; + /* + * One property per line, CRLF endings, folded at + * 75 octets (RFC 5545). + */ + const lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + `PRODID:-//Jitsi Meeting Launcher//NONSGML (${CONFIG.jitsiLauncher})//EN`, + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTAMP:${dtStamp}`, + `DTSTART:${dtStart}`, + `DTEND:${dtEnd}`, + `SUMMARY:${escapeICS(meetingName)}`, + `DESCRIPTION:${escapeICS(description)}`, + `LOCATION:${meetingUrl}`, + `ORGANIZER:MAILTO:${organizer}`, + "STATUS:CONFIRMED", + `URL:${meetingUrl}`, + "SEQUENCE:0", + "TRANSP:OPAQUE", + "BEGIN:VALARM", + "ACTION:DISPLAY", + "DESCRIPTION:Reminder", + "TRIGGER:-PT15M", + "END:VALARM", + "END:VEVENT", + "END:VCALENDAR" + ]; + + const icsContent = foldICS(lines.join("\r\n")) + "\r\n"; return { content: icsContent, @@ -945,10 +1012,22 @@ END:VCALENDAR`; }; } -function downloadICS(content, filename) { +let icsObjectUrl = null; + +/* + * Download URL for the ICS content. Revokes the URL of + * the previous download. + */ +function downloadICS(content) { const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); - const url = URL.createObjectURL(blob); - return url; + + if (icsObjectUrl !== null) { + URL.revokeObjectURL(icsObjectUrl); + } + + icsObjectUrl = URL.createObjectURL(blob); + + return icsObjectUrl; } const MINUTES_STEP = 15; @@ -1159,9 +1238,7 @@ function createMeeting() { const icsData = generateICS(meetingName, description || meetingName, startTime, endTime, organizer); /* Create download link */ - const blob = new Blob([icsData.content], { type: 'text/calendar;charset=utf-8' }); - const url = URL.createObjectURL(blob); - downloadIcsLink.href = url; + downloadIcsLink.href = downloadICS(icsData.content); downloadIcsLink.download = `${icsData.roomName}.ics`; /* Show success message */