/** * DC-161: Minimal CalDAV (RFC 4791) client over node:https — zero new deps. * * Enough of CalDAV for read-only dashboard use: * - PROPFIND to discover the calendar collection + display name * - REPORT (calendar-query) with a time-range filter to fetch VEVENTs * * Auth: Basic (Radicale default) or Bearer (addition for other servers). * Security: HTTPS enforced unless CALDAV_ALLOW_HTTP=1 (localhost testing); * credentials are never logged; response bodies are size-capped. */ 'use strict'; const https = require('https'); const http = require('http'); const { URL } = require('url'); const { parseIcs } = require('./ics-parser'); const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB cap on any single response const ALLOW_HTTP = process.env.CALDAV_ALLOW_HTTP === '1'; /** * Perform an HTTP request against the CalDAV server. * @param {string} method HTTP verb (PROPFIND / REPORT / OPTIONS) * @param {string} urlStr absolute https:// URL * @param {{user?: string, pass?: string, token?: string}} auth * @param {string} [body] request body * @param {Object} [extraHeaders] * @returns {Promise<{status: number, headers: Object, body: string}>} */ function davRequest(method, urlStr, auth, body, extraHeaders) { return new Promise((resolve, reject) => { let u; try { u = new URL(urlStr); } catch (e) { return reject(new Error(`Invalid CalDAV URL: ${e.message}`)); } if (u.protocol === 'http:' && !ALLOW_HTTP && !['127.0.0.1', 'localhost', '::1'].includes(u.hostname)) { return reject(new Error('[DC-401] Refusing plaintext HTTP to a non-local CalDAV server')); } const mod = u.protocol === 'http:' ? http : https; const headers = { ...extraHeaders, }; if (auth && auth.token) { headers.Authorization = `Bearer ${auth.token}`; } else if (auth && auth.user !== undefined) { headers.Authorization = 'Basic ' + Buffer.from(`${auth.user}:${auth.pass || ''}`).toString('base64'); } if (body) headers['Content-Length'] = Buffer.byteLength(body); const req = mod.request(u, { method, headers, timeout: 15000 }, (res) => { const chunks = []; let size = 0; res.on('data', (c) => { size += c.length; if (size > MAX_BODY_BYTES) { req.destroy(); return reject(new Error('[DC-402] CalDAV response exceeded 5 MiB size cap')); } chunks.push(c); }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') })); }); req.on('timeout', () => { req.destroy(); reject(new Error('[DC-403] CalDAV request timed out (15s)')); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } /** Pull textContent of the first matching element (namespace-agnostic localName). */ function firstText(xml, localName) { const re = new RegExp(`<([\\w-]+:)?${localName}(?:\\s[^>]*)?>([^<]*)<`, 'i'); const m = re.exec(xml); return m ? m[2] : ''; } /** Extract href + ICS body pairs from a multistatus response. */ function extractCalendarsAndEvents(multistatusXml) { const out = []; // Split per (namespace prefixes vary between servers — some // emit , Radicale emits bare in the default DAV // namespace, so the prefix is OPTIONAL here). const responses = multistatusXml.split(/<\/(?:[\w-]+:)?response>/i).filter(s => /<(?:[\w-]+:)?response[\s>]/i.test(s)); for (const chunk of responses) { const hrefMatch = /<([\w-]+:)?href[^>]*>([^<]+)]*>([\s\S]*?)<\/([\w-]+:)?calendar-data>/i.exec(chunk); const displayName = firstText(chunk, 'displayname'); if (hrefMatch) { out.push({ href: hrefMatch[2], displayName, calendarData: calDataMatch ? calDataMatch[2] : null }); } } return out; } /** Decode XML entities in ICS payloads embedded in XML. */ function decodeXmlEntities(s) { return String(s || '') .replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'") .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(+d)) .replace(/&/g, '&'); } /** * Probe a CalDAV server: OPTIONS + PROPFIND on the principal URL. * @returns {Promise<{ok: boolean, server: string, calendars: Array}>} */ async function probeCalendar(urlStr, auth) { const opt = await davRequest('OPTIONS', urlStr, auth); const server = opt.headers['server'] || 'unknown'; const davHeader = opt.headers['dav'] || ''; if (opt.status === 401) return { ok: false, status: 401, server, error: 'Authentication failed (401)' }; if (opt.status >= 400) return { ok: false, status: opt.status, server, error: `Server returned ${opt.status}` }; // PROPFIND for display name of the collection at this URL const propfindBody = ``; const pf = await davRequest('PROPFIND', urlStr, auth, propfindBody, { Depth: '0', 'Content-Type': 'application/xml' }); const entries = extractCalendarsAndEvents(pf.body || ''); const name = (entries[0] && entries[0].displayName) || ''; const isCalendar = /calendar/i.test(pf.body || ''); return { ok: true, status: 200, server, dav: davHeader, displayName: name, looksLikeCalendar: isCalendar }; } /** * Fetch events in a time range via REPORT calendar-query. * @param {string} urlStr calendar collection URL * @param {Object} auth {user, pass} or {token} * @param {Date} start * @param {Date} end * @returns {Promise<{events: Array, errors: string[], httpStatus: number}>} */ async function fetchEventsInRange(urlStr, auth, start, end) { const iso = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const reportBody = ` `; const res = await davRequest('REPORT', urlStr, auth, reportBody, { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8', }); if (res.status === 401) return { events: [], errors: ['Authentication failed (401)'], httpStatus: 401 }; if (res.status >= 400) return { events: [], errors: [`CalDAV server returned HTTP ${res.status}`], httpStatus: res.status }; const entries = extractCalendarsAndEvents(res.body); const errors = []; const events = []; for (const entry of entries) { if (!entry.calendarData) continue; const ics = decodeXmlEntities(entry.calendarData); const parsed = parseIcs(ics, { start, end }); for (const ev of parsed.events) { ev.href = entry.href; events.push(ev); } errors.push(...parsed.errors); } events.sort((a, b) => new Date(a.start) - new Date(b.start)); return { events: events.slice(0, 500), errors, httpStatus: res.status }; } module.exports = { davRequest, probeCalendar, fetchEventsInRange, extractCalendarsAndEvents, decodeXmlEntities };