Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df4666bb63 | |||
| 8c39275615 | |||
| 57bb1fb696 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "redlight",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +368,89 @@ export async function initDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default admin ───────────────────────────────────────────────────────
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
// 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);
|
||||
`);
|
||||
}
|
||||
|
||||
// User invite tokens (invite-only registration)
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
used_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL,
|
||||
used_by INTEGER,
|
||||
used_at DATETIME,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Default admin (only on very first start) ────────────────────────────
|
||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||
if (!adminAlreadySeeded) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
|
||||
if (!existingAdmin) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
|
||||
['Administrator', adminEmail, hash, 'admin']
|
||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
['Administrator', 'Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
console.log(`✅ Default admin created: ${adminEmail}`);
|
||||
// Mark as seeded so it never runs again, even if the admin email is changed
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1')");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -138,3 +139,46 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
||||
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user registration invite email.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} inviteUrl – full invite registration URL
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – You've been invited`,
|
||||
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;">You've been invited! 🎉</h2>
|
||||
<p>You have been invited to create an account on <strong style="color:#cdd6f4;">${safeAppName}</strong>.</p>
|
||||
<p>Click the button below to register:</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inviteUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
Create Account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
Or copy this link in your browser:<br/>
|
||||
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">This link is valid for 7 days.</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n– ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
154
server/jobs/federationSync.js
Normal file
154
server/jobs/federationSync.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
|
||||
import { log } from '../config/logger.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
||||
|
||||
@@ -57,7 +60,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 +78,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 +112,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 +142,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,9 +161,103 @@ 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── User Invite System ─────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/admin/invites - Create and send an invite
|
||||
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'A valid email address is required' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check if user with this email already exists
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'A user with this email already exists' });
|
||||
}
|
||||
|
||||
// Check if there's already a pending invite for this email
|
||||
const existingInvite = await db.get(
|
||||
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
if (existingInvite) {
|
||||
return res.status(409).json({ error: 'There is already a pending invite for this email' });
|
||||
}
|
||||
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
|
||||
[token, email.toLowerCase(), req.user.id, expiresAt]
|
||||
);
|
||||
|
||||
// Send invite email if SMTP is configured
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const inviteUrl = `${baseUrl}/register?invite=${token}`;
|
||||
|
||||
// Load app name
|
||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
|
||||
const appName = brandingSetting?.value || 'Redlight';
|
||||
|
||||
if (isMailerConfigured()) {
|
||||
try {
|
||||
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName);
|
||||
} catch (mailErr) {
|
||||
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
|
||||
} catch (err) {
|
||||
log.admin.error(`Create invite error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Invite could not be created' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/invites - List all invites
|
||||
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invites = await db.all(`
|
||||
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
|
||||
creator.name as created_by_name,
|
||||
used_user.name as used_by_name
|
||||
FROM user_invites ui
|
||||
LEFT JOIN users creator ON creator.id = ui.created_by
|
||||
LEFT JOIN users used_user ON used_user.id = ui.used_by
|
||||
ORDER BY ui.created_at DESC
|
||||
`);
|
||||
res.json({ invites });
|
||||
} catch (err) {
|
||||
log.admin.error(`List invites error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Invites could not be loaded' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/admin/invites/:id - Delete an invite
|
||||
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'Invite not found' });
|
||||
}
|
||||
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Invite deleted' });
|
||||
} catch (err) {
|
||||
log.admin.error(`Delete invite error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Invite could not be deleted' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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;
|
||||
@@ -111,7 +112,27 @@ const router = Router();
|
||||
// POST /api/auth/register
|
||||
router.post('/register', registerLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, display_name, email, password } = req.body;
|
||||
const { username, display_name, email, password, invite_token } = req.body;
|
||||
|
||||
// Check registration mode
|
||||
const db = getDb();
|
||||
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
|
||||
const registrationMode = regModeSetting?.value || 'open';
|
||||
|
||||
let validatedInvite = null;
|
||||
if (registrationMode === 'invite') {
|
||||
if (!invite_token) {
|
||||
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
|
||||
}
|
||||
// Validate the invite token
|
||||
validatedInvite = await db.get(
|
||||
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
|
||||
[invite_token]
|
||||
);
|
||||
if (!validatedInvite) {
|
||||
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!username || !display_name || !email || !password) {
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
@@ -137,7 +158,6 @@ router.post('/register', registerLimiter, async (req, res) => {
|
||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email is already in use' });
|
||||
@@ -160,6 +180,14 @@ router.post('/register', registerLimiter, async (req, res) => {
|
||||
[username, display_name, email.toLowerCase(), hash, verificationToken, expires]
|
||||
);
|
||||
|
||||
// Mark invite as used if applicable
|
||||
if (validatedInvite) {
|
||||
const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (newUser) {
|
||||
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build verification URL
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||
@@ -174,7 +202,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.' });
|
||||
}
|
||||
@@ -188,12 +216,17 @@ router.post('/register', registerLimiter, async (req, res) => {
|
||||
[username, display_name, email.toLowerCase(), hash]
|
||||
);
|
||||
|
||||
// Mark invite as used if applicable
|
||||
if (validatedInvite) {
|
||||
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
|
||||
}
|
||||
|
||||
const token = generateToken(result.lastInsertRowid);
|
||||
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
|
||||
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 +260,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 +315,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 +356,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 +374,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 +455,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 +490,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 +547,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 +566,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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -81,14 +82,17 @@ router.get('/', async (req, res) => {
|
||||
const defaultTheme = await getSetting('default_theme');
|
||||
const logoFile = findLogoFile();
|
||||
|
||||
const registrationMode = await getSetting('registration_mode');
|
||||
|
||||
res.json({
|
||||
appName: appName || 'Redlight',
|
||||
hasLogo: !!logoFile,
|
||||
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||
defaultTheme: defaultTheme || null,
|
||||
registrationMode: registrationMode || 'open',
|
||||
});
|
||||
} 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 +153,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 +171,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,9 +190,24 @@ 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/branding/registration-mode - Set registration mode (admin only)
|
||||
router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { registrationMode } = req.body;
|
||||
if (!registrationMode || !['open', 'invite'].includes(registrationMode)) {
|
||||
return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' });
|
||||
}
|
||||
await setSetting('registration_mode', registrationMode);
|
||||
res.json({ registrationMode });
|
||||
} catch (err) {
|
||||
log.branding.error('Update registration mode error:', err);
|
||||
res.status(500).json({ error: 'Could not update registration mode' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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.3.0',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/federation/rooms/${room.id}`)}>
|
||||
<div className={`card-hover group p-5 cursor-pointer ${isDeleted ? 'opacity-60' : ''}`} onClick={() => navigate(`/federation/rooms/${room.id}`)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -36,9 +39,16 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
||||
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
|
||||
{room.room_name}
|
||||
</h3>
|
||||
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
{t('federation.federated')}
|
||||
</span>
|
||||
{isDeleted ? (
|
||||
<span className="flex-shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<AlertTriangle size={10} />
|
||||
{t('federation.roomDeleted')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
{t('federation.federated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-0.5 truncate">
|
||||
{t('federation.from')}: <span className="font-medium">{room.from_user}</span>
|
||||
@@ -79,17 +89,21 @@ export default function FederatedRoomCard({ room, onRemove }) {
|
||||
</div>
|
||||
|
||||
{/* Read-only notice */}
|
||||
<p className="text-xs text-th-text-s mb-4 italic">{t('federation.readOnlyNotice')}</p>
|
||||
<p className="text-xs text-th-text-s mb-4 italic">
|
||||
{isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="btn-primary text-xs py-1.5 px-3 flex-1"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('federation.joinMeeting')}
|
||||
</button>
|
||||
{!isDeleted && (
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="btn-primary text-xs py-1.5 px-3 flex-1"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('federation.joinMeeting')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
|
||||
|
||||
@@ -28,8 +28,10 @@ export function AuthProvider({ children }) {
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username, displayName, email, password) => {
|
||||
const res = await api.post('/auth/register', { username, display_name: displayName, email, password });
|
||||
const register = useCallback(async (username, displayName, email, password, inviteToken) => {
|
||||
const payload = { username, display_name: displayName, email, password };
|
||||
if (inviteToken) payload.invite_token = inviteToken;
|
||||
const res = await api.post('/auth/register', payload);
|
||||
if (res.data.needsVerification) {
|
||||
return { needsVerification: true };
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@
|
||||
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
|
||||
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
|
||||
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
|
||||
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden"
|
||||
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
|
||||
"inviteOnly": "Nur mit Einladung",
|
||||
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen."
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
@@ -333,7 +335,26 @@
|
||||
"defaultThemeLabel": "Standard-Theme",
|
||||
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
|
||||
"defaultThemeSaved": "Standard-Theme gespeichert",
|
||||
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden"
|
||||
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden",
|
||||
"regModeTitle": "Registrierungsmodus",
|
||||
"regModeDescription": "Steuern Sie, wie sich neue Benutzer registrieren können. \"Offen\" erlaubt jedem die Anmeldung. \"Nur mit Einladung\" erfordert einen Einladungslink.",
|
||||
"regModeOpen": "Offene Registrierung",
|
||||
"regModeInvite": "Nur mit Einladung",
|
||||
"regModeSaved": "Registrierungsmodus aktualisiert",
|
||||
"regModeFailed": "Registrierungsmodus konnte nicht aktualisiert werden",
|
||||
"inviteTitle": "Benutzer-Einladungen",
|
||||
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
|
||||
"sendInvite": "Einladung senden",
|
||||
"inviteSent": "Einladung gesendet!",
|
||||
"inviteFailed": "Einladung konnte nicht gesendet werden",
|
||||
"inviteDeleted": "Einladung gelöscht",
|
||||
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
|
||||
"inviteLinkCopied": "Einladungslink kopiert!",
|
||||
"copyInviteLink": "Einladungslink kopieren",
|
||||
"inviteExpired": "Abgelaufen",
|
||||
"inviteUsedBy": "Verwendet von",
|
||||
"inviteExpiresAt": "Läuft ab am",
|
||||
"noInvites": "Noch keine Einladungen"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Einladungen",
|
||||
@@ -383,6 +404,8 @@
|
||||
"recordingOnHint": "Meetings in diesem Raum können aufgezeichnet werden",
|
||||
"recordingOffHint": "Meetings in diesem Raum werden nicht aufgezeichnet",
|
||||
"roomDetails": "Raumdetails",
|
||||
"joinUrl": "Beitritts-URL"
|
||||
"joinUrl": "Beitritts-URL",
|
||||
"roomDeleted": "Gelöscht",
|
||||
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar."
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@
|
||||
"emailVerificationResend": "Click here to receive a new verification email",
|
||||
"emailVerificationResendCooldown": "Resend in {seconds}s",
|
||||
"emailVerificationResendSuccess": "Verification email sent!",
|
||||
"emailVerificationResendFailed": "Could not send verification email"
|
||||
"emailVerificationResendFailed": "Could not send verification email",
|
||||
"inviteOnly": "Invite Only",
|
||||
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account."
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
@@ -333,7 +335,26 @@
|
||||
"defaultThemeLabel": "Default Theme",
|
||||
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
|
||||
"defaultThemeSaved": "Default theme saved",
|
||||
"defaultThemeUpdateFailed": "Could not update default theme"
|
||||
"defaultThemeUpdateFailed": "Could not update default theme",
|
||||
"regModeTitle": "Registration Mode",
|
||||
"regModeDescription": "Control how new users can register. \"Open\" allows everyone to sign up. \"Invite only\" requires an invitation link.",
|
||||
"regModeOpen": "Open registration",
|
||||
"regModeInvite": "Invite only",
|
||||
"regModeSaved": "Registration mode updated",
|
||||
"regModeFailed": "Could not update registration mode",
|
||||
"inviteTitle": "User Invitations",
|
||||
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
|
||||
"sendInvite": "Send invite",
|
||||
"inviteSent": "Invitation sent!",
|
||||
"inviteFailed": "Could not send invitation",
|
||||
"inviteDeleted": "Invitation deleted",
|
||||
"inviteDeleteFailed": "Could not delete invitation",
|
||||
"inviteLinkCopied": "Invite link copied!",
|
||||
"copyInviteLink": "Copy invite link",
|
||||
"inviteExpired": "Expired",
|
||||
"inviteUsedBy": "Used by",
|
||||
"inviteExpiresAt": "Expires",
|
||||
"noInvites": "No invitations yet"
|
||||
},
|
||||
"federation": {
|
||||
"inbox": "Invitations",
|
||||
@@ -383,6 +404,8 @@
|
||||
"recordingOnHint": "Meetings in this room may be recorded",
|
||||
"recordingOffHint": "Meetings in this room will not be recorded",
|
||||
"roomDetails": "Room Details",
|
||||
"joinUrl": "Join URL"
|
||||
"joinUrl": "Join URL",
|
||||
"roomDeleted": "Deleted",
|
||||
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available."
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
Upload, X as XIcon, Image, Type, Palette,
|
||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||
ShieldCheck, Globe,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -15,7 +16,7 @@ import toast from 'react-hot-toast';
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, refreshBranding } = useBranding();
|
||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, refreshBranding } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -27,6 +28,12 @@ export default function Admin() {
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
|
||||
|
||||
// Invite state
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [sendingInvite, setSendingInvite] = useState(false);
|
||||
const [savingRegMode, setSavingRegMode] = useState(false);
|
||||
|
||||
// Branding state
|
||||
const [editAppName, setEditAppName] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
@@ -41,6 +48,7 @@ export default function Admin() {
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
fetchInvites();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +70,15 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInvites = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/invites');
|
||||
setInvites(res.data.invites);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId, newRole) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||
@@ -172,6 +189,50 @@ export default function Admin() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvite = async (e) => {
|
||||
e.preventDefault();
|
||||
setSendingInvite(true);
|
||||
try {
|
||||
const res = await api.post('/admin/invites', { email: inviteEmail });
|
||||
toast.success(t('admin.inviteSent'));
|
||||
setInviteEmail('');
|
||||
fetchInvites();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.inviteFailed'));
|
||||
} finally {
|
||||
setSendingInvite(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvite = async (id) => {
|
||||
try {
|
||||
await api.delete(`/admin/invites/${id}`);
|
||||
toast.success(t('admin.inviteDeleted'));
|
||||
fetchInvites();
|
||||
} catch {
|
||||
toast.error(t('admin.inviteDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = (token) => {
|
||||
const baseUrl = window.location.origin;
|
||||
navigator.clipboard.writeText(`${baseUrl}/register?invite=${token}`);
|
||||
toast.success(t('admin.inviteLinkCopied'));
|
||||
};
|
||||
|
||||
const handleRegModeChange = async (mode) => {
|
||||
setSavingRegMode(true);
|
||||
try {
|
||||
await api.put('/branding/registration-mode', { registrationMode: mode });
|
||||
toast.success(t('admin.regModeSaved'));
|
||||
refreshBranding();
|
||||
} catch {
|
||||
toast.error(t('admin.regModeFailed'));
|
||||
} finally {
|
||||
setSavingRegMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -318,6 +379,128 @@ export default function Admin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Mode */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShieldCheck size={20} className="text-th-accent" />
|
||||
<h2 className="text-lg font-semibold text-th-text">{t('admin.regModeTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('admin.regModeDescription')}</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleRegModeChange('open')}
|
||||
disabled={savingRegMode}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
|
||||
registrationMode === 'open'
|
||||
? 'border-th-accent bg-th-accent/10 text-th-accent'
|
||||
: 'border-th-border text-th-text-s hover:bg-th-hover'
|
||||
}`}
|
||||
>
|
||||
<Globe size={16} />
|
||||
{t('admin.regModeOpen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRegModeChange('invite')}
|
||||
disabled={savingRegMode}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
|
||||
registrationMode === 'invite'
|
||||
? 'border-th-accent bg-th-accent/10 text-th-accent'
|
||||
: 'border-th-border text-th-text-s hover:bg-th-hover'
|
||||
}`}
|
||||
>
|
||||
<Mail size={16} />
|
||||
{t('admin.regModeInvite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Invites */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Send size={20} className="text-th-accent" />
|
||||
<h2 className="text-lg font-semibold text-th-text">{t('admin.inviteTitle')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mb-5">{t('admin.inviteDescription')}</p>
|
||||
|
||||
{/* Send invite form */}
|
||||
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={e => setInviteEmail(e.target.value)}
|
||||
className="input-field pl-9 text-sm"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendingInvite || !inviteEmail.trim()}
|
||||
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||
>
|
||||
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
{t('admin.sendInvite')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Invite list */}
|
||||
{invites.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{invites.map(inv => {
|
||||
const isExpired = new Date(inv.expires_at) < new Date();
|
||||
const isUsed = !!inv.used_at;
|
||||
return (
|
||||
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
|
||||
}`}>
|
||||
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text truncate">{inv.email}</p>
|
||||
<p className="text-xs text-th-text-s">
|
||||
{isUsed
|
||||
? `${t('admin.inviteUsedBy')} ${inv.used_by_name}`
|
||||
: isExpired
|
||||
? t('admin.inviteExpired')
|
||||
: `${t('admin.inviteExpiresAt')} ${new Date(inv.expires_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isUsed && !isExpired && (
|
||||
<button
|
||||
onClick={() => handleCopyInviteLink(inv.token)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
title={t('admin.copyInviteLink')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invites.length === 0 && (
|
||||
<p className="text-sm text-th-text-s text-center py-4">{t('admin.noInvites')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="relative">
|
||||
@@ -409,7 +592,7 @@ export default function Admin() {
|
||||
{openMenu === u.id && u.id !== user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
|
||||
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<div className="absolute right-0 bottom-full mb-1 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Globe, ExternalLink, Trash2, Hash, Users,
|
||||
Video, VideoOff, Loader2, Link2,
|
||||
Video, VideoOff, Loader2, Link2, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -66,6 +66,7 @@ export default function FederatedRoomDetail() {
|
||||
if (!room) return null;
|
||||
|
||||
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
|
||||
const isDeleted = room.deleted === 1 || room.deleted === true;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
@@ -78,19 +79,38 @@ export default function FederatedRoomDetail() {
|
||||
{t('federation.backToDashboard')}
|
||||
</button>
|
||||
|
||||
{/* Deleted banner */}
|
||||
{isDeleted && (
|
||||
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
|
||||
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="card p-6 mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-th-accent/15 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Globe size={20} className="text-th-accent" />
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
|
||||
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-th-text truncate">{room.room_name}</h1>
|
||||
<span className="px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
{t('federation.federated')}
|
||||
</span>
|
||||
{isDeleted ? (
|
||||
<span className="px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium">
|
||||
{t('federation.roomDeleted')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
|
||||
{t('federation.federated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{t('federation.from')}: <span className="font-medium text-th-text">{room.from_user}</span>
|
||||
@@ -99,13 +119,15 @@ export default function FederatedRoomDetail() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="btn-primary"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('federation.joinMeeting')}
|
||||
</button>
|
||||
{!isDeleted && (
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
className="btn-primary"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
{t('federation.joinMeeting')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
@@ -176,7 +198,7 @@ export default function FederatedRoomDetail() {
|
||||
|
||||
{/* Read-only notice */}
|
||||
<p className="text-xs text-th-text-s mt-4 text-center italic">
|
||||
{t('federation.readOnlyNotice')}
|
||||
{isDeleted ? t('federation.roomDeletedNotice') : t('federation.readOnlyNotice')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,12 @@ import { Link } from 'react-router-dom';
|
||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode } = useBranding();
|
||||
const isInviteOnly = registrationMode === 'invite';
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -54,10 +57,12 @@ export default function Home() {
|
||||
<Link to="/login" className="btn-ghost text-sm">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
<Link to="/register" className="btn-primary text-sm">
|
||||
{t('auth.register')}
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
{!isInviteOnly && (
|
||||
<Link to="/register" className="btn-primary text-sm">
|
||||
{t('auth.register')}
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -78,11 +83,13 @@ export default function Home() {
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 justify-center">
|
||||
<Link to="/register" className="btn-primary text-base px-8 py-3">
|
||||
{t('home.getStarted')}
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<Link to="/login" className="btn-secondary text-base px-8 py-3">
|
||||
{!isInviteOnly && (
|
||||
<Link to="/register" className="btn-primary text-base px-8 py-3">
|
||||
{t('home.getStarted')}
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}>
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import api from '../services/api';
|
||||
@@ -16,6 +17,7 @@ export default function Login() {
|
||||
const [resending, setResending] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -152,12 +154,14 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
{registrationMode !== 'invite' && (
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Register() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const inviteToken = searchParams.get('invite') || '';
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -16,8 +19,12 @@ export default function Register() {
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Invite-only mode without a token → show blocked message
|
||||
const isBlocked = registrationMode === 'invite' && !inviteToken;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -33,7 +40,7 @@ export default function Register() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await register(username, displayName, email, password);
|
||||
const result = await register(username, displayName, email, password, inviteToken);
|
||||
if (result?.needsVerification) {
|
||||
setNeedsVerification(true);
|
||||
toast.success(t('auth.verificationSent'));
|
||||
@@ -77,6 +84,15 @@ export default function Register() {
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
) : isBlocked ? (
|
||||
<div className="text-center space-y-4">
|
||||
<ShieldAlert size={48} className="mx-auto text-amber-400" />
|
||||
<h2 className="text-2xl font-bold text-th-text">{t('auth.inviteOnly')}</h2>
|
||||
<p className="text-th-text-s">{t('auth.inviteOnlyDesc')}</p>
|
||||
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
|
||||
Reference in New Issue
Block a user