Init v1.0.0
Some checks failed
Build & Push Docker Image / build (push) Failing after 53s

This commit is contained in:
2026-02-24 18:14:16 +01:00
commit 54d6ee553a
49 changed files with 10410 additions and 0 deletions

114
server/config/bbb.js Normal file
View File

@@ -0,0 +1,114 @@
import crypto from 'crypto';
import xml2js from 'xml2js';
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
const BBB_SECRET = process.env.BBB_SECRET || '';
function getChecksum(apiCall, params) {
const queryString = new URLSearchParams(params).toString();
const raw = apiCall + queryString + BBB_SECRET;
return crypto.createHash('sha256').update(raw).digest('hex');
}
function buildUrl(apiCall, params = {}) {
const checksum = getChecksum(apiCall, params);
const queryString = new URLSearchParams({ ...params, checksum }).toString();
return `${BBB_URL}${apiCall}?${queryString}`;
}
async function apiCall(apiCallName, params = {}) {
const url = buildUrl(apiCallName, params);
try {
const response = await fetch(url);
const xml = await response.text();
const result = await xml2js.parseStringPromise(xml, {
explicitArray: false,
trim: true,
});
return result.response;
} catch (error) {
console.error(`BBB API error (${apiCallName}):`, error.message);
throw error;
}
}
// Generate deterministic passwords from room UID
function getRoomPasswords(uid) {
const modPw = crypto.createHash('sha256').update(uid + '_mod_' + BBB_SECRET).digest('hex').substring(0, 16);
const attPw = crypto.createHash('sha256').update(uid + '_att_' + BBB_SECRET).digest('hex').substring(0, 16);
return { moderatorPW: modPw, attendeePW: attPw };
}
export async function createMeeting(room) {
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
const params = {
meetingID: room.uid,
name: room.name,
attendeePW,
moderatorPW,
welcome: room.welcome_message || 'Willkommen!',
record: room.record_meeting ? 'true' : 'false',
autoStartRecording: 'false',
allowStartStopRecording: 'true',
muteOnStart: room.mute_on_join ? 'true' : 'false',
'meta_bbb-origin': 'Redlight',
'meta_bbb-origin-server-name': 'Redlight',
};
if (room.max_participants > 0) {
params.maxParticipants = room.max_participants.toString();
}
if (room.access_code) {
params.lockSettingsLockOnJoin = 'true';
}
return apiCall('create', params);
}
export async function joinMeeting(uid, name, isModerator = false) {
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
const params = {
meetingID: uid,
fullName: name,
password: isModerator ? moderatorPW : attendeePW,
redirect: 'true',
};
return buildUrl('join', params);
}
export async function endMeeting(uid) {
const { moderatorPW } = getRoomPasswords(uid);
return apiCall('end', { meetingID: uid, password: moderatorPW });
}
export async function getMeetingInfo(uid) {
return apiCall('getMeetingInfo', { meetingID: uid });
}
export async function isMeetingRunning(uid) {
const result = await apiCall('isMeetingRunning', { meetingID: uid });
return result.running === 'true';
}
export async function getMeetings() {
return apiCall('getMeetings', {});
}
export async function getRecordings(meetingID) {
const params = meetingID ? { meetingID } : {};
const result = await apiCall('getRecordings', params);
if (result.returncode !== 'SUCCESS' || !result.recordings) {
return [];
}
const recordings = result.recordings.recording;
if (!recordings) return [];
return Array.isArray(recordings) ? recordings : [recordings];
}
export async function deleteRecording(recordID) {
return apiCall('deleteRecordings', { recordID });
}
export async function publishRecording(recordID, publish) {
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
}
export { getRoomPasswords };

230
server/config/database.js Normal file
View File

@@ -0,0 +1,230 @@
import bcrypt from 'bcryptjs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DATABASE_URL = process.env.DATABASE_URL;
const isPostgres = !!(DATABASE_URL && DATABASE_URL.startsWith('postgres'));
let db;
// Convert ? placeholders to $1, $2, ... for PostgreSQL
function convertPlaceholders(sql) {
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
// ── SQLite Adapter ──────────────────────────────────────────────────────────
class SqliteAdapter {
async init() {
const { default: Database } = await import('better-sqlite3');
const dbPath = process.env.SQLITE_PATH || path.join(__dirname, '..', '..', 'redlight.db');
this.db = Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
}
async get(sql, params = []) {
return this.db.prepare(sql).get(...params);
}
async all(sql, params = []) {
return this.db.prepare(sql).all(...params);
}
async run(sql, params = []) {
const result = this.db.prepare(sql).run(...params);
return { lastInsertRowid: Number(result.lastInsertRowid), changes: result.changes };
}
async exec(sql) {
this.db.exec(sql);
}
async columnExists(table, column) {
const columns = this.db.pragma(`table_info(${table})`);
return !!columns.find(c => c.name === column);
}
close() {
this.db.close();
}
}
// ── PostgreSQL Adapter ──────────────────────────────────────────────────────
class PostgresAdapter {
async init() {
const pg = await import('pg');
// Parse int8 (bigint / COUNT) as JS number instead of string
pg.default.types.setTypeParser(20, val => parseInt(val, 10));
this.pool = new pg.default.Pool({ connectionString: DATABASE_URL });
}
async get(sql, params = []) {
const result = await this.pool.query(convertPlaceholders(sql), params);
return result.rows[0] || undefined;
}
async all(sql, params = []) {
const result = await this.pool.query(convertPlaceholders(sql), params);
return result.rows;
}
async run(sql, params = []) {
let pgSql = convertPlaceholders(sql);
const isInsert = /^\s*INSERT/i.test(pgSql);
if (isInsert && !/RETURNING/i.test(pgSql)) {
pgSql += ' RETURNING id';
}
const result = await this.pool.query(pgSql, params);
return {
lastInsertRowid: isInsert ? result.rows[0]?.id : undefined,
changes: result.rowCount,
};
}
async exec(sql) {
await this.pool.query(sql);
}
async columnExists(table, column) {
const result = await this.pool.query(
'SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
[table, column]
);
return result.rows.length > 0;
}
close() {
this.pool?.end();
}
}
// ── Public API ──────────────────────────────────────────────────────────────
export function getDb() {
if (!db) {
throw new Error('Database not initialised call initDatabase() first');
}
return db;
}
export async function initDatabase() {
// Create the right adapter
if (isPostgres) {
console.log('📦 Using PostgreSQL database');
db = new PostgresAdapter();
} else {
console.log('📦 Using SQLite database');
db = new SqliteAdapter();
}
await db.init();
// ── Schema creation ─────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
language TEXT DEFAULT 'de',
theme TEXT DEFAULT 'dark',
avatar_color TEXT DEFAULT '#6366f1',
avatar_image TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rooms (
id SERIAL PRIMARY KEY,
uid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
welcome_message TEXT DEFAULT 'Willkommen im Meeting!',
max_participants INTEGER DEFAULT 0,
access_code TEXT,
mute_on_join INTEGER DEFAULT 1,
require_approval INTEGER DEFAULT 0,
anyone_can_start INTEGER DEFAULT 0,
all_join_moderator INTEGER DEFAULT 0,
record_meeting INTEGER DEFAULT 1,
guest_access INTEGER DEFAULT 0,
moderator_code TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
language TEXT DEFAULT 'de',
theme TEXT DEFAULT 'dark',
avatar_color TEXT DEFAULT '#6366f1',
avatar_image TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
user_id INTEGER NOT NULL,
welcome_message TEXT DEFAULT 'Willkommen im Meeting!',
max_participants INTEGER DEFAULT 0,
access_code TEXT,
mute_on_join INTEGER DEFAULT 1,
require_approval INTEGER DEFAULT 0,
anyone_can_start INTEGER DEFAULT 0,
all_join_moderator INTEGER DEFAULT 0,
record_meeting INTEGER DEFAULT 1,
guest_access INTEGER DEFAULT 0,
moderator_code TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
`);
}
// ── Migrations ──────────────────────────────────────────────────────────
if (!(await db.columnExists('users', 'avatar_image'))) {
await db.exec('ALTER TABLE users ADD COLUMN avatar_image TEXT DEFAULT NULL');
}
if (!(await db.columnExists('rooms', 'guest_access'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN guest_access INTEGER DEFAULT 0');
}
if (!(await db.columnExists('rooms', 'moderator_code'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
}
// ── Default admin ───────────────────────────────────────────────────────
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) VALUES (?, ?, ?, ?)',
['Administrator', adminEmail, hash, 'admin']
);
console.log(`✅ Default admin created: ${adminEmail}`);
}
}

48
server/index.js Normal file
View File

@@ -0,0 +1,48 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { initDatabase } from './config/database.js';
import authRoutes from './routes/auth.js';
import roomRoutes from './routes/rooms.js';
import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Initialize database & start server
async function start() {
await initDatabase();
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/recordings', recordingRoutes);
app.use('/api/admin', adminRoutes);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`🔴 Redlight server running on http://localhost:${PORT}`);
});
}
start().catch(err => {
console.error('❌ Failed to start server:', err);
process.exit(1);
});

37
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,37 @@
import jwt from 'jsonwebtoken';
import { getDb } from '../config/database.js';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
export async function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentifizierung erforderlich' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const db = getDb();
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]);
if (!user) {
return res.status(401).json({ error: 'Benutzer nicht gefunden' });
}
req.user = user;
next();
} catch (err) {
return res.status(403).json({ error: 'Ungültiges Token' });
}
}
export function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Administratorrechte erforderlich' });
}
next();
}
export function generateToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
}

139
server/routes/admin.js Normal file
View File

@@ -0,0 +1,139 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
const router = Router();
// POST /api/admin/users - Create user (admin)
router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const { name, email, password, role } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
const validRole = ['user', 'admin'].includes(role) ? role : 'user';
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
}
const hash = bcrypt.hashSync(password, 12);
const result = await db.run(
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
[name, email.toLowerCase(), hash, validRole]
);
const user = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ user });
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Benutzer konnte nicht erstellt werden' });
}
});
// GET /api/admin/users - List all users
router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const users = await db.all(`
SELECT id, name, email, role, language, theme, avatar_color, avatar_image, created_at,
(SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count
FROM users
ORDER BY created_at DESC
`);
res.json({ users });
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Benutzer konnten nicht geladen werden' });
}
});
// PUT /api/admin/users/:id/role - Update user role
router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res) => {
try {
const { role } = req.body;
if (!['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle' });
}
const db = getDb();
// Prevent demoting last admin
if (role === 'user') {
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
const currentUser = await db.get('SELECT role FROM users WHERE id = ?', [req.params.id]);
if (currentUser?.role === 'admin' && adminCount.count <= 1) {
return res.status(400).json({ error: 'Der letzte Admin kann nicht herabgestuft werden' });
}
}
await db.run('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [role, req.params.id]);
const updated = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [req.params.id]);
res.json({ user: updated });
} catch (err) {
console.error('Update role error:', err);
res.status(500).json({ error: 'Rolle konnte nicht aktualisiert werden' });
}
});
// DELETE /api/admin/users/:id - Delete user
router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
if (parseInt(req.params.id) === req.user.id) {
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
}
const user = await db.get('SELECT id, role FROM users WHERE id = ?', [req.params.id]);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
// Check if it's the last admin
if (user.role === 'admin') {
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
if (adminCount.count <= 1) {
return res.status(400).json({ error: 'Der letzte Admin kann nicht gelöscht werden' });
}
}
await db.run('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ message: 'Benutzer gelöscht' });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Benutzer konnte nicht gelöscht werden' });
}
});
// PUT /api/admin/users/:id/password - Reset user password (admin)
router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, res) => {
try {
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
const db = getDb();
const hash = bcrypt.hashSync(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
res.json({ message: 'Passwort zurückgesetzt' });
} catch (err) {
console.error('Reset password error:', err);
res.status(500).json({ error: 'Passwort konnte nicht zurückgesetzt werden' });
}
});
export default router;

219
server/routes/auth.js Normal file
View File

@@ -0,0 +1,219 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
// Ensure uploads directory exists
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const router = Router();
// POST /api/auth/register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
}
const hash = bcrypt.hashSync(password, 12);
const result = await db.run(
'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
[name, email.toLowerCase(), hash]
);
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ token, user });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const token = generateToken(user.id);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
}
});
// GET /api/auth/me
router.get('/me', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
// PUT /api/auth/profile
router.put('/profile', authenticateToken, async (req, res) => {
try {
const { name, email, theme, language, avatar_color } = req.body;
const db = getDb();
if (email && email !== req.user.email) {
const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
}
}
await db.run(`
UPDATE users SET
name = COALESCE(?, name),
email = COALESCE(?, email),
theme = COALESCE(?, theme),
language = COALESCE(?, language),
avatar_color = COALESCE(?, avatar_color),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Profile update error:', err);
res.status(500).json({ error: 'Profil konnte nicht aktualisiert werden' });
}
});
// PUT /api/auth/password
router.put('/password', authenticateToken, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
const db = getDb();
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
}
const hash = bcrypt.hashSync(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
res.json({ message: 'Passwort erfolgreich geändert' });
} catch (err) {
console.error('Password change error:', err);
res.status(500).json({ error: 'Passwort konnte nicht geändert werden' });
}
});
// POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, async (req, res) => {
try {
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
// Validate content type
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Nur Bilddateien sind erlaubt' });
}
// Max 2MB
if (buffer.length > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'Bild darf maximal 2MB groß sein' });
}
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg';
const filename = `${req.user.id}_${Date.now()}.${ext}`;
const filepath = path.join(uploadsDir, filename);
// Remove old avatar if exists
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
fs.writeFileSync(filepath, buffer);
await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar upload error:', err);
res.status(500).json({ error: 'Avatar konnte nicht hochgeladen werden' });
}
});
// DELETE /api/auth/avatar - Remove avatar image
router.delete('/avatar', authenticateToken, async (req, res) => {
try {
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar delete error:', err);
res.status(500).json({ error: 'Avatar konnte nicht entfernt werden' });
}
});
// GET /api/auth/avatar/:filename - Serve avatar image
router.get('/avatar/:filename', (req, res) => {
const filepath = path.join(uploadsDir, req.params.filename);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'Avatar nicht gefunden' });
}
const ext = path.extname(filepath).slice(1);
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
res.setHeader('Content-Type', mimeMap[ext] || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=86400');
fs.createReadStream(filepath).pipe(res);
});
export default router;

120
server/routes/recordings.js Normal file
View File

@@ -0,0 +1,120 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.js';
import { getDb } from '../config/database.js';
import {
getRecordings,
deleteRecording,
publishRecording,
} from '../config/bbb.js';
const router = Router();
// GET /api/recordings - Get recordings for a room (by meetingID/uid)
router.get('/', authenticateToken, async (req, res) => {
try {
const { meetingID } = req.query;
const recordings = await getRecordings(meetingID || undefined);
// Format recordings
const formatted = recordings.map(rec => {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || 'Aufnahme',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
metadata: rec.metadata || {},
};
});
res.json({ recordings: formatted });
} catch (err) {
console.error('Get recordings error:', err);
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
}
});
// GET /api/recordings/room/:uid - Get recordings for a specific room
router.get('/room/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
const recordings = await getRecordings(room.uid);
const formatted = recordings.map(rec => {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || room.name,
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
};
});
res.json({ recordings: formatted });
} catch (err) {
console.error('Get room recordings error:', err);
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
}
});
// DELETE /api/recordings/:recordID
router.delete('/:recordID', authenticateToken, async (req, res) => {
try {
await deleteRecording(req.params.recordID);
res.json({ message: 'Aufnahme gelöscht' });
} catch (err) {
console.error('Delete recording error:', err);
res.status(500).json({ error: 'Aufnahme konnte nicht gelöscht werden' });
}
});
// PUT /api/recordings/:recordID/publish
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
try {
const { publish } = req.body;
await publishRecording(req.params.recordID, publish);
res.json({ message: publish ? 'Aufnahme veröffentlicht' : 'Aufnahme nicht mehr öffentlich' });
} catch (err) {
console.error('Publish recording error:', err);
res.status(500).json({ error: 'Aufnahme konnte nicht aktualisiert werden' });
}
});
export default router;

368
server/routes/rooms.js Normal file
View File

@@ -0,0 +1,368 @@
import { Router } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import {
createMeeting,
joinMeeting,
endMeeting,
getMeetingInfo,
isMeetingRunning,
} from '../config/bbb.js';
const router = Router();
// GET /api/rooms - List user's rooms
router.get('/', authenticateToken, async (req, res) => {
try {
const db = getDb();
const rooms = await db.all(`
SELECT r.*, u.name as owner_name
FROM rooms r
JOIN users u ON r.user_id = u.id
WHERE r.user_id = ?
ORDER BY r.created_at DESC
`, [req.user.id]);
res.json({ rooms });
} catch (err) {
console.error('List rooms error:', err);
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
}
});
// GET /api/rooms/:uid - Get room details
router.get('/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get(`
SELECT r.*, u.name as owner_name
FROM rooms r
JOIN users u ON r.user_id = u.id
WHERE r.uid = ?
`, [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
res.json({ room });
} catch (err) {
console.error('Get room error:', err);
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
}
});
// POST /api/rooms - Create room
router.post('/', authenticateToken, async (req, res) => {
try {
const {
name,
welcome_message,
max_participants,
access_code,
mute_on_join,
require_approval,
anyone_can_start,
all_join_moderator,
record_meeting,
guest_access,
moderator_code,
} = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Raumname ist erforderlich' });
}
const uid = crypto.randomBytes(8).toString('hex');
const db = getDb();
const result = await db.run(`
INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
uid,
name.trim(),
req.user.id,
welcome_message || 'Willkommen im Meeting!',
max_participants || 0,
access_code || null,
mute_on_join !== false ? 1 : 0,
require_approval ? 1 : 0,
anyone_can_start ? 1 : 0,
all_join_moderator ? 1 : 0,
record_meeting !== false ? 1 : 0,
guest_access ? 1 : 0,
moderator_code || null,
]);
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);
res.status(500).json({ error: 'Raum konnte nicht erstellt werden' });
}
});
// PUT /api/rooms/:uid - Update room
router.put('/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
}
const {
name,
welcome_message,
max_participants,
access_code,
mute_on_join,
require_approval,
anyone_can_start,
all_join_moderator,
record_meeting,
guest_access,
moderator_code,
} = req.body;
await db.run(`
UPDATE rooms SET
name = COALESCE(?, name),
welcome_message = COALESCE(?, welcome_message),
max_participants = COALESCE(?, max_participants),
access_code = ?,
mute_on_join = COALESCE(?, mute_on_join),
require_approval = COALESCE(?, require_approval),
anyone_can_start = COALESCE(?, anyone_can_start),
all_join_moderator = COALESCE(?, all_join_moderator),
record_meeting = COALESCE(?, record_meeting),
guest_access = COALESCE(?, guest_access),
moderator_code = ?,
updated_at = CURRENT_TIMESTAMP
WHERE uid = ?
`, [
name,
welcome_message,
max_participants,
access_code ?? room.access_code,
mute_on_join !== undefined ? (mute_on_join ? 1 : 0) : null,
require_approval !== undefined ? (require_approval ? 1 : 0) : null,
anyone_can_start !== undefined ? (anyone_can_start ? 1 : 0) : null,
all_join_moderator !== undefined ? (all_join_moderator ? 1 : 0) : null,
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
req.params.uid,
]);
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);
res.status(500).json({ error: 'Raum konnte nicht aktualisiert werden' });
}
});
// DELETE /api/rooms/:uid - Delete room
router.delete('/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
await db.run('DELETE FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ message: 'Raum erfolgreich gelöscht' });
} catch (err) {
console.error('Delete room error:', err);
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
}
});
// POST /api/rooms/:uid/start - Start meeting
router.post('/:uid/start', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
}
await createMeeting(room);
const joinUrl = await joinMeeting(room.uid, req.user.name, true);
res.json({ joinUrl });
} catch (err) {
console.error('Start meeting error:', err);
res.status(500).json({ error: 'Meeting konnte nicht gestartet werden' });
}
});
// POST /api/rooms/:uid/join - Join meeting
router.post('/:uid/join', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
// Check access code if set
if (room.access_code && req.body.access_code !== room.access_code) {
return res.status(403).json({ error: 'Falscher Zugangscode' });
}
// Check if meeting is running
const running = await isMeetingRunning(room.uid);
if (!running) {
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
}
const isModerator = room.user_id === req.user.id || room.all_join_moderator;
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator);
res.json({ joinUrl });
} catch (err) {
console.error('Join meeting error:', err);
res.status(500).json({ error: 'Meeting konnte nicht beigetreten werden' });
}
});
// POST /api/rooms/:uid/end - End meeting
router.post('/:uid/end', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
}
await endMeeting(room.uid);
res.json({ message: 'Meeting beendet' });
} catch (err) {
console.error('End meeting error:', err);
res.status(500).json({ error: 'Meeting konnte nicht beendet werden' });
}
});
// GET /api/rooms/:uid/public - Get public room info (no auth needed)
router.get('/:uid/public', async (req, res) => {
try {
const db = getDb();
const room = await db.get(`
SELECT r.uid, r.name, r.guest_access, r.welcome_message, r.access_code,
u.name as owner_name
FROM rooms r
JOIN users u ON r.user_id = u.id
WHERE r.uid = ?
`, [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
if (!room.guest_access) {
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
}
const running = await isMeetingRunning(room.uid);
res.json({
room: {
uid: room.uid,
name: room.name,
owner_name: room.owner_name,
welcome_message: room.welcome_message,
has_access_code: !!room.access_code,
},
running,
});
} catch (err) {
console.error('Public room info error:', err);
res.status(500).json({ error: 'Rauminfos konnten nicht geladen werden' });
}
});
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
router.post('/:uid/guest-join', async (req, res) => {
try {
const { name, access_code, moderator_code } = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Name ist erforderlich' });
}
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
if (!room.guest_access) {
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
}
// Check access code if set
if (room.access_code && access_code !== room.access_code) {
return res.status(403).json({ error: 'Falscher Zugangscode' });
}
// Check if meeting is running (or if anyone_can_start is enabled)
const running = await isMeetingRunning(room.uid);
if (!running && !room.anyone_can_start) {
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
}
// If meeting not running but anyone_can_start, create it
if (!running && room.anyone_can_start) {
await createMeeting(room);
}
// Check moderator code
let isModerator = !!room.all_join_moderator;
if (!isModerator && moderator_code && room.moderator_code && moderator_code === room.moderator_code) {
isModerator = true;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator);
res.json({ joinUrl });
} catch (err) {
console.error('Guest join error:', err);
res.status(500).json({ error: 'Beitritt als Gast fehlgeschlagen' });
}
});
// GET /api/rooms/:uid/status - Check if meeting is running (public, no guard needed)
router.get('/:uid/status', async (req, res) => {
try {
const running = await isMeetingRunning(req.params.uid);
let info = null;
if (running) {
try {
info = await getMeetingInfo(req.params.uid);
} catch (e) {
// Meeting info might fail
}
}
res.json({
running,
participantCount: info?.participantCount || 0,
moderatorCount: info?.moderatorCount || 0,
});
} catch (err) {
res.json({ running: false, participantCount: 0, moderatorCount: 0 });
}
});
export default router;