diff --git a/server/index.js b/server/index.js index 8e09105..0be22a1 100644 --- a/server/index.js +++ b/server/index.js @@ -60,9 +60,10 @@ async function start() { await initDatabase(); initMailer(); - // Serve uploaded files (branding only — avatars served via /api/auth/avatar/:filename, presentations require auth) + // Serve uploaded files (avatars are served via /api/auth/avatar/:filename) const uploadsPath = path.join(__dirname, '..', 'uploads'); app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding'))); + // Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected) // API Routes app.use('/api/auth', authRoutes); diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 609a817..b4023b9 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -37,6 +37,17 @@ const __dirname = path.dirname(__filename); const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations'); if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true }); +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) { + const expires = Date.now() + PRESENTATION_TOKEN_TTL; + const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET) + .update(`${filename}:${expires}`) + .digest('hex'); + return { token, expires }; +} + // M8: rate limit unauthenticated guest-join to prevent access_code brute-force const guestJoinLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -484,9 +495,11 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; - const presentationUrl = room.presentation_file - ? `${baseUrl}/uploads/presentations/${room.presentation_file}` - : null; + let presentationUrl = null; + if (room.presentation_file) { + const { token, expires } = signPresentationUrl(room.presentation_file); + presentationUrl = `${baseUrl}/api/rooms/presentations/${room.presentation_file}?token=${token}&expires=${expires}`; + } const analyticsCallbackURL = room.learning_analytics ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` : null; @@ -689,6 +702,41 @@ router.get('/:uid/status', async (req, res) => { } }); +// GET /api/rooms/presentations/:filename - Serve presentation file (token-protected for BBB) +router.get('/presentations/:filename', (req, res) => { + const { token, expires } = req.query; + const { filename } = req.params; + + if (!token || !expires) { + return res.status(401).json({ error: 'Missing token' }); + } + + const expiresNum = Number(expires); + if (isNaN(expiresNum) || Date.now() > expiresNum) { + return res.status(403).json({ error: 'Token expired' }); + } + + const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET) + .update(`${filename}:${expires}`) + .digest('hex'); + + if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) { + return res.status(403).json({ error: 'Invalid token' }); + } + + // S8: prevent path traversal + const filepath = path.resolve(presentationsDir, filename); + if (!filepath.startsWith(presentationsDir + path.sep)) { + return res.status(400).json({ error: 'Invalid filename' }); + } + + if (!fs.existsSync(filepath)) { + return res.status(404).json({ error: 'File not found' }); + } + + res.sendFile(filepath); +}); + // POST /api/rooms/:uid/presentation - Upload a presentation file for the room router.post('/:uid/presentation', authenticateToken, async (req, res) => { try {