// 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 (DB, editable via /einstellungen — see lib/config.js): // 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 config = require('./config'); const TZ = 'Europe/Berlin'; // Read fresh on every call so an edit on the /einstellungen page takes effect // immediately (values live in the DB now, not in process.env/.env). function cfg() { return { url: (config.get('CALDAV_URL') || '').trim(), user: config.get('MAIL_USER') || '', pass: config.get('MAIL_PASSWORD') || '', alarmMin: Number(config.get('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 -> Berliner Wanduhr-Bestandteile { datum: 'YYYY-MM-DD', zeit: 'HH:MM' } // (für vorausgefüllte Bearbeiten-Formulare). function utcToWallInputs(date) { const p = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(date).reduce((a, x) => (a[x.type] = x.value, a), {}); return { datum: `${p.year}-${p.month}-${p.day}`, zeit: `${p.hour}:${p.minute}` }; } // 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:-//NextJobs//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: '', }); 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 = '' + '' + '' + `` + ''; 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, utcToWallInputs, buildICS, parseVEvent, getCtag, listEvents, createEvent, updateEvent, deleteEvent, // exported for tests _internals: { toIcsUtc, toIcsDate, icsToDate, escapeText, unescapeText, fold, unfold }, };