feat: implement token-based access for presentation files and add serving endpoint
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m43s
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m43s
This commit is contained in:
@@ -60,9 +60,10 @@ async function start() {
|
|||||||
await initDatabase();
|
await initDatabase();
|
||||||
initMailer();
|
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');
|
const uploadsPath = path.join(__dirname, '..', 'uploads');
|
||||||
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
|
app.use('/uploads/branding', express.static(path.join(uploadsPath, 'branding')));
|
||||||
|
// Presentations are served via /api/rooms/presentations/:filename?token=… (HMAC-protected)
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ const __dirname = path.dirname(__filename);
|
|||||||
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
|
||||||
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
|
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
|
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
|
||||||
const guestJoinLimiter = rateLimit({
|
const guestJoinLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -484,9 +495,11 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
const presentationUrl = room.presentation_file
|
let presentationUrl = null;
|
||||||
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
|
if (room.presentation_file) {
|
||||||
: null;
|
const { token, expires } = signPresentationUrl(room.presentation_file);
|
||||||
|
presentationUrl = `${baseUrl}/api/rooms/presentations/${room.presentation_file}?token=${token}&expires=${expires}`;
|
||||||
|
}
|
||||||
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)}`
|
||||||
: null;
|
: 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
|
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
|
||||||
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user