- 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.
223 lines
10 KiB
JavaScript
223 lines
10 KiB
JavaScript
/**
|
|
* DC-161: Calendar routes — wire any CalDAV/ICS calendar into the dashboard.
|
|
*
|
|
* GET /api/v1/calendar/sources list configured sources (secrets never returned)
|
|
* POST /api/v1/calendar/sources add a source (caldav | ics)
|
|
* PUT /api/v1/calendar/sources/:id update (url / user / pass / enabled / name)
|
|
* DELETE /api/v1/calendar/sources/:id remove
|
|
* GET /api/v1/calendar/events?start=&end=&sourceId= aggregated events across enabled sources
|
|
* POST /api/v1/calendar/radicale/provision auto-wire a DashCaddy-deployed Radicale
|
|
* POST /api/v1/calendar/discover probe URL for CalDAV capability (used by Settings modal)
|
|
*
|
|
* Auto-wiring contract (Sami, 2026-10): when DashCaddy itself deployed the
|
|
* calendar server, the widget must connect with zero user input — the
|
|
* provision endpoint pairs the credential manager with a calendar source
|
|
* record; the dashboard reads sources on load and renders silently.
|
|
*
|
|
* Route naming follows the public-routes list in src/utilities/middleware.js:
|
|
* everything here requires auth (calendar data is personal).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const { ValidationError } = require('../src/utilities/errors');
|
|
const { probeCalendar, fetchEventsInRange } = require('../src/calendar/caldav-client');
|
|
const { parseIcs } = require('../src/calendar/ics-parser');
|
|
const {
|
|
generatePassword, writeRadicaleConfig, registerAutoWiredSource,
|
|
} = require('../src/calendar/radicale-provisioner');
|
|
|
|
const CONFIG_DIR_ROOT = process.env.DC_RADICALE_CONFIG_ROOT || '/opt/radicale';
|
|
|
|
module.exports = function createCalendarRoutes({
|
|
calendarStateManager,
|
|
credentialManager,
|
|
docker,
|
|
asyncHandler,
|
|
ok,
|
|
errorResponse,
|
|
log,
|
|
fetchT,
|
|
buildServiceUrl,
|
|
}) {
|
|
const router = express.Router();
|
|
|
|
function findSource(id) {
|
|
return calendarStateManager.findItem(id);
|
|
}
|
|
|
|
// ---- Sources CRUD -------------------------------------------------------
|
|
|
|
router.get('/sources', asyncHandler(async (req, res) => {
|
|
const sources = await calendarStateManager.read();
|
|
// Never return stored secrets — only metadata.
|
|
ok(res, { sources });
|
|
}, 'calendar-list-sources'));
|
|
|
|
router.post('/sources', asyncHandler(async (req, res) => {
|
|
const { name, type, url, user, pass, token, enabled } = req.body || {};
|
|
if (!name || !type || !url) throw new ValidationError('[DC-405] name, type and url are required');
|
|
if (!['caldav', 'ics'].includes(type)) throw new ValidationError('[DC-405] type must be "caldav" or "ics"');
|
|
let parsed;
|
|
try { parsed = new URL(url); } catch (_) { throw new ValidationError('[DC-405] url must be an absolute URL'); }
|
|
if (!['https:', 'http:'].includes(parsed.protocol)) throw new ValidationError('[DC-405] url must be http(s)');
|
|
if (parsed.protocol === 'http:' && !['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)) {
|
|
throw new ValidationError('[DC-401] plaintext HTTP is only allowed for localhost sources');
|
|
}
|
|
if (type === 'caldav' && !user && !token) throw new ValidationError('[DC-405] CalDAV sources need user+pass or a token');
|
|
|
|
const source = {
|
|
id: `cal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
type,
|
|
name: String(name).slice(0, 120),
|
|
url: parsed.toString(),
|
|
user: user || null,
|
|
enabled: enabled !== false,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
if (pass || token) {
|
|
await credentialManager.store(`calendar:source:${source.id}`,
|
|
JSON.stringify({ pass: pass || null, token: token || null }),
|
|
{ label: `Calendar source ${name}`, createdAt: source.createdAt });
|
|
}
|
|
await calendarStateManager.addItem(source);
|
|
log.info('calendar', 'Source added', { id: source.id, type, url: source.url });
|
|
ok(res, { source });
|
|
}, 'calendar-add-source'));
|
|
|
|
router.put('/sources/:id', asyncHandler(async (req, res) => {
|
|
const existing = await findSource(req.params.id);
|
|
if (!existing) return errorResponse(res, 404, '[DC-404] Calendar source not found');
|
|
const updates = {};
|
|
for (const field of ['name', 'url', 'enabled']) {
|
|
if (req.body[field] !== undefined) updates[field] = req.body[field];
|
|
}
|
|
if (updates.url) {
|
|
try { new URL(updates.url); } catch (_) { throw new ValidationError('[DC-405] url must be an absolute URL'); }
|
|
}
|
|
if (req.body.pass !== undefined || req.body.token !== undefined) {
|
|
await credentialManager.store(`calendar:source:${existing.id}`,
|
|
JSON.stringify({ pass: req.body.pass ?? null, token: req.body.token ?? null }),
|
|
{ label: `Calendar source ${existing.name}`, updatedAt: new Date().toISOString() });
|
|
}
|
|
await calendarStateManager.updateItem(existing.id, updates);
|
|
ok(res, { updated: true });
|
|
}, 'calendar-update-source'));
|
|
|
|
router.delete('/sources/:id', asyncHandler(async (req, res) => {
|
|
const existing = await findSource(req.params.id);
|
|
if (!existing) return errorResponse(res, 404, '[DC-404] Calendar source not found');
|
|
await calendarStateManager.removeItem(existing.id);
|
|
try { await credentialManager.delete(`calendar:source:${existing.id}`); } catch (_) { /* already gone */ }
|
|
ok(res, { removed: true });
|
|
}, 'calendar-remove-source'));
|
|
|
|
// ---- Events -------------------------------------------------------------
|
|
|
|
router.get('/events', asyncHandler(async (req, res) => {
|
|
const now = new Date();
|
|
const start = req.query.start ? new Date(String(req.query.start)) : new Date(now.getTime() - 7 * 86400000);
|
|
const end = req.query.end ? new Date(String(req.query.end)) : new Date(now.getTime() + 30 * 86400000);
|
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
throw new ValidationError('[DC-405] start/end must be ISO dates');
|
|
}
|
|
if (end - start > 400 * 86400000) throw new ValidationError('[DC-405] range exceeds 400 days');
|
|
|
|
const sources = (await calendarStateManager.read()).filter(s => s.enabled !== false);
|
|
const wanted = req.query.sourceId ? sources.filter(s => s.id === req.query.sourceId) : sources;
|
|
|
|
const results = await Promise.allSettled(wanted.map(async (source) => {
|
|
if (source.type === 'ics') {
|
|
const resp = await fetchT(source.url, { timeout: 15000 });
|
|
if (!resp.ok) throw new Error(`ICS fetch HTTP ${resp.status}`);
|
|
const text = await resp.text();
|
|
const parsed = parseIcs(text, { start, end });
|
|
return { sourceId: source.id, name: source.name, events: parsed.events, errors: parsed.errors };
|
|
}
|
|
// caldav — resolve secret at call time
|
|
let auth = { user: source.user || '', pass: '' };
|
|
try {
|
|
const raw = await credentialManager.retrieve(`calendar:source:${source.id}`);
|
|
if (raw) {
|
|
const creds = JSON.parse(raw);
|
|
if (creds.token) auth = { token: creds.token };
|
|
else auth = { user: source.user || '', pass: creds.pass || '' };
|
|
}
|
|
} catch (_) { /* fall through with empty pass */ }
|
|
const out = await fetchEventsInRange(source.url, auth, start, end);
|
|
return { sourceId: source.id, name: source.name, events: out.events, errors: out.errors };
|
|
}));
|
|
|
|
const perSource = [];
|
|
const events = [];
|
|
const errors = [];
|
|
for (const r of results) {
|
|
if (r.status === 'fulfilled') {
|
|
perSource.push({ sourceId: r.value.sourceId, name: r.value.name, count: r.value.events.length, ok: true });
|
|
events.push(...r.value.events);
|
|
errors.push(...r.value.errors.map(e => `${r.value.name}: ${e}`));
|
|
} else {
|
|
const src = wanted[results.indexOf(r)];
|
|
perSource.push({ sourceId: src ? src.id : null, name: src ? src.name : 'unknown', count: 0, ok: false });
|
|
errors.push(`${src ? src.name : 'source'}: ${r.reason && r.reason.message}`);
|
|
}
|
|
}
|
|
events.sort((a, b) => new Date(a.start) - new Date(b.start));
|
|
ok(res, { events: events.slice(0, 500), perSource, errors, window: { start: start.toISOString(), end: end.toISOString() } });
|
|
}, 'calendar-events'));
|
|
|
|
// ---- Radicale auto-provision (the zero-config path) ---------------------
|
|
|
|
router.post('/radicale/provision', asyncHandler(async (req, res) => {
|
|
const { subdomain, serviceUrl, containerId } = req.body || {};
|
|
if (!subdomain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain)) {
|
|
throw new ValidationError('[DC-405] valid subdomain required');
|
|
}
|
|
|
|
// Verify the Radicale container actually exists (we only auto-wire what we deployed)
|
|
if (docker && containerId) {
|
|
try {
|
|
const c = docker.client.getContainer(containerId);
|
|
const info = await c.inspect();
|
|
if (!info || /radicale/i.test(info.Config?.Image || '') === false) {
|
|
log.warn('calendar', 'Provision requested for non-Radicale image', { image: info.Config?.Image });
|
|
}
|
|
} catch (e) {
|
|
log.warn('calendar', 'Container inspect failed during provision (continuing)', { error: e.message });
|
|
}
|
|
}
|
|
|
|
const user = 'dashcaddy';
|
|
const pass = generatePassword();
|
|
const configDir = `${CONFIG_DIR_ROOT}/config`;
|
|
await writeRadicaleConfig(configDir, { user, pass });
|
|
|
|
const url = serviceUrl || buildServiceUrl(subdomain);
|
|
const source = await registerAutoWiredSource({
|
|
calendarStateManager, credentialManager, subdomain,
|
|
serviceUrl: url, creds: { user, pass }, templateId: 'radicale',
|
|
});
|
|
log.info('calendar', 'Radicale auto-wired', { subdomain, sourceId: source.id });
|
|
ok(res, {
|
|
source,
|
|
message: 'Calendar auto-wired. Your appointments appear on the dashboard automatically.',
|
|
});
|
|
}, 'calendar-radicale-provision'));
|
|
|
|
// ---- Discovery probe (Settings modal "Test connection") ------------------
|
|
|
|
router.post('/discover', asyncHandler(async (req, res) => {
|
|
const { url, user, pass, token } = req.body || {};
|
|
if (!url) throw new ValidationError('[DC-405] url required');
|
|
try {
|
|
const result = await probeCalendar(url, token ? { token } : { user: user || '', pass: pass || '' });
|
|
return ok(res, { probe: result });
|
|
} catch (e) {
|
|
return ok(res, { probe: { ok: false, error: e.message } });
|
|
}
|
|
}, 'calendar-discover'));
|
|
|
|
return router;
|
|
};
|