backend: streamline storage initialization process and enhance API key handling

This commit is contained in:
juancarmore 2025-08-27 17:42:12 +02:00
parent 981c7e0d96
commit 8f7462c39a
10 changed files with 205 additions and 142 deletions

View File

@ -1,5 +1,4 @@
USE_HTTPS=false USE_HTTPS=false
MEET_LOG_LEVEL=debug MEET_LOG_LEVEL=debug
SERVER_CORS_ORIGIN=* SERVER_CORS_ORIGIN=*
MEET_INITIAL_API_KEY=meet-api-key MEET_INITIAL_API_KEY=meet-api-key
MEET_INITIAL_WEBHOOK_ENABLED=false

View File

@ -1,5 +1,4 @@
USE_HTTPS=false USE_HTTPS=false
MEET_LOG_LEVEL=verbose MEET_LOG_LEVEL=verbose
SERVER_CORS_ORIGIN=* SERVER_CORS_ORIGIN=*
MEET_INITIAL_API_KEY=meet-api-key MEET_INITIAL_API_KEY=meet-api-key
MEET_INITIAL_WEBHOOK_ENABLED=false

View File

@ -97,6 +97,6 @@ export const initializeEagerServices = async () => {
const storageService = container.get(MeetStorageService); const storageService = container.get(MeetStorageService);
await storageService.checkStartupHealth(); await storageService.checkStartupHealth();
// Initialize global preferences after health checks pass // Initialize storage after health checks pass
await storageService.initializeGlobalPreferences(); await storageService.initializeStorage();
}; };

View File

@ -42,7 +42,7 @@ export const {
* - To change them after the initial start, use the OpenVidu Meet API instead of modifying these environment variables. * - 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_ENABLED = 'false',
MEET_INITIAL_WEBHOOK_URL = 'http://localhost:5080/webhook', MEET_INITIAL_WEBHOOK_URL = '',
// LiveKit configuration // LiveKit configuration
LIVEKIT_URL = 'ws://localhost:7880', LIVEKIT_URL = 'ws://localhost:7880',

View File

@ -14,7 +14,8 @@ export class PasswordHelper {
} }
// Generate a secure API key using uid with a length of 32 characters // Generate a secure API key using uid with a length of 32 characters
static generateApiKey(): MeetApiKey { // If a key is provided, it will be used instead of generating a new one
return { key: `ovmeet-${uid(32)}`, creationDate: new Date().getTime() }; static generateApiKey(key?: string): MeetApiKey {
return { key: key || `ovmeet-${uid(32)}`, creationDate: new Date().getTime() };
} }
} }

View File

@ -34,8 +34,8 @@ export class MeetLock {
return `${RedisLockPrefix.BASE}${RedisLockName.ROOM_GARBAGE_COLLECTOR}`; return `${RedisLockPrefix.BASE}${RedisLockName.ROOM_GARBAGE_COLLECTOR}`;
} }
static getGlobalPreferencesLock(): string { static getStorageInitializationLock(): string {
return `${RedisLockPrefix.BASE}${RedisLockName.GLOBAL_PREFERENCES}`; return `${RedisLockPrefix.BASE}${RedisLockName.STORAGE_INITIALIZATION}`;
} }
static getWebhookLock(webhookEvent: WebhookEvent) { static getWebhookLock(webhookEvent: WebhookEvent) {

View File

@ -26,6 +26,6 @@ export const enum RedisLockName {
ROOM_GARBAGE_COLLECTOR = 'room_garbage_collector', ROOM_GARBAGE_COLLECTOR = 'room_garbage_collector',
RECORDING_ACTIVE = 'recording_active', RECORDING_ACTIVE = 'recording_active',
SCHEDULED_TASK = 'scheduled_task', SCHEDULED_TASK = 'scheduled_task',
GLOBAL_PREFERENCES = 'global_preferences', STORAGE_INITIALIZATION = 'storage_initialization',
WEBHOOK = 'webhook' WEBHOOK = 'webhook'
} }

View File

@ -1,6 +1,5 @@
import { User } from '@typings-ce'; import { MeetApiKey, User } from '@typings-ce';
import { inject, injectable } from 'inversify'; import { inject, injectable } from 'inversify';
import { MEET_INITIAL_API_KEY } from '../environment.js';
import { PasswordHelper } from '../helpers/index.js'; import { PasswordHelper } from '../helpers/index.js';
import { errorApiKeyNotConfigured } from '../models/error.model.js'; import { errorApiKeyNotConfigured } from '../models/error.model.js';
import { MeetStorageService, UserService } from './index.js'; import { MeetStorageService, UserService } from './index.js';
@ -22,24 +21,23 @@ export class AuthService {
return user; return user;
} }
async createApiKey() { async createApiKey(): Promise<MeetApiKey> {
const apiKey = PasswordHelper.generateApiKey(); const apiKey = PasswordHelper.generateApiKey();
await this.storageService.saveApiKey(apiKey); await this.storageService.saveApiKey(apiKey);
return apiKey; return apiKey;
} }
async getApiKeys() { async getApiKeys(): Promise<MeetApiKey[]> {
const apiKeys = await this.storageService.getApiKeys(); const apiKeys = await this.storageService.getApiKeys();
return apiKeys; return apiKeys;
} }
async deleteApiKeys() { async deleteApiKeys() {
await this.storageService.deleteApiKeys(); await this.storageService.deleteApiKeys();
return { message: 'API keys deleted successfully' };
} }
async validateApiKey(apiKey: string): Promise<boolean> { async validateApiKey(apiKey: string): Promise<boolean> {
let storedApiKeys: { key: string; creationDate: number }[]; let storedApiKeys: MeetApiKey[];
try { try {
storedApiKeys = await this.getApiKeys(); storedApiKeys = await this.getApiKeys();
@ -48,11 +46,11 @@ export class AuthService {
storedApiKeys = []; storedApiKeys = [];
} }
if (storedApiKeys.length === 0 && !MEET_INITIAL_API_KEY) { if (storedApiKeys.length === 0) {
throw errorApiKeyNotConfigured(); throw errorApiKeyNotConfigured();
} }
// Check if the provided API key matches any stored API key or the MEET_API_KEY // Check if the provided API key matches any stored API key
return storedApiKeys.some((key) => key.key === apiKey) || apiKey === MEET_INITIAL_API_KEY; return storedApiKeys.some((key) => key.key === apiKey);
} }
} }

View File

@ -1,4 +1,5 @@
import { import {
MeetApiKey,
MeetRecordingInfo, MeetRecordingInfo,
MeetRoom, MeetRoom,
MeetWebhookEvent, MeetWebhookEvent,
@ -8,9 +9,8 @@ import {
} from '@typings-ce'; } from '@typings-ce';
import crypto from 'crypto'; import crypto from 'crypto';
import { inject, injectable } from 'inversify'; 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 { errorWebhookUrlUnreachable } from '../models/error.model.js';
import { AuthService, LoggerService, MeetStorageService } from './index.js';
@injectable() @injectable()
export class OpenViduWebhookService { export class OpenViduWebhookService {
@ -218,14 +218,16 @@ export class OpenViduWebhookService {
} }
protected async getApiKey(): Promise<string> { protected async getApiKey(): Promise<string> {
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 (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.'); throw new Error('There are no API keys configured yet. Please, create one to use webhooks.');
} }

View File

@ -67,6 +67,10 @@ export class MeetStorageService<
this.keyBuilder = keyBuilder; this.keyBuilder = keyBuilder;
} }
// ==========================================
// INITIALIZATION LOGIC
// ==========================================
/** /**
* Performs a health check on the storage system. * Performs a health check on the storage system.
* Verifies both service connectivity and container/bucket existence. * 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. * Initializes the storage with default data and initial environment variables if not already initialized.
* @returns {Promise<GPrefs>} Default global preferences. * This includes global preferences, admin user and API key.
*/ */
async initializeGlobalPreferences(): Promise<void> { async initializeStorage(): Promise<void> {
try { try {
// Acquire a global lock to prevent multiple initializations at the same time when running in HA mode // 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) { if (!lock) {
this.logger.warn( 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; return;
} }
this.logger.verbose('Initializing global preferences with default values'); const isInitialized = await this.checkStorageInitialization();
const redisKey = RedisKeyName.GLOBAL_PREFERENCES;
const storageKey = this.keyBuilder.buildGlobalPreferencesKey();
const preferences = this.getDefaultPreferences(); if (isInitialized) {
this.logger.verbose('Initializing global preferences with default values'); this.logger.verbose('Storage already initialized for this project, skipping initialization');
const existing = await this.getFromCacheAndStorage<GPrefs>(redisKey, storageKey); return;
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);
}
} }
// Save the default admin user this.logger.info('Storage not initialized or different project detected, proceeding with initialization');
const admin = {
username: MEET_INITIAL_ADMIN_USER, await this.initializeGlobalPreferences();
passwordHash: await PasswordHelper.hashPassword(MEET_INITIAL_ADMIN_PASSWORD), await this.initializeAdminUser();
roles: [UserRole.ADMIN, UserRole.USER] await this.initializeApiKey();
} as MUser;
await this.saveUser(admin); this.logger.info('Storage initialization completed successfully');
} catch (error) { } catch (error) {
this.handleError(error, 'Error initializing default preferences'); this.handleError(error, 'Error initializing storage with default data');
throw internalError('Failed to initialize global preferences'); throw internalError('Failed to initialize storage');
} }
} }
// ==========================================
// GLOBAL PREFERENCES DOMAIN LOGIC
// ==========================================
async getGlobalPreferences(): Promise<GPrefs> { async getGlobalPreferences(): Promise<GPrefs> {
const redisKey = RedisKeyName.GLOBAL_PREFERENCES; const redisKey = RedisKeyName.GLOBAL_PREFERENCES;
const storageKey = this.keyBuilder.buildGlobalPreferencesKey(); const storageKey = this.keyBuilder.buildGlobalPreferencesKey();
@ -162,7 +151,6 @@ export class MeetStorageService<
// Build and save default preferences if not found in cache or storage // Build and save default preferences if not found in cache or storage
await this.initializeGlobalPreferences(); await this.initializeGlobalPreferences();
return this.getDefaultPreferences(); return this.getDefaultPreferences();
} }
@ -653,8 +641,158 @@ export class MeetStorageService<
// PRIVATE HELPER METHODS // PRIVATE HELPER METHODS
// ========================================== // ==========================================
/**
* Checks if storage is already initialized by verifying that global preferences exist
* and belong to the current project.
* @returns {Promise<boolean>} True if storage is already initialized for this project
*/
protected async checkStorageInitialization(): Promise<boolean> {
try {
const redisKey = RedisKeyName.GLOBAL_PREFERENCES;
const storageKey = this.keyBuilder.buildGlobalPreferencesKey();
const existing = await this.getFromCacheAndStorage<GPrefs>(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<void> {
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<void> {
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<void> {
// 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<number> {
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<number> {
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 { protected handleError(error: unknown, context: string): void {
if (error instanceof OpenViduMeetError) { if (error instanceof OpenViduMeetError) {
this.logger.error(`${context}: ${error.message}`); this.logger.error(`${context}: ${error.message}`);