diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 5fdce90..89140e0 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -40,10 +40,10 @@ if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive const PRESENTATION_TOKEN_SECRET = process.env.BBB_SECRET || crypto.randomBytes(32).toString('hex'); const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour -function signPresentationUrl(filename) { +function signPresentationUrl(roomUid, filename) { const expires = Date.now() + PRESENTATION_TOKEN_TTL; const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET) - .update(`${filename}:${expires}`) + .update(`${roomUid}/${filename}:${expires}`) .digest('hex'); return { token, expires }; } @@ -497,8 +497,8 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { const loginURL = `${baseUrl}/join/${room.uid}`; let presentationUrl = null; if (room.presentation_file) { - const { token, expires } = signPresentationUrl(room.presentation_file); - presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.presentation_file}`; + const { token, expires } = signPresentationUrl(room.uid, room.presentation_file); + presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`; } const analyticsCallbackURL = room.learning_analytics ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` @@ -702,11 +702,11 @@ router.get('/:uid/status', async (req, res) => { } }); -// GET /api/rooms/presentations/:token/:expires/:filename - Serve presentation file (token-protected for BBB) -// Token and expires are path segments so the URL ends with the filename, +// GET /api/rooms/presentations/:token/:expires/:roomUid/:filename - Serve presentation file (token-protected for BBB) +// Token and expires are path segments so the URL ends with the original filename, // allowing BBB to detect the file type from the extension. -router.get('/presentations/:token/:expires/:filename', (req, res) => { - const { token, expires, filename } = req.params; +router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => { + const { token, expires, roomUid, filename } = req.params; if (!token || !expires) { return res.status(401).json({ error: 'Missing token' }); @@ -718,7 +718,7 @@ router.get('/presentations/:token/:expires/:filename', (req, res) => { } const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET) - .update(`${filename}:${expires}`) + .update(`${roomUid}/${filename}:${expires}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) { @@ -726,8 +726,9 @@ router.get('/presentations/:token/:expires/:filename', (req, res) => { } // S8: prevent path traversal - const filepath = path.resolve(presentationsDir, filename); - if (!filepath.startsWith(presentationsDir + path.sep)) { + const roomDir = path.resolve(presentationsDir, roomUid); + const filepath = path.resolve(roomDir, filename); + if (!filepath.startsWith(presentationsDir + path.sep) || !filepath.startsWith(roomDir + path.sep)) { return res.status(400).json({ error: 'Invalid filename' }); } @@ -792,22 +793,28 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => { // Preserve original filename (sent as X-Filename header) const rawName = req.headers['x-filename']; - const originalName = rawName + const filename = 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); + // Each room gets its own folder: uploads/presentations/{roomUID}/ + const roomDir = path.join(presentationsDir, room.uid); + if (!fs.existsSync(roomDir)) fs.mkdirSync(roomDir, { recursive: true }); + const filepath = path.join(roomDir, filename); + + // S8: defense-in-depth path traversal check + if (!path.resolve(filepath).startsWith(roomDir + path.sep)) { + return res.status(400).json({ error: 'Invalid 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); + const oldPath = path.resolve(roomDir, room.presentation_file); + if (oldPath.startsWith(roomDir + 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]); + await db.run('UPDATE rooms SET presentation_file = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, req.params.uid]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); res.json({ room: updated }); } catch (err) { @@ -825,11 +832,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => { 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); + const roomDir = path.join(presentationsDir, room.uid); + const filepath = path.resolve(roomDir, room.presentation_file); + if (filepath.startsWith(roomDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath); + // Remove empty room folder + if (fs.existsSync(roomDir) && fs.readdirSync(roomDir).length === 0) fs.rmdirSync(roomDir); } - await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]); + await db.run('UPDATE rooms SET presentation_file = 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) { diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index 5e4e12f..f160e3f 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -717,7 +717,7 @@ export default function RoomDetail() {

{t('room.presentationCurrent')}

- {room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`} + {room.presentation_file}