- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
display, settings modal with CalDAV/ICS source management and a
Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
never in responses), aggregated events across sources with per-source
error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
verified byte-for-byte against openssl passwd -apr1 vectors), stores
credentials, registers the source. Deploy hook in routes/apps/deploy.js
wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
route tests). Full suite 3552 passed; only pre-existing pdf-parse
billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
171 lines
7.0 KiB
JavaScript
171 lines
7.0 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).
|
|
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 };
|