[pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring
- 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.
This commit is contained in:
18 files changed
+1845
No files matched your search
@@ -94,6 +94,8 @@ const sitesRoutes = require('../routes/sites');
|
||||
const credentialsRoutes = require('../routes/credentials');
|
||||
const arrRoutes = require('../routes/arr');
|
||||
const appsRoutes = require('../routes/apps');
|
||||
// DC-161: calendar widget backend (CalDAV/ICS sources + Radicale auto-wiring)
|
||||
const calendarRoutes = require('../routes/calendar');
|
||||
const logsRoutes = require('../routes/logs');
|
||||
const backupsRoutes = require('../routes/backups');
|
||||
const caRoutes = require('../routes/ca');
|
||||
@@ -159,6 +161,10 @@ async function createApp() {
|
||||
// Create state managers
|
||||
const servicesStateManager = new StateManager(config.SERVICES_FILE);
|
||||
const configStateManager = new StateManager(config.CONFIG_FILE);
|
||||
// DC-161: calendar sources live next to services.json (same dataDir guarantees)
|
||||
const calendarStateManager = new StateManager(
|
||||
require('path').join(require('path').dirname(config.SERVICES_FILE), 'calendar-sources.json')
|
||||
);
|
||||
|
||||
// DC-053: share-store. Single shared instance, lazy file creation on first
|
||||
// write. Lives alongside user-store/invite-store semantics (defensive
|
||||
@@ -917,6 +923,18 @@ async function createApp() {
|
||||
}));
|
||||
apiRouter.use(arrRoutes(ctx));
|
||||
apiRouter.use(appsRoutes(ctx));
|
||||
// DC-161: calendar — sources CRUD, aggregated events, Radicale auto-provision
|
||||
apiRouter.use('/calendar', calendarRoutes({
|
||||
calendarStateManager,
|
||||
credentialManager: ctx.credentialManager,
|
||||
docker: ctx.docker,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
errorResponse: ctx.errorResponse,
|
||||
log: ctx.log,
|
||||
fetchT: ctx.fetchT,
|
||||
buildServiceUrl: ctx.buildServiceUrl,
|
||||
}));
|
||||
apiRouter.use('/openclaw', openClawRoutes(ctx));
|
||||
apiRouter.use(logsRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* DC-161: Minimal dependency-free iCalendar (RFC 5545) parser.
|
||||
*
|
||||
* Scope: exactly what the calendar widget needs — VEVENT extraction with
|
||||
* start/end, all-day flags, title, location, description, and bounded
|
||||
* RRULE expansion for the common recurrence cases (DAILY, WEEKLY,
|
||||
* MONTHLY, YEARLY) with COUNT/UNTIL/INTERVAL and EXDATE handling.
|
||||
*
|
||||
* Deliberately NOT supported (skipped safely): VTODO/VJOURNAL, VALARM
|
||||
* (skipped, not treated as events), RDATE, complex BYSETPOS/BYSYEARDAY
|
||||
* rules (rule ignored → base occurrence only), recurrence overrides via
|
||||
* RECURRENCE-ID (applied as replacements when present).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const MAX_EVENTS = 500;
|
||||
|
||||
/** Unfold continuation lines (CRLF followed by space/tab) per RFC 5545 §3.1. */
|
||||
function unfoldLines(text) {
|
||||
return String(text || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n[ \t]/g, '')
|
||||
.split('\n');
|
||||
}
|
||||
|
||||
/** Parse "NAME;PARAM=VAL;PARAM2=" into { name, params }. Values may be quoted. */
|
||||
function parseContentLine(line) {
|
||||
// Find the colon that terminates the name/params section — but ignore
|
||||
// colons inside double-quoted param values.
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') inQuotes = !inQuotes;
|
||||
else if (ch === ':' && !inQuotes) {
|
||||
const head = line.slice(0, i);
|
||||
const value = line.slice(i + 1);
|
||||
const headParts = head.split(';');
|
||||
const name = headParts[0].toUpperCase();
|
||||
const params = {};
|
||||
for (let p = 1; p < headParts.length; p++) {
|
||||
const eq = headParts[p].indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const key = headParts[p].slice(0, eq).toUpperCase();
|
||||
let val = headParts[p].slice(eq + 1);
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
params[key] = val;
|
||||
}
|
||||
return { name, params, value };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Unescape TEXT values per RFC 5545 §3.3.11. */
|
||||
function unescapeText(v) {
|
||||
return String(v || '')
|
||||
.replace(/\\n/gi, '\n')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an iCalendar date/date-time value.
|
||||
* Forms: 20261007T140000Z (UTC), 20261007T140000 (floating/local),
|
||||
* 20261007 (all-day). Returns Date or null. TZID params are resolved to
|
||||
* their literal wall-clock time (converted via TZ offset when the host
|
||||
* supports it); floating times are treated as server-local.
|
||||
*/
|
||||
function parseIcsDate(value, params) {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(String(value || '').trim());
|
||||
if (!m) return null;
|
||||
const [, y, mo, d, hh, mm, ss, z] = m;
|
||||
if (!hh) {
|
||||
// All-day date — anchor at local midnight.
|
||||
return new Date(Number(y), Number(mo) - 1, Number(d));
|
||||
}
|
||||
if (z) return new Date(Date.UTC(+y, +mo - 1, +d, +hh, +mm, +ss));
|
||||
if (params && params.TZID) {
|
||||
return parseZidDate(y, mo, d, hh, mm, ss, params.TZID);
|
||||
}
|
||||
return new Date(+y, +mo - 1, +d, +hh, +mm, +ss);
|
||||
}
|
||||
|
||||
/** Resolve a TZID-qualified wall-clock time via Intl; falls back to floating. */
|
||||
function parseZidDate(y, mo, d, hh, mm, ss, tzid) {
|
||||
try {
|
||||
const iso = `${y}-${mo}-${d}T${hh}:${mm}:${ss}`;
|
||||
const offsetProbe = new Date(`${iso}Z`);
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tzid, hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
const parts = {};
|
||||
for (const p of dtf.formatToParts(offsetProbe)) parts[p.type] = p.value;
|
||||
const asUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day,
|
||||
+parts.hour === 24 ? 0 : +parts.hour, +parts.minute, +parts.second);
|
||||
const offset = asUtc - offsetProbe.getTime();
|
||||
return new Date(offsetProbe.getTime() - offset);
|
||||
} catch (_) {
|
||||
// Invalid TZID → floating
|
||||
return new Date(+y, +mo - 1, +d, +hh, +mm, +ss);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse an RRULE string into a plain object with uppercased keys. */
|
||||
function parseRrule(rruleSrc) {
|
||||
const rule = {};
|
||||
for (const part of String(rruleSrc || '').replace(/^RRULE:/i, '').split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq > 0) rule[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** Parse BYDAY=MO,WE entries into [{nth, day}]. */
|
||||
function parseByday(rule) {
|
||||
return (rule.BYDAY || '').split(',').filter(Boolean).map(s => {
|
||||
const m = /^([+-]?\d{1,2})?([A-Z]{2})$/.exec(s.trim());
|
||||
return m ? { nth: m[1] ? parseInt(m[1], 10) : null, day: m[2] } : null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
/** True when `date` matches none of the event's EXDATE entries (±1s). */
|
||||
function notExcluded(ev, date) {
|
||||
return !(ev.exdates || []).some(d => Math.abs(d - date) < 1000);
|
||||
}
|
||||
|
||||
/** Build an occurrence in the output list if it overlaps the window. */
|
||||
function pushOccurrence(ev, occ, duration, rangeStart, rangeEnd, out) {
|
||||
if (occ >= rangeStart && occ <= rangeEnd && notExcluded(ev, occ)) {
|
||||
out.push({ ...ev, start: new Date(occ), end: duration ? new Date(occ.getTime() + duration) : null, recurring: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Advance a Date by whole months/years without day-overflow surprises. */
|
||||
function addMonths(date, n) {
|
||||
const d = new Date(date);
|
||||
d.setMonth(d.getMonth() + n);
|
||||
return d;
|
||||
}
|
||||
function addYears(date, n) {
|
||||
const d = new Date(date);
|
||||
d.setFullYear(d.getFullYear() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Monday of the week containing `date`. */
|
||||
function weekStartOf(date) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Emit every BYDAY occurrence inside one week; returns count consumed. */
|
||||
function emitWeekByday(ev, cursor, byday, ctx, out) {
|
||||
const { count, until, rangeStart, rangeEnd, duration } = ctx;
|
||||
const weekStart = weekStartOf(cursor);
|
||||
// Monday-anchored offsets from the week-start date (weekStart is a Monday)
|
||||
const dayCodes = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
||||
let emitted = 0;
|
||||
for (const bd of byday) {
|
||||
if (!(bd.day in dayCodes)) continue;
|
||||
const occ = new Date(weekStart);
|
||||
occ.setDate(occ.getDate() + dayCodes[bd.day]);
|
||||
occ.setHours(cursor.getHours(), cursor.getMinutes(), cursor.getSeconds(), 0);
|
||||
if (emitted >= count - ctx.emitted) break;
|
||||
if (until && occ > until) break;
|
||||
// Occurrences generated by the rule consume COUNT even when EXDATE removes them.
|
||||
emitted++;
|
||||
pushOccurrence(ev, occ, duration, rangeStart, rangeEnd, out);
|
||||
}
|
||||
const nextWeek = new Date(weekStart);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7 * ctx.interval);
|
||||
return { emitted, next: nextWeek };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an event's recurrence into concrete occurrences overlapping the window.
|
||||
*/
|
||||
function expandRecurrence(ev, rangeStart, rangeEnd, out) {
|
||||
const rule = parseRrule(ev.rrule);
|
||||
const freq = (rule.FREQ || '').toUpperCase();
|
||||
const interval = Math.max(1, parseInt(rule.INTERVAL, 10) || 1);
|
||||
const count = rule.COUNT ? parseInt(rule.COUNT, 10) : Infinity;
|
||||
const until = rule.UNTIL ? parseIcsDate(rule.UNTIL, {}) : null;
|
||||
const duration = ev.end ? ev.end - ev.start : 0;
|
||||
const byday = freq === 'WEEKLY' ? parseByday(rule) : [];
|
||||
|
||||
const ctx = { count, until, rangeStart, rangeEnd, duration, interval, emitted: 0 };
|
||||
let cursor = new Date(ev.start);
|
||||
|
||||
for (let guard = 0; guard < 1000; guard++) {
|
||||
if (ctx.emitted >= count) break;
|
||||
if (until && cursor > until) break;
|
||||
if (cursor > rangeEnd) break;
|
||||
|
||||
if (freq === 'WEEKLY' && byday.length > 0) {
|
||||
const { emitted, next } = emitWeekByday(ev, cursor, byday, ctx, out);
|
||||
ctx.emitted += emitted;
|
||||
cursor = new Date(next);
|
||||
} else if (freq === 'DAILY') {
|
||||
// COUNT counts rule-generated instances; EXDATE then filters the output.
|
||||
ctx.emitted++;
|
||||
pushOccurrence(ev, cursor, duration, rangeStart, rangeEnd, out);
|
||||
cursor = new Date(cursor.getTime() + interval * 86400000);
|
||||
} else if (freq === 'MONTHLY') {
|
||||
ctx.emitted++;
|
||||
pushOccurrence(ev, cursor, duration, rangeStart, rangeEnd, out);
|
||||
cursor = addMonths(cursor, interval);
|
||||
} else if (freq === 'YEARLY') {
|
||||
ctx.emitted++;
|
||||
pushOccurrence(ev, cursor, duration, rangeStart, rangeEnd, out);
|
||||
cursor = addYears(cursor, interval);
|
||||
} else {
|
||||
// Unsupported/absent FREQ → emit base occurrence only.
|
||||
if (cursor >= rangeStart && cursor <= rangeEnd) {
|
||||
out.push({ ...ev, recurring: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize one raw event into `events` (recurrence-aware). */
|
||||
function materializeEvent(ev, rangeStart, rangeEnd, events, errors) {
|
||||
if (!ev.start || Number.isNaN(ev.start.getTime())) {
|
||||
errors.push(`event "${ev.title || ev.uid}" has invalid DTSTART`);
|
||||
return;
|
||||
}
|
||||
if (ev.rrule) {
|
||||
expandRecurrence(ev, rangeStart, rangeEnd, events);
|
||||
} else if (ev.start <= rangeEnd && (ev.end || ev.start) >= rangeStart) {
|
||||
events.push({ ...ev, recurring: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply RECURRENCE-ID overrides onto the expanded occurrence list. */
|
||||
function applyRecurrenceOverrides(rawEvents, events) {
|
||||
const overrides = rawEvents.filter(e => e.recurrenceId);
|
||||
for (const ov of overrides) {
|
||||
const target = events.find(e => e.uid && ov.uid && e.uid === ov.uid &&
|
||||
Math.abs(new Date(e.start).getTime() - ov.recurrenceId.getTime()) < 1000);
|
||||
if (target) {
|
||||
target.title = ov.title || target.title;
|
||||
target.location = ov.location !== undefined ? ov.location : target.location;
|
||||
target.description = ov.description !== undefined ? ov.description : target.description;
|
||||
target.start = ov.start;
|
||||
target.end = ov.end !== undefined ? ov.end : target.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ICS string into calendar events.
|
||||
* @param {string} ics raw iCalendar text
|
||||
* @param {{start: Date, end: Date}} range window; recurring events are expanded into it
|
||||
* @returns {{events: Array, errors: string[]}}
|
||||
*/
|
||||
function parseIcs(ics, range) {
|
||||
const errors = [];
|
||||
const rangeStart = range && range.start ? new Date(range.start) : new Date(Date.now() - 30 * 86400000);
|
||||
const rangeEnd = range && range.end ? new Date(range.end) : new Date(Date.now() + 60 * 86400000);
|
||||
|
||||
const rawEvents = collectRawEvents(unfoldLines(ics));
|
||||
|
||||
const events = [];
|
||||
for (const ev of rawEvents) {
|
||||
if (events.length >= MAX_EVENTS) break;
|
||||
materializeEvent(ev, rangeStart, rangeEnd, events, errors);
|
||||
}
|
||||
|
||||
applyRecurrenceOverrides(rawEvents, events);
|
||||
|
||||
events.sort((a, b) => new Date(a.start) - new Date(b.start));
|
||||
return { events: events.slice(0, MAX_EVENTS), errors };
|
||||
}
|
||||
|
||||
/** Walk unfolded lines and collect raw VEVENT records. */
|
||||
function collectRawEvents(lines) {
|
||||
const rawEvents = [];
|
||||
let cur = null;
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const parsed = parseContentLine(line);
|
||||
if (!parsed) continue;
|
||||
const { name, params, value } = parsed;
|
||||
if (name === 'BEGIN' && value.toUpperCase() === 'VEVENT') { cur = { params: {} }; continue; }
|
||||
if (name === 'END' && value.toUpperCase() === 'VEVENT') {
|
||||
// Keep events with a bad DTSTART too so they surface in `errors`
|
||||
if (cur && (cur.start || cur.badStart)) rawEvents.push(cur);
|
||||
cur = null;
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
assignEventProperty(cur, name, params, value);
|
||||
}
|
||||
return rawEvents;
|
||||
}
|
||||
|
||||
/** Store one content line onto the accumulating VEVENT record. */
|
||||
function assignEventProperty(cur, name, params, value) {
|
||||
switch (name) {
|
||||
case 'UID': cur.uid = value.trim(); break;
|
||||
case 'SUMMARY': cur.title = unescapeText(value).trim(); break;
|
||||
case 'LOCATION': cur.location = unescapeText(value).trim(); break;
|
||||
case 'DESCRIPTION': cur.description = unescapeText(value).trim(); break;
|
||||
case 'DTSTART':
|
||||
cur.start = parseIcsDate(value, params);
|
||||
cur.allDay = !/T\d{6}/.test(value.trim());
|
||||
if (!cur.start) cur.badStart = true;
|
||||
break;
|
||||
case 'DTEND': cur.end = parseIcsDate(value, params); break;
|
||||
case 'RRULE': cur.rrule = value; break;
|
||||
case 'EXDATE':
|
||||
cur.exdates = (cur.exdates || []).concat(
|
||||
value.split(',').map(v => parseIcsDate(v, params)).filter(Boolean)
|
||||
);
|
||||
break;
|
||||
case 'RECURRENCE-ID':
|
||||
cur.recurrenceId = parseIcsDate(value, params);
|
||||
break;
|
||||
default: break; // VALARM sub-components are skipped via their own BEGIN/END
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseIcs, parseIcsDate, unfoldLines, expandRecurrence, MAX_EVENTS };
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* DC-161: Radicale auto-provisioner — the zero-config auto-wiring core.
|
||||
*
|
||||
* When DashCaddy deploys Radicale through the App Selector, this module:
|
||||
* 1. Generates a strong calendar password (never shown in a deploy form)
|
||||
* 2. Writes the Radicale config (htpasswd auth) + htpasswd file into the
|
||||
* mounted config volume BEFORE the container starts
|
||||
* 3. Stores the credentials in the DashCaddy credential manager under
|
||||
* `calendar:radicale:<subdomain>`
|
||||
* 4. Registers a calendar source so the dashboard calendar widget finds
|
||||
* it automatically on first render — no user input at all
|
||||
*
|
||||
* The htpasswd hash is APR1 (MD5-crypt) — the format Radicale's default
|
||||
* `radicale.auth.type = htpasswd` accepts, generated with Node's crypto
|
||||
* (no external tools at deploy time).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const RADICALE_CONFIG_TEMPLATE = `# Generated by DashCaddy DC-161 auto-provisioner — safe to regenerate
|
||||
[server]
|
||||
hosts = 0.0.0.0:5232
|
||||
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = /config/users
|
||||
htpasswd_encryption = md5
|
||||
|
||||
[rights]
|
||||
type = owner_only
|
||||
|
||||
[storage]
|
||||
filesystem_folder = /data/collections
|
||||
|
||||
[web]
|
||||
type = internal
|
||||
`;
|
||||
|
||||
function generatePassword() {
|
||||
// 24 chars from a 62-char alphabet ≈ 142 bits — beyond brute-force reach
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const bytes = crypto.randomBytes(24);
|
||||
let out = '';
|
||||
for (const b of bytes) out += alphabet[b % alphabet.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** APR1 (Apache MD5-crypt) hash — pure Node, matches `openssl passwd -apr1`. */
|
||||
function apr1Hash(password, salt) {
|
||||
let saltStr = String(salt || '');
|
||||
if (!saltStr) {
|
||||
// Random 8-char salt from the APR1 alphabet when none supplied
|
||||
const alphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const bytes = crypto.randomBytes(8);
|
||||
saltStr = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
|
||||
}
|
||||
saltStr = saltStr.slice(0, 8);
|
||||
const pw = Buffer.from(String(password), 'utf8');
|
||||
const s = Buffer.from(saltStr, 'ascii');
|
||||
|
||||
// Step 2: B = md5(pw + salt + pw)
|
||||
const bDigest = crypto.createHash('md5').update(pw).update(s).update(pw).digest();
|
||||
|
||||
// Step 1 + 3 + 4: ONE rolling hash — pw, "$apr1$", salt, then B folded in
|
||||
// 16 bytes at a time (tail truncated), then the "weird" loop feeding
|
||||
// B[0] on odd bits and pw[0] on even bits. (md5(md5(x)||y) ≠ MD5Update —
|
||||
// the accumulator must CONTINUE, never re-digest.)
|
||||
const ctx = crypto.createHash('md5').update(pw).update('$apr1$').update(s);
|
||||
let i = pw.length;
|
||||
while (i > 0) {
|
||||
ctx.update(i > 16 ? bDigest : bDigest.subarray(0, i));
|
||||
i -= 16;
|
||||
}
|
||||
// Canonical APR (crypto/apr_md5.c): final is memset to ZERO before the
|
||||
// weird loop, so odd bits feed a literal 0x00 byte; even bits feed pw[0].
|
||||
i = pw.length;
|
||||
while (i > 0) {
|
||||
ctx.update(i & 1 ? Buffer.from([0]) : pw.subarray(0, 1));
|
||||
i >>= 1;
|
||||
}
|
||||
let final = ctx.digest();
|
||||
|
||||
// 1000-iteration round: odd → (pw, final), even → (final, pw).
|
||||
for (let k = 0; k < 1000; k++) {
|
||||
const h = crypto.createHash('md5');
|
||||
if (k & 1) h.update(pw); else h.update(final);
|
||||
if (k % 3) h.update(s);
|
||||
if (k % 7) h.update(pw);
|
||||
if (k & 1) h.update(final); else h.update(pw);
|
||||
final = h.digest();
|
||||
}
|
||||
|
||||
return `$apr1$${saltStr}$${toBase64Apr1(final)}`;
|
||||
}
|
||||
|
||||
function toBase64Apr1(digest) {
|
||||
const chars = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const groups = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]];
|
||||
let out = '';
|
||||
for (const [a, b, c] of groups) {
|
||||
let n = (digest[a] << 16) | (digest[b] << 8) | digest[c];
|
||||
for (let k = 0; k < 4; k++) { out += chars[n & 0x3f]; n >>= 6; }
|
||||
}
|
||||
const n = digest[11];
|
||||
out += chars[n & 0x3f];
|
||||
out += chars[(n >> 6) & 0x3f];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (idempotently) the Radicale config + htpasswd into the host-side
|
||||
* config volume directory. Called before container start; if the files
|
||||
* already exist with the same content, nothing is written.
|
||||
* @param {string} configDir host path bound to /config inside the container
|
||||
* @param {{user: string, pass: string}} creds
|
||||
* @returns {Promise<{written: boolean, configDir: string}>}
|
||||
*/
|
||||
async function writeRadicaleConfig(configDir, { user, pass }) {
|
||||
if (!configDir || path.basename(configDir) === '/') throw new Error('Radicale config dir missing');
|
||||
fsSync.mkdirSync(configDir, { recursive: true });
|
||||
const configFile = path.join(configDir, 'config');
|
||||
const usersFile = path.join(configDir, 'users');
|
||||
|
||||
const hash = apr1Hash(pass);
|
||||
const userLine = `${user}:${hash}\n`;
|
||||
|
||||
await fs.writeFile(configFile, RADICALE_CONFIG_TEMPLATE, 'utf8');
|
||||
await fs.writeFile(usersFile, userLine, { encoding: 'utf8', mode: 0o600 });
|
||||
return { written: true, configDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful Radicale deploy: register the calendar source so the
|
||||
* widget auto-discovers it, and persist credentials.
|
||||
* @param {Object} deps
|
||||
* @returns {Promise<{source: Object}>}
|
||||
*/
|
||||
async function registerAutoWiredSource({ calendarStateManager, credentialManager, subdomain, serviceUrl, creds, templateId = 'radicale' }) {
|
||||
const username = creds.user;
|
||||
const credKey = `calendar:${templateId}:${subdomain}`;
|
||||
await credentialManager.store(credKey, JSON.stringify({ user: username, pass: creds.pass }), {
|
||||
label: `Calendar (${templateId}) — ${subdomain}`, auto: true, createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const source = {
|
||||
id: `auto-${templateId}-${subdomain}`,
|
||||
type: 'caldav',
|
||||
name: `Calendar (${subdomain})`,
|
||||
url: `${String(serviceUrl).replace(/\/$/, '')}/${username}/`,
|
||||
user: username,
|
||||
autoProvisioned: true,
|
||||
templateId,
|
||||
subdomain,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await calendarStateManager.addItem(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
module.exports = { generatePassword, apr1Hash, writeRadicaleConfig, registerAutoWiredSource, RADICALE_CONFIG_TEMPLATE };
|
||||
@@ -33,6 +33,7 @@ function assembleContext({
|
||||
// State managers
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
calendarStateManager,
|
||||
|
||||
// DC-053 share store
|
||||
shareStore,
|
||||
@@ -194,6 +195,7 @@ function assembleContext({
|
||||
// State managers
|
||||
servicesStateManager,
|
||||
configStateManager,
|
||||
calendarStateManager,
|
||||
|
||||
// DC-053 share store
|
||||
shareStore,
|
||||
|
||||
@@ -251,6 +251,38 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
|
||||
// === PRODUCTIVITY & TOOLS ===
|
||||
|
||||
// DC-161: Radicale — lightweight CalDAV/CardDAV server. When deployed via
|
||||
// DashCaddy the calendar widget auto-wires to it (see routes/calendar.js):
|
||||
// the deploy hook provisions config+credentials and registers the source.
|
||||
"radicale": {
|
||||
name: "Radicale Calendar",
|
||||
description: "Lightweight self-hosted calendar (CalDAV) and contacts (CardDAV) server. Auto-connects to the DashCaddy calendar widget.",
|
||||
icon: "📅",
|
||||
category: "Productivity",
|
||||
popularity: 68,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "tomsquest/docker-radicale:latest",
|
||||
ports: ["{{PORT}}:5232"],
|
||||
volumes: [
|
||||
"/opt/radicale/config:/config",
|
||||
"/opt/radicale/data:/data"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "calendar",
|
||||
defaultPort: 5232,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Calendar is auto-wired into the DashCaddy dashboard widget",
|
||||
"Add the CalDAV URL to your phone/computer calendar apps: https://<domain>/<username>/",
|
||||
"Manage sharing and additional users from the Radicale web interface"
|
||||
],
|
||||
secrets: []
|
||||
},
|
||||
|
||||
"nextcloud": {
|
||||
name: "Nextcloud",
|
||||
description: "Self-hosted productivity platform and file sync",
|
||||
|
||||
Reference in new issue
Block a user