import { Router } from 'express'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { createNotification } from '../config/notifications.js'; import { createMeeting, joinMeeting, endMeeting, 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) { if (typeof a !== 'string' || typeof b !== 'string') return false; const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations'); if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true }); // M8: rate limit unauthenticated guest-join to prevent access_code brute-force const guestJoinLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 15, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many join attempts. Please try again later.' }, }); const router = Router(); // Build avatar URL for a user (uploaded image or generated initials) function getUserAvatarURL(req, user) { const baseUrl = getBaseUrl(req); if (user.avatar_image) { return `${baseUrl}/api/auth/avatar/${user.avatar_image}`; } const color = user.avatar_color ? `?color=${encodeURIComponent(user.avatar_color)}` : ''; return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.display_name || user.name)}${color}`; } // GET /api/rooms - List user's rooms (owned + shared) router.get('/', authenticateToken, async (req, res) => { try { const db = getDb(); const ownRooms = await db.all(` SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 0 as shared 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]); const sharedRooms = await db.all(` SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 1 as shared FROM rooms r JOIN users u ON r.user_id = u.id JOIN room_shares rs ON rs.room_id = r.id WHERE rs.user_id = ? ORDER BY r.created_at DESC `, [req.user.id]); res.json({ rooms: [...ownRooms, ...sharedRooms] }); } catch (err) { log.rooms.error(`List rooms error: ${err.message}`); res.status(500).json({ error: 'Rooms could not be loaded' }); } }); // GET /api/rooms/users/search - Search users for sharing (must be before /:uid routes) router.get('/users/search', authenticateToken, async (req, res) => { try { const { q } = req.query; if (!q || q.length < 2) { return res.json({ users: [] }); } const db = getDb(); const searchTerm = `%${q}%`; const users = await db.all(` SELECT id, name, display_name, email, avatar_color, avatar_image FROM users WHERE (name LIKE ? OR display_name LIKE ? OR email LIKE ?) AND id != ? LIMIT 10 `, [searchTerm, searchTerm, searchTerm, req.user.id]); res.json({ users }); } catch (err) { log.rooms.error(`Search users error: ${err.message}`); res.status(500).json({ error: 'User search failed' }); } }); // 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.*, COALESCE(NULLIF(u.display_name,''), 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: 'Room not found' }); } // Check access: owner, admin, or shared if (room.user_id !== req.user.id && req.user.role !== 'admin') { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission' }); } room.shared = 1; } // Get shared users const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM room_shares rs JOIN users u ON rs.user_id = u.id WHERE rs.room_id = ? `, [room.id]); res.json({ room, sharedUsers }); } catch (err) { log.rooms.error(`Get room error: ${err.message}`); res.status(500).json({ error: 'Room could not be loaded' }); } }); // 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: 'Room name is required' }); } if (name.trim().length < 2) { return res.status(400).json({ error: 'Room name must be at least 2 characters' }); } // M7: field length limits if (name.trim().length > 100) { return res.status(400).json({ error: 'Room name must not exceed 100 characters' }); } if (welcome_message && welcome_message.length > 2000) { return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' }); } if (access_code && access_code.length > 50) { return res.status(400).json({ error: 'Access code must not exceed 50 characters' }); } if (moderator_code && moderator_code.length > 50) { return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); } // S2: validate max_participants as non-negative integer if (max_participants !== undefined && max_participants !== null) { const mp = Number(max_participants); if (!Number.isInteger(mp) || mp < 0 || mp > 10000) { return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' }); } } 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) { log.rooms.error(`Create room error: ${err.message}`); res.status(500).json({ error: 'Room could not be created' }); } }); // 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: 'Room not found or no permission' }); } 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; // M12: field length limits (same as create) if (name && name.trim().length < 2) { return res.status(400).json({ error: 'Room name must be at least 2 characters' }); } if (name && name.trim().length > 100) { return res.status(400).json({ error: 'Room name must not exceed 100 characters' }); } if (welcome_message && welcome_message.length > 2000) { return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' }); } if (access_code && access_code.length > 50) { return res.status(400).json({ error: 'Access code must not exceed 50 characters' }); } if (moderator_code && moderator_code.length > 50) { return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); } // S2: validate max_participants on update if (max_participants !== undefined && max_participants !== null) { const mp = Number(max_participants); if (!Number.isInteger(mp) || mp < 0 || mp > 10000) { return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' }); } } 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) { log.rooms.error(`Update room error: ${err.message}`); res.status(500).json({ error: 'Room could not be updated' }); } }); // 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: 'Room not found' }); } if (room.user_id !== req.user.id && req.user.role !== 'admin') { 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) { log.rooms.error(`Delete room error: ${err.message}`); res.status(500).json({ error: 'Room could not be deleted' }); } }); // GET /api/rooms/:uid/shares - Get shared users for a room router.get('/:uid/shares', 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: 'Room not found or no permission' }); } const shares = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM room_shares rs JOIN users u ON rs.user_id = u.id WHERE rs.room_id = ? `, [room.id]); res.json({ shares }); } catch (err) { log.rooms.error(`Get shares error: ${err.message}`); res.status(500).json({ error: 'Error loading shares' }); } }); // POST /api/rooms/:uid/shares - Share room with a user router.post('/:uid/shares', authenticateToken, async (req, res) => { try { const { user_id } = req.body; if (!user_id) { return res.status(400).json({ error: 'User ID is required' }); } 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: 'Room not found or no permission' }); } if (user_id === req.user.id) { return res.status(400).json({ error: 'You cannot share the room with yourself' }); } // Check if already shared const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]); if (existing) { return res.status(400).json({ error: 'Room is already shared with this user' }); } await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]); const shares = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM room_shares rs JOIN users u ON rs.user_id = u.id WHERE rs.room_id = ? `, [room.id]); // Notify the user who was given access const sharerName = req.user.display_name || req.user.name; await createNotification( user_id, 'room_share_added', room.name, sharerName, `/rooms/${room.uid}`, ); res.json({ shares }); } catch (err) { log.rooms.error(`Share room error: ${err.message}`); res.status(500).json({ error: 'Error sharing room' }); } }); // DELETE /api/rooms/:uid/shares/:userId - Remove share router.delete('/:uid/shares/:userId', 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: 'Room not found or no permission' }); } const removedUserId = parseInt(req.params.userId); await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]); const shares = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM room_shares rs JOIN users u ON rs.user_id = u.id WHERE rs.room_id = ? `, [room.id]); // Notify the user whose access was removed await createNotification( removedUserId, 'room_share_removed', room.name, null, '/dashboard', ); res.json({ shares }); } catch (err) { log.rooms.error(`Remove share error: ${err.message}`); res.status(500).json({ error: 'Error removing share' }); } }); // 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 = ?', [req.params.uid]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } // Check access: owner or shared const isOwner = room.user_id === req.user.id; if (!isOwner) { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission' }); } } const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; const presentationUrl = room.presentation_file ? `${baseUrl}/uploads/presentations/${room.presentation_file}` : null; await createMeeting(room, baseUrl, loginURL, presentationUrl); const avatarURL = getUserAvatarURL(req, req.user); const displayName = req.user.display_name || req.user.name; const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL); res.json({ joinUrl }); } catch (err) { log.rooms.error(`Start meeting error: ${err.message}`); res.status(500).json({ error: 'Meeting could not be started' }); } }); // 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: 'Room not found' }); } // Check access code if set if (room.access_code && !timingSafeEqual(req.body.access_code || '', room.access_code)) { return res.status(403).json({ error: 'Wrong access code' }); } // Check if meeting is running const running = await isMeetingRunning(room.uid); if (!running) { return res.status(400).json({ error: 'Meeting is not running. Please wait for the moderator to start the meeting.' }); } // Owner and shared users join as moderator const isOwner = room.user_id === req.user.id; const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); const isModerator = isOwner || !!isShared || room.all_join_moderator; const avatarURL = getUserAvatarURL(req, req.user); const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL); res.json({ joinUrl }); } catch (err) { log.rooms.error(`Join meeting error: ${err.message}`); res.status(500).json({ error: 'Could not join meeting' }); } }); // 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 = ?', [req.params.uid]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } // Check access: owner or shared user const isOwner = room.user_id === req.user.id; if (!isOwner) { const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); if (!share) { return res.status(403).json({ error: 'No permission' }); } } await endMeeting(room.uid); res.json({ message: 'Meeting ended' }); } catch (err) { log.rooms.error(`End meeting error: ${err.message}`); res.status(500).json({ error: 'Meeting could not be ended' }); } }); // 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.welcome_message, r.access_code, r.record_meeting, r.max_participants, r.anyone_can_start, COALESCE(NULLIF(u.display_name,''), 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: 'Room not found' }); } 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, allow_recording: !!room.record_meeting, max_participants: room.max_participants ?? 0, anyone_can_start: !!room.anyone_can_start, }, running, }); } catch (err) { log.rooms.error(`Public room info error: ${err.message}`); res.status(500).json({ error: 'Room info could not be loaded' }); } }); // POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed) router.post('/:uid/guest-join', guestJoinLimiter, 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 is required' }); } // L1: limit guest name length if (name.trim().length > 100) { return res.status(400).json({ error: 'Name must not exceed 100 characters' }); } const db = getDb(); const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); if (!room) { return res.status(404).json({ error: 'Room not found' }); } // Check access code if set if (room.access_code && !timingSafeEqual(access_code || '', room.access_code)) { return res.status(403).json({ error: 'Wrong access code' }); } // 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 is not running. Please wait for the moderator to start the meeting.' }); } // If meeting not running but anyone_can_start, create it if (!running && room.anyone_can_start) { const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; await createMeeting(room, baseUrl, loginURL); } // Check moderator code let isModerator = !!room.all_join_moderator; if (!isModerator && moderator_code && room.moderator_code && timingSafeEqual(moderator_code, room.moderator_code)) { isModerator = true; } const baseUrl = getBaseUrl(req); const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`; const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); res.json({ joinUrl }); } catch (err) { log.rooms.error(`Guest join error: ${err.message}`); res.status(500).json({ error: 'Guest join failed' }); } }); // 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 }); } }); // POST /api/rooms/:uid/presentation - Upload a presentation file for the room router.post('/:uid/presentation', 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: 'Room not found or no permission' }); // M16: stream-level size limit - abort as soon as 50 MB is exceeded const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024; const buffer = await new Promise((resolve, reject) => { const chunks = []; let totalSize = 0; req.on('data', chunk => { totalSize += chunk.length; if (totalSize > MAX_PRESENTATION_SIZE) { req.destroy(); return reject(new Error('LIMIT_EXCEEDED')); } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }).catch(err => { if (err.message === 'LIMIT_EXCEEDED') return null; throw err; }); if (!buffer) { return res.status(400).json({ error: 'File must not exceed 50MB' }); } const contentType = req.headers['content-type'] || ''; const extMap = { 'application/pdf': 'pdf', 'application/vnd.ms-powerpoint': 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', 'application/vnd.oasis.opendocument.presentation': 'odp', 'application/msword': 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', }; const ext = extMap[contentType]; if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' }); // Validate magic bytes to prevent Content-Type spoofing const magic = buffer.slice(0, 8); const isPDF = magic[0] === 0x25 && magic[1] === 0x50 && magic[2] === 0x44 && magic[3] === 0x46; // %PDF const isZip = magic[0] === 0x50 && magic[1] === 0x4B && magic[2] === 0x03 && magic[3] === 0x04; // PK (PPTX, DOCX, ODP, etc.) const isOle = magic[0] === 0xD0 && magic[1] === 0xCF && magic[2] === 0x11 && magic[3] === 0xE0; // OLE2 (PPT, DOC) if (ext === 'pdf' && !isPDF) return res.status(400).json({ error: 'File content does not match PDF format' }); if (['pptx', 'docx', 'odp'].includes(ext) && !isZip) return res.status(400).json({ error: 'File content does not match expected archive format' }); if (['ppt', 'doc'].includes(ext) && !isOle) return res.status(400).json({ error: 'File content does not match expected document format' }); // Preserve original filename (sent as X-Filename header) const rawName = req.headers['x-filename']; const originalName = rawName ? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200) : `presentation.${ext}`; const filename = `${room.uid}_${Date.now()}.${ext}`; const filepath = path.join(presentationsDir, filename); // Remove old presentation file if exists if (room.presentation_file) { // S8: defense-in-depth path traversal check const oldPath = path.resolve(presentationsDir, room.presentation_file); if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } fs.writeFileSync(filepath, buffer); await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ room: updated }); } catch (err) { log.rooms.error(`Presentation upload error: ${err.message}`); res.status(500).json({ error: 'Presentation could not be uploaded' }); } }); // DELETE /api/rooms/:uid/presentation - Remove presentation file router.delete('/:uid/presentation', 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: 'Room not found or no permission' }); if (room.presentation_file) { // S8: defense-in-depth path traversal check const filepath = path.resolve(presentationsDir, room.presentation_file); if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath); } await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ room: updated }); } catch (err) { log.rooms.error(`Presentation delete error: ${err.message}`); res.status(500).json({ error: 'Presentation could not be removed' }); } }); export default router;