Refactor storage service and interfaces for improved separation of concerns
- Updated StorageFactory to create basic storage providers and key builders. - Simplified StorageProvider interface to focus on basic CRUD operations. - Enhanced MeetStorageService to handle domain-specific logic while delegating storage operations. - Implemented Redis caching for room data to improve performance. - Added error handling and logging improvements throughout the service. - Removed deprecated methods and streamlined object retrieval processes. refactor: update storage service and interfaces to include user key handling and improve initialization logic refactor: update beforeAll hooks in recording tests to clear rooms and recordings refactor: optimize integration recordings test command Revert "refactor: optimize integration recordings test command" This reverts commit d517a44fa282b91613f8c55130916c2af5f07267. refactor: enhance Redis cache storage operations refactor: streamline test setup and teardown for security and recordings APIs
This commit is contained in:
parent
b53092f2f6
commit
8aa1bbc64b
@ -14,14 +14,25 @@ import {
|
|||||||
S3Service,
|
S3Service,
|
||||||
S3StorageProvider,
|
S3StorageProvider,
|
||||||
StorageFactory,
|
StorageFactory,
|
||||||
|
StorageKeyBuilder,
|
||||||
|
StorageProvider,
|
||||||
SystemEventService,
|
SystemEventService,
|
||||||
TaskSchedulerService,
|
TaskSchedulerService,
|
||||||
TokenService,
|
TokenService,
|
||||||
UserService
|
UserService
|
||||||
} from '../services/index.js';
|
} from '../services/index.js';
|
||||||
|
import { MEET_PREFERENCES_STORAGE_MODE } from '../environment.js';
|
||||||
|
import { S3KeyBuilder } from '../services/storage/providers/s3/s3-storage-key.builder.js';
|
||||||
|
|
||||||
export const container: Container = new Container();
|
export const container: Container = new Container();
|
||||||
|
|
||||||
|
export const STORAGE_TYPES = {
|
||||||
|
StorageProvider: Symbol.for('StorageProvider'),
|
||||||
|
KeyBuilder: Symbol.for('KeyBuilder'),
|
||||||
|
S3StorageProvider: Symbol.for('S3StorageProvider'),
|
||||||
|
S3KeyBuilder: Symbol.for('S3KeyBuilder')
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers all necessary dependencies in the container.
|
* Registers all necessary dependencies in the container.
|
||||||
*
|
*
|
||||||
@ -38,6 +49,7 @@ export const registerDependencies = () => {
|
|||||||
container.bind(MutexService).toSelf().inSingletonScope();
|
container.bind(MutexService).toSelf().inSingletonScope();
|
||||||
container.bind(TaskSchedulerService).toSelf().inSingletonScope();
|
container.bind(TaskSchedulerService).toSelf().inSingletonScope();
|
||||||
|
|
||||||
|
configureStorage(MEET_PREFERENCES_STORAGE_MODE);
|
||||||
container.bind(S3Service).toSelf().inSingletonScope();
|
container.bind(S3Service).toSelf().inSingletonScope();
|
||||||
container.bind(S3StorageProvider).toSelf().inSingletonScope();
|
container.bind(S3StorageProvider).toSelf().inSingletonScope();
|
||||||
container.bind(StorageFactory).toSelf().inSingletonScope();
|
container.bind(StorageFactory).toSelf().inSingletonScope();
|
||||||
@ -55,8 +67,20 @@ export const registerDependencies = () => {
|
|||||||
container.bind(LivekitWebhookService).toSelf().inSingletonScope();
|
container.bind(LivekitWebhookService).toSelf().inSingletonScope();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const configureStorage = (storageMode: string) => {
|
||||||
|
container.get(LoggerService).info(`Creating ${storageMode} storage provider`);
|
||||||
|
|
||||||
|
switch (storageMode) {
|
||||||
|
default:
|
||||||
|
case 's3':
|
||||||
|
container.bind<StorageProvider>(STORAGE_TYPES.StorageProvider).to(S3StorageProvider).inSingletonScope();
|
||||||
|
container.bind<StorageKeyBuilder>(STORAGE_TYPES.KeyBuilder).to(S3KeyBuilder).inSingletonScope();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const initializeEagerServices = async () => {
|
export const initializeEagerServices = async () => {
|
||||||
// Force the creation of services that need to be initialized at startup
|
// Force the creation of services that need to be initialized at startup
|
||||||
container.get(RecordingService);
|
container.get(RecordingService);
|
||||||
await container.get(MeetStorageService).initialize();
|
await container.get(MeetStorageService).initializeGlobalPreferences();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -186,12 +186,6 @@ export class RecordingHelper {
|
|||||||
return size !== 0 ? size : undefined;
|
return size !== 0 ? size : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
static buildMetadataFilePath(recordingId: string): string {
|
|
||||||
const { roomId, egressId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
|
||||||
|
|
||||||
return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.metadata/${roomId}/${egressId}/${uid}.json`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toSeconds(nanoseconds: number): number {
|
private static toSeconds(nanoseconds: number): number {
|
||||||
const nanosecondsToSeconds = 1 / 1_000_000_000;
|
const nanosecondsToSeconds = 1 / 1_000_000_000;
|
||||||
return nanoseconds * nanosecondsToSeconds;
|
return nanoseconds * nanosecondsToSeconds;
|
||||||
|
|||||||
@ -5,6 +5,8 @@ export const enum RedisKeyPrefix {
|
|||||||
export const enum RedisKeyName {
|
export const enum RedisKeyName {
|
||||||
GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences`,
|
GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences`,
|
||||||
ROOM = `${RedisKeyPrefix.BASE}room:`,
|
ROOM = `${RedisKeyPrefix.BASE}room:`,
|
||||||
|
RECORDING = `${RedisKeyPrefix.BASE}recording:`,
|
||||||
|
ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`,
|
||||||
USER = `${RedisKeyPrefix.BASE}user:`,
|
USER = `${RedisKeyPrefix.BASE}user:`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ export * from './system-event.service.js';
|
|||||||
export * from './mutex.service.js';
|
export * from './mutex.service.js';
|
||||||
export * from './task-scheduler.service.js';
|
export * from './task-scheduler.service.js';
|
||||||
|
|
||||||
export * from './s3.service.js';
|
export * from './storage/providers/s3/s3.service.js';
|
||||||
export * from './storage/index.js';
|
export * from './storage/index.js';
|
||||||
|
|
||||||
export * from './token.service.js';
|
export * from './token.service.js';
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
OpenViduWebhookService,
|
OpenViduWebhookService,
|
||||||
RecordingService,
|
RecordingService,
|
||||||
RoomService,
|
RoomService,
|
||||||
S3Service,
|
|
||||||
SystemEventService
|
SystemEventService
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
@ -20,7 +19,6 @@ import {
|
|||||||
export class LivekitWebhookService {
|
export class LivekitWebhookService {
|
||||||
protected webhookReceiver: WebhookReceiver;
|
protected webhookReceiver: WebhookReceiver;
|
||||||
constructor(
|
constructor(
|
||||||
@inject(S3Service) protected s3Service: S3Service,
|
|
||||||
@inject(RecordingService) protected recordingService: RecordingService,
|
@inject(RecordingService) protected recordingService: RecordingService,
|
||||||
@inject(LiveKitService) protected livekitService: LiveKitService,
|
@inject(LiveKitService) protected livekitService: LiveKitService,
|
||||||
@inject(RoomService) protected roomService: RoomService,
|
@inject(RoomService) protected roomService: RoomService,
|
||||||
|
|||||||
@ -13,11 +13,9 @@ import {
|
|||||||
errorRecordingCannotBeStoppedWhileStarting,
|
errorRecordingCannotBeStoppedWhileStarting,
|
||||||
errorRecordingNotFound,
|
errorRecordingNotFound,
|
||||||
errorRecordingNotStopped,
|
errorRecordingNotStopped,
|
||||||
errorRecordingRangeNotSatisfiable,
|
|
||||||
errorRecordingStartTimeout,
|
errorRecordingStartTimeout,
|
||||||
errorRoomHasNoParticipants,
|
errorRoomHasNoParticipants,
|
||||||
errorRoomNotFound,
|
errorRoomNotFound,
|
||||||
internalError,
|
|
||||||
isErrorRecordingAlreadyStopped,
|
isErrorRecordingAlreadyStopped,
|
||||||
isErrorRecordingCannotBeStoppedWhileStarting,
|
isErrorRecordingCannotBeStoppedWhileStarting,
|
||||||
isErrorRecordingNotFound,
|
isErrorRecordingNotFound,
|
||||||
@ -206,29 +204,16 @@ export class RecordingService {
|
|||||||
async deleteRecording(recordingId: string): Promise<MeetRecordingInfo> {
|
async deleteRecording(recordingId: string): Promise<MeetRecordingInfo> {
|
||||||
try {
|
try {
|
||||||
// Get the recording metada and recording info from the S3 bucket
|
// Get the recording metada and recording info from the S3 bucket
|
||||||
const { binaryFilesToDelete, metadataFilesToDelete, recordingInfo } =
|
const { recordingInfo } = await this.storageService.getRecordingMetadata(recordingId);
|
||||||
await this.getDeletableRecordingFiles(recordingId);
|
|
||||||
const { roomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
|
||||||
const deleteRecordingTasks: Promise<unknown>[] = [];
|
|
||||||
|
|
||||||
if (binaryFilesToDelete.size > 0) {
|
// Validate the recording status
|
||||||
// Delete video files from S3
|
if (!RecordingHelper.canBeDeleted(recordingInfo)) throw errorRecordingNotStopped(recordingId);
|
||||||
deleteRecordingTasks.push(
|
|
||||||
this.storageService.deleteRecordingBinaryFilesByPaths(Array.from(binaryFilesToDelete))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadataFilesToDelete.size > 0) {
|
await this.storageService.deleteRecording(recordingId);
|
||||||
// Delete metadata files from storage provider
|
|
||||||
deleteRecordingTasks.push(
|
|
||||||
this.storageService.deleteRecordingMetadataByPaths(Array.from(metadataFilesToDelete))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(deleteRecordingTasks);
|
this.logger.info(`Successfully deleted recording ${recordingId}`);
|
||||||
|
|
||||||
this.logger.info(`Successfully deleted ${recordingId}`);
|
|
||||||
|
|
||||||
|
const { roomId } = recordingInfo;
|
||||||
const shouldDeleteRoomMetadata = await this.shouldDeleteRoomMetadata(roomId);
|
const shouldDeleteRoomMetadata = await this.shouldDeleteRoomMetadata(roomId);
|
||||||
|
|
||||||
if (shouldDeleteRoomMetadata) {
|
if (shouldDeleteRoomMetadata) {
|
||||||
@ -253,8 +238,7 @@ export class RecordingService {
|
|||||||
async bulkDeleteRecordingsAndAssociatedFiles(
|
async bulkDeleteRecordingsAndAssociatedFiles(
|
||||||
recordingIds: string[]
|
recordingIds: string[]
|
||||||
): Promise<{ deleted: string[]; notDeleted: { recordingId: string; error: string }[] }> {
|
): Promise<{ deleted: string[]; notDeleted: { recordingId: string; error: string }[] }> {
|
||||||
let allMetadataFilesToDelete: Set<string> = new Set<string>();
|
const validRecordingIds: Set<string> = new Set<string>();
|
||||||
let allBinaryFilesToDelete: Set<string> = new Set<string>();
|
|
||||||
const deletedRecordings: Set<string> = new Set<string>();
|
const deletedRecordings: Set<string> = new Set<string>();
|
||||||
const notDeletedRecordings: Set<{ recordingId: string; error: string }> = new Set();
|
const notDeletedRecordings: Set<{ recordingId: string; error: string }> = new Set();
|
||||||
const roomsToCheck: Set<string> = new Set();
|
const roomsToCheck: Set<string> = new Set();
|
||||||
@ -262,35 +246,32 @@ export class RecordingService {
|
|||||||
// Check if the recording is in progress
|
// Check if the recording is in progress
|
||||||
for (const recordingId of recordingIds) {
|
for (const recordingId of recordingIds) {
|
||||||
try {
|
try {
|
||||||
const { binaryFilesToDelete, metadataFilesToDelete } =
|
const { recordingInfo } = await this.storageService.getRecordingMetadata(recordingId);
|
||||||
await this.getDeletableRecordingFiles(recordingId);
|
|
||||||
// Add files to the set of files to delete
|
|
||||||
allBinaryFilesToDelete = new Set([...allBinaryFilesToDelete, ...binaryFilesToDelete]);
|
|
||||||
allMetadataFilesToDelete = new Set([...allMetadataFilesToDelete, ...metadataFilesToDelete]);
|
|
||||||
|
|
||||||
|
if (!RecordingHelper.canBeDeleted(recordingInfo)) {
|
||||||
|
throw errorRecordingNotStopped(recordingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
validRecordingIds.add(recordingId);
|
||||||
deletedRecordings.add(recordingId);
|
deletedRecordings.add(recordingId);
|
||||||
|
|
||||||
// Track the roomId for checking if the room metadata file should be deleted
|
// Track room for metadata cleanup
|
||||||
const { roomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
roomsToCheck.add(recordingInfo.roomId);
|
||||||
roomsToCheck.add(roomId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`BulkDelete: Error processing recording ${recordingId}: ${error}`);
|
this.logger.error(`BulkDelete: Error processing recording ${recordingId}: ${error}`);
|
||||||
notDeletedRecordings.add({ recordingId, error: (error as OpenViduMeetError).message });
|
notDeletedRecordings.add({ recordingId, error: (error as OpenViduMeetError).message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allBinaryFilesToDelete.size === 0) {
|
if (validRecordingIds.size === 0) {
|
||||||
this.logger.warn(`BulkDelete: No eligible recordings found for deletion.`);
|
this.logger.warn(`BulkDelete: No eligible recordings found for deletion.`);
|
||||||
return { deleted: Array.from(deletedRecordings), notDeleted: Array.from(notDeletedRecordings) };
|
return { deleted: Array.from(deletedRecordings), notDeleted: Array.from(notDeletedRecordings) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete recordings and its metadata from S3
|
// Delete recordings and its metadata from S3
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await this.storageService.deleteRecordings(Array.from(validRecordingIds));
|
||||||
this.storageService.deleteRecordingBinaryFilesByPaths(Array.from(allBinaryFilesToDelete)),
|
this.logger.info(`BulkDelete: Successfully deleted ${validRecordingIds.size} recordings.`);
|
||||||
this.storageService.deleteRecordingMetadataByPaths(Array.from(allMetadataFilesToDelete))
|
|
||||||
]);
|
|
||||||
this.logger.info(`BulkDelete: Successfully deleted ${allBinaryFilesToDelete.size} recordings.`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`BulkDelete: Error performing bulk deletion: ${error}`);
|
this.logger.error(`BulkDelete: Error performing bulk deletion: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
@ -298,27 +279,35 @@ export class RecordingService {
|
|||||||
|
|
||||||
// Check if the room metadata file should be deleted
|
// Check if the room metadata file should be deleted
|
||||||
const roomMetadataToDelete: string[] = [];
|
const roomMetadataToDelete: string[] = [];
|
||||||
const deleteTasks: Promise<void>[] = [];
|
|
||||||
|
|
||||||
for (const roomId of roomsToCheck) {
|
for (const roomId of roomsToCheck) {
|
||||||
const shouldDeleteRoomMetadata = await this.shouldDeleteRoomMetadata(roomId);
|
const shouldDeleteRoomMetadata = await this.shouldDeleteRoomMetadata(roomId);
|
||||||
|
|
||||||
if (shouldDeleteRoomMetadata) {
|
if (shouldDeleteRoomMetadata) {
|
||||||
deleteTasks.push(this.storageService.deleteArchivedRoomMetadata(roomId));
|
|
||||||
roomMetadataToDelete.push(roomId);
|
roomMetadataToDelete.push(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (roomMetadataToDelete.length === 0) {
|
||||||
|
this.logger.verbose(`BulkDelete: No room metadata files to delete.`);
|
||||||
|
return { deleted: Array.from(deletedRecordings), notDeleted: Array.from(notDeletedRecordings) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform bulk deletion of room metadata files
|
||||||
try {
|
try {
|
||||||
this.logger.verbose(`Deleting room_metadata.json for rooms: ${roomMetadataToDelete.join(', ')}`);
|
await Promise.all(
|
||||||
await Promise.all(deleteTasks);
|
roomMetadataToDelete.map((roomId) => this.storageService.deleteArchivedRoomMetadata(roomId))
|
||||||
|
);
|
||||||
this.logger.verbose(`BulkDelete: Successfully deleted ${roomMetadataToDelete.length} room metadata files.`);
|
this.logger.verbose(`BulkDelete: Successfully deleted ${roomMetadataToDelete.length} room metadata files.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`BulkDelete: Error performing bulk deletion: ${error}`);
|
this.logger.error(`BulkDelete: Error performing bulk deletion: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deleted: Array.from(deletedRecordings), notDeleted: Array.from(notDeletedRecordings) };
|
return {
|
||||||
|
deleted: Array.from(deletedRecordings),
|
||||||
|
notDeleted: Array.from(notDeletedRecordings)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -330,11 +319,10 @@ export class RecordingService {
|
|||||||
*/
|
*/
|
||||||
protected async shouldDeleteRoomMetadata(roomId: string): Promise<boolean | null> {
|
protected async shouldDeleteRoomMetadata(roomId: string): Promise<boolean | null> {
|
||||||
try {
|
try {
|
||||||
const metadataPrefix = `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.metadata/${roomId}`;
|
const { recordings } = await this.storageService.getAllRecordings(roomId, 1);
|
||||||
const { Contents } = await this.storageService.listObjects(metadataPrefix, 1);
|
|
||||||
|
|
||||||
// If no metadata files exist or the list is empty, the room metadata should be deleted
|
// If no recordings exist or the list is empty, the room metadata should be deleted
|
||||||
return !Contents || Contents.length === 0;
|
return !recordings || recordings.length === 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Error checking room metadata for deletion (room ${roomId}): ${error}`);
|
this.logger.warn(`Error checking room metadata for deletion (room ${roomId}): ${error}`);
|
||||||
return null;
|
return null;
|
||||||
@ -363,45 +351,32 @@ export class RecordingService {
|
|||||||
* - `nextPageToken`: (Optional) A token to retrieve the next page of results, if available.
|
* - `nextPageToken`: (Optional) A token to retrieve the next page of results, if available.
|
||||||
* @throws Will throw an error if there is an issue retrieving the recordings.
|
* @throws Will throw an error if there is an issue retrieving the recordings.
|
||||||
*/
|
*/
|
||||||
async getAllRecordings({ maxItems, nextPageToken, roomId, fields }: MeetRecordingFilters): Promise<{
|
async getAllRecordings(filters: MeetRecordingFilters): Promise<{
|
||||||
recordings: MeetRecordingInfo[];
|
recordings: MeetRecordingInfo[];
|
||||||
isTruncated: boolean;
|
isTruncated: boolean;
|
||||||
nextPageToken?: string;
|
nextPageToken?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// Construct the room prefix if a room ID is provided
|
const { maxItems, nextPageToken, roomId, fields } = filters;
|
||||||
const roomPrefix = roomId ? `/${roomId}` : '';
|
|
||||||
const recordingPrefix = `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.metadata${roomPrefix}`;
|
|
||||||
|
|
||||||
// Retrieve the recordings from the S3 bucket
|
const response = await this.storageService.getAllRecordings(roomId, maxItems, nextPageToken);
|
||||||
const { Contents, IsTruncated, NextContinuationToken } = await this.storageService.listObjects(
|
|
||||||
recordingPrefix,
|
|
||||||
maxItems,
|
|
||||||
nextPageToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!Contents) {
|
// Apply field filtering if specified
|
||||||
this.logger.verbose('No recordings found. Returning an empty array.');
|
if (fields) {
|
||||||
return { recordings: [], isTruncated: false };
|
response.recordings = response.recordings.map((rec) =>
|
||||||
|
UtilsHelper.filterObjectFields(rec, fields)
|
||||||
|
) as MeetRecordingInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const promises: Promise<MeetRecordingInfo>[] = [];
|
const { recordings, isTruncated, nextContinuationToken } = response;
|
||||||
// Retrieve the metadata for each recording
|
|
||||||
Contents.forEach((item) => {
|
|
||||||
if (item?.Key && item.Key.endsWith('.json') && !item.Key.endsWith('secrets.json')) {
|
|
||||||
promises.push(
|
|
||||||
this.storageService.getRecordingMetadataByPath(item.Key) as Promise<MeetRecordingInfo>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let recordings = await Promise.all(promises);
|
|
||||||
|
|
||||||
recordings = recordings.map((rec) => UtilsHelper.filterObjectFields(rec, fields)) as MeetRecordingInfo[];
|
|
||||||
|
|
||||||
this.logger.info(`Retrieved ${recordings.length} recordings.`);
|
this.logger.info(`Retrieved ${recordings.length} recordings.`);
|
||||||
// Return the paginated list of recordings
|
// Return the paginated list of recordings
|
||||||
return { recordings, isTruncated: !!IsTruncated, nextPageToken: NextContinuationToken };
|
return {
|
||||||
|
recordings,
|
||||||
|
isTruncated: Boolean(isTruncated),
|
||||||
|
nextPageToken: nextContinuationToken
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error getting recordings: ${error}`);
|
this.logger.error(`Error getting recordings: ${error}`);
|
||||||
throw error;
|
throw error;
|
||||||
@ -410,64 +385,33 @@ export class RecordingService {
|
|||||||
|
|
||||||
async getRecordingAsStream(
|
async getRecordingAsStream(
|
||||||
recordingId: string,
|
recordingId: string,
|
||||||
range?: string
|
rangeHeader?: string
|
||||||
): Promise<{ fileSize: number | undefined; fileStream: Readable; start?: number; end?: number }> {
|
): Promise<{ fileSize: number | undefined; fileStream: Readable; start?: number; end?: number }> {
|
||||||
const DEFAULT_RECORDING_FILE_PORTION_SIZE = 5 * 1024 * 1024; // 5MB
|
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
// Ensure the recording is streamable
|
||||||
const recordingInfo: MeetRecordingInfo = await this.getRecording(recordingId);
|
const recordingInfo: MeetRecordingInfo = await this.getRecording(recordingId);
|
||||||
|
|
||||||
if (recordingInfo.status !== MeetRecordingStatus.COMPLETE) {
|
if (recordingInfo.status !== MeetRecordingStatus.COMPLETE) {
|
||||||
throw errorRecordingNotStopped(recordingId);
|
throw errorRecordingNotStopped(recordingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordingPath = `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/${RecordingHelper.extractFilename(recordingInfo)}`;
|
let validatedRange = undefined;
|
||||||
|
|
||||||
if (!recordingPath) throw new Error(`Error extracting path from recording ${recordingId}`);
|
// Parse the range header if provided
|
||||||
|
if (rangeHeader) {
|
||||||
|
const match = rangeHeader.match(/^bytes=(\d+)-(\d*)$/)!;
|
||||||
|
const endStr = match[2];
|
||||||
|
|
||||||
const { contentLength: fileSize } = await this.storageService.getObjectHeaders(recordingPath);
|
const start = parseInt(match[1], 10);
|
||||||
|
const end = endStr ? parseInt(endStr, 10) : start + DEFAULT_CHUNK_SIZE - 1;
|
||||||
if (!fileSize) {
|
validatedRange = { start, end };
|
||||||
this.logger.error(`Error getting file size for recording ${recordingId}`);
|
this.logger.debug(`Streaming partial content for recording '${recordingId}' from ${start} to ${end}.`);
|
||||||
throw internalError(`getting file size for recording '${recordingId}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (range) {
|
|
||||||
// Parse the range header
|
|
||||||
const matches = range.match(/^bytes=(\d+)-(\d*)$/)!;
|
|
||||||
|
|
||||||
const start = parseInt(matches[1], 10);
|
|
||||||
let end = matches[2] ? parseInt(matches[2], 10) : start + DEFAULT_RECORDING_FILE_PORTION_SIZE;
|
|
||||||
|
|
||||||
// Validate the range values
|
|
||||||
if (isNaN(start) || isNaN(end) || start < 0) {
|
|
||||||
this.logger.warn(`Invalid range values for recording ${recordingId}: start=${start}, end=${end}`);
|
|
||||||
this.logger.warn(`Returning full stream for recording ${recordingId}`);
|
|
||||||
return this.getFullStreamResponse(recordingPath, fileSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start >= fileSize) {
|
|
||||||
this.logger.error(
|
|
||||||
`Invalid range values for recording ${recordingId}: start=${start}, end=${end}, fileSize=${fileSize}`
|
|
||||||
);
|
|
||||||
throw errorRecordingRangeNotSatisfiable(recordingId, fileSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the end value to ensure it doesn't exceed the file size
|
|
||||||
end = Math.min(end, fileSize - 1);
|
|
||||||
|
|
||||||
// If the start is greater than the end, return the full stream
|
|
||||||
if (start > end) {
|
|
||||||
this.logger.warn(`Invalid range values after adjustment: start=${start}, end=${end}`);
|
|
||||||
return this.getFullStreamResponse(recordingPath, fileSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileStream = await this.storageService.getRecordingMedia(recordingPath, {
|
|
||||||
start,
|
|
||||||
end
|
|
||||||
});
|
|
||||||
return { fileSize, fileStream, start, end };
|
|
||||||
} else {
|
} else {
|
||||||
return this.getFullStreamResponse(recordingPath, fileSize);
|
this.logger.debug(`Streaming full content for recording '${recordingId}'.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.storageService.getRecordingMedia(recordingId, validatedRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async validateRoomForStartRecording(roomId: string): Promise<void> {
|
protected async validateRoomForStartRecording(roomId: string): Promise<void> {
|
||||||
@ -486,14 +430,6 @@ export class RecordingService {
|
|||||||
if (!hasParticipants) throw errorRoomHasNoParticipants(roomId);
|
if (!hasParticipants) throw errorRoomHasNoParticipants(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getFullStreamResponse(
|
|
||||||
recordingPath: string,
|
|
||||||
fileSize: number
|
|
||||||
): Promise<{ fileSize: number; fileStream: Readable }> {
|
|
||||||
const fileStream = await this.storageService.getRecordingMedia(recordingPath);
|
|
||||||
return { fileSize, fileStream };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquires a Redis-based lock to indicate that a recording is active for a specific room.
|
* Acquires a Redis-based lock to indicate that a recording is active for a specific room.
|
||||||
*
|
*
|
||||||
@ -563,37 +499,6 @@ export class RecordingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the data required to delete a recording, including the file paths
|
|
||||||
* to be deleted and the recording's metadata information.
|
|
||||||
*
|
|
||||||
* @param recordingId - The unique identifier of the recording egress.
|
|
||||||
*/
|
|
||||||
protected async getDeletableRecordingFiles(recordingId: string): Promise<{
|
|
||||||
binaryFilesToDelete: Set<string>;
|
|
||||||
metadataFilesToDelete: Set<string>;
|
|
||||||
recordingInfo: MeetRecordingInfo;
|
|
||||||
}> {
|
|
||||||
const { metadataFilePath, recordingInfo } = await this.storageService.getRecordingMetadata(recordingId);
|
|
||||||
const binaryFilesToDelete: Set<string> = new Set();
|
|
||||||
const metadataFilesToDelete: Set<string> = new Set();
|
|
||||||
|
|
||||||
// Validate the recording status
|
|
||||||
if (!RecordingHelper.canBeDeleted(recordingInfo)) throw errorRecordingNotStopped(recordingId);
|
|
||||||
|
|
||||||
const filename = RecordingHelper.extractFilename(recordingInfo);
|
|
||||||
|
|
||||||
if (!filename) {
|
|
||||||
throw internalError(`extracting path from recording '${recordingId}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordingPath = `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/${filename}`;
|
|
||||||
binaryFilesToDelete.add(recordingPath);
|
|
||||||
metadataFilesToDelete.add(metadataFilePath);
|
|
||||||
|
|
||||||
return { binaryFilesToDelete, metadataFilesToDelete, recordingInfo };
|
|
||||||
}
|
|
||||||
|
|
||||||
protected generateCompositeOptionsFromRequest(layout = 'grid'): RoomCompositeOptions {
|
protected generateCompositeOptionsFromRequest(layout = 'grid'): RoomCompositeOptions {
|
||||||
return {
|
return {
|
||||||
layout: layout
|
layout: layout
|
||||||
|
|||||||
@ -222,9 +222,11 @@ export class RedisService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a key from Redis.
|
* Deletes one or more keys from Redis.
|
||||||
* @param key - The key to delete.
|
*
|
||||||
* @returns A promise that resolves to the number of keys deleted.
|
* @param keys - A single key string or an array of key strings to delete from Redis
|
||||||
|
* @returns A Promise that resolves to the number of keys that were successfully deleted
|
||||||
|
* @throws {Error} Throws an internal error if the deletion operation fails
|
||||||
*/
|
*/
|
||||||
delete(keys: string | string[]): Promise<number> {
|
delete(keys: string | string[]): Promise<number> {
|
||||||
try {
|
try {
|
||||||
@ -238,8 +240,19 @@ export class RedisService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
quit() {
|
cleanup() {
|
||||||
|
this.logger.verbose('Cleaning up Redis connections');
|
||||||
this.redisPublisher.quit();
|
this.redisPublisher.quit();
|
||||||
|
this.redisSubscriber.quit();
|
||||||
|
this.removeAllListeners();
|
||||||
|
|
||||||
|
if (this.eventHandler) {
|
||||||
|
this.off('systemEvent', this.eventHandler);
|
||||||
|
this.eventHandler = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false;
|
||||||
|
this.logger.verbose('Redis connections cleaned up');
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkHealth() {
|
async checkHealth() {
|
||||||
|
|||||||
@ -13,7 +13,12 @@ import { uid as secureUid } from 'uid/secure';
|
|||||||
import { uid } from 'uid/single';
|
import { uid } from 'uid/single';
|
||||||
import INTERNAL_CONFIG from '../config/internal-config.js';
|
import INTERNAL_CONFIG from '../config/internal-config.js';
|
||||||
import { MeetRoomHelper, OpenViduComponentsAdapterHelper, UtilsHelper } from '../helpers/index.js';
|
import { MeetRoomHelper, OpenViduComponentsAdapterHelper, UtilsHelper } from '../helpers/index.js';
|
||||||
import { errorInvalidRoomSecret, errorRoomMetadataNotFound, internalError } from '../models/error.model.js';
|
import {
|
||||||
|
errorInvalidRoomSecret,
|
||||||
|
errorRoomMetadataNotFound,
|
||||||
|
errorRoomNotFound,
|
||||||
|
internalError
|
||||||
|
} from '../models/error.model.js';
|
||||||
import {
|
import {
|
||||||
IScheduledTask,
|
IScheduledTask,
|
||||||
LiveKitService,
|
LiveKitService,
|
||||||
@ -126,7 +131,7 @@ export class RoomService {
|
|||||||
|
|
||||||
await this.storageService.saveMeetRoom(room);
|
await this.storageService.saveMeetRoom(room);
|
||||||
// Update the archived room metadata if it exists
|
// Update the archived room metadata if it exists
|
||||||
await this.storageService.updateArchivedRoomMetadata(roomId);
|
await this.storageService.archiveRoomMetadata(roomId);
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,8 +165,10 @@ export class RoomService {
|
|||||||
}> {
|
}> {
|
||||||
const response = await this.storageService.getMeetRooms(maxItems, nextPageToken);
|
const response = await this.storageService.getMeetRooms(maxItems, nextPageToken);
|
||||||
|
|
||||||
const filteredRooms = response.rooms.map((room) => UtilsHelper.filterObjectFields(room, fields));
|
if (fields) {
|
||||||
response.rooms = filteredRooms as MeetRoom[];
|
const filteredRooms = response.rooms.map((room: MeetRoom) => UtilsHelper.filterObjectFields(room, fields));
|
||||||
|
response.rooms = filteredRooms as MeetRoom[];
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -175,6 +182,11 @@ export class RoomService {
|
|||||||
async getMeetRoom(roomId: string, fields?: string): Promise<MeetRoom> {
|
async getMeetRoom(roomId: string, fields?: string): Promise<MeetRoom> {
|
||||||
const meetRoom = await this.storageService.getMeetRoom(roomId);
|
const meetRoom = await this.storageService.getMeetRoom(roomId);
|
||||||
|
|
||||||
|
if (!meetRoom) {
|
||||||
|
this.logger.error(`Meet room with ID ${roomId} not found.`);
|
||||||
|
throw errorRoomNotFound(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
return UtilsHelper.filterObjectFields(meetRoom, fields) as MeetRoom;
|
return UtilsHelper.filterObjectFields(meetRoom, fields) as MeetRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,6 +263,12 @@ export class RoomService {
|
|||||||
*/
|
*/
|
||||||
protected async markRoomAsDeleted(roomId: string): Promise<void> {
|
protected async markRoomAsDeleted(roomId: string): Promise<void> {
|
||||||
const room = await this.storageService.getMeetRoom(roomId);
|
const room = await this.storageService.getMeetRoom(roomId);
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
this.logger.error(`Room with ID ${roomId} not found for deletion.`);
|
||||||
|
throw errorRoomNotFound(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
room.markedForDeletion = true;
|
room.markedForDeletion = true;
|
||||||
await this.storageService.saveMeetRoom(room);
|
await this.storageService.saveMeetRoom(room);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export * from './storage.interface.js';
|
export * from './storage.interface.js';
|
||||||
export * from './providers/s3-storage.provider.js';
|
|
||||||
export * from './storage.factory.js';
|
export * from './storage.factory.js';
|
||||||
export * from './storage.service.js';
|
export * from './storage.service.js';
|
||||||
|
|
||||||
|
export * from './providers/s3/s3-storage.provider.js';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@
|
|||||||
|
import INTERNAL_CONFIG from '../../../../config/internal-config.js';
|
||||||
|
import { RecordingHelper } from '../../../../helpers/recording.helper.js';
|
||||||
|
import { StorageKeyBuilder } from '../../storage.interface.js';
|
||||||
|
|
||||||
|
export class S3KeyBuilder implements StorageKeyBuilder {
|
||||||
|
buildGlobalPreferencesKey(): string {
|
||||||
|
return `global-preferences.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMeetRoomKey(roomId: string): string {
|
||||||
|
return `${INTERNAL_CONFIG.S3_ROOMS_PREFIX}/${roomId}/${roomId}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAllMeetRoomsKey(): string {
|
||||||
|
return `${INTERNAL_CONFIG.S3_ROOMS_PREFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildArchivedMeetRoomKey(roomId: string): string {
|
||||||
|
return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.room_metadata/${roomId}/room_metadata.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildMeetRecordingKey(recordingId: string): string {
|
||||||
|
const { roomId, egressId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||||
|
|
||||||
|
return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.metadata/${roomId}/${egressId}/${uid}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBinaryRecordingKey(recordingId: string): string {
|
||||||
|
const { roomId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId);
|
||||||
|
return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/${roomId}/${roomId}--${uid}.mp4`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAllMeetRecordingsKey(roomId?: string): string {
|
||||||
|
const roomSegment = roomId ? `/${roomId}` : '';
|
||||||
|
return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.metadata${roomSegment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUserKey(userId: string): string {
|
||||||
|
return `${INTERNAL_CONFIG.S3_USERS_PREFIX}/${userId}.json`;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
backend/src/services/storage/providers/s3/s3-storage.provider.ts
Normal file
140
backend/src/services/storage/providers/s3/s3-storage.provider.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { inject, injectable } from 'inversify';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { LoggerService, S3Service } from '../../../index.js';
|
||||||
|
import { StorageProvider } from '../../storage.interface.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic S3 storage provider that implements only primitive storage operations.
|
||||||
|
*/
|
||||||
|
@injectable()
|
||||||
|
export class S3StorageProvider implements StorageProvider {
|
||||||
|
constructor(
|
||||||
|
@inject(LoggerService) protected logger: LoggerService,
|
||||||
|
@inject(S3Service) protected s3Service: S3Service
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an object from S3 as a JSON object.
|
||||||
|
*/
|
||||||
|
async getObject<T = Record<string, unknown>>(key: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Getting object from S3: ${key}`);
|
||||||
|
const result = await this.s3Service.getObjectAsJson(key);
|
||||||
|
return result as T;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`Object not found in S3: ${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores an object in S3 as JSON.
|
||||||
|
*/
|
||||||
|
async putObject<T = Record<string, unknown>>(key: string, data: T): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Storing object in S3: ${key}`);
|
||||||
|
await this.s3Service.saveObject(key, data as Record<string, unknown>);
|
||||||
|
this.logger.verbose(`Successfully stored object in S3: ${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error storing object in S3 ${key}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a single object from S3.
|
||||||
|
*/
|
||||||
|
async deleteObject(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Deleting object from S3: ${key}`);
|
||||||
|
await this.s3Service.deleteObjects([key]);
|
||||||
|
this.logger.verbose(`Successfully deleted object from S3: ${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error deleting object from S3 ${key}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple objects from S3.
|
||||||
|
*/
|
||||||
|
async deleteObjects(keys: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Deleting ${keys.length} objects from S3`);
|
||||||
|
await this.s3Service.deleteObjects(keys);
|
||||||
|
this.logger.verbose(`Successfully deleted ${keys.length} objects from S3`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error deleting objects from S3: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an object exists in S3.
|
||||||
|
*/
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Checking if object exists in S3: ${key}`);
|
||||||
|
return await this.s3Service.exists(key);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`Error checking object existence in S3 ${key}: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists objects in S3 with a given prefix.
|
||||||
|
*/
|
||||||
|
async listObjects(
|
||||||
|
prefix: string,
|
||||||
|
maxItems?: number,
|
||||||
|
continuationToken?: string
|
||||||
|
): Promise<{
|
||||||
|
Contents?: Array<{
|
||||||
|
Key?: string;
|
||||||
|
LastModified?: Date;
|
||||||
|
Size?: number;
|
||||||
|
ETag?: string;
|
||||||
|
}>;
|
||||||
|
IsTruncated?: boolean;
|
||||||
|
NextContinuationToken?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Listing objects in S3 with prefix: ${prefix}`);
|
||||||
|
return await this.s3Service.listObjectsPaginated(prefix, maxItems, continuationToken);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error listing objects in S3 with prefix ${prefix}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves metadata headers for an object in S3.
|
||||||
|
*/
|
||||||
|
async getObjectHeaders(key: string): Promise<{ contentLength?: number; contentType?: string }> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Getting object headers from S3: ${key}`);
|
||||||
|
const data = await this.s3Service.getHeaderObject(key);
|
||||||
|
return {
|
||||||
|
contentLength: data.ContentLength,
|
||||||
|
contentType: data.ContentType
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error fetching object headers from S3 ${key}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an object from S3 as a readable stream.
|
||||||
|
*/
|
||||||
|
async getObjectAsStream(key: string, range?: { start: number; end: number }): Promise<Readable> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Getting object stream from S3: ${key}`);
|
||||||
|
return await this.s3Service.getObjectAsStream(key, range);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error fetching object stream from S3 ${key}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,10 +22,10 @@ import {
|
|||||||
MEET_S3_SERVICE_ENDPOINT,
|
MEET_S3_SERVICE_ENDPOINT,
|
||||||
MEET_S3_SUBBUCKET,
|
MEET_S3_SUBBUCKET,
|
||||||
MEET_S3_WITH_PATH_STYLE_ACCESS
|
MEET_S3_WITH_PATH_STYLE_ACCESS
|
||||||
} from '../environment.js';
|
} from '../../../../environment.js';
|
||||||
import { errorS3NotAvailable, internalError } from '../models/error.model.js';
|
import { errorS3NotAvailable, internalError } from '../../../../models/error.model.js';
|
||||||
import { LoggerService } from './index.js';
|
import { LoggerService } from '../../../index.js';
|
||||||
import INTERNAL_CONFIG from '../config/internal-config.js';
|
import INTERNAL_CONFIG from '../../../../config/internal-config.js';
|
||||||
|
|
||||||
@injectable()
|
@injectable()
|
||||||
export class S3Service {
|
export class S3Service {
|
||||||
@ -64,7 +64,11 @@ export class S3Service {
|
|||||||
* Saves an object to a S3 bucket.
|
* Saves an object to a S3 bucket.
|
||||||
* Uses an internal retry mechanism in case of errors.
|
* Uses an internal retry mechanism in case of errors.
|
||||||
*/
|
*/
|
||||||
async saveObject(name: string, body: any, bucket: string = MEET_S3_BUCKET): Promise<PutObjectCommandOutput> {
|
async saveObject(
|
||||||
|
name: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
bucket: string = MEET_S3_BUCKET
|
||||||
|
): Promise<PutObjectCommandOutput> {
|
||||||
const fullKey = this.getFullKey(name);
|
const fullKey = this.getFullKey(name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -76,10 +80,10 @@ export class S3Service {
|
|||||||
const result = await this.retryOperation<PutObjectCommandOutput>(() => this.run(command));
|
const result = await this.retryOperation<PutObjectCommandOutput>(() => this.run(command));
|
||||||
this.logger.verbose(`S3: successfully saved object '${fullKey}' in bucket '${bucket}'`);
|
this.logger.verbose(`S3: successfully saved object '${fullKey}' in bucket '${bucket}'`);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
this.logger.error(`S3: error saving object '${fullKey}' in bucket '${bucket}': ${error}`);
|
this.logger.error(`S3: error saving object '${fullKey}' in bucket '${bucket}': ${error}`);
|
||||||
|
|
||||||
if (error.code === 'ECONNREFUSED') {
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ECONNREFUSED') {
|
||||||
throw errorS3NotAvailable(error);
|
throw errorS3NotAvailable(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,30 +1,33 @@
|
|||||||
import { inject, injectable } from 'inversify';
|
import { inject, injectable } from 'inversify';
|
||||||
import { MEET_PREFERENCES_STORAGE_MODE } from '../../environment.js';
|
import { LoggerService } from '../index.js';
|
||||||
import { LoggerService, S3StorageProvider, StorageProvider } from '../index.js';
|
import { StorageKeyBuilder, StorageProvider } from './storage.interface.js';
|
||||||
|
import { container, STORAGE_TYPES } from '../../config/dependency-injector.config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory class responsible for creating the appropriate storage provider based on configuration.
|
* Factory class responsible for creating the appropriate basic storage provider
|
||||||
|
* based on configuration.
|
||||||
*
|
*
|
||||||
* This factory determines which storage implementation to use based on the `MEET_PREFERENCES_STORAGE_MODE`
|
* This factory determines which basic storage implementation to use based on the
|
||||||
* environment variable. Currently supports S3 storage, with more providers potentially added in the future.
|
* `MEET_PREFERENCES_STORAGE_MODE` environment variable. It creates providers that
|
||||||
|
* handle only basic CRUD operations, following the Single Responsibility Principle.
|
||||||
|
*
|
||||||
|
* Domain-specific logic should be handled in the MeetStorageService layer.
|
||||||
*/
|
*/
|
||||||
@injectable()
|
@injectable()
|
||||||
export class StorageFactory {
|
export class StorageFactory {
|
||||||
constructor(
|
constructor(@inject(LoggerService) protected logger: LoggerService) {}
|
||||||
@inject(S3StorageProvider) protected s3StorageProvider: S3StorageProvider,
|
|
||||||
@inject(LoggerService) protected logger: LoggerService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
create(): StorageProvider {
|
/**
|
||||||
const storageMode = MEET_PREFERENCES_STORAGE_MODE;
|
* Creates a basic storage provider based on the configured storage mode.
|
||||||
|
*
|
||||||
switch (storageMode) {
|
* @returns StorageProvider instance configured for the specified storage backend
|
||||||
case 's3':
|
*/
|
||||||
return this.s3StorageProvider;
|
create(): { provider: StorageProvider; keyBuilder: StorageKeyBuilder } {
|
||||||
|
// The actual binding is handled in the DI configuration
|
||||||
default:
|
// This factory just returns the pre-configured instances
|
||||||
this.logger.info('No preferences storage mode specified. Defaulting to S3.');
|
return {
|
||||||
return this.s3StorageProvider;
|
provider: container.get<StorageProvider>(STORAGE_TYPES.StorageProvider),
|
||||||
}
|
keyBuilder: container.get<StorageKeyBuilder>(STORAGE_TYPES.KeyBuilder)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,67 @@
|
|||||||
import { GlobalPreferences, MeetRecordingInfo, MeetRoom, User } from '@typings-ce';
|
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An interface that defines the contract for storage providers in the OpenVidu Meet application.
|
* Basic storage interface that defines primitive storage operations.
|
||||||
* Storage providers handle persistence of global application preferences, rooms, recordings metadata and users.
|
* This interface follows the Single Responsibility Principle by focusing
|
||||||
|
* only on basic CRUD operations for object storage.
|
||||||
*
|
*
|
||||||
* @template GPrefs - The type of global preferences, extending GlobalPreferences
|
* This allows easy integration of different storage backends (S3, PostgreSQL,
|
||||||
* @template MRoom - The type of room data, extending MeetRoom
|
* FileSystem, etc.) without mixing domain-specific business logic.
|
||||||
* @template MRec - The type of recording metadata, extending MeetRecordingInfo
|
|
||||||
* @template MUser - The type of user data, extending User
|
|
||||||
*
|
|
||||||
* Implementations of this interface should handle the persistent storage
|
|
||||||
* of application settings, room information, recording metadata, and user data,
|
|
||||||
* which could be backed by various storage solutions (database, file system, cloud storage, etc.).
|
|
||||||
*/
|
*/
|
||||||
export interface StorageProvider<
|
export interface StorageProvider {
|
||||||
GPrefs extends GlobalPreferences = GlobalPreferences,
|
|
||||||
MRoom extends MeetRoom = MeetRoom,
|
|
||||||
MRec extends MeetRecordingInfo = MeetRecordingInfo,
|
|
||||||
MUser extends User = User
|
|
||||||
> {
|
|
||||||
/**
|
/**
|
||||||
* Initializes the storage with default preferences if they are not already set.
|
* Retrieves an object from storage as a JSON object.
|
||||||
*
|
*
|
||||||
* @param defaultPreferences - The default preferences to initialize with.
|
* @param key - The storage key/path of the object
|
||||||
* @returns A promise that resolves when the initialization is complete.
|
* @returns A promise that resolves to the parsed JSON object, or null if not found
|
||||||
*/
|
*/
|
||||||
initialize(defaultPreferences: GPrefs): Promise<void>;
|
getObject<T = Record<string, unknown>>(key: string): Promise<T | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrives the headers of an object stored in the storage provider.
|
* Stores an object in storage as JSON.
|
||||||
* This is useful to get the content length and content type of the object without downloading it.
|
|
||||||
*
|
*
|
||||||
* @param filePath - The path of the file to retrieve headers for.
|
* @param key - The storage key/path where the object should be stored
|
||||||
|
* @param data - The object to store (will be serialized to JSON)
|
||||||
|
* @returns A promise that resolves when the object is successfully stored
|
||||||
*/
|
*/
|
||||||
getObjectHeaders(filePath: string): Promise<{ contentLength?: number; contentType?: string }>;
|
putObject<T = Record<string, unknown>>(key: string, data: T): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists objects in the storage with optional pagination support.
|
* Deletes a single object from storage.
|
||||||
*
|
*
|
||||||
* @param prefix - The prefix to filter objects by (acts as a folder path)
|
* @param key - The storage key/path of the object to delete
|
||||||
|
* @returns A promise that resolves when the object is successfully deleted
|
||||||
|
*/
|
||||||
|
deleteObject(key: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes multiple objects from storage.
|
||||||
|
*
|
||||||
|
* @param keys - Array of storage keys/paths of the objects to delete
|
||||||
|
* @returns A promise that resolves when all objects are successfully deleted
|
||||||
|
*/
|
||||||
|
deleteObjects(keys: string[]): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an object exists in storage.
|
||||||
|
*
|
||||||
|
* @param key - The storage key/path to check
|
||||||
|
* @returns A promise that resolves to true if the object exists, false otherwise
|
||||||
|
*/
|
||||||
|
exists(key: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists objects in storage with a given prefix (acts like a folder).
|
||||||
|
*
|
||||||
|
* @param prefix - The prefix to filter objects by
|
||||||
* @param maxItems - Maximum number of items to return (optional)
|
* @param maxItems - Maximum number of items to return (optional)
|
||||||
* @param nextPageToken - Token for pagination to get the next page (optional)
|
* @param continuationToken - Token for pagination (optional)
|
||||||
* @returns Promise resolving to paginated list of objects with metadata
|
* @returns A promise that resolves to a paginated list of objects
|
||||||
*/
|
*/
|
||||||
listObjects(
|
listObjects(
|
||||||
prefix: string,
|
prefix: string,
|
||||||
maxItems?: number,
|
maxItems?: number,
|
||||||
nextPageToken?: string
|
continuationToken?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
Contents?: Array<{
|
Contents?: Array<{
|
||||||
Key?: string;
|
Key?: string;
|
||||||
@ -60,165 +74,80 @@ export interface StorageProvider<
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the global preferences of Openvidu Meet.
|
* Retrieves metadata headers for an object without downloading the content.
|
||||||
*
|
*
|
||||||
* @returns A promise that resolves to the global preferences, or null if not set.
|
* @param key - The storage key/path of the object
|
||||||
|
* @returns A promise that resolves to object metadata
|
||||||
*/
|
*/
|
||||||
getGlobalPreferences(): Promise<GPrefs | null>;
|
getObjectHeaders(key: string): Promise<{
|
||||||
|
contentLength?: number;
|
||||||
/**
|
contentType?: string;
|
||||||
* Saves the given preferences.
|
|
||||||
*
|
|
||||||
* @param preferences - The preferences to save.
|
|
||||||
* @returns A promise that resolves to the saved preferences.
|
|
||||||
*/
|
|
||||||
saveGlobalPreferences(preferences: GPrefs): Promise<GPrefs>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Retrieves the OpenVidu Meet Rooms.
|
|
||||||
*
|
|
||||||
* @param maxItems - The maximum number of items to retrieve. If not provided, all items will be retrieved.
|
|
||||||
* @param nextPageToken - The token for the next page of results. If not provided, the first page will be retrieved.
|
|
||||||
* @returns A promise that resolves to an object containing:
|
|
||||||
* - the retrieved rooms.
|
|
||||||
* - a boolean indicating if there are more items to retrieve.
|
|
||||||
* - an optional next page token.
|
|
||||||
*/
|
|
||||||
getMeetRooms(
|
|
||||||
maxItems?: number,
|
|
||||||
nextPageToken?: string
|
|
||||||
): Promise<{
|
|
||||||
rooms: MRoom[];
|
|
||||||
isTruncated: boolean;
|
|
||||||
nextPageToken?: string;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the {@link MeetRoom}.
|
* Retrieves an object as a readable stream.
|
||||||
|
* Useful for large files or when you need streaming access.
|
||||||
*
|
*
|
||||||
* @param roomId - The identifier of the room to retrieve.
|
* @param key - The storage key/path of the object
|
||||||
* @returns A promise that resolves to the OpenVidu Room, or null if not found.
|
* @param range - Optional byte range for partial content retrieval
|
||||||
**/
|
* @returns A promise that resolves to a readable stream of the object content
|
||||||
getMeetRoom(roomId: string): Promise<MRoom | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the OpenVidu Meet Room.
|
|
||||||
*
|
|
||||||
* @param meetRoom - The OpenVidu Room to save.
|
|
||||||
* @returns A promise that resolves to the saved OpenVidu Room.
|
|
||||||
**/
|
|
||||||
saveMeetRoom(meetRoom: MRoom): Promise<MRoom>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes OpenVidu Meet Rooms.
|
|
||||||
*
|
|
||||||
* @param roomIds - The room IDs to delete.
|
|
||||||
* @returns A promise that resolves when the room have been deleted.
|
|
||||||
**/
|
|
||||||
deleteMeetRooms(roomIds: string[]): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the archived metadata for a specific room.
|
|
||||||
*
|
|
||||||
* The archived metadata is necessary for checking the permissions of the recording viewer when the room is deleted.
|
|
||||||
*
|
|
||||||
* @param roomId - The name of the room to retrieve.
|
|
||||||
*/
|
*/
|
||||||
getArchivedRoomMetadata(roomId: string): Promise<Partial<MRoom> | null>;
|
getObjectAsStream(key: string, range?: { start: number; end: number }): Promise<Readable>;
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Archives the metadata for a specific room.
|
/**
|
||||||
*
|
* Interface for building storage keys used throughout the application.
|
||||||
* This is necessary for persisting the metadata of a room although it is deleted.
|
* Provides methods to generate standardized keys for different types of data storage operations.
|
||||||
* The metadata will be used to check the permissions of the recording viewer.
|
*/
|
||||||
*
|
export interface StorageKeyBuilder {
|
||||||
* @param roomId: The room ID to archive.
|
/**
|
||||||
*/
|
* Builds the key for global preferences storage.
|
||||||
archiveRoomMetadata(roomId: string): Promise<void>;
|
*/
|
||||||
|
buildGlobalPreferencesKey(): string;
|
||||||
/**
|
/**
|
||||||
* Updates the archived metadata for a specific room.
|
* Builds the key for a specific room.
|
||||||
*
|
*
|
||||||
* This is necessary for keeping the metadata of a room up to date.
|
* @param roomId - The unique identifier of the meeting room
|
||||||
*
|
*/
|
||||||
* @param roomId: The room ID to update.
|
buildMeetRoomKey(roomId: string): string;
|
||||||
*/
|
|
||||||
updateArchivedRoomMetadata(roomId: string): Promise<void>;
|
/**
|
||||||
|
* Builds the key for all meeting rooms.
|
||||||
/**
|
*/
|
||||||
* Deletes the archived metadata for a specific room.
|
buildAllMeetRoomsKey(): string;
|
||||||
*
|
|
||||||
* @param roomId - The room ID to delete the archived metadata for.
|
/**
|
||||||
*/
|
* Builds the key for archived room metadata.
|
||||||
deleteArchivedRoomMetadata(roomId: string): Promise<void>;
|
*
|
||||||
|
* @param roomId - The unique identifier of the meeting room
|
||||||
/**
|
*/
|
||||||
* Saves the recording metadata.
|
buildArchivedMeetRoomKey(roomId: string): string;
|
||||||
*
|
|
||||||
* @param recordingInfo - The recording information to save.
|
/**
|
||||||
* @returns A promise that resolves to the saved recording information.
|
* Builds the key for a specific recording.
|
||||||
*/
|
*
|
||||||
saveRecordingMetadata(recordingInfo: MRec): Promise<MRec>;
|
* @param recordingId - The unique identifier of the recording
|
||||||
|
*/
|
||||||
/**
|
buildBinaryRecordingKey(recordingId: string): string;
|
||||||
* Retrieves the recording metadata for a specific recording ID.
|
|
||||||
*
|
/**
|
||||||
* @param recordingId - The unique identifier of the recording.
|
* Builds the key for a specific recording metadata.
|
||||||
* @returns A promise that resolves to the recording metadata, or null if not found.
|
*
|
||||||
*/
|
* @param recordingId - The unique identifier of the recording
|
||||||
getRecordingMetadata(recordingId: string): Promise<{ recordingInfo: MRec; metadataFilePath: string }>;
|
*/
|
||||||
|
buildMeetRecordingKey(recordingId: string): string;
|
||||||
/**
|
|
||||||
* Retrieves the recording metadata for multiple recording IDs.
|
/**
|
||||||
*
|
* Builds the key for all recordings in a room or globally.
|
||||||
* @param recordingPath - The path of the recording file to retrieve metadata for.
|
*
|
||||||
* @returns A promise that resolves to the recording metadata, or null if not found.
|
* @param roomId - Optional room identifier to filter recordings by room
|
||||||
*/
|
*/
|
||||||
getRecordingMetadataByPath(recordingPath: string): Promise<MRec | undefined>;
|
buildAllMeetRecordingsKey(roomId?: string): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes multiple recording metadata files by their paths.
|
* Builds the key for a specific user
|
||||||
*
|
*
|
||||||
* @param metadataPaths - An array of metadata file paths to delete.
|
* @param userId - The unique identifier of the user
|
||||||
*/
|
*/
|
||||||
deleteRecordingMetadataByPaths(metadataPaths: string[]): Promise<void>;
|
buildUserKey(userId: string): string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the media content of a recording file.
|
|
||||||
*
|
|
||||||
* @param recordingPath - The path of the recording file to retrieve.
|
|
||||||
* @param range - An optional range object specifying the start and end byte positions to retrieve.
|
|
||||||
*/
|
|
||||||
getRecordingMedia(
|
|
||||||
recordingPath: string,
|
|
||||||
range?: {
|
|
||||||
end: number;
|
|
||||||
start: number;
|
|
||||||
}
|
|
||||||
): Promise<Readable>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes multiple recording binary files by their paths.
|
|
||||||
*
|
|
||||||
* @param recordingPaths - An array of recording file paths to delete.
|
|
||||||
* @returns A promise that resolves when the recording binary files have been deleted.
|
|
||||||
*/
|
|
||||||
deleteRecordingBinaryFilesByPaths(recordingPaths: string[]): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the user data for a specific username.
|
|
||||||
*
|
|
||||||
* @param username - The username of the user to retrieve.
|
|
||||||
* @returns A promise that resolves to the user data, or null if not found.
|
|
||||||
*/
|
|
||||||
getUser(username: string): Promise<MUser | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the user data.
|
|
||||||
*
|
|
||||||
* @param user - The user data to save.
|
|
||||||
* @returns A promise that resolves to the saved user data.
|
|
||||||
*/
|
|
||||||
saveUser(user: MUser): Promise<MUser>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeAll, describe, expect, it } from '@jest/globals';
|
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||||
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
|
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
|
||||||
import {
|
import {
|
||||||
getSecurityPreferences,
|
getSecurityPreferences,
|
||||||
@ -6,6 +6,8 @@ import {
|
|||||||
updateSecurityPreferences
|
updateSecurityPreferences
|
||||||
} from '../../../helpers/request-helpers.js';
|
} from '../../../helpers/request-helpers.js';
|
||||||
import { AuthMode, AuthType } from '../../../../src/typings/ce/index.js';
|
import { AuthMode, AuthType } from '../../../../src/typings/ce/index.js';
|
||||||
|
import { container } from '../../../../src/config/dependency-injector.config.js';
|
||||||
|
import { MeetStorageService } from '../../../../src/services/index.js';
|
||||||
|
|
||||||
const defaultPreferences = {
|
const defaultPreferences = {
|
||||||
authentication: {
|
authentication: {
|
||||||
@ -16,17 +18,18 @@ const defaultPreferences = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreDefaultSecurityPreferences = async () => {
|
const restoreDefaultGlobalPreferences = async () => {
|
||||||
await updateSecurityPreferences(defaultPreferences);
|
const defaultPref = await container.get(MeetStorageService)['buildDefaultPreferences']();
|
||||||
|
await container.get(MeetStorageService).saveGlobalPreferences(defaultPref);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Security Preferences API Tests', () => {
|
describe('Security Preferences API Tests', () => {
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
beforeEach(async () => {
|
||||||
await restoreDefaultSecurityPreferences();
|
await restoreDefaultGlobalPreferences();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Update security preferences', () => {
|
describe('Update security preferences', () => {
|
||||||
|
|||||||
@ -13,8 +13,9 @@ import {
|
|||||||
import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios';
|
import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios';
|
||||||
|
|
||||||
describe('Recording API Tests', () => {
|
describe('Recording API Tests', () => {
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@ -15,8 +15,13 @@ import {
|
|||||||
import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios';
|
import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios';
|
||||||
|
|
||||||
describe('Recording API Tests', () => {
|
describe('Recording API Tests', () => {
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Delete Recording Tests', () => {
|
describe('Delete Recording Tests', () => {
|
||||||
|
|||||||
@ -17,6 +17,8 @@ describe('Recording API Tests', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
|
|
||||||
const testContext = await setupMultiRecordingsTestContext(1, 1, 1, '3s');
|
const testContext = await setupMultiRecordingsTestContext(1, 1, 1, '3s');
|
||||||
const roomData = testContext.getRoomByIndex(0)!;
|
const roomData = testContext.getRoomByIndex(0)!;
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,8 @@ describe('Recording API Tests', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
await deleteAllRecordings();
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
|
|
||||||
// Create a room and join a participant
|
// Create a room and join a participant
|
||||||
context = await setupMultiRecordingsTestContext(1, 1, 1);
|
context = await setupMultiRecordingsTestContext(1, 1, 1);
|
||||||
({ room, moderatorCookie, recordingId = '' } = context.getRoomByIndex(0)!);
|
({ room, moderatorCookie, recordingId = '' } = context.getRoomByIndex(0)!);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||||
import { MeetRecordingInfo, MeetRecordingStatus, MeetRoom } from '../../../../src/typings/ce/index.js';
|
import { MeetRecordingInfo, MeetRecordingStatus, MeetRoom } from '../../../../src/typings/ce/index.js';
|
||||||
import {
|
import {
|
||||||
expectSuccessListRecordingResponse,
|
expectSuccessListRecordingResponse,
|
||||||
@ -28,16 +28,14 @@ describe('Recordings API Tests', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
await deleteAllRecordings();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('List Recordings Tests', () => {
|
describe('List Recordings Tests', () => {
|
||||||
afterEach(async () => {
|
beforeEach(async () => {
|
||||||
await deleteAllRecordings();
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
const response = await getAllRecordings();
|
const response = await getAllRecordings();
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expectSuccessListRecordingResponse(response, 0, false, false);
|
expectSuccessListRecordingResponse(response, 0, false, false);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@ -33,6 +33,8 @@ describe('Recording API Race Conditions Tests', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
|
|
||||||
recordingService = container.get(RecordingService);
|
recordingService = container.get(RecordingService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -24,15 +24,15 @@ describe('Recording API Tests', () => {
|
|||||||
let context: TestContext | null = null;
|
let context: TestContext | null = null;
|
||||||
let room: MeetRoom, moderatorCookie: string;
|
let room: MeetRoom, moderatorCookie: string;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await stopAllRecordings(moderatorCookie);
|
await stopAllRecordings(moderatorCookie);
|
||||||
await disconnectFakeParticipants();
|
await disconnectFakeParticipants();
|
||||||
await deleteAllRooms();
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
await deleteAllRecordings();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Start Recording Tests', () => {
|
describe('Start Recording Tests', () => {
|
||||||
|
|||||||
@ -18,6 +18,8 @@ describe('Recording API Tests', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
startTestServer();
|
startTestServer();
|
||||||
|
await Promise.all([deleteAllRooms(), deleteAllRecordings()]);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@ -25,14 +25,14 @@ describe('Room API Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Room Creation Tests', () => {
|
describe('Room Creation Tests', () => {
|
||||||
it('✅ Should create a room without autoDeletionDate (default behavior)', async () => {
|
it('Should create a room without autoDeletionDate (default behavior)', async () => {
|
||||||
const room = await createRoom({
|
const room = await createRoom({
|
||||||
roomIdPrefix: ' Test Room '
|
roomIdPrefix: ' Test Room '
|
||||||
});
|
});
|
||||||
expectValidRoom(room, 'TestRoom');
|
expectValidRoom(room, 'TestRoom');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('✅ Should create a room with a valid autoDeletionDate', async () => {
|
it('Should create a room with a valid autoDeletionDate', async () => {
|
||||||
const room = await createRoom({
|
const room = await createRoom({
|
||||||
autoDeletionDate: validAutoDeletionDate,
|
autoDeletionDate: validAutoDeletionDate,
|
||||||
roomIdPrefix: ' .,-------}{¡$#<+My Room *123 '
|
roomIdPrefix: ' .,-------}{¡$#<+My Room *123 '
|
||||||
@ -41,7 +41,7 @@ describe('Room API Tests', () => {
|
|||||||
expectValidRoom(room, 'MyRoom123', validAutoDeletionDate);
|
expectValidRoom(room, 'MyRoom123', validAutoDeletionDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('✅ Should create a room when sending full valid payload', async () => {
|
it('Should create a room when sending full valid payload', async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
roomIdPrefix: ' =Example Room&/ ',
|
roomIdPrefix: ' =Example Room&/ ',
|
||||||
autoDeletionDate: validAutoDeletionDate,
|
autoDeletionDate: validAutoDeletionDate,
|
||||||
|
|||||||
@ -1,38 +1,38 @@
|
|||||||
export interface AuthenticationPreferences {
|
export interface AuthenticationPreferences {
|
||||||
authMethod: ValidAuthMethod;
|
authMethod: ValidAuthMethod;
|
||||||
authModeToAccessRoom: AuthMode;
|
authModeToAccessRoom: AuthMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication modes available to enter a room.
|
* Authentication modes available to enter a room.
|
||||||
*/
|
*/
|
||||||
export const enum AuthMode {
|
export const enum AuthMode {
|
||||||
NONE = 'none', // No authentication required
|
NONE = 'none', // No authentication required
|
||||||
MODERATORS_ONLY = 'moderators_only', // Only moderators need authentication
|
MODERATORS_ONLY = 'moderators_only', // Only moderators need authentication
|
||||||
ALL_USERS = 'all_users' // All users need authentication
|
ALL_USERS = 'all_users', // All users need authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication method base interface.
|
* Authentication method base interface.
|
||||||
*/
|
*/
|
||||||
export interface AuthMethod {
|
export interface AuthMethod {
|
||||||
type: AuthType;
|
type: AuthType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enum for authentication types.
|
* Enum for authentication types.
|
||||||
*/
|
*/
|
||||||
export const enum AuthType {
|
export const enum AuthType {
|
||||||
SINGLE_USER = 'single-user'
|
SINGLE_USER = 'single-user',
|
||||||
// MULTI_USER = 'multi-user',
|
// MULTI_USER = 'multi-user',
|
||||||
// OAUTH_ONLY = 'oauth-only'
|
// OAUTH_ONLY = 'oauth-only'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication method: Single user with fixed credentials.
|
* Authentication method: Single user with fixed credentials.
|
||||||
*/
|
*/
|
||||||
export interface SingleUserAuth extends AuthMethod {
|
export interface SingleUserAuth extends AuthMethod {
|
||||||
type: AuthType.SINGLE_USER;
|
type: AuthType.SINGLE_USER;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +54,8 @@ export interface SingleUserAuth extends AuthMethod {
|
|||||||
/**
|
/**
|
||||||
* Union type for allowed authentication methods.
|
* Union type for allowed authentication methods.
|
||||||
*/
|
*/
|
||||||
export type ValidAuthMethod = SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
|
export type ValidAuthMethod =
|
||||||
|
SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for OAuth authentication.
|
* Configuration for OAuth authentication.
|
||||||
|
|||||||
@ -4,18 +4,18 @@ import { AuthenticationPreferences } from './auth-preferences.js';
|
|||||||
* Represents global preferences for OpenVidu Meet.
|
* Represents global preferences for OpenVidu Meet.
|
||||||
*/
|
*/
|
||||||
export interface GlobalPreferences {
|
export interface GlobalPreferences {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
// roomFeaturesPreferences: RoomFeaturesPreferences;
|
// roomFeaturesPreferences: RoomFeaturesPreferences;
|
||||||
webhooksPreferences: WebhookPreferences;
|
webhooksPreferences: WebhookPreferences;
|
||||||
securityPreferences: SecurityPreferences;
|
securityPreferences: SecurityPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookPreferences {
|
export interface WebhookPreferences {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
// events: WebhookEvent[];
|
// events: WebhookEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecurityPreferences {
|
export interface SecurityPreferences {
|
||||||
authentication: AuthenticationPreferences;
|
authentication: AuthenticationPreferences;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,34 +5,34 @@ import { OpenViduMeetPermissions } from './permissions/openvidu-permissions.js';
|
|||||||
* Options for a participant to join a room.
|
* Options for a participant to join a room.
|
||||||
*/
|
*/
|
||||||
export interface ParticipantOptions {
|
export interface ParticipantOptions {
|
||||||
/**
|
/**
|
||||||
* The unique identifier for the room.
|
* The unique identifier for the room.
|
||||||
*/
|
*/
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the participant.
|
* The name of the participant.
|
||||||
*/
|
*/
|
||||||
participantName: string;
|
participantName: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A secret key for room access.
|
* A secret key for room access.
|
||||||
*/
|
*/
|
||||||
secret: string;
|
secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the permissions for an individual participant.
|
* Represents the permissions for an individual participant.
|
||||||
*/
|
*/
|
||||||
export interface ParticipantPermissions {
|
export interface ParticipantPermissions {
|
||||||
livekit: LiveKitPermissions;
|
livekit: LiveKitPermissions;
|
||||||
openvidu: OpenViduMeetPermissions;
|
openvidu: OpenViduMeetPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the role of a participant in a room.
|
* Represents the role of a participant in a room.
|
||||||
*/
|
*/
|
||||||
export const enum ParticipantRole {
|
export const enum ParticipantRole {
|
||||||
MODERATOR = 'moderator',
|
MODERATOR = 'moderator',
|
||||||
PUBLISHER = 'publisher'
|
PUBLISHER = 'publisher',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,30 +2,30 @@
|
|||||||
* Interface representing the preferences for a room.
|
* Interface representing the preferences for a room.
|
||||||
*/
|
*/
|
||||||
export interface MeetRoomPreferences {
|
export interface MeetRoomPreferences {
|
||||||
chatPreferences: MeetChatPreferences;
|
chatPreferences: MeetChatPreferences;
|
||||||
recordingPreferences: MeetRecordingPreferences;
|
recordingPreferences: MeetRecordingPreferences;
|
||||||
virtualBackgroundPreferences: MeetVirtualBackgroundPreferences;
|
virtualBackgroundPreferences: MeetVirtualBackgroundPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface representing the preferences for recording.
|
* Interface representing the preferences for recording.
|
||||||
*/
|
*/
|
||||||
export interface MeetRecordingPreferences {
|
export interface MeetRecordingPreferences {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
allowAccessTo?: MeetRecordingAccess;
|
allowAccessTo?: MeetRecordingAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum MeetRecordingAccess {
|
export const enum MeetRecordingAccess {
|
||||||
ADMIN = 'admin', // Only admins can access the recording
|
ADMIN = 'admin', // Only admins can access the recording
|
||||||
ADMIN_MODERATOR = 'admin-moderator', // Admins and moderators can access
|
ADMIN_MODERATOR = 'admin-moderator', // Admins and moderators can access
|
||||||
ADMIN_MODERATOR_PUBLISHER = 'admin-moderator-publisher', // Admins, moderators and publishers can access
|
ADMIN_MODERATOR_PUBLISHER = 'admin-moderator-publisher', // Admins, moderators and publishers can access
|
||||||
PUBLIC = 'public' // Everyone can access
|
PUBLIC = 'public', // Everyone can access
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeetChatPreferences {
|
export interface MeetChatPreferences {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeetVirtualBackgroundPreferences {
|
export interface MeetVirtualBackgroundPreferences {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
username: string;
|
username: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
roles: UserRole[];
|
roles: UserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum UserRole {
|
export const enum UserRole {
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
APP = 'app'
|
APP = 'app',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserDTO = Omit<User, 'passwordHash'>;
|
export type UserDTO = Omit<User, 'passwordHash'>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user