Files
redlight/server/routes/calendar.js
Michelle d8c52aae4e
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m36s
feat(database): make token column nullable in caldav_tokens table for improved flexibility
2026-03-04 22:24:11 +01:00

787 lines
31 KiB
JavaScript

import { Router } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } 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})$/;
// Allowed reminder intervals in minutes
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
// 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, reminder_minutes } = 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 validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
? Number(reminder_minutes) : null;
const result = await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
uid,
title.trim(),
description || null,
startDate.toISOString(),
endDate.toISOString(),
room_uid || null,
req.user.id,
color || '#6366f1',
validReminder,
]);
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, reminder_minutes } = 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' });
}
const validReminder = (reminder_minutes !== undefined)
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
: undefined;
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
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),
reminder_minutes = COALESCE(?, reminder_minutes),
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
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,
validReminder !== undefined ? validReminder : 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 = getBaseUrl(req);
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 = getBaseUrl(req);
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 = getBaseUrl(req);
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 = getBaseUrl(req);
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}`);
}
if (event.reminder_minutes) {
ics.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
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(
// Store only the hash — never the plaintext — to limit exposure on DB breach.
'INSERT INTO caldav_tokens (user_id, token_hash, name) VALUES (?, ?, ?)',
[req.user.id, 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;