538 lines
19 KiB
JavaScript
538 lines
19 KiB
JavaScript
/**
|
|
* 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';
|
|
import { getBaseUrl } from '../middleware/auth.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 '<?xml version="1.0" encoding="UTF-8"?>';
|
|
}
|
|
|
|
function escapeXml(str) {
|
|
return String(str || '')
|
|
.replace(/&/g, '&')
|
|
.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, base, user) {
|
|
// Determine the most useful join URL
|
|
const joinUrl = event.federated_join_url
|
|
|| (event.room_uid ? `${base}/join/${event.room_uid}` : null);
|
|
const roomUrl = event.room_uid ? `${base}/rooms/${event.room_uid}` : null;
|
|
|
|
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)}`,
|
|
];
|
|
|
|
// LOCATION: show join link so calendar apps display "where" the meeting is
|
|
if (joinUrl) {
|
|
lines.push(`LOCATION:${escapeICS(joinUrl)}`);
|
|
lines.push(`URL:${joinUrl}`);
|
|
} else if (roomUrl) {
|
|
lines.push(`LOCATION:${escapeICS(roomUrl)}`);
|
|
lines.push(`URL:${roomUrl}`);
|
|
}
|
|
|
|
// DESCRIPTION: combine user description + join link hint
|
|
const descParts = [];
|
|
if (event.description) descParts.push(event.description);
|
|
if (joinUrl) {
|
|
descParts.push(`Join meeting: ${joinUrl}`);
|
|
}
|
|
if (roomUrl && roomUrl !== joinUrl) {
|
|
descParts.push(`Room page: ${roomUrl}`);
|
|
}
|
|
if (descParts.length > 0) {
|
|
lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n'))}`);
|
|
}
|
|
|
|
// ORGANIZER
|
|
if (user) {
|
|
const cn = user.display_name || user.name || user.email;
|
|
lines.push(`ORGANIZER;CN=${escapeICS(cn)}:mailto:${user.email}`);
|
|
}
|
|
|
|
if (event.room_uid) {
|
|
lines.push(`X-REDLIGHT-ROOM-UID:${event.room_uid}`);
|
|
}
|
|
if (joinUrl) {
|
|
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
|
|
}
|
|
if (event.reminder_minutes) {
|
|
lines.push(
|
|
'BEGIN:VALARM',
|
|
'ACTION:DISPLAY',
|
|
`DESCRIPTION:${escapeICS(event.title)}`,
|
|
`TRIGGER:-PT${event.reminder_minutes}M`,
|
|
'END:VALARM',
|
|
);
|
|
}
|
|
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();
|
|
}
|
|
// Hash the provided token with SHA-256 for constant-time comparison in SQL
|
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
|
const tokenRow = await db.get(
|
|
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token_hash = ?',
|
|
[user.id, tokenHash],
|
|
);
|
|
// Fallback: also check legacy plaintext tokens for backward compatibility
|
|
const tokenRowLegacy = !tokenRow ? await db.get(
|
|
'SELECT * FROM caldav_tokens WHERE user_id = ? AND token = ?',
|
|
[user.id, token],
|
|
) : null;
|
|
const matchedToken = tokenRow || tokenRowLegacy;
|
|
if (!matchedToken) {
|
|
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
|
|
return res.status(401).end();
|
|
}
|
|
// Migrate legacy plaintext token to hashed version
|
|
if (tokenRowLegacy && !tokenRow) {
|
|
db.run("UPDATE caldav_tokens SET token_hash = ?, token = '' WHERE id = ?", [tokenHash, matchedToken.id]).catch(() => {});
|
|
}
|
|
// Update last_used_at (fire and forget)
|
|
db.run('UPDATE caldav_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [matchedToken.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');
|
|
}
|
|
|
|
// ── CalDAV username authorization ──────────────────────────────────────────
|
|
// Ensures the :username param matches the authenticated user's email
|
|
function validateCalDAVUser(req, res, next) {
|
|
if (req.params.username && decodeURIComponent(req.params.username) !== req.caldavUser.email) {
|
|
return res.status(403).end();
|
|
}
|
|
next();
|
|
}
|
|
|
|
// ── Base URL helper (uses shared getBaseUrl from auth.js) ──────────────────
|
|
const baseUrl = getBaseUrl;
|
|
|
|
// ── PROPFIND response builders ─────────────────────────────────────────────
|
|
|
|
function multistatus(responses) {
|
|
return `${xmlHeader()}
|
|
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"
|
|
xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
${responses.join('\n')}
|
|
</d:multistatus>`;
|
|
}
|
|
|
|
function propResponse(href, props, status = '200 OK') {
|
|
const propXml = Object.entries(props)
|
|
.map(([k, v]) => ` <${k}>${v}</${k}>`)
|
|
.join('\n');
|
|
return ` <d:response>
|
|
<d:href>${escapeXml(href)}</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
${propXml}
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 ${status}</d:status>
|
|
</d:propstat>
|
|
</d:response>`;
|
|
}
|
|
|
|
// ── 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': `<d:href>${principalHref}</d:href>`,
|
|
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
|
|
'd:resourcetype': '<d:collection/>',
|
|
'd:displayname': 'Redlight CalDAV',
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
// ── PROPFIND /{username}/ ──────────────────────────────────────────────────
|
|
// User principal: tells the client where the calendar home is.
|
|
router.all('/:username/', caldavAuth, validateCalDAVUser, 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:collection/><d:principal/>',
|
|
'd:displayname': escapeXml(req.caldavUser.display_name || req.caldavUser.name),
|
|
'd:principal-URL': `<d:href>${principalHref}</d:href>`,
|
|
'c:calendar-home-set': `<d:href>${calendarHref}</d:href>`,
|
|
'd:current-user-principal': `<d:href>${principalHref}</d:href>`,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
// ── PROPFIND + REPORT /{username}/calendar/ ────────────────────────────────
|
|
router.all('/:username/calendar/', caldavAuth, validateCalDAVUser, 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:collection/><c:calendar/>',
|
|
'd:displayname': 'Redlight Calendar',
|
|
'c:supported-calendar-component-set': '<c:comp name="VEVENT"/>',
|
|
'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(eventToICS(ev, baseUrl(req), req.caldavUser)),
|
|
}),
|
|
);
|
|
}
|
|
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, baseUrl(req), req.caldavUser)),
|
|
}),
|
|
);
|
|
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, validateCalDAVUser, 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, baseUrl(req), req.caldavUser));
|
|
});
|
|
|
|
// ── PUT /{username}/calendar/{uid}.ics — create or update ─────────────────
|
|
router.put('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, 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, validateCalDAVUser, 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;
|