fix: update presentation display to show filename instead of name
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m21s

This commit is contained in:
2026-04-01 11:34:48 +02:00
parent c058ba3bf1
commit 9bf4228d04
2 changed files with 32 additions and 22 deletions

View File

@@ -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_SECRET = process.env.BBB_SECRET || crypto.randomBytes(32).toString('hex');
const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour const PRESENTATION_TOKEN_TTL = 60 * 60 * 1000; // 1 hour
function signPresentationUrl(filename) { function signPresentationUrl(roomUid, filename) {
const expires = Date.now() + PRESENTATION_TOKEN_TTL; const expires = Date.now() + PRESENTATION_TOKEN_TTL;
const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET) const token = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
.update(`${filename}:${expires}`) .update(`${roomUid}/${filename}:${expires}`)
.digest('hex'); .digest('hex');
return { token, expires }; return { token, expires };
} }
@@ -497,8 +497,8 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
const loginURL = `${baseUrl}/join/${room.uid}`; const loginURL = `${baseUrl}/join/${room.uid}`;
let presentationUrl = null; let presentationUrl = null;
if (room.presentation_file) { if (room.presentation_file) {
const { token, expires } = signPresentationUrl(room.presentation_file); const { token, expires } = signPresentationUrl(room.uid, room.presentation_file);
presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.presentation_file}`; presentationUrl = `${baseUrl}/api/rooms/presentations/${token}/${expires}/${room.uid}/${encodeURIComponent(room.presentation_file)}`;
} }
const analyticsCallbackURL = room.learning_analytics const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` ? `${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) // 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 filename, // Token and expires are path segments so the URL ends with the original filename,
// allowing BBB to detect the file type from the extension. // allowing BBB to detect the file type from the extension.
router.get('/presentations/:token/:expires/:filename', (req, res) => { router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
const { token, expires, filename } = req.params; const { token, expires, roomUid, filename } = req.params;
if (!token || !expires) { if (!token || !expires) {
return res.status(401).json({ error: 'Missing token' }); 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) const expected = crypto.createHmac('sha256', PRESENTATION_TOKEN_SECRET)
.update(`${filename}:${expires}`) .update(`${roomUid}/${filename}:${expires}`)
.digest('hex'); .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) { 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 // S8: prevent path traversal
const filepath = path.resolve(presentationsDir, filename); const roomDir = path.resolve(presentationsDir, roomUid);
if (!filepath.startsWith(presentationsDir + path.sep)) { 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' }); 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) // Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename']; const rawName = req.headers['x-filename'];
const originalName = rawName const filename = rawName
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200) ? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
: `presentation.${ext}`; : `presentation.${ext}`;
const filename = `${room.uid}_${Date.now()}.${ext}`; // Each room gets its own folder: uploads/presentations/{roomUID}/
const filepath = path.join(presentationsDir, filename); 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 // Remove old presentation file if exists
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check const oldPath = path.resolve(roomDir, room.presentation_file);
const oldPath = path.resolve(presentationsDir, room.presentation_file); if (oldPath.startsWith(roomDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
fs.writeFileSync(filepath, buffer); 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]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {
@@ -825,11 +832,14 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
if (room.presentation_file) { if (room.presentation_file) {
// S8: defense-in-depth path traversal check // S8: defense-in-depth path traversal check
const filepath = path.resolve(presentationsDir, room.presentation_file); const roomDir = path.join(presentationsDir, room.uid);
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath); 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]); const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated }); res.json({ room: updated });
} catch (err) { } catch (err) {

View File

@@ -717,7 +717,7 @@ export default function RoomDetail() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p> <p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate"> <p className="text-sm text-th-text font-medium truncate">
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`} {room.presentation_file}
</p> </p>
</div> </div>
</div> </div>