Manche Kalender speichern SUMMARY/LOCATION/DESCRIPTION mit wörtlichen HTML-Entities (z. B. "Vorstellungsgespräch – dot.haus"). Diese wurden roh angezeigt. Beim Parsen werden sie jetzt in echte Zeichen dekodiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
345 lines
13 KiB
JavaScript
345 lines
13 KiB
JavaScript
// Minimal CalDAV client for the application calendar (SOGo).
|
|
//
|
|
// Two-way sync: reads events in a time window (REPORT calendar-query) and
|
|
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
|
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
|
|
//
|
|
// Config (env):
|
|
// CALDAV_URL full URL of the calendar collection (must end with /)
|
|
// MAIL_USER login (shared with mail)
|
|
// MAIL_PASSWORD password (shared with mail)
|
|
// CALDAV_ALARM_MIN default reminder lead time in minutes (default 60)
|
|
//
|
|
// Times are handled as UTC instants internally; the UI renders them in
|
|
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const TZ = 'Europe/Berlin';
|
|
|
|
function cfg() {
|
|
return {
|
|
url: (process.env.CALDAV_URL || '').trim(),
|
|
user: process.env.MAIL_USER || '',
|
|
pass: process.env.MAIL_PASSWORD || '',
|
|
alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
|
|
};
|
|
}
|
|
|
|
function isConfigured() {
|
|
const c = cfg();
|
|
return !!(c.url && c.user && c.pass);
|
|
}
|
|
|
|
function collectionUrl() {
|
|
let u = cfg().url;
|
|
if (u && !u.endsWith('/')) u += '/';
|
|
return u;
|
|
}
|
|
|
|
function authHeader() {
|
|
const c = cfg();
|
|
return 'Basic ' + Buffer.from(`${c.user}:${c.pass}`).toString('base64');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Time helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
|
|
// Offset (ms) of a time zone at a given instant: local(tz) - UTC.
|
|
function tzOffsetMs(timeZone, date) {
|
|
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
|
|
const loc = new Date(date.toLocaleString('en-US', { timeZone }));
|
|
return loc.getTime() - utc.getTime();
|
|
}
|
|
|
|
// Interpret (y, mo[1-12], d, h, mi) as wall-clock time in `timeZone` and return
|
|
// the corresponding UTC Date (DST-correct, including boundary re-check).
|
|
function wallToUtc(y, mo, d, h, mi, timeZone = TZ) {
|
|
const asIfUtc = new Date(Date.UTC(y, mo - 1, d, h, mi, 0));
|
|
const off1 = tzOffsetMs(timeZone, asIfUtc);
|
|
let result = new Date(asIfUtc.getTime() - off1);
|
|
const off2 = tzOffsetMs(timeZone, result);
|
|
if (off2 !== off1) result = new Date(asIfUtc.getTime() - off2);
|
|
return result;
|
|
}
|
|
|
|
// "YYYY-MM-DDTHH:MM" (local Berlin wall time from a form) -> UTC Date.
|
|
function localInputToUtc(dateStr, timeStr) {
|
|
const [y, mo, d] = String(dateStr).split('-').map(Number);
|
|
const [h, mi] = String(timeStr || '00:00').split(':').map(Number);
|
|
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
|
|
}
|
|
|
|
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
|
|
function toIcsUtc(date) {
|
|
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
|
+ 'T' + pad(date.getUTCHours()) + pad(date.getUTCMinutes()) + pad(date.getUTCSeconds()) + 'Z';
|
|
}
|
|
|
|
// UTC Date -> iCalendar DATE "YYYYMMDD" in Europe/Berlin (for all-day events).
|
|
function toIcsDate(date) {
|
|
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' })
|
|
.formatToParts(date).reduce((a, p) => (a[p.type] = p.value, a), {});
|
|
return `${parts.year}${parts.month}${parts.day}`;
|
|
}
|
|
|
|
// Parse an iCalendar DTSTART/DTEND value (+params) to { date: Date, allDay }.
|
|
function icsToDate(value, params) {
|
|
const v = String(value).trim();
|
|
const tzid = (params && params.TZID) || null;
|
|
if (/^\d{8}$/.test(v)) {
|
|
const y = +v.slice(0, 4), mo = +v.slice(4, 6), d = +v.slice(6, 8);
|
|
return { date: wallToUtc(y, mo, d, 0, 0, tzid || TZ), allDay: true };
|
|
}
|
|
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
|
|
if (!m) return { date: null, allDay: false };
|
|
const [, y, mo, d, h, mi, s, z] = m;
|
|
if (z) return { date: new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)), allDay: false };
|
|
// TZID or floating -> treat as wall time in that zone (Berlin fallback).
|
|
return { date: wallToUtc(+y, +mo, +d, +h, +mi, tzid || TZ), allDay: false };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// iCalendar generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function escapeText(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/;/g, '\\;')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/\r?\n/g, '\\n');
|
|
}
|
|
|
|
function unescapeText(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/\\n/gi, '\n')
|
|
.replace(/\\,/g, ',')
|
|
.replace(/\\;/g, ';')
|
|
.replace(/\\\\/g, '\\');
|
|
}
|
|
|
|
// Some clients store SUMMARY/LOCATION/DESCRIPTION with HTML entities baked in
|
|
// (e.g. "Vorstellungsgespräch – dot.haus"). iCal text is plain, so
|
|
// decode them back to real characters before we hand the value on.
|
|
function decodeHtmlEntities(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
|
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
// Fold a content line to <=75 octets per RFC 5545 (CRLF + single space).
|
|
function fold(line) {
|
|
if (Buffer.byteLength(line, 'utf8') <= 75) return line;
|
|
const out = [];
|
|
let cur = '';
|
|
for (const ch of line) {
|
|
if (Buffer.byteLength(cur + ch, 'utf8') > 73) { out.push(cur); cur = ' ' + ch; }
|
|
else cur += ch;
|
|
}
|
|
if (cur) out.push(cur);
|
|
return out.join('\r\n');
|
|
}
|
|
|
|
// Build a full VCALENDAR/VEVENT document.
|
|
// evt: { uid, summary, location, description, start(Date), end(Date), allDay, alarmMin }
|
|
function buildICS(evt) {
|
|
const uid = evt.uid || (crypto.randomUUID() + '@bewerbungs-tracker');
|
|
const lines = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'PRODID:-//Bewerbungs-Tracker//Kalender//DE',
|
|
'CALSCALE:GREGORIAN',
|
|
'BEGIN:VEVENT',
|
|
'UID:' + uid,
|
|
'DTSTAMP:' + toIcsUtc(new Date()),
|
|
];
|
|
if (evt.allDay) {
|
|
lines.push('DTSTART;VALUE=DATE:' + toIcsDate(evt.start));
|
|
const endDate = evt.end || new Date(evt.start.getTime() + 24 * 3600 * 1000);
|
|
lines.push('DTEND;VALUE=DATE:' + toIcsDate(endDate));
|
|
} else {
|
|
lines.push('DTSTART:' + toIcsUtc(evt.start));
|
|
const end = evt.end || new Date(evt.start.getTime() + 60 * 60 * 1000);
|
|
lines.push('DTEND:' + toIcsUtc(end));
|
|
}
|
|
lines.push('SUMMARY:' + escapeText(evt.summary || 'Termin'));
|
|
if (evt.location) lines.push('LOCATION:' + escapeText(evt.location));
|
|
if (evt.description) lines.push('DESCRIPTION:' + escapeText(evt.description));
|
|
const alarm = Number(evt.alarmMin);
|
|
if (!evt.allDay && alarm > 0) {
|
|
lines.push('BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:' + escapeText(evt.summary || 'Termin'),
|
|
'TRIGGER:-PT' + Math.round(alarm) + 'M', 'END:VALARM');
|
|
}
|
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
|
return { uid, body: lines.map(fold).join('\r\n') + '\r\n' };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// iCalendar parsing (single VEVENT out of a VCALENDAR)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function unfold(ics) {
|
|
const raw = String(ics).split(/\r\n|\n|\r/);
|
|
const out = [];
|
|
for (const line of raw) {
|
|
if ((line.startsWith(' ') || line.startsWith('\t')) && out.length) out[out.length - 1] += line.slice(1);
|
|
else out.push(line);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseVEvent(ics) {
|
|
const lines = unfold(ics);
|
|
let inEvent = false, nested = 0;
|
|
const ev = {};
|
|
for (const line of lines) {
|
|
if (line === 'BEGIN:VEVENT') { inEvent = true; continue; }
|
|
if (line === 'END:VEVENT') break;
|
|
if (!inEvent) continue;
|
|
// Skip nested components (e.g. VALARM) so their fields don't leak in.
|
|
if (line.startsWith('BEGIN:')) { nested++; continue; }
|
|
if (line.startsWith('END:')) { if (nested > 0) nested--; continue; }
|
|
if (nested > 0) continue;
|
|
const idx = line.indexOf(':');
|
|
if (idx === -1) continue;
|
|
const namePart = line.slice(0, idx);
|
|
const value = line.slice(idx + 1);
|
|
const [name, ...paramParts] = namePart.split(';');
|
|
const params = {};
|
|
paramParts.forEach((p) => { const [k, v] = p.split('='); if (k) params[k.toUpperCase()] = v; });
|
|
const key = name.toUpperCase();
|
|
if (key === 'UID') ev.uid = value.trim();
|
|
else if (key === 'SUMMARY') ev.summary = decodeHtmlEntities(unescapeText(value));
|
|
else if (key === 'LOCATION') ev.location = decodeHtmlEntities(unescapeText(value));
|
|
else if (key === 'DESCRIPTION') ev.description = decodeHtmlEntities(unescapeText(value));
|
|
else if (key === 'DTSTART') { const r = icsToDate(value, params); ev.start = r.date; ev.allDay = r.allDay; }
|
|
else if (key === 'DTEND') { const r = icsToDate(value, params); ev.end = r.date; }
|
|
}
|
|
return ev.uid || ev.start ? ev : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP / CalDAV
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function request(method, url, { headers = {}, body } = {}) {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { Authorization: authHeader(), ...headers },
|
|
body,
|
|
});
|
|
const text = await res.text().catch(() => '');
|
|
if (res.status === 401) throw new Error('CalDAV-Anmeldung fehlgeschlagen (401) — Zugangsdaten prüfen.');
|
|
return { status: res.status, ok: res.ok, headers: res.headers, text };
|
|
}
|
|
|
|
function decodeXml(s) {
|
|
return String(s).replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, '\r').replace(/&/g, '&');
|
|
}
|
|
|
|
function tagContent(xml, tag) {
|
|
const m = xml.match(new RegExp('<[^>]*' + tag + '[^>]*>([\\s\\S]*?)</[^>]*' + tag + '>', 'i'));
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
// Change tag of the collection — cheap way to detect any change.
|
|
async function getCtag() {
|
|
if (!isConfigured()) return null;
|
|
const r = await request('PROPFIND', collectionUrl(), {
|
|
headers: { Depth: '0', 'Content-Type': 'application/xml; charset=utf-8' },
|
|
body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/></d:prop></d:propfind>',
|
|
});
|
|
return tagContent(r.text, 'getctag');
|
|
}
|
|
|
|
// List events overlapping [from, to] (Date objects). Returns parsed events with
|
|
// href/etag so they can be updated/deleted later.
|
|
async function listEvents({ from, to }) {
|
|
if (!isConfigured()) return [];
|
|
const body =
|
|
'<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
|
|
'<d:prop><d:getetag/><c:calendar-data/></d:prop>' +
|
|
'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">' +
|
|
`<c:time-range start="${toIcsUtc(from)}" end="${toIcsUtc(to)}"/>` +
|
|
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
|
const r = await request('REPORT', collectionUrl(), {
|
|
headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8' },
|
|
body,
|
|
});
|
|
const events = [];
|
|
const responses = r.text.match(/<[^>]*:?response[^>]*>[\s\S]*?<\/[^>]*:?response>/gi) || [];
|
|
for (const resp of responses) {
|
|
const href = tagContent(resp, 'href');
|
|
const etag = tagContent(resp, 'getetag');
|
|
const calData = tagContent(resp, 'calendar-data');
|
|
if (!calData) continue;
|
|
const ev = parseVEvent(decodeXml(calData));
|
|
if (!ev || !ev.start) continue;
|
|
ev.href = href ? new URL(decodeXml(href).trim(), collectionUrl()).toString() : null;
|
|
ev.etag = etag ? etag.replace(/^["']|["']$/g, '').trim() : null;
|
|
events.push(ev);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
async function createEvent(evt) {
|
|
if (!isConfigured()) throw new Error('CalDAV ist nicht konfiguriert.');
|
|
const built = buildICS(evt);
|
|
const url = collectionUrl() + encodeURIComponent(built.uid) + '.ics';
|
|
const r = await request('PUT', url, {
|
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'If-None-Match': '*' },
|
|
body: built.body,
|
|
});
|
|
if (!r.ok) throw new Error(`Termin konnte nicht angelegt werden (HTTP ${r.status}).`);
|
|
return { uid: built.uid, href: url, etag: (r.headers.get('etag') || '').replace(/^["']|["']$/g, '') || null };
|
|
}
|
|
|
|
async function updateEvent({ href, etag, ...evt }) {
|
|
if (!isConfigured()) throw new Error('CalDAV ist nicht konfiguriert.');
|
|
const built = buildICS(evt);
|
|
const headers = { 'Content-Type': 'text/calendar; charset=utf-8' };
|
|
if (etag) headers['If-Match'] = `"${etag}"`;
|
|
const r = await request('PUT', href, { headers, body: built.body });
|
|
if (!r.ok) throw new Error(`Termin konnte nicht aktualisiert werden (HTTP ${r.status}).`);
|
|
return { uid: evt.uid || built.uid, href, etag: (r.headers.get('etag') || '').replace(/^["']|["']$/g, '') || null };
|
|
}
|
|
|
|
async function deleteEvent({ href, etag }) {
|
|
if (!isConfigured() || !href) return;
|
|
const headers = {};
|
|
if (etag) headers['If-Match'] = `"${etag}"`;
|
|
const r = await request('DELETE', href, { headers });
|
|
// 404/412 are non-fatal: the event is already gone or changed remotely.
|
|
if (!r.ok && r.status !== 404 && r.status !== 412) {
|
|
throw new Error(`Termin konnte nicht gelöscht werden (HTTP ${r.status}).`);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
TZ,
|
|
isConfigured,
|
|
collectionUrl,
|
|
wallToUtc,
|
|
localInputToUtc,
|
|
buildICS,
|
|
parseVEvent,
|
|
getCtag,
|
|
listEvents,
|
|
createEvent,
|
|
updateEvent,
|
|
deleteEvent,
|
|
// exported for tests
|
|
_internals: { toIcsUtc, toIcsDate, icsToDate, escapeText, unescapeText, fold, unfold },
|
|
};
|