diff --git a/backend/src/config/dependency-injector.config.ts b/backend/src/config/dependency-injector.config.ts index 9f304c1..45b5ea6 100644 --- a/backend/src/config/dependency-injector.config.ts +++ b/backend/src/config/dependency-injector.config.ts @@ -58,5 +58,5 @@ export const registerDependencies = () => { export const initializeEagerServices = async () => { // Force the creation of services that need to be initialized at startup container.get(RecordingService); - await container.get(MeetStorageService).initializeGlobalPreferences(); + await container.get(MeetStorageService).initialize(); }; diff --git a/backend/src/config/internal-config.ts b/backend/src/config/internal-config.ts index 994d38f..e50e424 100644 --- a/backend/src/config/internal-config.ts +++ b/backend/src/config/internal-config.ts @@ -24,6 +24,7 @@ const INTERNAL_CONFIG = { S3_INITIAL_RETRY_DELAY_MS: '100', S3_ROOMS_PREFIX: 'rooms', S3_RECORDINGS_PREFIX: 'recordings', + S3_USERS_PREFIX: 'users', // Garbage collection and recording lock intervals ROOM_GC_INTERVAL: '1h' as StringValue, // e.g. garbage collector interval for rooms diff --git a/backend/src/models/redis.model.ts b/backend/src/models/redis.model.ts index 696840e..a324f0c 100644 --- a/backend/src/models/redis.model.ts +++ b/backend/src/models/redis.model.ts @@ -4,7 +4,8 @@ export const enum RedisKeyPrefix { export const enum RedisKeyName { GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences`, - ROOM = `${RedisKeyPrefix.BASE}room:` + ROOM = `${RedisKeyPrefix.BASE}room:`, + USER = `${RedisKeyPrefix.BASE}user:`, } export const enum RedisLockPrefix { diff --git a/backend/src/services/storage/providers/s3-storage.provider.ts b/backend/src/services/storage/providers/s3-storage.provider.ts index df54083..395f31e 100644 --- a/backend/src/services/storage/providers/s3-storage.provider.ts +++ b/backend/src/services/storage/providers/s3-storage.provider.ts @@ -1,5 +1,5 @@ import { PutObjectCommandOutput } from '@aws-sdk/client-s3'; -import { GlobalPreferences, MeetRecordingInfo, MeetRoom } from '@typings-ce'; +import { GlobalPreferences, MeetRecordingInfo, MeetRoom, User } from '@typings-ce'; import { inject, injectable } from 'inversify'; import INTERNAL_CONFIG from '../../../config/internal-config.js'; import { errorRecordingNotFound, OpenViduMeetError, RedisKeyName } from '../../../models/index.js'; @@ -11,8 +11,8 @@ import { Readable } from 'stream'; * Implementation of the StorageProvider interface using AWS S3 for persistent storage * with Redis caching for improved performance. * - * This class provides operations for storing and retrieving application preferences and room data - * with a two-tiered storage approach: + * This class provides operations for storing and retrieving application preferences, + * rooms, recordings metadata and users with a two-tiered storage approach: * - Redis is used as a primary cache for fast access * - S3 serves as the persistent storage layer and fallback when data is not in Redis * @@ -21,6 +21,8 @@ import { Readable } from 'stream'; * * @template GPrefs - Type for global preferences data, defaults to GlobalPreferences * @template MRoom - Type for room data, defaults to MeetRoom + * @template MRec - Type for recording metadata, defaults to MeetRecordingInfo + * @template MUser - Type for user data, defaults to User * * @implements {StorageProvider} */ @@ -28,10 +30,12 @@ import { Readable } from 'stream'; export class S3StorageProvider< GPrefs extends GlobalPreferences = GlobalPreferences, MRoom extends MeetRoom = MeetRoom, - MRec extends MeetRecordingInfo = MeetRecordingInfo + MRec extends MeetRecordingInfo = MeetRecordingInfo, + MUser extends User = User > implements StorageProvider { protected readonly S3_GLOBAL_PREFERENCES_KEY = `global-preferences.json`; + constructor( @inject(LoggerService) protected logger: LoggerService, @inject(S3Service) protected s3Service: S3Service, @@ -479,15 +483,13 @@ export class S3StorageProvider< */ async getRecordingMetadataByPath(recordingPath: string): Promise { try { - return await this.s3Service.getObjectAsJson(recordingPath) as MRec; + return (await this.s3Service.getObjectAsJson(recordingPath)) as MRec; } catch (error) { this.handleError(error, `Error fetching recording metadata for path ${recordingPath}`); throw error; - } } - async saveRecordingMetadata(recordingInfo: MRec): Promise { try { const metadataPath = RecordingHelper.buildMetadataFilePath(recordingInfo.recordingId); @@ -516,6 +518,77 @@ export class S3StorageProvider< } } + async getUser(username: string): Promise { + try { + const userKey = RedisKeyName.USER + username; + const user: MUser | null = await this.getFromRedis(userKey); + + if (!user) { + this.logger.debug(`User ${username} not found in Redis. Fetching from S3...`); + const s3Path = `${INTERNAL_CONFIG.S3_USERS_PREFIX}/${username}.json`; + return await this.getFromS3(s3Path); + } + + this.logger.debug(`User ${username} retrieved from Redis`); + return user; + } catch (error) { + this.handleError(error, `Error fetching user ${username}`); + return null; + } + } + + /** + * Saves a user object to both S3 storage and Redis cache atomically. + * + * This method attempts to persist the user data in S3 (as a JSON file) and in Redis (as a serialized string). + * If both operations succeed, the user is considered saved and the method returns the user object. + * If either operation fails, the method attempts to roll back any successful operation to maintain consistency. + * In case of failure, the error is logged and rethrown after rollback attempts. + * + * @param user - The user object to be saved. + * @returns A promise that resolves to the saved user object if both operations succeed. + * @throws An error if either the S3 or Redis operation fails, after attempting rollback. + */ + async saveUser(user: MUser): Promise { + const userKey = RedisKeyName.USER + user.username; + const redisPayload = JSON.stringify(user); + const s3Path = `${INTERNAL_CONFIG.S3_USERS_PREFIX}/${user.username}.json`; + + const [s3Result, redisResult] = await Promise.allSettled([ + this.s3Service.saveObject(s3Path, user), + this.redisService.set(userKey, redisPayload, false) + ]); + + if (s3Result.status === 'fulfilled' && redisResult.status === 'fulfilled') { + this.logger.info(`User ${user.username} saved successfully`); + return user; + } + + // Rollback any changes made by the successful operation + if (s3Result.status === 'fulfilled') { + try { + await this.s3Service.deleteObjects([s3Path]); + } catch (rollbackError) { + this.logger.error(`Error rolling back S3 save for user ${user.username}: ${rollbackError}`); + } + } + + if (redisResult.status === 'fulfilled') { + try { + await this.redisService.delete(userKey); + } catch (rollbackError) { + this.logger.error(`Error rolling back Redis set for user ${user.username}: ${rollbackError}`); + } + } + + // Return the error that occurred first + const failedOperation: PromiseRejectedResult = + s3Result.status === 'rejected' ? s3Result : (redisResult as PromiseRejectedResult); + const error = failedOperation.reason; + this.handleError(error, `Error saving user ${user.username}`); + throw error; + } + /** * Retrieves an object of type U from Redis by the given key. * Returns null if the key is not found or an error occurs. diff --git a/backend/src/services/storage/storage.interface.ts b/backend/src/services/storage/storage.interface.ts index 596afe7..64546fb 100644 --- a/backend/src/services/storage/storage.interface.ts +++ b/backend/src/services/storage/storage.interface.ts @@ -1,21 +1,24 @@ -import { GlobalPreferences, MeetRecordingInfo, MeetRoom } from '@typings-ce'; +import { GlobalPreferences, MeetRecordingInfo, MeetRoom, User } from '@typings-ce'; import { Readable } from 'stream'; /** * An interface that defines the contract for storage providers in the OpenVidu Meet application. - * Storage providers handle persistence of global application preferences and meeting room data. + * Storage providers handle persistence of global application preferences, rooms, recordings metadata and users. * * @template GPrefs - The type of global preferences, extending GlobalPreferences * @template MRoom - The type of room data, extending MeetRoom + * @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 and room information, which could be backed by - * various storage solutions (database, file system, cloud storage, etc.). + * 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< GPrefs extends GlobalPreferences = GlobalPreferences, MRoom extends MeetRoom = MeetRoom, - MRec extends MeetRecordingInfo = MeetRecordingInfo + MRec extends MeetRecordingInfo = MeetRecordingInfo, + MUser extends User = User > { /** * Initializes the storage with default preferences if they are not already set. @@ -77,9 +80,9 @@ export interface StorageProvider< * * @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 + * @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( @@ -94,7 +97,7 @@ export interface StorageProvider< /** * Retrieves the {@link MeetRoom}. * - * @param roomId - The name of the room to retrieve. + * @param roomId - The identifier of the room to retrieve. * @returns A promise that resolves to the OpenVidu Room, or null if not found. **/ getMeetRoom(roomId: string): Promise; @@ -103,14 +106,14 @@ export interface StorageProvider< * Saves the OpenVidu Meet Room. * * @param meetRoom - The OpenVidu Room to save. - * @returns A promise that resolves to the saved + * @returns A promise that resolves to the saved OpenVidu Room. **/ saveMeetRoom(meetRoom: MRoom): Promise; /** * Deletes OpenVidu Meet Rooms. * - * @param roomIds - The room names to delete. + * @param roomIds - The room IDs to delete. * @returns A promise that resolves when the room have been deleted. **/ deleteMeetRooms(roomIds: string[]): Promise; @@ -194,6 +197,7 @@ export interface StorageProvider< start: number; } ): Promise; + /** * Deletes multiple recording binary files by their paths. * @@ -201,4 +205,20 @@ export interface StorageProvider< * @returns A promise that resolves when the recording binary files have been deleted. */ deleteRecordingBinaryFilesByPaths(recordingPaths: string[]): Promise; + + /** + * 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; + + /** + * 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; } diff --git a/backend/src/services/storage/storage.service.ts b/backend/src/services/storage/storage.service.ts index c0d4c5a..0f16494 100644 --- a/backend/src/services/storage/storage.service.ts +++ b/backend/src/services/storage/storage.service.ts @@ -1,11 +1,17 @@ -import { AuthMode, AuthType, GlobalPreferences, MeetRecordingInfo, MeetRoom } from '@typings-ce'; +import { AuthMode, AuthType, GlobalPreferences, MeetRecordingInfo, MeetRoom, User, UserRole } from '@typings-ce'; import { inject, injectable } from 'inversify'; import ms from 'ms'; -import { MEET_NAME_ID, MEET_SECRET, MEET_USER, MEET_WEBHOOK_ENABLED, MEET_WEBHOOK_URL } from '../../environment.js'; +import { Readable } from 'stream'; +import { + MEET_ADMIN_SECRET, + MEET_ADMIN_USER, + MEET_NAME_ID, + MEET_WEBHOOK_ENABLED, + MEET_WEBHOOK_URL +} from '../../environment.js'; import { MeetLock, PasswordHelper } from '../../helpers/index.js'; import { errorRoomNotFound, internalError, OpenViduMeetError } from '../../models/error.model.js'; import { LoggerService, MutexService, StorageFactory, StorageProvider } from '../index.js'; -import { Readable } from 'stream'; /** * A service for managing storage operations related to OpenVidu Meet rooms and preferences. @@ -20,9 +26,11 @@ import { Readable } from 'stream'; export class MeetStorageService< GPrefs extends GlobalPreferences = GlobalPreferences, MRoom extends MeetRoom = MeetRoom, - MRec extends MeetRecordingInfo = MeetRecordingInfo + MRec extends MeetRecordingInfo = MeetRecordingInfo, + MUser extends User = User > { protected storageProvider: StorageProvider; + constructor( @inject(LoggerService) protected logger: LoggerService, @inject(StorageFactory) protected storageFactory: StorageFactory, @@ -68,10 +76,10 @@ export class MeetStorageService< } /** - * Initializes default preferences if not already initialized. + * Initializes default preferences if not already initialized and saves the admin user. * @returns {Promise} Default global preferences. */ - async initializeGlobalPreferences(): Promise { + async initialize(): Promise { try { // Acquire a global lock to prevent multiple initializations at the same time when running in HA mode const lock = await this.mutexService.acquire(MeetLock.getGlobalPreferencesLock(), ms('30s')); @@ -83,10 +91,17 @@ export class MeetStorageService< return; } - const preferences = await this.getDefaultPreferences(); - this.logger.verbose('Initializing global preferences with default values'); + const preferences = await this.getDefaultPreferences(); await this.storageProvider.initialize(preferences); + + // Save the default admin user + const admin = { + username: MEET_ADMIN_USER, + passwordHash: await PasswordHelper.hashPassword(MEET_ADMIN_SECRET), + roles: [UserRole.ADMIN, UserRole.USER] + } as MUser; + await this.saveUser(admin); } catch (error) { this.handleError(error, 'Error initializing default preferences'); } @@ -101,7 +116,7 @@ export class MeetStorageService< if (preferences) return preferences as GPrefs; - await this.initializeGlobalPreferences(); + await this.initialize(); preferences = await this.storageProvider.getGlobalPreferences(); if (!preferences) { @@ -125,8 +140,8 @@ export class MeetStorageService< /** * Saves the meet room to the storage provider. * - * @param meetRoom - The meeting room object to be saved - * @returns A promise that resolves to the saved meeting room object + * @param meetRoom - The room object to be saved + * @returns A promise that resolves to the saved room object */ async saveMeetRoom(meetRoom: MRoom): Promise { this.logger.info(`Saving OpenVidu room ${meetRoom.roomId}`); @@ -134,12 +149,12 @@ export class MeetStorageService< } /** - * Retrieves a paginated list of meeting rooms from the storage provider. + * Retrieves a paginated list of rooms from the storage provider. * * @param maxItems - Optional maximum number of rooms to retrieve in a single request * @param nextPageToken - Optional token for pagination to get the next page of results * @returns A promise that resolves to an object containing: - * - rooms: Array of MRoom objects representing the meeting rooms + * - rooms: Array of MRoom objects representing the rooms * - isTruncated: Boolean indicating if there are more results available * - nextPageToken: Optional token for retrieving the next page of results */ @@ -159,7 +174,7 @@ export class MeetStorageService< } /** - * Retrieves the preferences associated with a specific room. + * Retrieves the room by its unique identifier. * * @param roomId - The unique identifier for the room. * @returns A promise that resolves to the room's preferences. @@ -177,7 +192,7 @@ export class MeetStorageService< } /** - * Deletes multiple meeting rooms from storage. + * Deletes multiple rooms from storage. * * @param roomIds - Array of room identifiers to be deleted * @returns A promise that resolves when all rooms have been successfully deleted @@ -305,6 +320,27 @@ export class MeetStorageService< return this.storageProvider.deleteRecordingBinaryFilesByPaths(recordingPaths); } + /** + * Retrieves 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 + */ + async getUser(username: string): Promise { + return this.storageProvider.getUser(username) as Promise; + } + + /** + * Saves user data to the storage provider. + * + * @param user - The user data to be saved + * @returns A promise that resolves to the saved user data + */ + async saveUser(user: MUser): Promise { + this.logger.info(`Saving user data for ${user.username}`); + return this.storageProvider.saveUser(user) as Promise; + } + /** * Returns the default global preferences. * @returns {GPrefs} @@ -317,19 +353,11 @@ export class MeetStorageService< url: MEET_WEBHOOK_URL }, securityPreferences: { - roomCreationPolicy: { - allowRoomCreation: true, - requireAuthentication: true - }, authentication: { - authMode: AuthMode.NONE, - method: { - type: AuthType.SINGLE_USER, - credentials: { - username: MEET_USER, - passwordHash: await PasswordHelper.hashPassword(MEET_SECRET) - } - } + authMethod: { + type: AuthType.SINGLE_USER + }, + authModeToAccessRoom: AuthMode.NONE } } } as GPrefs;