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

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

View File

@ -2,4 +2,3 @@ USE_HTTPS=false
MEET_LOG_LEVEL=verbose
SERVER_CORS_ORIGIN=*
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);
await storageService.checkStartupHealth();
// Initialize global preferences after health checks pass
await storageService.initializeGlobalPreferences();
// Initialize storage after health checks pass
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.
*/
MEET_INITIAL_WEBHOOK_ENABLED = 'false',
MEET_INITIAL_WEBHOOK_URL = 'http://localhost:5080/webhook',
MEET_INITIAL_WEBHOOK_URL = '',
// LiveKit configuration
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
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() };
}
}

View File

@ -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) {

View File

@ -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'
}

View File

@ -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<MeetApiKey> {
const apiKey = PasswordHelper.generateApiKey();
await this.storageService.saveApiKey(apiKey);
return apiKey;
}
async getApiKeys() {
async getApiKeys(): Promise<MeetApiKey[]> {
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<boolean> {
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);
}
}

View File

@ -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<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 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.');
}

View File

@ -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<GPrefs>} 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<void> {
async initializeStorage(): Promise<void> {
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<GPrefs>(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<GPrefs> {
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<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 {
if (error instanceof OpenViduMeetError) {
this.logger.error(`${context}: ${error.message}`);