diff --git a/backend/.env.dev b/backend/.env.dev index 9b1e72a..c3c05dd 100644 --- a/backend/.env.dev +++ b/backend/.env.dev @@ -1,5 +1,4 @@ USE_HTTPS=false MEET_LOG_LEVEL=debug SERVER_CORS_ORIGIN=* -MEET_INITIAL_API_KEY=meet-api-key -MEET_INITIAL_WEBHOOK_ENABLED=false \ No newline at end of file +MEET_INITIAL_API_KEY=meet-api-key \ No newline at end of file diff --git a/backend/.env.test b/backend/.env.test index 5219e2f..ebe5cf4 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -1,5 +1,4 @@ USE_HTTPS=false MEET_LOG_LEVEL=verbose SERVER_CORS_ORIGIN=* -MEET_INITIAL_API_KEY=meet-api-key -MEET_INITIAL_WEBHOOK_ENABLED=false \ No newline at end of file +MEET_INITIAL_API_KEY=meet-api-key \ No newline at end of file diff --git a/backend/src/config/dependency-injector.config.ts b/backend/src/config/dependency-injector.config.ts index 2641860..0fdaeef 100644 --- a/backend/src/config/dependency-injector.config.ts +++ b/backend/src/config/dependency-injector.config.ts @@ -97,6 +97,6 @@ export const initializeEagerServices = async () => { const storageService = container.get(MeetStorageService); await storageService.checkStartupHealth(); - // Initialize global preferences after health checks pass - await storageService.initializeGlobalPreferences(); + // Initialize storage after health checks pass + await storageService.initializeStorage(); }; diff --git a/backend/src/environment.ts b/backend/src/environment.ts index c9693ca..4b9fe09 100644 --- a/backend/src/environment.ts +++ b/backend/src/environment.ts @@ -42,7 +42,7 @@ export const { * - To change them after the initial start, use the OpenVidu Meet API instead of modifying these environment variables. */ MEET_INITIAL_WEBHOOK_ENABLED = 'false', - MEET_INITIAL_WEBHOOK_URL = 'http://localhost:5080/webhook', + MEET_INITIAL_WEBHOOK_URL = '', // LiveKit configuration LIVEKIT_URL = 'ws://localhost:7880', diff --git a/backend/src/helpers/password.helper.ts b/backend/src/helpers/password.helper.ts index 512f604..0112a26 100644 --- a/backend/src/helpers/password.helper.ts +++ b/backend/src/helpers/password.helper.ts @@ -14,7 +14,8 @@ export class PasswordHelper { } // Generate a secure API key using uid with a length of 32 characters - static generateApiKey(): MeetApiKey { - return { key: `ovmeet-${uid(32)}`, creationDate: new Date().getTime() }; + // If a key is provided, it will be used instead of generating a new one + static generateApiKey(key?: string): MeetApiKey { + return { key: key || `ovmeet-${uid(32)}`, creationDate: new Date().getTime() }; } } diff --git a/backend/src/helpers/redis.helper.ts b/backend/src/helpers/redis.helper.ts index 12a7ca4..5351532 100644 --- a/backend/src/helpers/redis.helper.ts +++ b/backend/src/helpers/redis.helper.ts @@ -34,8 +34,8 @@ export class MeetLock { return `${RedisLockPrefix.BASE}${RedisLockName.ROOM_GARBAGE_COLLECTOR}`; } - static getGlobalPreferencesLock(): string { - return `${RedisLockPrefix.BASE}${RedisLockName.GLOBAL_PREFERENCES}`; + static getStorageInitializationLock(): string { + return `${RedisLockPrefix.BASE}${RedisLockName.STORAGE_INITIALIZATION}`; } static getWebhookLock(webhookEvent: WebhookEvent) { diff --git a/backend/src/models/redis.model.ts b/backend/src/models/redis.model.ts index f5dd2b7..509c430 100644 --- a/backend/src/models/redis.model.ts +++ b/backend/src/models/redis.model.ts @@ -26,6 +26,6 @@ export const enum RedisLockName { ROOM_GARBAGE_COLLECTOR = 'room_garbage_collector', RECORDING_ACTIVE = 'recording_active', SCHEDULED_TASK = 'scheduled_task', - GLOBAL_PREFERENCES = 'global_preferences', + STORAGE_INITIALIZATION = 'storage_initialization', WEBHOOK = 'webhook' } diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index a12767c..05c410f 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,6 +1,5 @@ -import { User } from '@typings-ce'; +import { MeetApiKey, User } from '@typings-ce'; import { inject, injectable } from 'inversify'; -import { MEET_INITIAL_API_KEY } from '../environment.js'; import { PasswordHelper } from '../helpers/index.js'; import { errorApiKeyNotConfigured } from '../models/error.model.js'; import { MeetStorageService, UserService } from './index.js'; @@ -22,24 +21,23 @@ export class AuthService { return user; } - async createApiKey() { + async createApiKey(): Promise { const apiKey = PasswordHelper.generateApiKey(); await this.storageService.saveApiKey(apiKey); return apiKey; } - async getApiKeys() { + async getApiKeys(): Promise { const apiKeys = await this.storageService.getApiKeys(); return apiKeys; } async deleteApiKeys() { await this.storageService.deleteApiKeys(); - return { message: 'API keys deleted successfully' }; } async validateApiKey(apiKey: string): Promise { - let storedApiKeys: { key: string; creationDate: number }[]; + let storedApiKeys: MeetApiKey[]; try { storedApiKeys = await this.getApiKeys(); @@ -48,11 +46,11 @@ export class AuthService { storedApiKeys = []; } - if (storedApiKeys.length === 0 && !MEET_INITIAL_API_KEY) { + if (storedApiKeys.length === 0) { throw errorApiKeyNotConfigured(); } - // Check if the provided API key matches any stored API key or the MEET_API_KEY - return storedApiKeys.some((key) => key.key === apiKey) || apiKey === MEET_INITIAL_API_KEY; + // Check if the provided API key matches any stored API key + return storedApiKeys.some((key) => key.key === apiKey); } } diff --git a/backend/src/services/openvidu-webhook.service.ts b/backend/src/services/openvidu-webhook.service.ts index d1d8fcc..3c54d98 100644 --- a/backend/src/services/openvidu-webhook.service.ts +++ b/backend/src/services/openvidu-webhook.service.ts @@ -1,4 +1,5 @@ import { + MeetApiKey, MeetRecordingInfo, MeetRoom, MeetWebhookEvent, @@ -8,9 +9,8 @@ import { } from '@typings-ce'; import crypto from 'crypto'; import { inject, injectable } from 'inversify'; -import { MEET_INITIAL_API_KEY } from '../environment.js'; -import { AuthService, LoggerService, MeetStorageService } from './index.js'; import { errorWebhookUrlUnreachable } from '../models/error.model.js'; +import { AuthService, LoggerService, MeetStorageService } from './index.js'; @injectable() export class OpenViduWebhookService { @@ -218,14 +218,16 @@ export class OpenViduWebhookService { } protected async getApiKey(): Promise { - const apiKeys = await this.authService.getApiKeys(); + let apiKeys: MeetApiKey[]; + + try { + apiKeys = await this.authService.getApiKeys(); + } catch (error) { + // If there is an error retrieving API keys, we assume they are not configured + apiKeys = []; + } if (apiKeys.length === 0) { - // If no API keys are configured, check if the MEET_API_KEY environment variable is set - if (MEET_INITIAL_API_KEY) { - return MEET_INITIAL_API_KEY; - } - throw new Error('There are no API keys configured yet. Please, create one to use webhooks.'); } diff --git a/backend/src/services/storage/storage.service.ts b/backend/src/services/storage/storage.service.ts index 4f74cc6..6f80066 100644 --- a/backend/src/services/storage/storage.service.ts +++ b/backend/src/services/storage/storage.service.ts @@ -67,6 +67,10 @@ export class MeetStorageService< this.keyBuilder = keyBuilder; } + // ========================================== + // INITIALIZATION LOGIC + // ========================================== + /** * Performs a health check on the storage system. * Verifies both service connectivity and container/bucket existence. @@ -97,61 +101,46 @@ export class MeetStorageService< } } - // ========================================== - // GLOBAL PREFERENCES DOMAIN LOGIC - // ========================================== - /** - * Initializes default preferences if not already initialized. - * @returns {Promise} Default global preferences. + * Initializes the storage with default data and initial environment variables if not already initialized. + * This includes global preferences, admin user and API key. */ - async initializeGlobalPreferences(): Promise { + async initializeStorage(): 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')); + const lock = await this.mutexService.acquire(MeetLock.getStorageInitializationLock(), ms('30s')); if (!lock) { this.logger.warn( - 'Unable to acquire lock for global preferences initialization. May be already initialized by another instance.' + 'Unable to acquire lock for storage initialization. May be already initialized by another instance.' ); return; } - this.logger.verbose('Initializing global preferences with default values'); - const redisKey = RedisKeyName.GLOBAL_PREFERENCES; - const storageKey = this.keyBuilder.buildGlobalPreferencesKey(); + const isInitialized = await this.checkStorageInitialization(); - const preferences = this.getDefaultPreferences(); - this.logger.verbose('Initializing global preferences with default values'); - const existing = await this.getFromCacheAndStorage(redisKey, storageKey); - - if (!existing) { - await this.saveCacheAndStorage(redisKey, storageKey, preferences); - this.logger.info('Global preferences initialized with default values'); - } else { - // Check if it's from a different project - const existingProjectId = (existing as GlobalPreferences)?.projectId; - const newProjectId = (preferences as GlobalPreferences)?.projectId; - - if (existingProjectId !== newProjectId) { - this.logger.info('Different project detected, overwriting global preferences'); - await this.saveCacheAndStorage(redisKey, storageKey, preferences); - } + if (isInitialized) { + this.logger.verbose('Storage already initialized for this project, skipping initialization'); + return; } - // Save the default admin user - const admin = { - username: MEET_INITIAL_ADMIN_USER, - passwordHash: await PasswordHelper.hashPassword(MEET_INITIAL_ADMIN_PASSWORD), - roles: [UserRole.ADMIN, UserRole.USER] - } as MUser; - await this.saveUser(admin); + this.logger.info('Storage not initialized or different project detected, proceeding with initialization'); + + await this.initializeGlobalPreferences(); + await this.initializeAdminUser(); + await this.initializeApiKey(); + + this.logger.info('Storage initialization completed successfully'); } catch (error) { - this.handleError(error, 'Error initializing default preferences'); - throw internalError('Failed to initialize global preferences'); + this.handleError(error, 'Error initializing storage with default data'); + throw internalError('Failed to initialize storage'); } } + // ========================================== + // GLOBAL PREFERENCES DOMAIN LOGIC + // ========================================== + async getGlobalPreferences(): Promise { const redisKey = RedisKeyName.GLOBAL_PREFERENCES; const storageKey = this.keyBuilder.buildGlobalPreferencesKey(); @@ -162,7 +151,6 @@ export class MeetStorageService< // Build and save default preferences if not found in cache or storage await this.initializeGlobalPreferences(); - return this.getDefaultPreferences(); } @@ -653,8 +641,158 @@ export class MeetStorageService< // PRIVATE HELPER METHODS // ========================================== + /** + * Checks if storage is already initialized by verifying that global preferences exist + * and belong to the current project. + * @returns {Promise} True if storage is already initialized for this project + */ + protected async checkStorageInitialization(): Promise { + try { + const redisKey = RedisKeyName.GLOBAL_PREFERENCES; + const storageKey = this.keyBuilder.buildGlobalPreferencesKey(); + + const existing = await this.getFromCacheAndStorage(redisKey, storageKey); + + if (!existing) { + this.logger.verbose('No global preferences found, storage needs initialization'); + return false; + } + + // Check if it's from the same project + const existingProjectId = (existing as GlobalPreferences)?.projectId; + const currentProjectId = MEET_NAME_ID; + + if (existingProjectId !== currentProjectId) { + this.logger.info( + `Different project detected: existing='${existingProjectId}', current='${currentProjectId}'. Re-initialization required.` + ); + return false; + } + + this.logger.verbose(`Storage already initialized for project '${currentProjectId}'`); + return true; + } catch (error) { + this.logger.warn('Error checking storage initialization status:', error); + throw error; + } + } + + /** + * Initializes default global preferences if not already present. + */ + protected async initializeGlobalPreferences(): Promise { + const preferences = this.getDefaultPreferences(); + await this.saveGlobalPreferences(preferences); + this.logger.info('Global preferences initialized with default values'); + } + + /** + * Returns the default global preferences. + * @returns {GPrefs} + */ + protected getDefaultPreferences(): GPrefs { + return { + projectId: MEET_NAME_ID, + webhooksPreferences: { + enabled: MEET_INITIAL_WEBHOOK_ENABLED === 'true', + url: MEET_INITIAL_WEBHOOK_URL + }, + securityPreferences: { + authentication: { + authMethod: { + type: AuthType.SINGLE_USER + }, + authModeToAccessRoom: AuthMode.NONE + } + } + } as GPrefs; + } + + /** + * Initializes the default admin user + */ + protected async initializeAdminUser(): Promise { + const admin = { + username: MEET_INITIAL_ADMIN_USER, + passwordHash: await PasswordHelper.hashPassword(MEET_INITIAL_ADMIN_PASSWORD), + roles: [UserRole.ADMIN, UserRole.USER] + } as MUser; + + await this.saveUser(admin); + this.logger.info(`Admin user initialized with default credentials`); + } + + /** + * Initializes the API key if configured + */ + protected async initializeApiKey(): Promise { + // Check if initial API key is configured + const initialApiKey = process.env.MEET_INITIAL_API_KEY; + + if (!initialApiKey) { + this.logger.verbose('No initial API key configured, skipping API key initialization'); + return; + } + + const apiKeyData: MeetApiKey = PasswordHelper.generateApiKey(initialApiKey); + await this.saveApiKey(apiKeyData); + this.logger.info('API key initialized'); + } + + protected async getRecordingFileSize(key: string, recordingId: string): Promise { + const { contentLength: fileSize } = await this.storageProvider.getObjectHeaders(key); + + if (!fileSize) { + this.logger.warn(`Recording media not found for recording ${recordingId}`); + throw errorRecordingNotFound(recordingId); + } + + return fileSize; + } + + protected validateAndAdjustRange( + range: { end: number; start: number } | undefined, + fileSize: number, + recordingId: string + ): { start: number; end: number } | undefined { + if (!range) return undefined; + + const { start, end: originalEnd } = range; + + // Validate input values + if (isNaN(start) || isNaN(originalEnd) || start < 0) { + this.logger.warn(`Invalid range values for recording ${recordingId}: start=${start}, end=${originalEnd}`); + this.logger.warn(`Returning full stream for recording ${recordingId}`); + return undefined; + } + + // Check if start is beyond file size + if (start >= fileSize) { + this.logger.error( + `Invalid range: start=${start} exceeds fileSize=${fileSize} for recording ${recordingId}` + ); + throw errorRecordingRangeNotSatisfiable(recordingId, fileSize); + } + + // Adjust end to not exceed file bounds + const adjustedEnd = Math.min(originalEnd, fileSize - 1); + + // Validate final range + if (start > adjustedEnd) { + this.logger.warn( + `Invalid range after adjustment: start=${start}, end=${adjustedEnd} for recording ${recordingId}` + ); + return undefined; + } + + this.logger.debug( + `Valid range for recording ${recordingId}: start=${start}, end=${adjustedEnd}, fileSize=${fileSize}` + ); + return { start, end: adjustedEnd }; + } + // ========================================== - // HYBRID CACHE METHODS (Redis + Storage) + // PRIVATE HYBRID CACHE METHODS (Redis + Storage) // ========================================== /** @@ -877,80 +1015,6 @@ export class MeetStorageService< } } - /** - * Returns the default global preferences. - * @returns {GPrefs} - */ - protected getDefaultPreferences(): GPrefs { - return { - projectId: MEET_NAME_ID, - webhooksPreferences: { - enabled: MEET_INITIAL_WEBHOOK_ENABLED === 'true', - url: MEET_INITIAL_WEBHOOK_URL - }, - securityPreferences: { - authentication: { - authMethod: { - type: AuthType.SINGLE_USER - }, - authModeToAccessRoom: AuthMode.NONE - } - } - } as GPrefs; - } - - protected async getRecordingFileSize(key: string, recordingId: string): Promise { - const { contentLength: fileSize } = await this.storageProvider.getObjectHeaders(key); - - if (!fileSize) { - this.logger.warn(`Recording media not found for recording ${recordingId}`); - throw errorRecordingNotFound(recordingId); - } - - return fileSize; - } - - protected validateAndAdjustRange( - range: { end: number; start: number } | undefined, - fileSize: number, - recordingId: string - ): { start: number; end: number } | undefined { - if (!range) return undefined; - - const { start, end: originalEnd } = range; - - // Validate input values - if (isNaN(start) || isNaN(originalEnd) || start < 0) { - this.logger.warn(`Invalid range values for recording ${recordingId}: start=${start}, end=${originalEnd}`); - this.logger.warn(`Returning full stream for recording ${recordingId}`); - return undefined; - } - - // Check if start is beyond file size - if (start >= fileSize) { - this.logger.error( - `Invalid range: start=${start} exceeds fileSize=${fileSize} for recording ${recordingId}` - ); - throw errorRecordingRangeNotSatisfiable(recordingId, fileSize); - } - - // Adjust end to not exceed file bounds - const adjustedEnd = Math.min(originalEnd, fileSize - 1); - - // Validate final range - if (start > adjustedEnd) { - this.logger.warn( - `Invalid range after adjustment: start=${start}, end=${adjustedEnd} for recording ${recordingId}` - ); - return undefined; - } - - this.logger.debug( - `Valid range for recording ${recordingId}: start=${start}, end=${adjustedEnd}, fileSize=${fileSize}` - ); - return { start, end: adjustedEnd }; - } - protected handleError(error: unknown, context: string): void { if (error instanceof OpenViduMeetError) { this.logger.error(`${context}: ${error.message}`);