/** * CalDAV server for Redlight * * Supports PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS — enough for * Thunderbird/Lightning, Apple Calendar, GNOME Calendar and DAVx⁵ (Android). * * Authentication: HTTP Basic Auth → email:caldav_token * Token management: POST/GET/DELETE /api/calendar/caldav-tokens * * Mounted at: /caldav */ import { Router, text } from 'express'; import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { log } from '../config/logger.js'; const router = Router(); // ── Body parsing for XML and iCalendar payloads ──────────────────────────── router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] })); // ── Helpers ──────────────────────────────────────────────────────────────── function xmlHeader() { return ''; } function escapeXml(str) { return String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function escapeICS(str) { return String(str || '') .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\n/g, '\\n'); } function foldICSLine(line) { // RFC 5545: fold lines longer than 75 octets const bytes = Buffer.from(line, 'utf8'); if (bytes.length <= 75) return line; const chunks = []; let offset = 0; let first = true; while (offset < bytes.length) { const chunk = first ? bytes.slice(0, 75) : bytes.slice(offset, offset + 74); chunks.push((first ? '' : ' ') + chunk.toString('utf8')); offset += first ? 75 : 74; first = false; } return chunks.join('\r\n'); } function toICSDate(dateStr) { const d = new Date(dateStr); return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); } function parseICSDate(str) { if (!str) return null; // Strip TZID= prefix if present const raw = str.includes(':') ? str.split(':').pop() : str; if (raw.length === 8) { // All-day: YYYYMMDD return new Date(`${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T00:00:00Z`); } return new Date( `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}T` + `${raw.slice(9, 11)}:${raw.slice(11, 13)}:${raw.slice(13, 15)}` + (raw.endsWith('Z') ? 'Z' : 'Z'), ); } function getICSProp(ics, key) { const re = new RegExp(`^${key}(?:;[^:]*)?:(.+)$`, 'im'); const m = ics.match(re); if (!m) return null; // Unfold: join continuation lines let v = m[1]; const unfoldRe = /\r?\n[ \t](.+)/g; v = v.replace(unfoldRe, '$1'); return v.trim(); } function eventToICS(event) { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Redlight//CalDAV//EN', 'CALSCALE:GREGORIAN', 'BEGIN:VEVENT', `UID:${event.uid}`, `SUMMARY:${escapeICS(event.title)}`, `DTSTART:${toICSDate(event.start_time)}`, `DTEND:${toICSDate(event.end_time)}`, `DTSTAMP:${toICSDate(event.updated_at || event.created_at)}`, `LAST-MODIFIED:${toICSDate(event.updated_at || event.created_at)}`, ]; if (event.description) { lines.push(`DESCRIPTION:${escapeICS(event.description)}`); } if (event.room_uid) { lines.push(`X-REDLIGHT-ROOM-UID:${event.room_uid}`); } if (event.federated_join_url) { lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(event.federated_join_url)}`); } lines.push('END:VEVENT', 'END:VCALENDAR'); return lines.map(foldICSLine).join('\r\n'); } function parseICSBody(body) { const uid = getICSProp(body, 'UID'); const summary = (getICSProp(body, 'SUMMARY') || '') .replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';'); const description = (getICSProp(body, 'DESCRIPTION') || '') .replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';'); const dtstart = getICSProp(body, 'DTSTART'); const dtend = getICSProp(body, 'DTEND'); return { uid: uid || null, title: summary || 'Untitled', description: description || null, start_time: parseICSDate(dtstart), end_time: parseICSDate(dtend), }; } // Build etag from updated_at function etag(event) { return `"${Buffer.from(event.updated_at || event.created_at).toString('base64')}"`; } // ── CalDAV authentication middleware ─────────────────────────────────────── async function caldavAuth(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Basic ')) { res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); return res.status(401).end(); } try { const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); const colonIdx = decoded.indexOf(':'); if (colonIdx === -1) throw new Error('no colon'); const email = decoded.slice(0, colonIdx); const token = decoded.slice(colonIdx + 1); const db = getDb(); const user = await db.get( 'SELECT id, name, display_name, email FROM users WHERE email = ?', [email], ); if (!user) { res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); return res.status(401).end(); } const tokenRow = await db.get( 'SELECT * FROM caldav_tokens WHERE user_id = ? AND token = ?', [user.id, token], ); if (!tokenRow) { res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); return res.status(401).end(); } // Update last_used_at (fire and forget) db.run('UPDATE caldav_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [tokenRow.id]).catch(() => {}); req.caldavUser = user; next(); } catch (err) { log.server.error(`CalDAV auth error: ${err.message}`); res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"'); return res.status(401).end(); } } // ── Common response headers ──────────────────────────────────────────────── function setDAVHeaders(res) { res.set('DAV', '1, 2, calendar-access'); res.set('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT'); res.set('MS-Author-Via', 'DAV'); } // ── Base URL helper ──────────────────────────────────────────────────────── function baseUrl(req) { const proto = req.get('x-forwarded-proto') || req.protocol; const host = req.get('x-forwarded-host') || req.get('host'); return `${proto}://${host}`; } // ── PROPFIND response builders ───────────────────────────────────────────── function multistatus(responses) { return `${xmlHeader()} ${responses.join('\n')} `; } function propResponse(href, props, status = '200 OK') { const propXml = Object.entries(props) .map(([k, v]) => ` <${k}>${v}`) .join('\n'); return ` ${escapeXml(href)} ${propXml} HTTP/1.1 ${status} `; } // ── OPTIONS ──────────────────────────────────────────────────────────────── router.options('*', (req, res) => { setDAVHeaders(res); res.status(200).end(); }); // ── PROPFIND / ───────────────────────────────────────────────────────────── // Service discovery root: tells the client where the user principal lives. router.all('/', caldavAuth, async (req, res) => { if (req.method !== 'PROPFIND') { setDAVHeaders(res); return res.status(405).end(); } const base = baseUrl(req); const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`; setDAVHeaders(res); res.status(207).type('application/xml; charset=utf-8').send( multistatus([ propResponse('/caldav/', { 'd:current-user-principal': `${principalHref}`, 'd:principal-URL': `${principalHref}`, 'd:resourcetype': '', 'd:displayname': 'Redlight CalDAV', }), ]), ); }); // ── PROPFIND /{username}/ ────────────────────────────────────────────────── // User principal: tells the client where the calendar home is. router.all('/:username/', caldavAuth, async (req, res) => { if (req.method !== 'PROPFIND') { setDAVHeaders(res); return res.status(405).end(); } const principalHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/`; const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`; setDAVHeaders(res); res.status(207).type('application/xml; charset=utf-8').send( multistatus([ propResponse(principalHref, { 'd:resourcetype': '', 'd:displayname': escapeXml(req.caldavUser.display_name || req.caldavUser.name), 'd:principal-URL': `${principalHref}`, 'c:calendar-home-set': `${calendarHref}`, 'd:current-user-principal': `${principalHref}`, }), ]), ); }); // ── PROPFIND + REPORT /{username}/calendar/ ──────────────────────────────── router.all('/:username/calendar/', caldavAuth, async (req, res) => { const db = getDb(); const calendarHref = `/caldav/${encodeURIComponent(req.caldavUser.email)}/calendar/`; // PROPFIND: return calendar collection metadata if (req.method === 'PROPFIND') { const latestEvent = await db.get( 'SELECT updated_at, created_at FROM calendar_events WHERE user_id = ? ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 1', [req.caldavUser.id], ); const ctag = latestEvent ? Buffer.from(String(latestEvent.updated_at || latestEvent.created_at)).toString('base64') : '0'; // If Depth: 1, also return all event hrefs const depth = req.headers.depth || '0'; const responses = [ propResponse(calendarHref, { 'd:resourcetype': '', 'd:displayname': 'Redlight Calendar', 'c:supported-calendar-component-set': '', 'cs:getctag': escapeXml(ctag), 'd:sync-token': escapeXml(ctag), }), ]; if (depth === '1') { const events = await db.all( 'SELECT uid, updated_at, created_at FROM calendar_events WHERE user_id = ?', [req.caldavUser.id], ); for (const ev of events) { responses.push( propResponse(`${calendarHref}${ev.uid}.ics`, { 'd:resourcetype': '', 'd:getcontenttype': 'text/calendar; charset=utf-8', 'd:getetag': escapeXml(etag(ev)), }), ); } } setDAVHeaders(res); return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses)); } // REPORT: calendar-query or calendar-multiget if (req.method === 'REPORT') { const body = typeof req.body === 'string' ? req.body : ''; // calendar-multiget: client sends explicit hrefs if (body.includes('calendar-multiget')) { const hrefMatches = [...body.matchAll(/<[^:>]*:?href[^>]*>([^<]+)<\//gi)]; const uids = hrefMatches .map(m => m[1].trim()) .filter(h => h.endsWith('.ics')) .map(h => h.split('/').pop().replace('.ics', '')); const responses = []; for (const uid of uids) { const ev = await db.get( 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id], ); if (!ev) continue; const ics = eventToICS(ev); responses.push( propResponse(`${calendarHref}${ev.uid}.ics`, { 'd:getetag': escapeXml(etag(ev)), 'c:calendar-data': escapeXml(ics), }), ); } setDAVHeaders(res); return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses)); } // calendar-query: filter by time range let sql = 'SELECT * FROM calendar_events WHERE user_id = ?'; const params = [req.caldavUser.id]; const startMatch = body.match(/start="([^"]+)"/i); const endMatch = body.match(/end="([^"]+)"/i); if (startMatch) { const startIso = parseICSDate(startMatch[1])?.toISOString(); if (startIso) { sql += ' AND end_time >= ?'; params.push(startIso); } } if (endMatch) { const endIso = parseICSDate(endMatch[1])?.toISOString(); if (endIso) { sql += ' AND start_time <= ?'; params.push(endIso); } } sql += ' ORDER BY start_time ASC'; const events = await db.all(sql, params); const responses = events.map(ev => propResponse(`${calendarHref}${ev.uid}.ics`, { 'd:getetag': escapeXml(etag(ev)), 'c:calendar-data': escapeXml(eventToICS(ev)), }), ); setDAVHeaders(res); return res.status(207).type('application/xml; charset=utf-8').send(multistatus(responses)); } setDAVHeaders(res); res.status(405).end(); }); // ── GET /{username}/calendar/{uid}.ics ──────────────────────────────────── router.get('/:username/calendar/:filename', caldavAuth, async (req, res) => { const uid = req.params.filename.replace(/\.ics$/, ''); const db = getDb(); const ev = await db.get( 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id], ); if (!ev) return res.status(404).end(); setDAVHeaders(res); res.set('ETag', etag(ev)); res.set('Content-Type', 'text/calendar; charset=utf-8'); res.send(eventToICS(ev)); }); // ── PUT /{username}/calendar/{uid}.ics — create or update ───────────────── router.put('/:username/calendar/:filename', caldavAuth, async (req, res) => { const uid = req.params.filename.replace(/\.ics$/, ''); const body = typeof req.body === 'string' ? req.body : ''; if (!body) return res.status(400).end(); const parsed = parseICSBody(body); if (!parsed.start_time || !parsed.end_time) return res.status(400).end(); // Normalize UID: prefer from ICS, fall back to filename const eventUid = parsed.uid || uid; const db = getDb(); const existing = await db.get( 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', [eventUid, req.caldavUser.id], ); try { if (existing) { await db.run( `UPDATE calendar_events SET title = ?, description = ?, start_time = ?, end_time = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ? AND user_id = ?`, [ parsed.title, parsed.description, parsed.start_time.toISOString(), parsed.end_time.toISOString(), eventUid, req.caldavUser.id, ], ); const updated = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]); setDAVHeaders(res); res.set('ETag', etag(updated)); return res.status(204).end(); } else { await db.run( `INSERT INTO calendar_events (uid, title, description, start_time, end_time, user_id, color) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ eventUid, parsed.title, parsed.description, parsed.start_time.toISOString(), parsed.end_time.toISOString(), req.caldavUser.id, '#6366f1', ], ); const created = await db.get('SELECT * FROM calendar_events WHERE uid = ?', [eventUid]); setDAVHeaders(res); res.set('ETag', etag(created)); return res.status(201).end(); } } catch (err) { log.server.error(`CalDAV PUT error: ${err.message}`); return res.status(500).end(); } }); // ── DELETE /{username}/calendar/{uid}.ics ───────────────────────────────── router.delete('/:username/calendar/:filename', caldavAuth, async (req, res) => { const uid = req.params.filename.replace(/\.ics$/, ''); const db = getDb(); const ev = await db.get( 'SELECT * FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id], ); if (!ev) return res.status(404).end(); await db.run('DELETE FROM calendar_events WHERE uid = ? AND user_id = ?', [uid, req.caldavUser.id]); setDAVHeaders(res); res.status(204).end(); }); // ── Fallback ─────────────────────────────────────────────────────────────── router.all('*', caldavAuth, (req, res) => { setDAVHeaders(res); res.status(405).end(); }); export default router;