diff --git a/backend/README.md b/backend/README.md index dad2fff..38d4025 100644 --- a/backend/README.md +++ b/backend/README.md @@ -104,6 +104,10 @@ openvidu/ │ │ └── room-123/ │ │ └── {egressId}/ │ │ └── {uid}.json +│ ├── .secrets/ +│ │ └── room-123/ +│ │ └── {egressId}/ +│ │ └── {uid}.json | ├── .room_metadata/ │ │ └── room-123/ │ │ └── room_metadata.json diff --git a/backend/src/helpers/recording.helper.ts b/backend/src/helpers/recording.helper.ts index bb8cb5b..b35cac5 100644 --- a/backend/src/helpers/recording.helper.ts +++ b/backend/src/helpers/recording.helper.ts @@ -1,7 +1,7 @@ import { EgressStatus } from '@livekit/protocol'; import { MeetRecordingInfo, MeetRecordingStatus } from '@typings-ce'; import { EgressInfo } from 'livekit-server-sdk'; -import INTERNAL_CONFIG from '../config/internal-config.js'; +import { uid as secureUid } from 'uid/secure'; export class RecordingHelper { private constructor() { @@ -186,6 +186,18 @@ export class RecordingHelper { return size !== 0 ? size : undefined; } + + /** + * Builds the secrets for public and private access to recordings. + * @returns An object containing public and private access secrets. + */ + static buildAccessSecrets(): { publicAccessSecret: string; privateAccessSecret: string } { + return { + publicAccessSecret: secureUid(10), + privateAccessSecret: secureUid(10) + }; + } + private static toSeconds(nanoseconds: number): number { const nanosecondsToSeconds = 1 / 1_000_000_000; return nanoseconds * nanosecondsToSeconds; diff --git a/backend/src/models/redis.model.ts b/backend/src/models/redis.model.ts index 8103023..a745a4e 100644 --- a/backend/src/models/redis.model.ts +++ b/backend/src/models/redis.model.ts @@ -6,8 +6,9 @@ export const enum RedisKeyName { GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences`, ROOM = `${RedisKeyPrefix.BASE}room:`, RECORDING = `${RedisKeyPrefix.BASE}recording:`, + RECORDING_SECRETS = `${RedisKeyPrefix.BASE}recording_secrets:`, ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`, - USER = `${RedisKeyPrefix.BASE}user:`, + USER = `${RedisKeyPrefix.BASE}user:` } export const enum RedisLockPrefix { diff --git a/backend/src/services/livekit-webhook.service.ts b/backend/src/services/livekit-webhook.service.ts index c3d52e3..7db3ea2 100644 --- a/backend/src/services/livekit-webhook.service.ts +++ b/backend/src/services/livekit-webhook.service.ts @@ -229,7 +229,10 @@ export class LivekitWebhookService { // Send webhook notification switch (webhookAction) { case 'started': - specificTasks.push(this.storageService.archiveRoomMetadata(roomId)); + specificTasks.push( + this.storageService.archiveRoomMetadata(roomId), + this.storageService.saveAccessRecordingSecrets(recordingId) + ); this.openViduWebhookService.sendRecordingStartedWebhook(recordingInfo); break; case 'updated': diff --git a/backend/src/services/storage/providers/s3/s3-storage-key.builder.ts b/backend/src/services/storage/providers/s3/s3-storage-key.builder.ts index e9ca54c..ee58c32 100644 --- a/backend/src/services/storage/providers/s3/s3-storage-key.builder.ts +++ b/backend/src/services/storage/providers/s3/s3-storage-key.builder.ts @@ -38,4 +38,9 @@ export class S3KeyBuilder implements StorageKeyBuilder { buildUserKey(userId: string): string { return `${INTERNAL_CONFIG.S3_USERS_PREFIX}/${userId}.json`; } + + buildAccessRecordingSecretsKey(recordingId: string): string { + const { roomId, egressId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId); + return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.secrets/${roomId}/${egressId}/${uid}.json`; + } } diff --git a/backend/src/services/storage/storage.interface.ts b/backend/src/services/storage/storage.interface.ts index 001c81f..71b9717 100644 --- a/backend/src/services/storage/storage.interface.ts +++ b/backend/src/services/storage/storage.interface.ts @@ -150,4 +150,7 @@ export interface StorageKeyBuilder { * @param userId - The unique identifier of the user */ buildUserKey(userId: string): string; + + buildAccessRecordingSecretsKey(recordingId:string): string; + } diff --git a/backend/src/services/storage/storage.service.ts b/backend/src/services/storage/storage.service.ts index e3c7d8e..ad20e85 100644 --- a/backend/src/services/storage/storage.service.ts +++ b/backend/src/services/storage/storage.service.ts @@ -9,7 +9,7 @@ import { MEET_WEBHOOK_ENABLED, MEET_WEBHOOK_URL } from '../../environment.js'; -import { MeetLock, PasswordHelper } from '../../helpers/index.js'; +import { MeetLock, PasswordHelper, RecordingHelper } from '../../helpers/index.js'; import { errorRecordingNotFound, errorRecordingRangeNotSatisfiable, @@ -393,15 +393,25 @@ export class MeetStorageService< */ async deleteRecording(recordingId: string): Promise { try { + // Keys for recording metadata const redisMetadataKey = RedisKeyName.RECORDING + recordingId; const storageMetadataKey = this.keyBuilder.buildMeetRecordingKey(recordingId); + + // Key for access recording secrets + const storageSecretsKey = this.keyBuilder.buildAccessRecordingSecretsKey(recordingId); + const redisSecretsKey = RedisKeyName.RECORDING_SECRETS + recordingId; + + // Binary recording key const binaryRecordingKey = this.keyBuilder.buildBinaryRecordingKey(recordingId); this.logger.info(`Deleting recording ${recordingId} with metadata key ${storageMetadataKey}`); - // Delete both metadata and binary files + // Delete secrets, metadata and binary recording files await Promise.all([ - this.deleteFromCacheAndStorage(redisMetadataKey, storageMetadataKey), + this.deleteFromCacheAndStorageBatch( + [redisMetadataKey, redisSecretsKey], + [storageMetadataKey, storageSecretsKey] + ), this.storageProvider.deleteObject(binaryRecordingKey) ]); @@ -426,13 +436,17 @@ export class MeetStorageService< try { // Build all paths from recordingIds - const metadataKeys: string[] = []; const redisKeys: string[] = []; + const storageKeys: string[] = []; const binaryKeys: string[] = []; for (const recordingId of recordingIds) { redisKeys.push(RedisKeyName.RECORDING + recordingId); - metadataKeys.push(this.keyBuilder.buildMeetRecordingKey(recordingId)); + redisKeys.push(RedisKeyName.RECORDING_SECRETS + recordingId); + + storageKeys.push(this.keyBuilder.buildMeetRecordingKey(recordingId)); + storageKeys.push(this.keyBuilder.buildAccessRecordingSecretsKey(recordingId)); + binaryKeys.push(this.keyBuilder.buildBinaryRecordingKey(recordingId)); } @@ -440,7 +454,7 @@ export class MeetStorageService< // Delete all files in parallel using batch operations await Promise.all([ - this.deleteFromCacheAndStorageBatch(redisKeys, metadataKeys), + this.deleteFromCacheAndStorageBatch(redisKeys, storageKeys), this.storageProvider.deleteObjects(binaryKeys) ]); this.logger.verbose(`Successfully bulk deleted ${recordingIds.length} recordings`); @@ -478,6 +492,54 @@ export class MeetStorageService< // USER DOMAIN LOGIC // ========================================== + /** + * Saves access recording secrets (public and private) for a specific recording. + * + * @param recordingId - The unique identifier of the recording + * @param secrets - Object containing the public and private access secrets + * @param secrets.publicAccessSecret - The public access secret for the recording + * @param secrets.privateAccessSecret - The private access secret for the recording + * @returns A promise that resolves when the secrets are successfully saved + * @throws Will throw an error if the storage operation fails + */ + async saveAccessRecordingSecrets(recordingId: string): Promise { + try { + const redisKey = RedisKeyName.RECORDING_SECRETS + recordingId; + const storageKey = this.keyBuilder.buildAccessRecordingSecretsKey(recordingId); + const secrets = RecordingHelper.buildAccessSecrets(); + this.logger.debug(`Saving access secrets for recording ${recordingId} at ${storageKey}`); + await this.saveCacheAndStorage(redisKey, storageKey, secrets); + } catch (error) { + this.handleError(error, `Error saving access secrets for recording ${recordingId}`); + throw error; + } + } + + async getAccessRecordingSecrets( + recordingId: string + ): Promise<{ publicAccessSecret: string; privateAccessSecret: string } | null> { + try { + const redisKey = RedisKeyName.RECORDING_SECRETS + recordingId; + const secretsKey = this.keyBuilder.buildAccessRecordingSecretsKey(recordingId); + this.logger.debug(`Retrieving access secrets for recording ${recordingId} from ${secretsKey}`); + + const secrets = await this.getFromCacheAndStorage<{ + publicAccessSecret: string; + privateAccessSecret: string; + }>(redisKey, secretsKey); + + if (!secrets) { + this.logger.warn(`No access secrets found for recording ${recordingId}`); + return null; + } + + return secrets; + } catch (error) { + this.handleError(error, `Error fetching access secrets for recording ${recordingId}`); + throw error; + } + } + /** * Retrieves user data for a specific username. * @@ -733,6 +795,10 @@ export class MeetStorageService< } } + // ========================================== + // PRIVATE HELPER METHODS + // ========================================== + /** * Returns the default global preferences. * @returns {GPrefs} diff --git a/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts b/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts index 330118b..b13ca66 100644 --- a/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts +++ b/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts @@ -23,7 +23,7 @@ describe('Recording API Tests', () => { }); describe('Bulk Delete Recording Tests', () => { - it('"should return 200 when mixed valid and non-existent IDs are provided', async () => { + it('should return 200 when mixed valid and non-existent IDs are provided', async () => { const testContext = await setupMultiRecordingsTestContext(3, 3, 3); const recordingIds = testContext.rooms.map((room) => room.recordingId); const nonExistentIds = ['nonExistent--EG_000--1234', 'nonExistent--EG_111--5678']; @@ -102,6 +102,21 @@ describe('Recording API Tests', () => { expect(deleteResponse.status).toBe(204); }); + it('should delete all recordings and their secrets', async () => { + const response = await setupMultiRecordingsTestContext(3, 3, 3); + const recordingIds = response.rooms.map((room) => room.recordingId); + const deleteResponse = await bulkDeleteRecordings(recordingIds); + + expect(deleteResponse.status).toBe(204); + + const storageService = container.get(MeetStorageService); + + for (const recordingId of recordingIds) { + const recSecrets = await storageService.getAccessRecordingSecrets(recordingId!); + expect(recSecrets).toBeNull(); + } + }); + it('should handle single recording deletion correctly', async () => { const testContext = await setupMultiRecordingsTestContext(1, 1, 1); const recordingId = testContext.rooms[0].recordingId; @@ -139,7 +154,6 @@ describe('Recording API Tests', () => { expect(roomMetadata!.publisherRoomUrl).toContain(room.roomId); const response = await startRecording(room.roomId, moderatorCookie); - console.log('Start recording response:', response.body); expectValidStartRecordingResponse(response, room.roomId); const secondRecordingId = response.body.recordingId; diff --git a/backend/tests/integration/api/recordings/delete-recording.test.ts b/backend/tests/integration/api/recordings/delete-recording.test.ts index c06ee7b..d642de3 100644 --- a/backend/tests/integration/api/recordings/delete-recording.test.ts +++ b/backend/tests/integration/api/recordings/delete-recording.test.ts @@ -49,6 +49,22 @@ describe('Recording API Tests', () => { expect(getResponse.status).toBe(404); }); + it('should secrets be deleted when recording is deleted', async () => { + const storageService = container.get(MeetStorageService); + + let recSecrets = await storageService.getAccessRecordingSecrets(recordingId); + expect(recSecrets).toBeDefined(); + expect(recSecrets?.publicAccessSecret).toBeDefined(); + expect(recSecrets?.privateAccessSecret).toBeDefined(); + + // Check that the room metadata still exists after deleteing the first recording + const deleteResponse = await deleteRecording(recordingId!); + expect(deleteResponse.status).toBe(204); + + recSecrets = await storageService.getAccessRecordingSecrets(recordingId); + expect(recSecrets).toBe(null); + }); + it('should delete room metadata when deleting the last recording', async () => { const meetStorageService = container.get(MeetStorageService); diff --git a/backend/tests/integration/api/recordings/start-recording.test.ts b/backend/tests/integration/api/recordings/start-recording.test.ts index 6eb0817..13f84b4 100644 --- a/backend/tests/integration/api/recordings/start-recording.test.ts +++ b/backend/tests/integration/api/recordings/start-recording.test.ts @@ -19,6 +19,8 @@ import { stopRecording } from '../../../helpers/request-helpers.js'; import { setupMultiRoomTestContext, TestContext } from '../../../helpers/test-scenarios.js'; +import { container } from '../../../../src/config/dependency-injector.config.js'; +import { MeetStorageService } from '../../../../src/services/index.js'; describe('Recording API Tests', () => { let context: TestContext | null = null; @@ -59,6 +61,31 @@ describe('Recording API Tests', () => { expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId); }); + it('should secrets and archived room files be created when recording starts', async () => { + const response = await startRecording(room.roomId, moderatorCookie); + const recordingId = response.body.recordingId; + expectValidStartRecordingResponse(response, room.roomId); + + expectValidRecordingLocationHeader(response); + + const storageService = container.get(MeetStorageService); + + const recSecrets = await storageService.getAccessRecordingSecrets(recordingId); + expect(recSecrets).toBeDefined(); + expect(recSecrets?.publicAccessSecret).toBeDefined(); + expect(recSecrets?.privateAccessSecret).toBeDefined(); + + const archivedRoom = await storageService.getArchivedRoomMetadata(room.roomId); + expect(archivedRoom).toBeDefined(); + expect(archivedRoom?.moderatorRoomUrl).toBeDefined(); + expect(archivedRoom?.publisherRoomUrl).toBeDefined(); + expect(archivedRoom?.preferences).toBeDefined(); + + // Check if secrets file is created + const secretsResponse = await stopRecording(recordingId, moderatorCookie); + expectValidStopRecordingResponse(secretsResponse, recordingId, room.roomId); + }); + it('should successfully start recording, stop it, and start again (sequential operations)', async () => { const firstStartResponse = await startRecording(room.roomId, moderatorCookie); const firstRecordingId = firstStartResponse.body.recordingId;