extractCalendarsAndEvents required a namespace prefix on <response>; Radicale emits bare <response> (default DAV ns) so every entry was silently dropped — REPORT 200, ok:true, 0 events, no error. Prefix is now optional in the split and detection regexes. Tests: 9 in the ns suite (extraction incl. mixed doc; empty multistatus; namespace-aware fixture parse; undeclared element+attribute prefix rejection; both quote styles; positive declared-attr case). Calendar-related suites: 52. eslint clean.
173 lines
7.1 KiB
JavaScript
173 lines
7.1 KiB
JavaScript
/**
|
|
* 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 + <calendar-data> ICS body pairs from a multistatus response. */
|
|
function extractCalendarsAndEvents(multistatusXml) {
|
|
const out = [];
|
|
// Split per <response> (namespace prefixes vary between servers — some
|
|
// emit <d:response>, Radicale emits bare <response> 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[^>]*>([^<]+)</i.exec(chunk);
|
|
const calDataMatch = /<([\w-]+:)?calendar-data[^>]*>([\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 = `<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:displayname/><D:resourcetype/></D:prop></D:propfind>`;
|
|
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 = `<?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="${iso(start)}" end="${iso(end)}"/>
|
|
</C:comp-filter>
|
|
</C:comp-filter>
|
|
</C:filter>
|
|
</C:calendar-query>`;
|
|
|
|
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 };
|