All checks were successful
Build & Push Docker Image / build (push) Successful in 6m31s
779 lines
29 KiB
JavaScript
779 lines
29 KiB
JavaScript
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,
|
|
learning_analytics,
|
|
} = 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 = ?,
|
|
learning_analytics = COALESCE(?, learning_analytics),
|
|
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,
|
|
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
|
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;
|
|
const analyticsCallbackURL = room.learning_analytics
|
|
? `${baseUrl}/api/analytics/callback/${room.uid}`
|
|
: null;
|
|
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL);
|
|
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}`;
|
|
const analyticsCallbackURL = room.learning_analytics
|
|
? `${baseUrl}/api/analytics/callback/${room.uid}`
|
|
: null;
|
|
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL);
|
|
}
|
|
|
|
// 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;
|