backend: streamline storage initialization process and enhance API key handling
This commit is contained in:
parent
981c7e0d96
commit
8f7462c39a
@ -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
|
||||
@ -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
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user