Files
redlight/server/routes/recordings.js
T
2026-04-08 00:10:25 +02:00

326 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express';
import { authenticateToken } from '../middleware/auth.js';
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import {
getRecordings,
getRecordingByRecordId,
deleteRecording,
publishRecording,
} from '../config/bbb.js';
const router = Router();
/**
* Format a raw BBB recording into a normalised object.
*/
function formatBbbRecording(rec, fallbackName) {
const playback = rec.playback?.format;
let formats = [];
if (playback) {
formats = Array.isArray(playback) ? playback : [playback];
}
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || fallbackName || 'Recording',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
endTime: rec.endTime,
participants: rec.participants,
size: rec.size,
formats: formats.map(f => ({
type: f.type,
url: f.url,
length: f.length,
size: f.size,
})),
metadata: rec.metadata || {},
};
}
/**
* Format a DB row into the same shape the frontend expects.
*/
function formatDbRecording(row) {
return {
recordID: row.record_id,
meetingID: row.meeting_id,
name: row.name || 'Recording',
state: row.state,
published: row.published === 1,
startTime: row.start_time,
endTime: row.end_time,
participants: row.participants,
size: row.size,
formats: JSON.parse(row.formats || '[]'),
metadata: JSON.parse(row.metadata || '{}'),
fromCache: true,
};
}
/**
* Upsert a single formatted recording into the DB.
* - If the record_id already exists AND is the same recording, merge new formats.
* - If it's a new recording or a different one with the same record_id, overwrite.
*/
async function upsertRecording(db, rec) {
const existing = await db.get('SELECT * FROM recordings WHERE record_id = ?', [rec.recordID]);
const formatsJson = JSON.stringify(rec.formats);
const metadataJson = JSON.stringify(rec.metadata || {});
if (existing) {
// Verify it's still the same recording (same startTime)
if (String(rec.startTime) === String(existing.start_time)) {
// Merge formats: keep existing formats, add any new types
const existingFormats = JSON.parse(existing.formats || '[]');
const existingTypes = new Set(existingFormats.map(f => f.type));
const mergedFormats = [...existingFormats];
for (const f of rec.formats) {
if (existingTypes.has(f.type)) {
// Update URL for existing format type (server may have changed)
const idx = mergedFormats.findIndex(ef => ef.type === f.type);
if (idx !== -1) mergedFormats[idx] = f;
} else {
// New format type added
mergedFormats.push(f);
}
}
await db.run(
`UPDATE recordings SET name = ?, state = ?, published = ?, end_time = ?,
participants = ?, size = ?, formats = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE record_id = ?`,
[rec.name, rec.state, rec.published ? 1 : 0, rec.endTime,
rec.participants, rec.size, JSON.stringify(mergedFormats), metadataJson,
rec.recordID]
);
} else {
// Different recording with same record_id overwrite completely
await db.run(
`UPDATE recordings SET meeting_id = ?, name = ?, state = ?, published = ?,
start_time = ?, end_time = ?, participants = ?, size = ?, formats = ?,
metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE record_id = ?`,
[rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
rec.startTime, rec.endTime, rec.participants, rec.size,
formatsJson, metadataJson, rec.recordID]
);
}
} else {
// Completely new recording insert
await db.run(
`INSERT INTO recordings (record_id, meeting_id, name, state, published, start_time,
end_time, participants, size, formats, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[rec.recordID, rec.meetingID, rec.name, rec.state, rec.published ? 1 : 0,
rec.startTime, rec.endTime, rec.participants, rec.size,
formatsJson, metadataJson]
);
}
}
/**
* Core logic: fetch recordings from BBB, sync with DB, fall back to DB on error.
* Returns an array of formatted recordings.
*/
async function fetchAndSyncRecordings(meetingID, fallbackName) {
const db = getDb();
let bbbRecordings = null;
try {
const raw = await getRecordings(meetingID || undefined);
bbbRecordings = raw.map(rec => formatBbbRecording(rec, fallbackName));
} catch (err) {
log.recordings.warn(`BBB API unreachable, falling back to cached recordings: ${err.message}`);
}
if (bbbRecordings !== null) {
// BBB API was reachable sync each recording into the DB
for (const rec of bbbRecordings) {
try {
await upsertRecording(db, rec);
} catch (err) {
log.recordings.error(`Failed to cache recording ${rec.recordID}: ${err.message}`);
}
}
// For recordings in DB that were NOT returned by BBB (possibly deleted on server
// or server changed), keep them in DB with their cached URLs don't delete.
// However, we return the BBB result merged with any DB-only recordings.
const bbbIds = new Set(bbbRecordings.map(r => r.recordID));
const params = meetingID ? [meetingID] : [];
const query = meetingID
? 'SELECT * FROM recordings WHERE meeting_id = ?'
: 'SELECT * FROM recordings';
const dbRows = await db.all(query, params);
// Add DB-only recordings (not on current BBB server) with cached URLs
for (const row of dbRows) {
if (!bbbIds.has(row.record_id)) {
bbbRecordings.push(formatDbRecording(row));
}
}
return bbbRecordings;
} else {
// BBB API unreachable serve everything from DB
const params = meetingID ? [meetingID] : [];
const query = meetingID
? 'SELECT * FROM recordings WHERE meeting_id = ?'
: 'SELECT * FROM recordings';
const rows = await db.all(query, params);
return rows.map(formatDbRecording);
}
}
// GET /api/recordings - Get recordings for a room (by meetingID/uid)
router.get('/', authenticateToken, async (req, res) => {
try {
const { meetingID } = req.query;
// M11: verify user has access to the room if a meetingID is specified
if (meetingID) {
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
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 to view recordings for this room' });
}
}
} else if (req.user.role !== 'admin') {
// Non-admins must specify a meetingID
return res.status(400).json({ error: 'meetingID query parameter is required' });
}
const formatted = await fetchAndSyncRecordings(meetingID);
res.json({ recordings: formatted });
} catch (err) {
log.recordings.error(`Get recordings error: ${err.message}`);
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
}
});
// GET /api/recordings/room/:uid - Get recordings for a specific room
router.get('/room/: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' });
}
// H9: verify requesting user has access to this room
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 to view recordings for this room' });
}
}
const formatted = await fetchAndSyncRecordings(room.uid, room.name);
res.json({ recordings: formatted });
} catch (err) {
log.recordings.error(`Get room recordings error: ${err.message}`);
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
}
});
// DELETE /api/recordings/:recordID
router.delete('/:recordID', authenticateToken, async (req, res) => {
try {
const db = getDb();
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room.
if (req.user.role !== 'admin') {
// Try BBB first, fall back to DB cache
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
if (!rec) {
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
if (dbRow) {
rec = { meetingID: dbRow.meeting_id };
}
}
if (!rec) {
return res.status(404).json({ error: 'Recording not found' });
}
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (room.user_id !== req.user.id) {
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 to delete this recording' });
}
}
}
// Try to delete on BBB (may fail if server changed that's OK)
try {
await deleteRecording(req.params.recordID);
} catch (err) {
log.recordings.warn(`BBB deleteRecording failed (may already be gone): ${err.message}`);
}
// Always remove from local cache
await db.run('DELETE FROM recordings WHERE record_id = ?', [req.params.recordID]);
res.json({ message: 'Recording deleted' });
} catch (err) {
log.recordings.error(`Delete recording error: ${err.message}`);
res.status(500).json({ error: 'Recording could not be deleted' });
}
});
// PUT /api/recordings/:recordID/publish
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
try {
const db = getDb();
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room.
if (req.user.role !== 'admin') {
let rec = await getRecordingByRecordId(req.params.recordID).catch(() => null);
if (!rec) {
const dbRow = await db.get('SELECT * FROM recordings WHERE record_id = ?', [req.params.recordID]);
if (dbRow) {
rec = { meetingID: dbRow.meeting_id };
}
}
if (!rec) {
return res.status(404).json({ error: 'Recording not found' });
}
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (room.user_id !== req.user.id) {
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 to update this recording' });
}
}
}
const { publish } = req.body;
// Try BBB API
try {
await publishRecording(req.params.recordID, publish);
} catch (err) {
log.recordings.warn(`BBB publishRecording failed: ${err.message}`);
}
// Update DB cache
await db.run('UPDATE recordings SET published = ?, updated_at = CURRENT_TIMESTAMP WHERE record_id = ?',
[publish ? 1 : 0, req.params.recordID]);
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
} catch (err) {
log.recordings.error(`Publish recording error: ${err.message}`);
res.status(500).json({ error: 'Recording could not be updated' });
}
});
export default router;