diff --git a/package-lock.json b/package-lock.json index 326b4ed..a6ac2db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "redlight", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "redlight", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "axios": "^1.7.0", "bcryptjs": "^2.4.3", diff --git a/package.json b/package.json index cdb315b..56cbb4f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "redlight", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"", diff --git a/server/config/bbb.js b/server/config/bbb.js index fb345e0..fd2288b 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -1,5 +1,6 @@ import crypto from 'crypto'; import xml2js from 'xml2js'; +import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_SECRET = process.env.BBB_SECRET || ''; @@ -18,39 +19,7 @@ function buildUrl(apiCall, params = {}) { async function apiCall(apiCallName, params = {}, xmlBody = null) { const url = buildUrl(apiCallName, params); - // Logging: compact key=value style, filter sensitive params - function formatUTC(d) { - const pad = n => String(n).padStart(2, '0'); - const Y = d.getUTCFullYear(); - const M = pad(d.getUTCMonth() + 1); - const D = pad(d.getUTCDate()); - const h = pad(d.getUTCHours()); - const m = pad(d.getUTCMinutes()); - const s = pad(d.getUTCSeconds()); - return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`; - } - - const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i]; - const isSensitive = key => SENSITIVE_KEYS.some(rx => rx.test(key)); - - function sanitizeParams(p) { - try { - const out = []; - for (const k of Object.keys(p || {})) { - if (k.toLowerCase() === 'checksum') continue; // never log checksum - if (isSensitive(k)) { - out.push(`${k}=[FILTERED]`); - } else { - let v = p[k]; - if (typeof v === 'string' && v.length > 100) v = v.slice(0, 100) + '...[truncated]'; - out.push(`${k}=${String(v)}`); - } - } - return out.join('&') || '-'; - } catch (e) { - return '-'; - } - } + const method = xmlBody ? 'POST' : 'GET'; const start = Date.now(); try { @@ -65,38 +34,20 @@ async function apiCall(apiCallName, params = {}, xmlBody = null) { trim: true, }); - // Compact log: time=... method=GET path=getMeetings format=xml status=200 duration=12.34 bbb_returncode=SUCCESS params=meetingID=123 - try { - const tokens = []; - tokens.push(`time=${formatUTC(new Date())}`); - tokens.push(`method=${xmlBody ? 'POST' : 'GET'}`); - // include standard BBB api base path - let apiBasePath = '/bigbluebutton/api'; - try { - const u = new URL(BBB_URL); - apiBasePath = (u.pathname || '/bigbluebutton/api').replace(/\/$/, ''); - } catch (e) { - // keep default - } - // ensure single slash separation - const fullPath = `${apiBasePath}/${apiCallName}`.replace(/\/\/+/, '/'); - tokens.push(`path=${fullPath}`); - tokens.push(`format=xml`); - tokens.push(`status=${response.status}`); - tokens.push(`duration=${(duration).toFixed(2)}`); - const returnCode = result && result.response && result.response.returncode ? result.response.returncode : '-'; - tokens.push(`returncode=${returnCode}`); - const safeParams = sanitizeParams(params); - tokens.push(`params=${safeParams}`); - console.info(tokens.join(' ')); - } catch (e) { - // ignore logging errors - } + const returncode = result?.response?.returncode || '-'; + const paramStr = sanitizeBBBParams(params); + + // Greenlight-style: method action β†’ status returncode (duration) params + log.bbb.info( + `${fmtMethod(method)} ${apiCallName} β†’ ${fmtStatus(response.status)} ${fmtReturncode(returncode)} (${fmtDuration(duration)}) ${paramStr}` + ); return result.response; } catch (error) { const duration = Date.now() - start; - console.error(`BBB API error (${apiCallName}) status=error duration=${(duration).toFixed(2)} err=${error.message}`); + log.bbb.error( + `${fmtMethod(method)} ${apiCallName} βœ— FAILED (${fmtDuration(duration)}) ${error.message}` + ); throw error; } } diff --git a/server/config/database.js b/server/config/database.js index d50df4f..f02af90 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -1,6 +1,7 @@ import bcrypt from 'bcryptjs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { log } from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -113,10 +114,10 @@ export function getDb() { export async function initDatabase() { // Create the right adapter if (isPostgres) { - console.log('πŸ“¦ Using PostgreSQL database'); + log.db.info('Using PostgreSQL database'); db = new PostgresAdapter(); } else { - console.log('πŸ“¦ Using SQLite database'); + log.db.info('Using SQLite database'); db = new SqliteAdapter(); } await db.init(); @@ -367,6 +368,43 @@ export async function initDatabase() { } } + // Federation sync: add deleted + updated_at to federated_rooms + if (!(await db.columnExists('federated_rooms', 'deleted'))) { + await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0'); + } + if (!(await db.columnExists('federated_rooms', 'updated_at'))) { + if (isPostgres) { + await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at TIMESTAMP DEFAULT NOW()'); + } else { + await db.exec('ALTER TABLE federated_rooms ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP'); + } + } + + // Track outbound federation invites for deletion propagation + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS federation_outbound_invites ( + id SERIAL PRIMARY KEY, + room_uid TEXT NOT NULL, + remote_domain TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(room_uid, remote_domain) + ); + CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS federation_outbound_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_uid TEXT NOT NULL, + remote_domain TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(room_uid, remote_domain) + ); + CREATE INDEX IF NOT EXISTS idx_fed_out_room_uid ON federation_outbound_invites(room_uid); + `); + } + // ── Default admin ─────────────────────────────────────────────────────── const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com'; const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; @@ -378,6 +416,6 @@ export async function initDatabase() { 'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)', ['Administrator', adminEmail, hash, 'admin'] ); - console.log(`βœ… Default admin created: ${adminEmail}`); + log.db.info(`Default admin created: ${adminEmail}`); } } diff --git a/server/config/federation.js b/server/config/federation.js index 84b956a..810daca 100644 --- a/server/config/federation.js +++ b/server/config/federation.js @@ -2,6 +2,7 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { log } from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,14 +20,14 @@ if (FEDERATION_DOMAIN) { } if (!privateKeyPem) { - console.log('Generating new Ed25519 federation key pair...'); + log.federation.info('Generating new Ed25519 key pair...'); const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); privateKeyPem = privateKey; fs.writeFileSync(keyPath, privateKeyPem, 'utf8'); - console.log(`Saved new federation private key to ${keyPath}`); + log.federation.info(`Saved new private key to ${keyPath}`); } // Derive public key from the loaded private key @@ -83,7 +84,7 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) { const data = Buffer.from(JSON.stringify(payload)); return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64')); } catch (e) { - console.error('Signature verification error:', e.message); + log.federation.error(`Signature verification error: ${e.message}`); return false; } } @@ -131,7 +132,7 @@ export async function discoverInstance(domain) { discoveryCache.set(domain, result); return result; } catch (error) { - console.error(`Federation discovery failed for ${domain}:`, error.message); + log.federation.error(`Discovery failed for ${domain}: ${error.message}`); throw new Error(`Could not discover Redlight instance at ${domain}: ${error.message}`); } } diff --git a/server/config/logger.js b/server/config/logger.js new file mode 100644 index 0000000..8161d26 --- /dev/null +++ b/server/config/logger.js @@ -0,0 +1,157 @@ +/** + * Centralized logger for Redlight server. + * + * Produces clean, colorized, tagged log lines inspired by Greenlight/Rails lograge style. + * + * Format: TIMESTAMP LEVEL [TAG] message + * Example: 2026-03-01 12:00:00 INFO [BBB] GET getMeetings β†’ 200 SUCCESS (45ms) + */ + +// ── ANSI colors ───────────────────────────────────────────────────────────── +const C = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + gray: '\x1b[90m', +}; + +const USE_COLOR = process.env.NO_COLOR ? false : true; +const c = (color, text) => USE_COLOR ? `${color}${text}${C.reset}` : text; + +// ── Timestamp ─────────────────────────────────────────────────────────────── +function ts() { + const d = new Date(); + const pad = n => String(n).padStart(2, '0'); + const ms = String(d.getMilliseconds()).padStart(3, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${ms}`; +} + +// ── Level formatting ──────────────────────────────────────────────────────── +const LEVEL_STYLE = { + DEBUG: { color: C.gray, label: 'DEBUG' }, + INFO: { color: C.green, label: ' INFO' }, + WARN: { color: C.yellow, label: ' WARN' }, + ERROR: { color: C.red, label: 'ERROR' }, +}; + +// ── Tag colors ────────────────────────────────────────────────────────────── +const TAG_COLORS = { + BBB: C.magenta, + HTTP: C.cyan, + Federation: C.blue, + FedSync: C.blue, + DB: C.yellow, + Auth: C.green, + Server: C.white, + Mailer: C.cyan, + Redis: C.magenta, + Admin: C.yellow, + Rooms: C.green, + Recordings: C.cyan, + Branding: C.white, +}; + +function formatLine(level, tag, message) { + const lvl = LEVEL_STYLE[level] || LEVEL_STYLE.INFO; + const tagColor = TAG_COLORS[tag] || C.white; + const timestamp = c(C.gray, ts()); + const levelStr = c(lvl.color, lvl.label); + const tagStr = c(tagColor, `[${tag}]`); + return `${timestamp} ${levelStr} ${tagStr} ${message}`; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Create a tagged logger. + * @param {string} tag - e.g. 'BBB', 'HTTP', 'Federation' + */ +export function createLogger(tag) { + return { + debug: (msg, ...args) => console.debug(formatLine('DEBUG', tag, msg), ...args), + info: (msg, ...args) => console.info(formatLine('INFO', tag, msg), ...args), + warn: (msg, ...args) => console.warn(formatLine('WARN', tag, msg), ...args), + error: (msg, ...args) => console.error(formatLine('ERROR', tag, msg), ...args), + }; +} + +// ── Pre-built loggers for common subsystems ───────────────────────────────── +export const log = { + bbb: createLogger('BBB'), + http: createLogger('HTTP'), + federation: createLogger('Federation'), + fedSync: createLogger('FedSync'), + db: createLogger('DB'), + auth: createLogger('Auth'), + server: createLogger('Server'), + mailer: createLogger('Mailer'), + redis: createLogger('Redis'), + admin: createLogger('Admin'), + rooms: createLogger('Rooms'), + recordings: createLogger('Recordings'), + branding: createLogger('Branding'), +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Format duration with unit and color. */ +export function fmtDuration(ms) { + const num = Number(ms); + if (num < 100) return c(C.green, `${num.toFixed(0)}ms`); + if (num < 1000) return c(C.yellow, `${num.toFixed(0)}ms`); + return c(C.red, `${(num / 1000).toFixed(2)}s`); +} + +/** Format HTTP status with color. */ +export function fmtStatus(status) { + const s = Number(status); + if (s < 300) return c(C.green, String(s)); + if (s < 400) return c(C.cyan, String(s)); + if (s < 500) return c(C.yellow, String(s)); + return c(C.red, String(s)); +} + +/** Format HTTP method with color. */ +export function fmtMethod(method) { + const m = String(method).toUpperCase(); + const colors = { GET: C.green, POST: C.cyan, PUT: C.yellow, PATCH: C.yellow, DELETE: C.red }; + return c(colors[m] || C.white, m.padEnd(6)); +} + +/** Format BBB returncode with color. */ +export function fmtReturncode(code) { + if (code === 'SUCCESS') return c(C.green, code); + if (code === 'FAILED') return c(C.red, code); + return c(C.yellow, code || '-'); +} + +// ── Sensitive value filtering ─────────────────────────────────────────────── +const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /api[_-]?key/i]; +export function isSensitiveKey(key) { + return SENSITIVE_KEYS.some(rx => rx.test(key)); +} + +/** + * Sanitize BBB params for logging: filter sensitive values, truncate long strings, omit checksum. + */ +export function sanitizeBBBParams(params) { + const parts = []; + for (const k of Object.keys(params || {})) { + if (k.toLowerCase() === 'checksum') continue; + if (isSensitiveKey(k)) { + parts.push(`${k}=${c(C.dim, '[FILTERED]')}`); + } else { + let v = params[k]; + if (typeof v === 'string' && v.length > 80) v = v.slice(0, 80) + '…'; + parts.push(`${c(C.gray, k)}=${v}`); + } + } + return parts.join(' ') || '-'; +} diff --git a/server/config/mailer.js b/server/config/mailer.js index aba8a04..f943ec6 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -1,4 +1,5 @@ import nodemailer from 'nodemailer'; +import { log } from './logger.js'; let transporter; @@ -20,7 +21,7 @@ export function initMailer() { const pass = process.env.SMTP_PASS; if (!host || !user || !pass) { - console.warn('⚠️ SMTP not configured – email verification disabled'); + log.mailer.warn('SMTP not configured – email verification disabled'); return false; } @@ -34,7 +35,7 @@ export function initMailer() { socketTimeout: 15_000, // 15 s of inactivity before abort }); - console.log('βœ… SMTP mailer configured'); + log.mailer.info('SMTP mailer configured'); return true; } diff --git a/server/config/redis.js b/server/config/redis.js index 4273a88..45b633d 100644 --- a/server/config/redis.js +++ b/server/config/redis.js @@ -1,4 +1,5 @@ import Redis from 'ioredis'; +import { log } from './logger.js'; const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; @@ -13,12 +14,12 @@ const redis = new Redis(REDIS_URL, { redis.on('error', (err) => { // Suppress ECONNREFUSED noise after initial failure β€” only warn if (err.code !== 'ECONNREFUSED') { - console.warn('⚠️ DragonflyDB error:', err.message); + log.redis.warn(`DragonflyDB error: ${err.message}`); } }); redis.on('connect', () => { - console.log('πŸ‰ DragonflyDB connected'); + log.redis.info('DragonflyDB connected'); }); export default redis; diff --git a/server/index.js b/server/index.js index 63b4715..76f7030 100644 --- a/server/index.js +++ b/server/index.js @@ -3,6 +3,7 @@ import express from 'express'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; +import { log } from './config/logger.js'; import requestResponseLogger from './middleware/logging.js'; import { initDatabase } from './config/database.js'; import { initMailer } from './config/mailer.js'; @@ -12,6 +13,7 @@ import recordingRoutes from './routes/recordings.js'; import adminRoutes from './routes/admin.js'; import brandingRoutes from './routes/branding.js'; import federationRoutes, { wellKnownHandler } from './routes/federation.js'; +import { startFederationSync } from './jobs/federationSync.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -62,11 +64,14 @@ async function start() { } app.listen(PORT, () => { - console.log(`πŸ”΄ Redlight server running on http://localhost:${PORT}`); + log.server.info(`Redlight server running on http://localhost:${PORT}`); }); + + // Start periodic federation sync job (checks remote room settings every 60s) + startFederationSync(); } start().catch(err => { - console.error('❌ Failed to start server:', err); + log.server.error(`Failed to start server: ${err.message}`); process.exit(1); }); diff --git a/server/jobs/federationSync.js b/server/jobs/federationSync.js new file mode 100644 index 0000000..bd6cabd --- /dev/null +++ b/server/jobs/federationSync.js @@ -0,0 +1,154 @@ +import { getDb } from '../config/database.js'; +import { log, fmtDuration } from '../config/logger.js'; +import { + isFederationEnabled, + getFederationDomain, + signPayload, + discoverInstance, + parseAddress, +} from '../config/federation.js'; + +const SYNC_INTERVAL_MS = 60_000; // 1 minute + +let syncTimer = null; + +/** + * Periodic federation sync job. + * Groups federated rooms by origin domain, then batch-queries each origin + * for current room settings. Updates local records if settings changed or + * if the room was deleted on the origin. + */ +async function runSync() { + if (!isFederationEnabled()) return; + + const syncStart = Date.now(); + let totalUpdated = 0; + let totalDeleted = 0; + let totalRooms = 0; + + try { + const db = getDb(); + + // Fetch all non-deleted federated rooms + const rooms = await db.all( + 'SELECT id, meet_id, from_user, room_name, max_participants, allow_recording FROM federated_rooms WHERE deleted = 0' + ); + + if (rooms.length === 0) return; + totalRooms = rooms.length; + + // Group by origin domain + const byDomain = new Map(); + for (const room of rooms) { + if (!room.meet_id) continue; // no room UID, can't sync + const { domain } = parseAddress(room.from_user); + if (!domain) continue; + if (!byDomain.has(domain)) byDomain.set(domain, []); + byDomain.get(domain).push(room); + } + + // Query each origin domain + for (const [domain, domainRooms] of byDomain) { + try { + const roomUids = [...new Set(domainRooms.map(r => r.meet_id))]; + + const payload = { + room_uids: roomUids, + timestamp: new Date().toISOString(), + }; + const signature = signPayload(payload); + const { baseUrl: remoteApi } = await discoverInstance(domain); + + const response = await fetch(`${remoteApi}/room-sync`, { + 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) { + log.fedSync.warn(`${domain} responded with status ${response.status}`); + continue; + } + + const data = await response.json(); + const remoteRooms = data.rooms || {}; + + // Update local records + for (const localRoom of domainRooms) { + const remote = remoteRooms[localRoom.meet_id]; + if (!remote) continue; // UID not in response, skip + + if (remote.deleted) { + // Room was deleted on origin + await db.run( + 'UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [localRoom.id] + ); + totalDeleted++; + log.fedSync.info(`Room ${localRoom.meet_id} deleted on origin ${domain}`); + } else { + // Check if settings changed + const changed = + localRoom.room_name !== remote.room_name || + (localRoom.max_participants ?? 0) !== (remote.max_participants ?? 0) || + (localRoom.allow_recording ?? 1) !== (remote.allow_recording ?? 1); + + if (changed) { + await db.run( + `UPDATE federated_rooms + SET room_name = ?, max_participants = ?, allow_recording = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [remote.room_name, remote.max_participants ?? 0, remote.allow_recording ?? 1, localRoom.id] + ); + totalUpdated++; + log.fedSync.info(`Room ${localRoom.meet_id} settings updated from ${domain}`); + } + } + } + } catch (err) { + log.fedSync.warn(`Sync with ${domain} failed: ${err.message}`); + } + } + + // Summary log (only if something happened) + if (totalUpdated > 0 || totalDeleted > 0) { + log.fedSync.info( + `Sync complete: ${totalRooms} rooms, ${totalUpdated} updated, ${totalDeleted} deleted (${fmtDuration(Date.now() - syncStart)})` + ); + } + } catch (err) { + log.fedSync.error(`Sync job failed: ${err.message}`); + } +} + +/** + * Start the periodic federation sync job. + */ +export function startFederationSync() { + if (!isFederationEnabled()) { + log.fedSync.info('Disabled (federation not configured)'); + return; + } + + // Run first sync after a short delay to let the server fully start + setTimeout(() => { + runSync(); + syncTimer = setInterval(runSync, SYNC_INTERVAL_MS); + log.fedSync.info('Started (interval: 60s)'); + }, 5_000); +} + +/** + * Stop the periodic federation sync job. + */ +export function stopFederationSync() { + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + } +} diff --git a/server/middleware/auth.js b/server/middleware/auth.js index b357df2..5997a96 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -2,9 +2,10 @@ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; +import { log } from '../config/logger.js'; if (!process.env.JWT_SECRET) { - console.error('FATAL: JWT_SECRET environment variable is not set. '); + log.auth.error('FATAL: JWT_SECRET environment variable is not set.'); process.exit(1); } const JWT_SECRET = process.env.JWT_SECRET; @@ -29,7 +30,7 @@ export async function authenticateToken(req, res, next) { } } catch (redisErr) { // Graceful degradation: if Redis is unavailable, allow the request - console.warn('Redis blacklist check skipped:', redisErr.message); + log.auth.warn(`Redis blacklist check skipped: ${redisErr.message}`); } } diff --git a/server/middleware/logging.js b/server/middleware/logging.js index 1cf8a05..bb16b2a 100644 --- a/server/middleware/logging.js +++ b/server/middleware/logging.js @@ -1,118 +1,25 @@ -import util from 'util'; - -const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i]; -const MAX_LOG_BODY_LENGTH = 200000; // 200 KB - -function isSensitiveKey(key) { - return SENSITIVE_KEYS.some(rx => rx.test(key)); -} - -function filterValue(key, value, depth = 0) { - if (depth > 5) return '[MAX_DEPTH]'; - if (key && isSensitiveKey(key)) return '[FILTERED]'; - if (value === null || value === undefined) return value; - if (typeof value === 'string') { - if (value.length > MAX_LOG_BODY_LENGTH) return '[TOO_LARGE]'; - return value; - } - if (typeof value === 'number' || typeof value === 'boolean') return value; - if (Array.isArray(value)) return value.map(v => filterValue(null, v, depth + 1)); - if (typeof value === 'object') { - const out = {}; - for (const k of Object.keys(value)) { - out[k] = filterValue(k, value[k], depth + 1); - } - return out; - } - return typeof value; -} - -function filterHeaders(headers) { - const out = {}; - for (const k of Object.keys(headers || {})) { - if (/^authorization$/i.test(k) || /^cookie$/i.test(k)) { - out[k] = '[FILTERED]'; - continue; - } - if (isSensitiveKey(k)) { - out[k] = '[FILTERED]'; - continue; - } - out[k] = headers[k]; - } - return out; -} - -function formatUTC(d) { - const pad = n => String(n).padStart(2, '0'); - const Y = d.getUTCFullYear(); - const M = pad(d.getUTCMonth() + 1); - const D = pad(d.getUTCDate()); - const h = pad(d.getUTCHours()); - const m = pad(d.getUTCMinutes()); - const s = pad(d.getUTCSeconds()); - return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`; -} +import { log, fmtDuration, fmtStatus, fmtMethod } from '../config/logger.js'; export default function requestResponseLogger(req, res, next) { - try { - const start = Date.now(); - const { method, originalUrl } = req; - const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress; + const start = Date.now(); + const { method, originalUrl } = req; - const reqHeaders = filterHeaders(req.headers); + res.on('finish', () => { + try { + const duration = Date.now() - start; + const status = res.statusCode; + const contentType = (res.getHeader?.('content-type') || '').toString().toLowerCase(); + const format = contentType.includes('json') ? 'json' : contentType.includes('html') ? 'html' : ''; + const formatStr = format ? ` ${format}` : ''; - let reqBody = '[not-logged]'; - const contentType = (req.headers['content-type'] || '').toLowerCase(); - if (contentType.includes('multipart/form-data')) { - reqBody = '[multipart/form-data]'; - } else if (req.body) { - try { - reqBody = filterValue(null, req.body); - } catch (e) { - reqBody = '[unserializable]'; - } + // METHOD /path β†’ status (duration) + log.http.info( + `${fmtMethod(method)} ${originalUrl} β†’ ${fmtStatus(status)}${formatStr} (${fmtDuration(duration)})` + ); + } catch { + // never break the request pipeline } - - // Capture response body by wrapping res.send - const oldSend = res.send.bind(res); - let responseBody = undefined; - res.send = function sendOverWrite(body) { - responseBody = body; - return oldSend(body); - }; - - res.on('finish', () => { - try { - const duration = Date.now() - start; // ms - const resContentType = (res.getHeader && (res.getHeader('content-type') || '')).toString().toLowerCase(); - - // Compact key=value log (no bodies, sensitive data filtered) - const tokens = []; - tokens.push(`time=${formatUTC(new Date())}`); - tokens.push(`method=${method}`); - tokens.push(`path=${originalUrl.replace(/\s/g, '%20')}`); - const fmt = resContentType.includes('json') ? 'json' : (resContentType.includes('html') ? 'html' : 'other'); - tokens.push(`format=${fmt}`); - tokens.push(`status=${res.statusCode}`); - tokens.push(`duration=${(duration).toFixed(2)}`); - - // Optional: content-length if available - try { - const cl = res.getHeader && (res.getHeader('content-length') || res.getHeader('Content-Length')); - if (cl) tokens.push(`length=${String(cl)}`); - } catch (e) { - // ignore - } - - console.info(tokens.join(' ')); - } catch (e) { - console.error('RequestLogger error:', e); - } - }); - } catch (e) { - console.error('RequestLogger setup failure:', e); - } + }); return next(); } diff --git a/server/routes/admin.js b/server/routes/admin.js index 70249bc..9a241a2 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { log } from '../config/logger.js'; const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; @@ -57,7 +58,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => { const user = await db.get('SELECT id, name, display_name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]); res.status(201).json({ user }); } catch (err) { - console.error('Create user error:', err); + log.admin.error(`Create user error: ${err.message}`); res.status(500).json({ error: 'User could not be created' }); } }); @@ -75,7 +76,7 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => { res.json({ users }); } catch (err) { - console.error('List users error:', err); + log.admin.error(`List users error: ${err.message}`); res.status(500).json({ error: 'Users could not be loaded' }); } }); @@ -109,7 +110,7 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res) res.json({ user: updated }); } catch (err) { - console.error('Update role error:', err); + log.admin.error(`Update role error: ${err.message}`); res.status(500).json({ error: 'Role could not be updated' }); } }); @@ -139,7 +140,7 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => await db.run('DELETE FROM users WHERE id = ?', [req.params.id]); res.json({ message: 'User deleted' }); } catch (err) { - console.error('Delete user error:', err); + log.admin.error(`Delete user error: ${err.message}`); res.status(500).json({ error: 'User could not be deleted' }); } }); @@ -158,7 +159,7 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r res.json({ message: 'Password reset' }); } catch (err) { - console.error('Reset password error:', err); + log.admin.error(`Reset password error: ${err.message}`); res.status(500).json({ error: 'Password could not be reset' }); } }); diff --git a/server/routes/auth.js b/server/routes/auth.js index 7804fc0..75bb024 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -11,9 +11,10 @@ import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; import { authenticateToken, generateToken } from '../middleware/auth.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; +import { log } from '../config/logger.js'; if (!process.env.JWT_SECRET) { - console.error('FATAL: JWT_SECRET environment variable is not set.'); + log.auth.error('FATAL: JWT_SECRET environment variable is not set.'); process.exit(1); } const JWT_SECRET = process.env.JWT_SECRET; @@ -174,7 +175,7 @@ router.post('/register', registerLimiter, async (req, res) => { try { await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName); } catch (mailErr) { - console.error('Verification mail failed:', mailErr.message); + log.auth.error(`Verification mail failed: ${mailErr.message}`); // Account is created but email failed β€” user can resend from login page return res.status(201).json({ needsVerification: true, emailFailed: true, message: 'Account created but verification email could not be sent. Please try resending.' }); } @@ -193,7 +194,7 @@ router.post('/register', registerLimiter, async (req, res) => { res.status(201).json({ token, user }); } catch (err) { - console.error('Register error:', err); + log.auth.error(`Register error: ${err.message}`); res.status(500).json({ error: 'Registration failed' }); } }); @@ -227,7 +228,7 @@ router.get('/verify-email', async (req, res) => { res.json({ verified: true, message: 'Email verified successfully' }); } catch (err) { - console.error('Verify email error:', err); + log.auth.error(`Verify email error: ${err.message}`); res.status(500).json({ error: 'Verification failed' }); } }); @@ -282,13 +283,13 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res) try { await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName); } catch (mailErr) { - console.error('Resend verification mail failed:', mailErr.message); + log.auth.error(`Resend verification mail failed: ${mailErr.message}`); return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' }); } res.json({ message: 'If an account exists, a new email has been sent.' }); } catch (err) { - console.error('Resend verification error:', err); + log.auth.error(`Resend verification error: ${err.message}`); res.status(500).json({ error: 'Internal server error' }); } }); @@ -323,7 +324,7 @@ router.post('/login', loginLimiter, async (req, res) => { res.json({ token, user: safeUser }); } catch (err) { - console.error('Login error:', err); + log.auth.error(`Login error: ${err.message}`); res.status(500).json({ error: 'Login failed' }); } }); @@ -341,14 +342,14 @@ router.post('/logout', authenticateToken, async (req, res) => { try { await redis.setex(`blacklist:${decoded.jti}`, ttl, '1'); } catch (redisErr) { - console.warn('Redis blacklist write failed:', redisErr.message); + log.auth.warn(`Redis blacklist write failed: ${redisErr.message}`); } } } res.json({ message: 'Logged out successfully' }); } catch (err) { - console.error('Logout error:', err); + log.auth.error(`Logout error: ${err.message}`); res.status(500).json({ error: 'Logout failed' }); } }); @@ -422,7 +423,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => { const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { - console.error('Profile update error:', err); + log.auth.error(`Profile update error: ${err.message}`); res.status(500).json({ error: 'Profile could not be updated' }); } }); @@ -457,7 +458,7 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) => res.json({ message: 'Password changed successfully' }); } catch (err) { - console.error('Password change error:', err); + log.auth.error(`Password change error: ${err.message}`); res.status(500).json({ error: 'Password could not be changed' }); } }); @@ -514,7 +515,7 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => { const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { - console.error('Avatar upload error:', err); + log.auth.error(`Avatar upload error: ${err.message}`); res.status(500).json({ error: 'Avatar could not be uploaded' }); } }); @@ -533,7 +534,7 @@ router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => { const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { - console.error('Avatar delete error:', err); + log.auth.error(`Avatar delete error: ${err.message}`); res.status(500).json({ error: 'Avatar could not be removed' }); } }); diff --git a/server/routes/branding.js b/server/routes/branding.js index 330a2cd..e6c5735 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -5,6 +5,7 @@ import fs from 'fs'; import { fileURLToPath } from 'url'; import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { log } from '../config/logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -88,7 +89,7 @@ router.get('/', async (req, res) => { defaultTheme: defaultTheme || null, }); } catch (err) { - console.error('Get branding error:', err); + log.branding.error('Get branding error:', err); res.status(500).json({ error: 'Could not load branding' }); } }); @@ -149,7 +150,7 @@ router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => { } res.json({ message: 'Logo removed' }); } catch (err) { - console.error('Delete logo error:', err); + log.branding.error('Delete logo error:', err); res.status(500).json({ error: 'Could not remove logo' }); } }); @@ -167,7 +168,7 @@ router.put('/name', authenticateToken, requireAdmin, async (req, res) => { await setSetting('app_name', appName.trim()); res.json({ appName: appName.trim() }); } catch (err) { - console.error('Update app name error:', err); + log.branding.error('Update app name error:', err); res.status(500).json({ error: 'Could not update app name' }); } }); @@ -186,7 +187,7 @@ router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) = await setSetting('default_theme', defaultTheme.trim()); res.json({ defaultTheme: defaultTheme.trim() }); } catch (err) { - console.error('Update default theme error:', err); + log.branding.error('Update default theme error:', err); res.status(500).json({ error: 'Could not update default theme' }); } }); diff --git a/server/routes/federation.js b/server/routes/federation.js index 4d55fa9..871193f 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -4,6 +4,7 @@ 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 { log } from '../config/logger.js'; // M13: rate limit the unauthenticated federation receive endpoint const federationReceiveLimiter = rateLimit({ @@ -38,7 +39,7 @@ export function wellKnownHandler(req, res) { federation_api: '/api/federation', public_key: getPublicKey(), software: 'Redlight', - version: '1.2.0', + version: '1.2.1', }); } @@ -119,9 +120,18 @@ router.post('/invite', authenticateToken, async (req, res) => { throw new Error(data.error || `Remote server responded with ${response.status}`); } + // Track outbound invite for deletion propagation + try { + await db.run( + `INSERT INTO federation_outbound_invites (room_uid, remote_domain) VALUES (?, ?) + ON CONFLICT(room_uid, remote_domain) DO NOTHING`, + [room.uid, domain] + ); + } catch { /* table may not exist yet on upgrade */ } + res.json({ success: true, invite_id: inviteId }); } catch (err) { - console.error('Federation invite error:', err); + log.federation.error('Federation invite error:', err); res.status(500).json({ error: err.message || 'Failed to send federation invite' }); } }); @@ -219,13 +229,13 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { targetUser.email, targetUser.name, from_user, room_name, message || null, inboxUrl, appName ).catch(mailErr => { - console.warn('Federation invite mail failed (non-fatal):', mailErr.message); + log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); }); } res.json({ success: true }); } catch (err) { - console.error('Federation receive error:', err); + log.federation.error('Federation receive error:', err); res.status(500).json({ error: 'Failed to process federation invitation' }); } }); @@ -242,7 +252,7 @@ router.get('/invitations', authenticateToken, async (req, res) => { ); res.json({ invitations }); } catch (err) { - console.error('List federation invitations error:', err); + log.federation.error('List invitations error:', err); res.status(500).json({ error: 'Failed to load invitations' }); } }); @@ -301,7 +311,7 @@ router.post('/invitations/:id/accept', authenticateToken, async (req, res) => { res.json({ success: true, join_url: invitation.join_url }); } catch (err) { - console.error('Accept invitation error:', err); + log.federation.error('Accept invitation error:', err); res.status(500).json({ error: 'Failed to accept invitation' }); } }); @@ -323,7 +333,7 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => { res.json({ success: true }); } catch (err) { - console.error('Decline invitation error:', err); + log.federation.error('Decline invitation error:', err); res.status(500).json({ error: 'Failed to decline invitation' }); } }); @@ -338,7 +348,7 @@ router.get('/federated-rooms', authenticateToken, async (req, res) => { ); res.json({ rooms }); } catch (err) { - console.error('List federated rooms error:', err); + log.federation.error('List federated rooms error:', err); res.status(500).json({ error: 'Failed to load federated rooms' }); } }); @@ -355,9 +365,104 @@ router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => { await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]); res.json({ success: true }); } catch (err) { - console.error('Delete federated room error:', err); + log.federation.error('Delete federated room error:', err); res.status(500).json({ error: 'Failed to remove room' }); } }); +// ── POST /api/federation/room-sync β€” Remote instances query room settings ─── +// Called by federated instances to pull current room info for one or more UIDs. +// Signed request from remote, no auth token needed. +router.post('/room-sync', 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' }); + } + + // Verify signature using the remote instance's public key + const { publicKey } = await discoverInstance(originDomain); + if (!publicKey || !verifyPayload(payload, signature, publicKey)) { + return res.status(403).json({ error: 'Invalid federation signature' }); + } + + const { room_uids } = payload; + if (!Array.isArray(room_uids) || room_uids.length === 0 || room_uids.length > 100) { + return res.status(400).json({ error: 'room_uids must be an array of 1-100 UIDs' }); + } + + const db = getDb(); + const result = {}; + + for (const uid of room_uids) { + if (typeof uid !== 'string' || uid.length > 100) continue; + const room = await db.get('SELECT uid, name, max_participants, record_meeting FROM rooms WHERE uid = ?', [uid]); + if (room) { + result[uid] = { + room_name: room.name, + max_participants: room.max_participants ?? 0, + allow_recording: room.record_meeting ?? 1, + deleted: false, + }; + } else { + result[uid] = { deleted: true }; + } + } + + res.json({ rooms: result }); + } catch (err) { + log.federation.error('Room-sync error:', err); + res.status(500).json({ error: 'Failed to process room sync request' }); + } +}); + +// ── 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) => { + 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 { room_uid } = payload; + if (!room_uid || typeof room_uid !== 'string') { + return res.status(400).json({ error: 'room_uid is required' }); + } + + const db = getDb(); + // Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted + await db.run( + `UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP + WHERE meet_id = ? AND from_user LIKE ?`, + [room_uid, `%@${originDomain}`] + ); + + log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`); + res.json({ success: true }); + } catch (err) { + log.federation.error('Room-deleted error:', err); + res.status(500).json({ error: 'Failed to process deletion notification' }); + } +}); + export default router; diff --git a/server/routes/recordings.js b/server/routes/recordings.js index f4b8cc8..75d280e 100644 --- a/server/routes/recordings.js +++ b/server/routes/recordings.js @@ -1,6 +1,7 @@ import { Router } from 'express'; import { authenticateToken } from '../middleware/auth.js'; import { getDb } from '../config/database.js'; +import { log } from '../config/logger.js'; import { getRecordings, getRecordingByRecordId, @@ -65,7 +66,7 @@ router.get('/', authenticateToken, async (req, res) => { res.json({ recordings: formatted }); } catch (err) { - console.error('Get recordings error:', err); + log.recordings.error(`Get recordings error: ${err.message}`); res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] }); } }); @@ -117,7 +118,7 @@ router.get('/room/:uid', authenticateToken, async (req, res) => { res.json({ recordings: formatted }); } catch (err) { - console.error('Get room recordings error:', err); + log.recordings.error(`Get room recordings error: ${err.message}`); res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] }); } }); @@ -147,7 +148,7 @@ router.delete('/:recordID', authenticateToken, async (req, res) => { await deleteRecording(req.params.recordID); res.json({ message: 'Recording deleted' }); } catch (err) { - console.error('Delete recording error:', err); + log.recordings.error(`Delete recording error: ${err.message}`); res.status(500).json({ error: 'Recording could not be deleted' }); } }); @@ -178,7 +179,7 @@ router.put('/:recordID/publish', authenticateToken, async (req, res) => { await publishRecording(req.params.recordID, publish); res.json({ message: publish ? 'Recording published' : 'Recording unpublished' }); } catch (err) { - console.error('Publish recording error:', err); + log.recordings.error(`Publish recording error: ${err.message}`); res.status(500).json({ error: 'Recording could not be updated' }); } }); diff --git a/server/routes/rooms.js b/server/routes/rooms.js index a074f08..a0ce978 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; +import { log } from '../config/logger.js'; import { createMeeting, joinMeeting, @@ -13,6 +14,12 @@ import { getMeetingInfo, isMeetingRunning, } from '../config/bbb.js'; +import { + isFederationEnabled, + getFederationDomain, + signPayload, + discoverInstance, +} from '../config/federation.js'; // L6: constant-time string comparison for access/moderator codes function timingSafeEqual(a, b) { @@ -72,7 +79,7 @@ router.get('/', authenticateToken, async (req, res) => { res.json({ rooms: [...ownRooms, ...sharedRooms] }); } catch (err) { - console.error('List rooms error:', err); + log.rooms.error(`List rooms error: ${err.message}`); res.status(500).json({ error: 'Rooms could not be loaded' }); } }); @@ -94,7 +101,7 @@ router.get('/users/search', authenticateToken, async (req, res) => { `, [searchTerm, searchTerm, searchTerm, req.user.id]); res.json({ users }); } catch (err) { - console.error('Search users error:', err); + log.rooms.error(`Search users error: ${err.message}`); res.status(500).json({ error: 'User search failed' }); } }); @@ -133,7 +140,7 @@ router.get('/:uid', authenticateToken, async (req, res) => { res.json({ room, sharedUsers }); } catch (err) { - console.error('Get room error:', err); + log.rooms.error(`Get room error: ${err.message}`); res.status(500).json({ error: 'Room could not be loaded' }); } }); @@ -205,7 +212,7 @@ router.post('/', authenticateToken, async (req, res) => { const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]); res.status(201).json({ room }); } catch (err) { - console.error('Create room error:', err); + log.rooms.error(`Create room error: ${err.message}`); res.status(500).json({ error: 'Room could not be created' }); } }); @@ -288,7 +295,7 @@ router.put('/:uid', authenticateToken, async (req, res) => { const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ room: updated }); } catch (err) { - console.error('Update room error:', err); + log.rooms.error(`Update room error: ${err.message}`); res.status(500).json({ error: 'Room could not be updated' }); } }); @@ -307,10 +314,43 @@ router.delete('/:uid', authenticateToken, async (req, res) => { return res.status(403).json({ error: 'No permission' }); } + // Notify federated instances about deletion (fire-and-forget) + if (isFederationEnabled()) { + try { + const outbound = await db.all( + 'SELECT remote_domain FROM federation_outbound_invites WHERE room_uid = ?', + [room.uid] + ); + for (const { remote_domain } of outbound) { + const payload = { + room_uid: room.uid, + timestamp: new Date().toISOString(), + }; + const signature = signPayload(payload); + discoverInstance(remote_domain).then(({ baseUrl: remoteApi }) => { + fetch(`${remoteApi}/room-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(err => log.federation.warn(`Delete notify to ${remote_domain} failed: ${err.message}`)); + }).catch(err => log.federation.warn(`Discovery for ${remote_domain} failed: ${err.message}`)); + } + // Clean up outbound records + await db.run('DELETE FROM federation_outbound_invites WHERE room_uid = ?', [room.uid]); + } catch (fedErr) { + log.federation.warn(`Delete notification error (non-fatal): ${fedErr.message}`); + } + } + await db.run('DELETE FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ message: 'Room deleted successfully' }); } catch (err) { - console.error('Delete room error:', err); + log.rooms.error(`Delete room error: ${err.message}`); res.status(500).json({ error: 'Room could not be deleted' }); } }); @@ -330,7 +370,7 @@ router.get('/:uid/shares', authenticateToken, async (req, res) => { `, [room.id]); res.json({ shares }); } catch (err) { - console.error('Get shares error:', err); + log.rooms.error(`Get shares error: ${err.message}`); res.status(500).json({ error: 'Error loading shares' }); } }); @@ -364,7 +404,7 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => { `, [room.id]); res.json({ shares }); } catch (err) { - console.error('Share room error:', err); + log.rooms.error(`Share room error: ${err.message}`); res.status(500).json({ error: 'Error sharing room' }); } }); @@ -386,7 +426,7 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => { `, [room.id]); res.json({ shares }); } catch (err) { - console.error('Remove share error:', err); + log.rooms.error(`Remove share error: ${err.message}`); res.status(500).json({ error: 'Error removing share' }); } }); @@ -421,7 +461,7 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); res.json({ joinUrl }); } catch (err) { - console.error('Start meeting error:', err); + log.rooms.error(`Start meeting error: ${err.message}`); res.status(500).json({ error: 'Meeting could not be started' }); } }); @@ -455,7 +495,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => { const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL); res.json({ joinUrl }); } catch (err) { - console.error('Join meeting error:', err); + log.rooms.error(`Join meeting error: ${err.message}`); res.status(500).json({ error: 'Could not join meeting' }); } }); @@ -482,7 +522,7 @@ router.post('/:uid/end', authenticateToken, async (req, res) => { await endMeeting(room.uid); res.json({ message: 'Meeting ended' }); } catch (err) { - console.error('End meeting error:', err); + log.rooms.error(`End meeting error: ${err.message}`); res.status(500).json({ error: 'Meeting could not be ended' }); } }); @@ -519,7 +559,7 @@ router.get('/:uid/public', async (req, res) => { running, }); } catch (err) { - console.error('Public room info error:', err); + log.rooms.error(`Public room info error: ${err.message}`); res.status(500).json({ error: 'Room info could not be loaded' }); } }); @@ -574,7 +614,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); res.json({ joinUrl }); } catch (err) { - console.error('Guest join error:', err); + log.rooms.error(`Guest join error: ${err.message}`); res.status(500).json({ error: 'Guest join failed' }); } }); @@ -665,7 +705,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => { const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ room: updated }); } catch (err) { - console.error('Presentation upload error:', err); + log.rooms.error(`Presentation upload error: ${err.message}`); res.status(500).json({ error: 'Presentation could not be uploaded' }); } }); @@ -687,7 +727,7 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => { const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ room: updated }); } catch (err) { - console.error('Presentation delete error:', err); + log.rooms.error(`Presentation delete error: ${err.message}`); res.status(500).json({ error: 'Presentation could not be removed' }); } }); diff --git a/src/components/FederatedRoomCard.jsx b/src/components/FederatedRoomCard.jsx index 4669bb6..79c74e0 100644 --- a/src/components/FederatedRoomCard.jsx +++ b/src/components/FederatedRoomCard.jsx @@ -1,4 +1,4 @@ -import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff } from 'lucide-react'; +import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff, AlertTriangle } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useLanguage } from '../contexts/LanguageContext'; import api from '../services/api'; @@ -8,8 +8,11 @@ export default function FederatedRoomCard({ room, onRemove }) { const { t } = useLanguage(); const navigate = useNavigate(); + const isDeleted = room.deleted === 1 || room.deleted === true; + const handleJoin = (e) => { e.stopPropagation(); + if (isDeleted) return; window.open(room.join_url, '_blank'); }; @@ -28,7 +31,7 @@ export default function FederatedRoomCard({ room, onRemove }) { const recordingOn = room.allow_recording === 1 || room.allow_recording === true; return ( -
navigate(`/federation/rooms/${room.id}`)}> +
navigate(`/federation/rooms/${room.id}`)}>
@@ -36,9 +39,16 @@ export default function FederatedRoomCard({ room, onRemove }) {

{room.room_name}

- - {t('federation.federated')} - + {isDeleted ? ( + + + {t('federation.roomDeleted')} + + ) : ( + + {t('federation.federated')} + + )}

{t('federation.from')}: {room.from_user} @@ -79,17 +89,21 @@ export default function FederatedRoomCard({ room, onRemove }) {

{/* Read-only notice */} -

{t('federation.readOnlyNotice')}

+

+ {isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')} +

{/* Actions */}
- + {!isDeleted && ( + + )} + {/* Deleted banner */} + {isDeleted && ( +
+
+ +
+

{t('federation.roomDeleted')}

+

{t('federation.roomDeletedNotice')}

+
+
+
+ )} + {/* Header */}
-
- +
+ {isDeleted ? : }

{room.room_name}

- - {t('federation.federated')} - + {isDeleted ? ( + + {t('federation.roomDeleted')} + + ) : ( + + {t('federation.federated')} + + )}

{t('federation.from')}: {room.from_user} @@ -99,13 +119,15 @@ export default function FederatedRoomDetail() {

- + {!isDeleted && ( + + )}
);