Files
redlight/server/routes/calendar.js
2026-03-02 10:35:01 +01:00

490 lines
19 KiB
JavaScript

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 {
isFederationEnabled,
getFederationDomain,
signPayload,
verifyPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
import { rateLimit } from 'express-rate-limit';
const router = Router();
// 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;
res.json({ event, sharedUsers });
} 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' });
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' });
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' });
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 — Share event with local user ───────
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' });
await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [event.id, user_id]);
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]);
res.json({ sharedUsers });
} 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 ────────────
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' });
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [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]);
res.json({ sharedUsers });
} catch (err) {
log.server.error(`Calendar unshare error: ${err.message}`);
res.status(500).json({ error: 'Could not remove share' });
}
});
// ── 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}`);
}
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 FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate
const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]);
if (existing) return res.json({ success: true, message: 'Event already received' });
// Create event for the target user
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
event_uid,
title,
description || null,
start_time,
end_time,
room_uid || null,
targetUser.id,
'#6366f1',
from_user,
join_url || null,
]);
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');
}
export default router;