feat(caldav): implement CalDAV support with token management and calendar operations
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
479
server/routes/caldav.js
Normal 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, '&')
|
||||
.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()}
|
||||
<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;
|
||||
@@ -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;
|
||||
|
||||
@@ -294,7 +294,29 @@
|
||||
"passwordChanged": "Passwort geändert",
|
||||
"passwordChangeFailed": "Fehler beim Ändern",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"selectLanguage": "Sprache auswählen"
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
|
||||
"serverUrl": "Server-URL",
|
||||
"username": "Benutzername (E-Mail)",
|
||||
"hint": "Gib niemals dein echtes Redlight-Passwort in einer Kalender-App ein. Verwende stattdessen ein App-Token.",
|
||||
"newToken": "Neues App-Token generieren",
|
||||
"tokenNamePlaceholder": "z. B. \"iPhone\" oder \"Thunderbird\"",
|
||||
"generate": "Generieren",
|
||||
"existingTokens": "Aktive Tokens",
|
||||
"noTokens": "Noch keine Tokens erstellt.",
|
||||
"created": "Erstellt",
|
||||
"lastUsed": "Zuletzt verwendet",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeConfirm": "Dieses Token wirklich widerrufen? Alle Kalender-Apps, die dieses Token verwenden, verlieren den Zugriff.",
|
||||
"revoked": "Token widerrufen",
|
||||
"revokeFailed": "Token konnte nicht widerrufen werden",
|
||||
"createFailed": "Token konnte nicht erstellt werden",
|
||||
"newTokenCreated": "Token erstellt — jetzt kopieren!",
|
||||
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
|
||||
"dismiss": "Ich habe das Token kopiert"
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"selectTheme": "Theme auswählen",
|
||||
|
||||
@@ -294,7 +294,29 @@
|
||||
"passwordChanged": "Password changed",
|
||||
"passwordChangeFailed": "Error changing password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"selectLanguage": "Select language"
|
||||
"selectLanguage": "Select language",
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
|
||||
"serverUrl": "Server URL",
|
||||
"username": "Username (Email)",
|
||||
"hint": "Never enter your real Redlight password in a calendar app. Use an app token instead.",
|
||||
"newToken": "Generate new app token",
|
||||
"tokenNamePlaceholder": "e.g. \"iPhone\" or \"Thunderbird\"",
|
||||
"generate": "Generate",
|
||||
"existingTokens": "Active tokens",
|
||||
"noTokens": "No tokens created yet.",
|
||||
"created": "Created",
|
||||
"lastUsed": "Last used",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "Really revoke this token? All connected calendar apps using this token will lose access.",
|
||||
"revoked": "Token revoked",
|
||||
"revokeFailed": "Could not revoke token",
|
||||
"createFailed": "Could not create token",
|
||||
"newTokenCreated": "Token created — copy it now!",
|
||||
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
|
||||
"dismiss": "I have copied the token"
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"selectTheme": "Select theme",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -38,6 +38,53 @@ export default function Settings() {
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// CalDAV token state
|
||||
const [caldavTokens, setCaldavTokens] = useState([]);
|
||||
const [caldavLoading, setCaldavLoading] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [creatingToken, setCreatingToken] = useState(false);
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
||||
const [tokenVisible, setTokenVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'caldav') {
|
||||
setCaldavLoading(true);
|
||||
api.get('/calendar/caldav-tokens')
|
||||
.then(r => setCaldavTokens(r.data.tokens || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setCaldavLoading(false));
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const handleCreateToken = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newTokenName.trim()) return;
|
||||
setCreatingToken(true);
|
||||
try {
|
||||
const res = await api.post('/calendar/caldav-tokens', { name: newTokenName.trim() });
|
||||
setNewlyCreatedToken(res.data.plainToken);
|
||||
setTokenVisible(false);
|
||||
setNewTokenName('');
|
||||
const r = await api.get('/calendar/caldav-tokens');
|
||||
setCaldavTokens(r.data.tokens || []);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.caldav.createFailed'));
|
||||
} finally {
|
||||
setCreatingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeToken = async (id) => {
|
||||
if (!confirm(t('settings.caldav.revokeConfirm'))) return;
|
||||
try {
|
||||
await api.delete(`/calendar/caldav-tokens/${id}`);
|
||||
setCaldavTokens(prev => prev.filter(tk => tk.id !== id));
|
||||
toast.success(t('settings.caldav.revoked'));
|
||||
} catch {
|
||||
toast.error(t('settings.caldav.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const groups = getThemeGroups();
|
||||
|
||||
const avatarColors = [
|
||||
@@ -139,6 +186,7 @@ export default function Settings() {
|
||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||
{ id: 'language', label: t('settings.language'), icon: Globe },
|
||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -425,8 +473,126 @@ export default function Settings() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* CalDAV section */}
|
||||
{activeSection === 'caldav' && (
|
||||
<div className="space-y-5">
|
||||
{/* Info Card */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.caldav.title')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('settings.caldav.subtitle')}</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.serverUrl')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono truncate">
|
||||
{`${window.location.origin}/caldav/`}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.caldav.username')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-text font-mono">
|
||||
{user?.email}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s">{t('settings.caldav.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New token was just created */}
|
||||
{newlyCreatedToken && (
|
||||
<div className="card p-5 border-2 border-th-success/40 bg-th-success/5">
|
||||
<p className="text-sm font-semibold text-th-success mb-2">{t('settings.caldav.newTokenCreated')}</p>
|
||||
<p className="text-xs text-th-text-s mb-3">{t('settings.caldav.newTokenHint')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
|
||||
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
|
||||
</code>
|
||||
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0">
|
||||
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewlyCreatedToken(null)}
|
||||
className="mt-3 text-xs text-th-text-s hover:text-th-text underline"
|
||||
>
|
||||
{t('settings.caldav.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new token */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.newToken')}</h3>
|
||||
<form onSubmit={handleCreateToken} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTokenName}
|
||||
onChange={e => setNewTokenName(e.target.value)}
|
||||
placeholder={t('settings.caldav.tokenNamePlaceholder')}
|
||||
className="input-field flex-1 text-sm"
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={creatingToken || !newTokenName.trim()} className="btn-primary py-1.5 px-4">
|
||||
{creatingToken ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
{t('settings.caldav.generate')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-3">{t('settings.caldav.existingTokens')}</h3>
|
||||
{caldavLoading ? (
|
||||
<div className="flex items-center justify-center py-6"><Loader2 size={20} className="animate-spin text-th-text-s" /></div>
|
||||
) : caldavTokens.length === 0 ? (
|
||||
<p className="text-sm text-th-text-s py-3">{t('settings.caldav.noTokens')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{caldavTokens.map(tk => (
|
||||
<div key={tk.id} className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg bg-th-bg-t">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text truncate">{tk.name}</p>
|
||||
<p className="text-xs text-th-text-s">
|
||||
{t('settings.caldav.created')}: {new Date(tk.created_at).toLocaleDateString()}
|
||||
{tk.last_used_at && ` · ${t('settings.caldav.lastUsed')}: ${new Date(tk.last_used_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevokeToken(tk.id)}
|
||||
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0"
|
||||
title={t('settings.caldav.revoke')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div> </div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user