import { Router } from 'express'; import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { sendCalendarInviteEmail } from '../config/mailer.js'; import { isFederationEnabled, getFederationDomain, signPayload, verifyPayload, discoverInstance, parseAddress, } from '../config/federation.js'; import { rateLimit } from 'express-rate-limit'; const router = Router(); // Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/; // Rate limit for federation calendar receive const calendarFederationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests. Please try again later.' }, }); // ── GET /api/calendar/events — List events for the current user ───────────── router.get('/events', authenticateToken, async (req, res) => { try { const db = getDb(); const { from, to } = req.query; let sql = ` SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE (ce.user_id = ? OR ce.id IN ( SELECT event_id FROM calendar_event_shares WHERE user_id = ? )) `; const params = [req.user.id, req.user.id]; if (from) { sql += ' AND ce.end_time >= ?'; params.push(from); } if (to) { sql += ' AND ce.start_time <= ?'; params.push(to); } sql += ' ORDER BY ce.start_time ASC'; const events = await db.all(sql, params); // Mark shared events for (const ev of events) { ev.is_owner = ev.user_id === req.user.id; } res.json({ events }); } catch (err) { log.server.error(`Calendar list error: ${err.message}`); res.status(500).json({ error: 'Events could not be loaded' }); } }); // ── GET /api/calendar/events/:id — Get single event ───────────────────────── router.get('/events/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get(` SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE ce.id = ? `, [req.params.id]); if (!event) return res.status(404).json({ error: 'Event not found' }); // Check access if (event.user_id !== req.user.id) { const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]); if (!share) return res.status(403).json({ error: 'No permission' }); } // Get shared users const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces JOIN users u ON ces.user_id = u.id WHERE ces.event_id = ? `, [event.id]); event.is_owner = event.user_id === req.user.id; let pendingInvitations = []; if (event.user_id === req.user.id) { pendingInvitations = await db.all(` SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_local_invitations cli JOIN users u ON cli.to_user_id = u.id WHERE cli.event_id = ? AND cli.status = 'pending' `, [event.id]); } res.json({ event, sharedUsers, pendingInvitations }); } catch (err) { log.server.error(`Calendar get event error: ${err.message}`); res.status(500).json({ error: 'Event could not be loaded' }); } }); // ── POST /api/calendar/events — Create event ──────────────────────────────── router.post('/events', authenticateToken, async (req, res) => { try { const { title, description, start_time, end_time, room_uid, color } = req.body; if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' }); if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' }); if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' }); if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' }); // Validate color format if (color && !SAFE_COLOR_RE.test(color)) { return res.status(400).json({ error: 'Invalid color format' }); } const startDate = new Date(start_time); const endDate = new Date(end_time); if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { return res.status(400).json({ error: 'Invalid date format' }); } if (endDate <= startDate) { return res.status(400).json({ error: 'End time must be after start time' }); } // Verify room exists if specified const db = getDb(); if (room_uid) { const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]); if (!room) return res.status(400).json({ error: 'Linked room not found' }); } const uid = crypto.randomBytes(12).toString('hex'); const result = await db.run(` INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, [ uid, title.trim(), description || null, startDate.toISOString(), endDate.toISOString(), room_uid || null, req.user.id, color || '#6366f1', ]); const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]); res.status(201).json({ event }); } catch (err) { log.server.error(`Calendar create error: ${err.message}`); res.status(500).json({ error: 'Event could not be created' }); } }); // ── PUT /api/calendar/events/:id — Update event ───────────────────────────── router.put('/events/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); const { title, description, start_time, end_time, room_uid, color } = req.body; if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' }); if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' }); // Validate color format if (color && !SAFE_COLOR_RE.test(color)) { return res.status(400).json({ error: 'Invalid color format' }); } if (start_time && end_time) { const s = new Date(start_time); const e = new Date(end_time); if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' }); if (e <= s) return res.status(400).json({ error: 'End time must be after start time' }); } if (room_uid) { const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]); if (!room) return res.status(400).json({ error: 'Linked room not found' }); } await db.run(` UPDATE calendar_events SET title = COALESCE(?, title), description = ?, start_time = COALESCE(?, start_time), end_time = COALESCE(?, end_time), room_uid = ?, color = COALESCE(?, color), updated_at = CURRENT_TIMESTAMP WHERE id = ? `, [ title || null, description !== undefined ? description : event.description, start_time || null, end_time || null, room_uid !== undefined ? (room_uid || null) : event.room_uid, color || null, req.params.id, ]); const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]); res.json({ event: updated }); } catch (err) { log.server.error(`Calendar update error: ${err.message}`); res.status(500).json({ error: 'Event could not be updated' }); } }); // ── DELETE /api/calendar/events/:id — Delete event ────────────────────────── router.delete('/events/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); // Propagate deletion to all remote instances that received this event if (isFederationEnabled()) { try { const outbound = await db.all( 'SELECT remote_domain FROM calendar_event_outbound WHERE event_uid = ?', [event.uid] ); for (const { remote_domain } of outbound) { try { const payload = { event_uid: event.uid, from_user: `@${req.user.name}@${getFederationDomain()}`, timestamp: new Date().toISOString(), }; const signature = signPayload(payload); const { baseUrl: remoteApi } = await discoverInstance(remote_domain); await fetch(`${remoteApi}/calendar-event-deleted`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Federation-Signature': signature, 'X-Federation-Origin': getFederationDomain(), }, body: JSON.stringify(payload), signal: AbortSignal.timeout(10_000), }); } catch (remoteErr) { log.server.warn(`Calendar deletion propagation failed for ${remote_domain}: ${remoteErr.message}`); } } await db.run('DELETE FROM calendar_event_outbound WHERE event_uid = ?', [event.uid]); } catch (propErr) { log.server.warn(`Calendar deletion propagation error: ${propErr.message}`); } } await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]); res.json({ message: 'Event deleted' }); } catch (err) { log.server.error(`Calendar delete error: ${err.message}`); res.status(500).json({ error: 'Event could not be deleted' }); } }); // ── POST /api/calendar/events/:id/share — Invite local user to event ──────── router.post('/events/:id/share', authenticateToken, async (req, res) => { try { const { user_id } = req.body; if (!user_id) return res.status(400).json({ error: 'User ID is required' }); const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' }); const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]); if (existing) return res.status(400).json({ error: 'Already shared with this user' }); const pendingCheck = await db.get( "SELECT id FROM calendar_local_invitations WHERE event_id = ? AND to_user_id = ? AND status = 'pending'", [event.id, user_id] ); if (pendingCheck) return res.status(400).json({ error: 'Invitation already pending for this user' }); await db.run( 'INSERT INTO calendar_local_invitations (event_id, from_user_id, to_user_id) VALUES (?, ?, ?)', [event.id, req.user.id, user_id] ); // Send notification email (fire-and-forget) const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]); if (targetUser?.email) { const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]); const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name); sendCalendarInviteEmail( targetUser.email, (targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name, fromDisplay, event.title, event.start_time, event.end_time, event.description, inboxUrl, appName, targetUser.language || 'en' ).catch(mailErr => { log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message); }); } const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces JOIN users u ON ces.user_id = u.id WHERE ces.event_id = ? `, [event.id]); const pendingInvitations = await db.all(` SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_local_invitations cli JOIN users u ON cli.to_user_id = u.id WHERE cli.event_id = ? AND cli.status = 'pending' `, [event.id]); res.json({ sharedUsers, pendingInvitations }); } catch (err) { log.server.error(`Calendar share error: ${err.message}`); res.status(500).json({ error: 'Could not share event' }); } }); // ── DELETE /api/calendar/events/:id/share/:userId — Remove share or cancel invitation ── router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); // Remove accepted share await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]); // Also cancel any pending local invitation for this user await db.run( "UPDATE calendar_local_invitations SET status = 'declined' WHERE event_id = ? AND to_user_id = ? AND status = 'pending'", [event.id, parseInt(req.params.userId)] ); const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces JOIN users u ON ces.user_id = u.id WHERE ces.event_id = ? `, [event.id]); const pendingInvitations = await db.all(` SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_local_invitations cli JOIN users u ON cli.to_user_id = u.id WHERE cli.event_id = ? AND cli.status = 'pending' `, [event.id]); res.json({ sharedUsers, pendingInvitations }); } catch (err) { log.server.error(`Calendar unshare error: ${err.message}`); res.status(500).json({ error: 'Could not remove share' }); } }); // ── GET /api/calendar/local-invitations — List local calendar invitations for current user ── router.get('/local-invitations', authenticateToken, async (req, res) => { try { const db = getDb(); const invitations = await db.all(` SELECT cli.id, cli.event_id, cli.status, cli.created_at, ce.title, ce.start_time, ce.end_time, ce.description, ce.color, COALESCE(NULLIF(u.display_name,''), u.name) as from_name FROM calendar_local_invitations cli JOIN calendar_events ce ON cli.event_id = ce.id JOIN users u ON cli.from_user_id = u.id WHERE cli.to_user_id = ? ORDER BY cli.created_at DESC `, [req.user.id]); res.json({ invitations }); } catch (err) { log.server.error(`Calendar local invitations error: ${err.message}`); res.status(500).json({ error: 'Could not load invitations' }); } }); // ── POST /api/calendar/local-invitations/:id/accept — Accept local invitation ── router.post('/local-invitations/:id/accept', authenticateToken, async (req, res) => { try { const db = getDb(); const inv = await db.get( "SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", [req.params.id, req.user.id] ); if (!inv) return res.status(404).json({ error: 'Invitation not found' }); await db.run("UPDATE calendar_local_invitations SET status = 'accepted' WHERE id = ?", [inv.id]); // Insert into calendar_event_shares so the event appears in the user's calendar const existingShare = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]); if (!existingShare) { await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [inv.event_id, req.user.id]); } res.json({ message: 'Invitation accepted' }); } catch (err) { log.server.error(`Calendar local invitation accept error: ${err.message}`); res.status(500).json({ error: 'Could not accept invitation' }); } }); // ── DELETE /api/calendar/local-invitations/:id — Decline/remove local invitation ── router.delete('/local-invitations/:id', authenticateToken, async (req, res) => { try { const db = getDb(); const inv = await db.get( 'SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ?', [req.params.id, req.user.id] ); if (!inv) return res.status(404).json({ error: 'Invitation not found' }); if (inv.status === 'pending') { await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]); } else { // Accepted/declined - remove the share too if it was accepted if (inv.status === 'accepted') { await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]); } await db.run('DELETE FROM calendar_local_invitations WHERE id = ?', [inv.id]); } res.json({ message: 'Invitation removed' }); } catch (err) { log.server.error(`Calendar local invitation delete error: ${err.message}`); res.status(500).json({ error: 'Could not remove invitation' }); } }); // ── GET /api/calendar/events/:id/ics — Download event as ICS ──────────────── router.get('/events/:id/ics', authenticateToken, async (req, res) => { try { const db = getDb(); const event = await db.get(` SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email FROM calendar_events ce JOIN users u ON ce.user_id = u.id WHERE ce.id = ? `, [req.params.id]); if (!event) return res.status(404).json({ error: 'Event not found' }); // Check access if (event.user_id !== req.user.id) { const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]); if (!share) return res.status(403).json({ error: 'No permission' }); } // Build room join URL if linked const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; let location = ''; if (event.room_uid) { location = `${baseUrl}/join/${event.room_uid}`; } const ics = generateICS(event, location, baseUrl); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`); res.send(ics); } catch (err) { log.server.error(`ICS download error: ${err.message}`); res.status(500).json({ error: 'Could not generate ICS file' }); } }); // ── POST /api/calendar/events/:id/federation — Send event to remote user ──── router.post('/events/:id/federation', authenticateToken, async (req, res) => { try { if (!isFederationEnabled()) { return res.status(400).json({ error: 'Federation is not configured on this instance' }); } const { to } = req.body; if (!to) return res.status(400).json({ error: 'Remote address is required' }); const { username, domain } = parseAddress(to); if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' }); if (domain === getFederationDomain()) { return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' }); } const db = getDb(); const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; let joinUrl = null; if (event.room_uid) { joinUrl = `${baseUrl}/join/${event.room_uid}`; } const payload = { type: 'calendar_event', event_uid: event.uid, title: event.title, description: event.description || '', start_time: event.start_time, end_time: event.end_time, room_uid: event.room_uid || null, join_url: joinUrl, from_user: `@${req.user.name}@${getFederationDomain()}`, to_user: to, timestamp: new Date().toISOString(), }; const signature = signPayload(payload); const { baseUrl: remoteApi } = await discoverInstance(domain); const response = await fetch(`${remoteApi}/calendar-event`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Federation-Signature': signature, 'X-Federation-Origin': getFederationDomain(), }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15_000), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Remote server responded with ${response.status}`); } // Track outbound send for deletion propagation try { await db.run( `INSERT INTO calendar_event_outbound (event_uid, remote_domain) VALUES (?, ?) ON CONFLICT(event_uid, remote_domain) DO NOTHING`, [event.uid, domain] ); } catch { /* table may not exist yet on upgrade */ } res.json({ success: true }); } catch (err) { log.server.error(`Calendar federation send error: ${err.message}`); res.status(500).json({ error: err.message || 'Could not send event to remote instance' }); } }); // ── POST /receive-event or /calendar-event — Receive calendar event from remote ── // '/receive-event' when mounted at /api/calendar // '/calendar-event' when mounted at /api/federation (for remote instance discovery) router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => { try { if (!isFederationEnabled()) { return res.status(400).json({ error: 'Federation is not configured on this instance' }); } const signature = req.headers['x-federation-signature']; const payload = req.body || {}; if (!signature) return res.status(401).json({ error: 'Missing federation signature' }); const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload; if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) { return res.status(400).json({ error: 'Incomplete event payload' }); } // Validate lengths if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) || from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) { return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' }); } // Verify signature const { domain: senderDomain } = parseAddress(from_user); if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' }); const { publicKey } = await discoverInstance(senderDomain); if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' }); if (!verifyPayload(payload, signature, publicKey)) { return res.status(403).json({ error: 'Invalid federation signature' }); } // Find local user const { username } = parseAddress(to_user); const db = getDb(); const targetUser = await db.get('SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)', [username]); if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' }); // Check duplicate (already in invitations or already accepted into calendar) const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]); if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' }); // Store as pending invitation — user must accept before it appears in calendar await db.run(` INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ event_uid, title, description || null, start_time, end_time, room_uid || null, join_url || null, from_user, targetUser.id, '#6366f1', ]); // Send notification email (fire-and-forget) if (targetUser.email) { const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; sendCalendarInviteEmail( targetUser.email, targetUser.name, from_user, title, start_time, end_time, description || null, inboxUrl, appName, targetUser.language || 'en' ).catch(mailErr => { log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message); }); } res.json({ success: true }); } catch (err) { log.server.error(`Calendar federation receive error: ${err.message}`); res.status(500).json({ error: 'Failed to process calendar event' }); } }); // ── Helper: Generate ICS content ──────────────────────────────────────────── function generateICS(event, location, prodIdDomain) { const formatDate = (dateStr) => { const d = new Date(dateStr); return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); }; const escapeICS = (str) => { if (!str) return ''; return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); }; const now = formatDate(new Date().toISOString()); const dtStart = formatDate(event.start_time); const dtEnd = formatDate(event.end_time); let ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:-//${prodIdDomain}//Redlight Calendar//EN`, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', `UID:${event.uid}@${prodIdDomain}`, `DTSTAMP:${now}`, `DTSTART:${dtStart}`, `DTEND:${dtEnd}`, `SUMMARY:${escapeICS(event.title)}`, ]; if (event.description) { ics.push(`DESCRIPTION:${escapeICS(event.description)}`); } if (location) { ics.push(`LOCATION:${escapeICS(location)}`); ics.push(`URL:${location}`); } if (event.organizer_name && event.organizer_email) { ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`); } ics.push('END:VEVENT', 'END:VCALENDAR'); 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 tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const result = await db.run( 'INSERT INTO caldav_tokens (user_id, token, token_hash, name) VALUES (?, ?, ?, ?)', [req.user.id, token, tokenHash, 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;