backend: implement access recording secrets management in storage service
This commit is contained in:
parent
cf27433e2d
commit
5089df16a7
@ -104,6 +104,10 @@ openvidu/
|
||||
│ │ └── room-123/
|
||||
│ │ └── {egressId}/
|
||||
│ │ └── {uid}.json
|
||||
│ ├── .secrets/
|
||||
│ │ └── room-123/
|
||||
│ │ └── {egressId}/
|
||||
│ │ └── {uid}.json
|
||||
| ├── .room_metadata/
|
||||
│ │ └── room-123/
|
||||
│ │ └── room_metadata.json
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,4 +150,7 @@ export interface StorageKeyBuilder {
|
||||
* @param userId - The unique identifier of the user
|
||||
*/
|
||||
buildUserKey(userId: string): string;
|
||||
|
||||
buildAccessRecordingSecretsKey(recordingId:string): string;
|
||||
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>(MeetStorageService);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user