New federation features
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m58s

This commit is contained in:
2026-02-27 15:24:18 +01:00
parent 83849bd2f6
commit e5b6c225e9
8 changed files with 246 additions and 10 deletions

View File

@@ -193,6 +193,17 @@ export async function initDatabase() {
);
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
CREATE TABLE IF NOT EXISTS federated_rooms (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invite_id TEXT UNIQUE NOT NULL,
room_name TEXT NOT NULL,
from_user TEXT NOT NULL,
join_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id);
`);
} else {
await db.exec(`
@@ -270,6 +281,18 @@ export async function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
CREATE TABLE IF NOT EXISTS federated_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
invite_id TEXT UNIQUE NOT NULL,
room_name TEXT NOT NULL,
from_user TEXT NOT NULL,
join_url TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id);
`);
}

View File

@@ -68,3 +68,45 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n ${appName}`,
});
}
/**
* Send a federation meeting invitation email.
* @param {string} to recipient email
* @param {string} name recipient display name
* @param {string} fromUser sender federated address (user@domain)
* @param {string} roomName name of the invited room
* @param {string} message optional personal message
* @param {string} inboxUrl URL to the federation inbox
* @param {string} appName branding app name (default "Redlight")
*/
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') {
if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
await transporter.sendMail({
from: `"${appName}" <${from}>`,
to,
subject: `${appName} Meeting invitation from ${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 ${name} 👋</h2>
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${fromUser}</strong>.</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${roomName}</p>
${message ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${message}"</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 meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n ${appName}`,
});
}

View File

@@ -2,6 +2,7 @@ import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { sendFederationInviteEmail } from '../config/mailer.js';
import {
getFederationDomain,
isFederationEnabled,
@@ -152,7 +153,7 @@ router.post('/receive', async (req, res) => {
// Look up user by name (case-insensitive)
const targetUser = await db.get(
'SELECT id FROM users WHERE LOWER(name) = LOWER(?)',
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)',
[username]
);
@@ -176,6 +177,19 @@ router.post('/receive', async (req, res) => {
[invite_id, from_user, targetUser.id, room_name, message || null, join_url]
);
// Send notification email (fire-and-forget, don't fail the request if mail fails)
try {
const appUrl = process.env.APP_URL || '';
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
await sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName
);
} catch (mailErr) {
console.warn('Federation invite mail failed (non-fatal):', mailErr.message);
}
res.json({ success: true });
} catch (err) {
console.error('Federation receive error:', err);
@@ -233,6 +247,19 @@ router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
[invitation.id]
);
// Upsert into federated_rooms so the room appears in the user's dashboard
const existing = await db.get(
'SELECT id FROM federated_rooms WHERE invite_id = ? AND user_id = ?',
[invitation.invite_id, req.user.id]
);
if (!existing) {
await db.run(
`INSERT INTO federated_rooms (user_id, invite_id, room_name, from_user, join_url)
VALUES (?, ?, ?, ?, ?)`,
[req.user.id, invitation.invite_id, invitation.room_name, invitation.from_user, invitation.join_url]
);
}
res.json({ success: true, join_url: invitation.join_url });
} catch (err) {
console.error('Accept invitation error:', err);
@@ -262,4 +289,36 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
}
});
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
router.get('/federated-rooms', authenticateToken, async (req, res) => {
try {
const db = getDb();
const rooms = await db.all(
`SELECT * FROM federated_rooms WHERE user_id = ? ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ rooms });
} catch (err) {
console.error('List federated rooms error:', err);
res.status(500).json({ error: 'Failed to load federated rooms' });
}
});
// ── DELETE /api/federation/federated-rooms/:id — Remove a federated room ────
router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get(
'SELECT * FROM federated_rooms WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
if (!room) return res.status(404).json({ error: 'Room not found' });
await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]);
res.json({ success: true });
} catch (err) {
console.error('Delete federated room error:', err);
res.status(500).json({ error: 'Failed to remove room' });
}
});
export default router;