All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
490 lines
19 KiB
JavaScript
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;
|