feat(caldav): implement CalDAV support with token management and calendar operations
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s

This commit is contained in:
2026-03-03 11:41:35 +01:00
parent 68f31467af
commit ddc0c684ec
7 changed files with 794 additions and 7 deletions

View File

@@ -649,6 +649,36 @@ export async function initDatabase() {
`);
}
// ── CalDAV tokens ────────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS caldav_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS caldav_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_user_id ON caldav_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_caldav_tokens_token ON caldav_tokens(token);
`);
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {

View File

@@ -14,6 +14,7 @@ import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js';
import caldavRoutes from './routes/caldav.js';
import notificationRoutes from './routes/notifications.js';
import { startFederationSync } from './jobs/federationSync.js';
@@ -57,6 +58,8 @@ async function start() {
app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/notifications', notificationRoutes);
// CalDAV — mounted outside /api so calendar clients use a clean path
app.use('/caldav', caldavRoutes);
// Mount calendar federation receive also under /api/federation for remote instances
app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler);

479
server/routes/caldav.js Normal file
View File

@@ -0,0 +1,479 @@
/**
* 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 '<?xml version="1.0" encoding="UTF-8"?>';
}
function escapeXml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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()}
<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, 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, 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(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;

View File

@@ -677,4 +677,69 @@ function generateICS(event, location, prodIdDomain) {
return ics.join('\r\n');
}
// ── CalDAV token management ────────────────────────────────────────────────
// GET /api/calendar/caldav-tokens
router.get('/caldav-tokens', authenticateToken, async (req, res) => {
try {
const db = getDb();
const tokens = await db.all(
'SELECT id, name, created_at, last_used_at FROM caldav_tokens WHERE user_id = ? ORDER BY created_at DESC',
[req.user.id],
);
res.json({ tokens });
} catch (err) {
log.server.error(`CalDAV list tokens error: ${err.message}`);
res.status(500).json({ error: 'Could not load tokens' });
}
});
// POST /api/calendar/caldav-tokens
router.post('/caldav-tokens', authenticateToken, async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Token name is required' });
}
const db = getDb();
const count = await db.get(
'SELECT COUNT(*) as c FROM caldav_tokens WHERE user_id = ?',
[req.user.id],
);
if (count.c >= 10) {
return res.status(400).json({ error: 'Maximum of 10 tokens allowed' });
}
const token = crypto.randomBytes(32).toString('hex');
const result = await db.run(
'INSERT INTO caldav_tokens (user_id, token, name) VALUES (?, ?, ?)',
[req.user.id, token, name.trim()],
);
res.status(201).json({
token: { id: result.lastInsertRowid, name: name.trim() },
plainToken: token,
});
} catch (err) {
log.server.error(`CalDAV create token error: ${err.message}`);
res.status(500).json({ error: 'Could not create token' });
}
});
// DELETE /api/calendar/caldav-tokens/:id
router.delete('/caldav-tokens/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.run(
'DELETE FROM caldav_tokens WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id],
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Token not found' });
}
res.json({ ok: true });
} catch (err) {
log.server.error(`CalDAV delete token error: ${err.message}`);
res.status(500).json({ error: 'Could not delete token' });
}
});
export default router;