feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s
- Added functionality to create, accept, decline, and delete local calendar invitations. - Integrated email notifications for calendar event invitations and deletions. - Updated database schema to support local invitations and outbound event tracking. - Enhanced the calendar UI to display pending invitations and allow users to manage them. - Localized new strings for invitations in English and German.
This commit is contained in:
@@ -558,6 +558,65 @@ export async function initDatabase() {
|
||||
`);
|
||||
}
|
||||
|
||||
// Track outbound calendar event federation sends for deletion propagation
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_uid TEXT NOT NULL,
|
||||
remote_domain TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(event_uid, remote_domain)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_uid TEXT NOT NULL,
|
||||
remote_domain TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_uid, remote_domain)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
|
||||
`);
|
||||
}
|
||||
|
||||
// Local calendar event invitations (share-with-acceptance flow)
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(event_id, to_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL,
|
||||
from_user_id INTEGER NOT NULL,
|
||||
to_user_id INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id, to_user_id),
|
||||
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Default admin (only on very first start) ────────────────────────────
|
||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||
if (!adminAlreadySeeded) {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
|
||||
subject: `${headerAppName} - Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
@@ -140,6 +140,83 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a calendar event invitation email (federated).
|
||||
*/
|
||||
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight') {
|
||||
if (!transporter) return;
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeDesc = description ? escapeHtml(description) : null;
|
||||
|
||||
const formatDate = (iso) => {
|
||||
try { return new Date(iso).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
|
||||
catch { return iso; }
|
||||
};
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} - Calendar invitation from ${sanitizeHeaderValue(fromUser)}`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
<p>You have received a calendar invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
|
||||
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} – ${escapeHtml(formatDate(endTime))}</p>
|
||||
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeDesc}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inboxUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
View Invitation
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hey ${name},\n\nYou have received a calendar invitation from ${fromUser}.\nEvent: ${title}\nTime: ${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\nView invitation: ${inboxUrl}\n\n\u2013 ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a user that a federated calendar event they received was deleted by the organiser.
|
||||
*/
|
||||
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight') {
|
||||
if (!transporter) return;
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeTitle = escapeHtml(title);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – Calendar event cancelled: ${sanitizeHeaderValue(title)}`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#f38ba8;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
<p>The following calendar event was deleted by the organiser (<strong style="color:#cdd6f4;">${safeFromUser}</strong>) and is no longer available:</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
|
||||
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#7f849c;">The event has been automatically removed from your calendar.</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">This message was sent automatically by ${escapeHtml(appName)}.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hey ${name},\n\nThe calendar event "${title}" by ${fromUser} has been deleted and removed from your calendar.\n\n\u2013 ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user registration invite email.
|
||||
* @param {string} to – recipient email
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from 'crypto';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { log } from '../config/logger.js';
|
||||
import { sendCalendarInviteEmail } from '../config/mailer.js';
|
||||
import {
|
||||
isFederationEnabled,
|
||||
getFederationDomain,
|
||||
@@ -92,7 +93,18 @@ router.get('/events/:id', authenticateToken, async (req, res) => {
|
||||
`, [event.id]);
|
||||
|
||||
event.is_owner = event.user_id === req.user.id;
|
||||
res.json({ event, sharedUsers });
|
||||
|
||||
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' });
|
||||
@@ -207,6 +219,42 @@ router.delete('/events/:id', authenticateToken, async (req, res) => {
|
||||
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) {
|
||||
@@ -215,7 +263,7 @@ router.delete('/events/:id', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/calendar/events/:id/share — Share event with local user ───────
|
||||
// ── 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;
|
||||
@@ -229,7 +277,35 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
|
||||
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 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 FROM users WHERE id = ?', [user_id]);
|
||||
if (targetUser?.email) {
|
||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
|
||||
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
|
||||
sendCalendarInviteEmail(
|
||||
targetUser.email,
|
||||
(targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name,
|
||||
fromDisplay,
|
||||
event.title, event.start_time, event.end_time, event.description,
|
||||
inboxUrl, appName
|
||||
).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
|
||||
@@ -238,22 +314,36 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
|
||||
WHERE ces.event_id = ?
|
||||
`, [event.id]);
|
||||
|
||||
res.json({ sharedUsers });
|
||||
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 ────────────
|
||||
// ── 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
|
||||
@@ -261,13 +351,91 @@ router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) =
|
||||
WHERE ces.event_id = ?
|
||||
`, [event.id]);
|
||||
|
||||
res.json({ sharedUsers });
|
||||
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 {
|
||||
@@ -364,6 +532,15 @@ router.post('/events/:id/federation', authenticateToken, async (req, res) => {
|
||||
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}`);
|
||||
@@ -434,6 +611,20 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
|
||||
'#6366f1',
|
||||
]);
|
||||
|
||||
// Send notification email (fire-and-forget)
|
||||
if (targetUser.email) {
|
||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
sendCalendarInviteEmail(
|
||||
targetUser.email, targetUser.name, from_user,
|
||||
title, start_time, end_time, description || null,
|
||||
inboxUrl, appName
|
||||
).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}`);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { sendFederationInviteEmail } from '../config/mailer.js';
|
||||
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
||||
import { log } from '../config/logger.js';
|
||||
|
||||
// M13: rate limit the unauthenticated federation receive endpoint
|
||||
@@ -39,7 +39,7 @@ export function wellKnownHandler(req, res) {
|
||||
federation_api: '/api/federation',
|
||||
public_key: getPublicKey(),
|
||||
software: 'Redlight',
|
||||
version: '1.3.0',
|
||||
version: '1.4.0',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,7 +274,15 @@ router.get('/invitations/pending-count', authenticateToken, async (req, res) =>
|
||||
[req.user.id]
|
||||
);
|
||||
} catch { /* table may not exist yet */ }
|
||||
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) });
|
||||
let localCalResult = { count: 0 };
|
||||
try {
|
||||
localCalResult = await db.get(
|
||||
`SELECT COUNT(*) as count FROM calendar_local_invitations
|
||||
WHERE to_user_id = ? AND status = 'pending'`,
|
||||
[req.user.id]
|
||||
);
|
||||
} catch { /* table may not exist yet */ }
|
||||
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) });
|
||||
} catch (err) {
|
||||
res.json({ count: 0 });
|
||||
}
|
||||
@@ -519,6 +527,96 @@ router.post('/room-sync', federationReceiveLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─
|
||||
router.post('/calendar-event-deleted', federationReceiveLimiter, 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 originDomain = req.headers['x-federation-origin'];
|
||||
const payload = req.body || {};
|
||||
|
||||
if (!signature || !originDomain) {
|
||||
return res.status(401).json({ error: 'Missing federation signature or origin' });
|
||||
}
|
||||
|
||||
const { publicKey } = await discoverInstance(originDomain);
|
||||
if (!publicKey || !verifyPayload(payload, signature, publicKey)) {
|
||||
return res.status(403).json({ error: 'Invalid federation signature' });
|
||||
}
|
||||
|
||||
const { event_uid } = payload;
|
||||
if (!event_uid || typeof event_uid !== 'string') {
|
||||
return res.status(400).json({ error: 'event_uid is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Collect all affected users before deleting (for email notifications)
|
||||
let affectedUsers = [];
|
||||
try {
|
||||
// Users with pending/declined invitations
|
||||
const invUsers = await db.all(
|
||||
`SELECT u.email, u.name, ci.title, ci.from_user
|
||||
FROM calendar_invitations ci
|
||||
JOIN users u ON ci.to_user_id = u.id
|
||||
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`,
|
||||
[event_uid, `%@${originDomain}`]
|
||||
);
|
||||
// Users who already accepted (event in their calendar)
|
||||
const calUsers = await db.all(
|
||||
`SELECT u.email, u.name, ce.title, ce.federated_from AS from_user
|
||||
FROM calendar_events ce
|
||||
JOIN users u ON ce.user_id = u.id
|
||||
WHERE ce.uid = ? AND ce.federated_from LIKE ?`,
|
||||
[event_uid, `%@${originDomain}`]
|
||||
);
|
||||
// Merge, deduplicate by email
|
||||
const seen = new Set();
|
||||
for (const row of [...invUsers, ...calUsers]) {
|
||||
if (row.email && !seen.has(row.email)) {
|
||||
seen.add(row.email);
|
||||
affectedUsers.push(row);
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
// Remove from calendar_invitations for all users on this instance
|
||||
await db.run(
|
||||
`DELETE FROM calendar_invitations
|
||||
WHERE event_uid = ? AND from_user LIKE ?`,
|
||||
[event_uid, `%@${originDomain}`]
|
||||
);
|
||||
|
||||
// Remove from calendar_events (accepted invitations) for all users on this instance
|
||||
await db.run(
|
||||
`DELETE FROM calendar_events
|
||||
WHERE uid = ? AND federated_from LIKE ?`,
|
||||
[event_uid, `%@${originDomain}`]
|
||||
);
|
||||
|
||||
log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`);
|
||||
|
||||
// Notify affected users by email (fire-and-forget)
|
||||
if (affectedUsers.length > 0) {
|
||||
const appName = process.env.APP_NAME || 'Redlight';
|
||||
for (const u of affectedUsers) {
|
||||
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName)
|
||||
.catch(mailErr => {
|
||||
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.federation.error('Calendar-event-deleted error:', err);
|
||||
res.status(500).json({ error: 'Failed to process calendar event deletion' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/federation/room-deleted — Receive deletion notification ───────
|
||||
// Origin instance pushes this to notify that a room has been deleted.
|
||||
router.post('/room-deleted', federationReceiveLimiter, async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user