786 lines
31 KiB
JavaScript
786 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(
|
|
'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;
|