feat(logging): implement centralized logging system and replace console errors with structured logs
feat(federation): add room sync and deletion notification endpoints for federated instances fix(federation): handle room deletion and update settings during sync process feat(federation): enhance FederatedRoomCard and FederatedRoomDetail components to display deleted rooms i18n: add translations for room deletion messages in English and German
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
157
server/config/logger.js
Normal file
157
server/config/logger.js
Normal file
@@ -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(' ') || '-';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user