diff --git a/meet-ce/backend/src/helpers/ov-components-adapter.helper.ts b/meet-ce/backend/src/helpers/ov-components-adapter.helper.ts index 89e9a311..8325a537 100644 --- a/meet-ce/backend/src/helpers/ov-components-adapter.helper.ts +++ b/meet-ce/backend/src/helpers/ov-components-adapter.helper.ts @@ -1,37 +1,6 @@ import { MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings'; import { SendDataOptions } from 'livekit-server-sdk'; - -const enum OpenViduComponentsDataTopic { - CHAT = 'chat', - RECORDING_STARTING = 'recordingStarting', - RECORDING_STARTED = 'recordingStarted', - RECORDING_STOPPING = 'recordingStopping', - RECORDING_STOPPED = 'recordingStopped', - RECORDING_DELETED = 'recordingDeleted', - RECORDING_FAILED = 'recordingFailed', - ROOM_STATUS = 'roomStatus' -} - -interface RecordingSignalPayload { - id: string; - roomName: string; - roomId: string; - status: string; - filename?: string; - startedAt?: number; - endedAt?: number; - duration?: number; - size?: number; - location?: string; - error?: string; -} - -interface RoomStatusSignalPayload { - isRecordingStarted: boolean; - recordingList: RecordingSignalPayload[]; -} - -export type OpenViduComponentsSignalPayload = RecordingSignalPayload | RoomStatusSignalPayload; +import { OpenViduComponentsDataTopic, RecordingSignalPayload, RoomStatusSignalPayload } from '../models/index.js'; export class OpenViduComponentsAdapterHelper { private constructor() { diff --git a/meet-ce/backend/src/helpers/room.helper.ts b/meet-ce/backend/src/helpers/room.helper.ts index 27fb9764..ea482d96 100644 --- a/meet-ce/backend/src/helpers/room.helper.ts +++ b/meet-ce/backend/src/helpers/room.helper.ts @@ -6,6 +6,28 @@ export class MeetRoomHelper { // Prevent instantiation of this utility class } + /** + * Sanitizes a room name by normalizing format. + * + * @param val The string to sanitize + * @returns A sanitized string safe for use as a room name + */ + static sanitizeRoomName(val: string): string { + return val + .trim() // Remove leading/trailing spaces + .replace(/\s+/g, ' '); // Replace multiple consecutive spaces with a single space + } + + /** + * Sanitizes an identifier by removing invalid characters + * + * @param val The string to sanitize + * @returns A sanitized string safe for use as an identifier + */ + static sanitizeRoomId(val: string): string { + return val.replace(/[^a-zA-Z0-9_-]/g, ''); // Allow only letters, numbers, hyphens and underscores + } + /** * Creates a sanitized room ID prefix from the given room name. * diff --git a/meet-ce/backend/src/middlewares/request-validators/auth-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/auth-validator.middleware.ts index 5dcaceb0..e647da89 100644 --- a/meet-ce/backend/src/middlewares/request-validators/auth-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/auth-validator.middleware.ts @@ -1,11 +1,6 @@ import { NextFunction, Request, Response } from 'express'; -import { z } from 'zod'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; - -const LoginRequestSchema = z.object({ - username: z.string().min(4, 'Username must be at least 4 characters long'), - password: z.string().min(4, 'Password must be at least 4 characters long') -}); +import { LoginRequestSchema } from '../../models/zod-schemas/index.js'; export const validateLoginRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = LoginRequestSchema.safeParse(req.body); diff --git a/meet-ce/backend/src/middlewares/request-validators/config-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/config-validator.middleware.ts index 915b15d0..99c9e0d9 100644 --- a/meet-ce/backend/src/middlewares/request-validators/config-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/config-validator.middleware.ts @@ -1,66 +1,11 @@ -import { - AuthenticationConfig, - AuthMode, - AuthType, - SecurityConfig, - SingleUserAuth, - ValidAuthMethod, - WebhookConfig -} from '@openvidu-meet/typings'; import { NextFunction, Request, Response } from 'express'; -import { z } from 'zod'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; -import { AppearanceConfigSchema } from './room-validator.middleware.js'; - -const WebhookConfigSchema: z.ZodType = z - .object({ - enabled: z.boolean(), - url: z - .string() - .url('Must be a valid URL') - .regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' }) - .optional() - }) - .refine( - (data) => { - // If webhooks are enabled, URL must be provided - return !data.enabled || Boolean(data.url); - }, - { - message: 'URL is required when webhooks are enabled', - path: ['url'] - } - ); - -const WebhookTestSchema = z.object({ - url: z - .string() - .url('Must be a valid URL') - .regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' }) -}); - -const AuthModeSchema: z.ZodType = z.nativeEnum(AuthMode); - -const AuthTypeSchema: z.ZodType = z.nativeEnum(AuthType); - -const SingleUserAuthSchema: z.ZodType = z.object({ - type: AuthTypeSchema -}); - -const ValidAuthMethodSchema: z.ZodType = SingleUserAuthSchema; - -const AuthenticationConfigSchema: z.ZodType = z.object({ - authMethod: ValidAuthMethodSchema, - authModeToAccessRoom: AuthModeSchema -}); - -const SecurityConfigSchema: z.ZodType = z.object({ - authentication: AuthenticationConfigSchema -}); - -const RoomsAppearanceConfigSchema = z.object({ - appearance: AppearanceConfigSchema -}); +import { + RoomsAppearanceConfigSchema, + SecurityConfigSchema, + WebhookConfigSchema, + WebhookTestSchema +} from '../../models/zod-schemas/index.js'; export const validateWebhookConfig = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = WebhookConfigSchema.safeParse(req.body); diff --git a/meet-ce/backend/src/middlewares/request-validators/participant-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/participant-validator.middleware.ts index a9d5323b..fd8ad17b 100644 --- a/meet-ce/backend/src/middlewares/request-validators/participant-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/participant-validator.middleware.ts @@ -1,25 +1,7 @@ -import { MeetPermissions, MeetRoomMemberRole, MeetRoomMemberTokenMetadata } from '@openvidu-meet/typings'; +import { MeetRoomMemberTokenMetadata } from '@openvidu-meet/typings'; import { NextFunction, Request, Response } from 'express'; -import { z } from 'zod'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; - -const UpdateParticipantRequestSchema = z.object({ - role: z.nativeEnum(MeetRoomMemberRole) -}); - -const MeetPermissionsSchema: z.ZodType = z.object({ - canRecord: z.boolean(), - canRetrieveRecordings: z.boolean(), - canDeleteRecordings: z.boolean(), - canChat: z.boolean(), - canChangeVirtualBackground: z.boolean() -}); - -const RoomMemberTokenMetadataSchema: z.ZodType = z.object({ - livekitUrl: z.string().url('LiveKit URL must be a valid URL'), - role: z.nativeEnum(MeetRoomMemberRole), - permissions: MeetPermissionsSchema -}); +import { RoomMemberTokenMetadataSchema, UpdateParticipantRequestSchema } from '../../models/zod-schemas/index.js'; export const validateUpdateParticipantRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = UpdateParticipantRequestSchema.safeParse(req.body); diff --git a/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts index 34a6515a..d185c56e 100644 --- a/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts @@ -1,141 +1,14 @@ -import { MeetRecordingFilters } from '@openvidu-meet/typings'; import { NextFunction, Request, Response } from 'express'; -import { z } from 'zod'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; -import { nonEmptySanitizedRoomId } from './room-validator.middleware.js'; - -const nonEmptySanitizedRecordingId = (fieldName: string) => - z - .string() - .min(1, { message: `${fieldName} is required and cannot be empty` }) - .transform((val) => { - const sanitizedValue = val.trim(); - - // Verify the format of the recording ID - // The recording ID should be in the format 'roomId--EG_xxx--uid' - const parts = sanitizedValue.split('--'); - - // If the recording ID is not in the expected format, return the sanitized value - // The next validation will check if the format is correct - if (parts.length !== 3) return sanitizedValue; - - // If the recording ID is in the expected format, sanitize the roomId part - const { success, data } = nonEmptySanitizedRoomId('roomId').safeParse(parts[0]); - - if (!success) { - // If the roomId part is not valid, return the sanitized value - return sanitizedValue; - } - - return `${data}--${parts[1]}--${parts[2]}`; - }) - .refine((data) => data !== '', { - message: `${fieldName} cannot be empty after sanitization` - }) - .refine( - (data) => { - const parts = data.split('--'); - - if (parts.length !== 3) return false; - - if (parts[0].length === 0) return false; - - if (!parts[1].startsWith('EG_') || parts[1].length <= 3) return false; - - if (parts[2].length === 0) return false; - - return true; - }, - { - message: `${fieldName} does not follow the expected format` - } - ); - -const StartRecordingRequestSchema = z.object({ - roomId: nonEmptySanitizedRoomId('roomId') -}); - -const GetRecordingSchema = z.object({ - params: z.object({ - recordingId: nonEmptySanitizedRecordingId('recordingId') - }), - query: z.object({ - secret: z.string().optional() - }) -}); - -const MultipleRecordingIdsSchema = z.object({ - recordingIds: z.preprocess( - (arg) => { - if (typeof arg === 'string') { - // If the argument is a string, it is expected to be a comma-separated list of recording IDs. - return arg - .split(',') - .map((s) => s.trim()) - .filter((s) => s !== ''); - } - - return []; - }, - z - .array(nonEmptySanitizedRecordingId('recordingId')) - .nonempty({ message: 'recordingIds must contain at least one item' }) - ) -}); - -const GetRecordingMediaSchema = z.object({ - params: z.object({ - recordingId: nonEmptySanitizedRecordingId('recordingId') - }), - query: z.object({ - secret: z.string().optional() - }), - headers: z - .object({ - range: z - .string() - .regex(/^bytes=\d+-\d*$/, { - message: 'Invalid range header format. Expected: bytes=start-end' - }) - .optional() - }) - .passthrough() // Allow other headers to pass through -}); - -const GetRecordingsFiltersSchema: z.ZodType = z.object({ - maxItems: z.coerce - .number() - .positive('maxItems must be a positive number') - .transform((val) => { - // Convert the value to a number - const intVal = Math.floor(val); - // Ensure it's not greater than 100 - return intVal > 100 ? 100 : intVal; - }) - .default(10), - // status: z.string().optional(), - roomId: nonEmptySanitizedRoomId('roomId').optional(), - roomName: z.string().optional(), - nextPageToken: z.string().optional(), - fields: z.string().optional() -}); - -const GetRecordingUrlSchema = z.object({ - params: z.object({ - recordingId: nonEmptySanitizedRecordingId('recordingId') - }), - query: z.object({ - privateAccess: z - .preprocess((val) => { - if (typeof val === 'string') { - return val.toLowerCase() === 'true'; - } - - return val; - }, z.boolean()) - .default(false) - }) -}); +import { + GetRecordingMediaSchema, + GetRecordingSchema, + GetRecordingsFiltersSchema, + GetRecordingUrlSchema, + MultipleRecordingIdsSchema, + nonEmptySanitizedRecordingId, + StartRecordingRequestSchema +} from '../../models/zod-schemas/index.js'; export const withValidStartRecordingRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = StartRecordingRequestSchema.safeParse(req.body); diff --git a/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts index 3629bbeb..a00efd28 100644 --- a/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/room-validator.middleware.ts @@ -1,293 +1,15 @@ -import { - MeetAppearanceConfig, - MeetChatConfig, - MeetE2EEConfig, - MeetRecordingAccess, - MeetRecordingConfig, - MeetRoomAutoDeletionPolicy, - MeetRoomConfig, - MeetRoomDeletionPolicyWithMeeting, - MeetRoomDeletionPolicyWithRecordings, - MeetRoomFilters, - MeetRoomMemberTokenOptions, - MeetRoomOptions, - MeetRoomStatus, - MeetRoomTheme, - MeetRoomThemeMode, - MeetVirtualBackgroundConfig -} from '@openvidu-meet/typings'; import { NextFunction, Request, Response } from 'express'; -import ms from 'ms'; -import { z } from 'zod'; -import { INTERNAL_CONFIG } from '../../config/internal-config.js'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; - -/** - * Sanitizes a room name by normalizing format. - * - * @param val The string to sanitize - * @returns A sanitized string safe for use as a room name - */ -const sanitizeRoomName = (val: string): string => { - return val - .trim() // Remove leading/trailing spaces - .replace(/\s+/g, ' '); // Replace multiple consecutive spaces with a single space -}; - -/** - * Sanitizes an identifier by removing invalid characters - * - * @param val The string to sanitize - * @returns A sanitized string safe for use as an identifier - */ -export const sanitizeRoomId = (val: string): string => { - return val.replace(/[^a-zA-Z0-9_-]/g, ''); // Allow only letters, numbers, hyphens and underscores -}; - -export const nonEmptySanitizedRoomId = (fieldName: string) => - z - .string() - .min(1, { message: `${fieldName} is required and cannot be empty` }) - .max(100, { message: `${fieldName} cannot exceed 100 characters` }) - .transform(sanitizeRoomId) - .refine((data) => data !== '', { - message: `${fieldName} cannot be empty after sanitization` - }); - -const RecordingAccessSchema: z.ZodType = z.nativeEnum(MeetRecordingAccess); - -const RecordingConfigSchema: z.ZodType = z - .object({ - enabled: z.boolean(), - allowAccessTo: RecordingAccessSchema.optional() - }) - .refine( - (data) => { - // If recording is enabled, allowAccessTo must be provided - return !data.enabled || data.allowAccessTo !== undefined; - }, - { - message: 'allowAccessTo is required when recording is enabled', - path: ['allowAccessTo'] - } - ); - -const ChatConfigSchema: z.ZodType = z.object({ - enabled: z.boolean() -}); - -const VirtualBackgroundConfigSchema: z.ZodType = z.object({ - enabled: z.boolean() -}); - -const E2EEConfigSchema: z.ZodType = z.object({ - enabled: z.boolean() -}); - -const ThemeModeSchema: z.ZodType = z.nativeEnum(MeetRoomThemeMode); - -const hexColorSchema = z - .string() - .regex( - /^#([0-9A-Fa-f]{8}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{3})$/, - 'Must be a valid hex color code (with or without alpha)' - ); - -const RoomThemeSchema: z.ZodType = z.object({ - name: z - .string() - .min(1, 'Theme name cannot be empty') - .max(50, 'Theme name cannot exceed 50 characters') - .regex(/^[a-zA-Z0-9_-]+$/, 'Theme name can only contain letters, numbers, hyphens and underscores'), - enabled: z.boolean(), - baseTheme: ThemeModeSchema, - backgroundColor: hexColorSchema.optional(), - primaryColor: hexColorSchema.optional(), - secondaryColor: hexColorSchema.optional(), - accentColor: hexColorSchema.optional(), - surfaceColor: hexColorSchema.optional() -}); - -export const AppearanceConfigSchema: z.ZodType = z.object({ - themes: z.array(RoomThemeSchema).length(1, 'There must be exactly one theme defined') -}); - -const RoomConfigSchema: z.ZodType> = z - .object({ - recording: RecordingConfigSchema.optional().default({ - enabled: true, - allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER - }), - chat: ChatConfigSchema.optional().default({ enabled: true }), - virtualBackground: VirtualBackgroundConfigSchema.optional().default({ enabled: true }), - e2ee: E2EEConfigSchema.optional().default({ enabled: false }) - // appearance: AppearanceConfigSchema, - }) - .transform((data) => { - // Automatically disable recording when E2EE is enabled - if (data.e2ee.enabled && data.recording.enabled) { - return { - ...data, - recording: { - ...data.recording, - enabled: false - } - }; - } - - return data; - }); - -const RoomDeletionPolicyWithMeetingSchema: z.ZodType = z.nativeEnum( - MeetRoomDeletionPolicyWithMeeting -); - -const RoomDeletionPolicyWithRecordingsSchema: z.ZodType = z.nativeEnum( - MeetRoomDeletionPolicyWithRecordings -); - -const RoomAutoDeletionPolicySchema: z.ZodType = z.object({ - withMeeting: RoomDeletionPolicyWithMeetingSchema, - withRecordings: RoomDeletionPolicyWithRecordingsSchema -}); - -const RoomRequestOptionsSchema: z.ZodType = z.object({ - roomName: z - .string() - .max(50, 'roomName cannot exceed 50 characters') - .transform(sanitizeRoomName) - .optional() - .default('Room'), - autoDeletionDate: z - .number() - .positive('autoDeletionDate must be a positive integer') - .refine( - (date) => date >= Date.now() + ms(INTERNAL_CONFIG.MIN_FUTURE_TIME_FOR_ROOM_AUTODELETION_DATE), - `autoDeletionDate must be at least ${INTERNAL_CONFIG.MIN_FUTURE_TIME_FOR_ROOM_AUTODELETION_DATE} in the future` - ) - .optional(), - autoDeletionPolicy: RoomAutoDeletionPolicySchema.optional() - .default({ - withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS, - withRecordings: MeetRoomDeletionPolicyWithRecordings.CLOSE - }) - .refine( - (policy) => { - return !policy || policy.withMeeting !== MeetRoomDeletionPolicyWithMeeting.FAIL; - }, - { - message: 'FAIL policy is not allowed for withMeeting auto-deletion policy', - path: ['withMeeting'] - } - ) - .refine( - (policy) => { - return !policy || policy.withRecordings !== MeetRoomDeletionPolicyWithRecordings.FAIL; - }, - { - message: 'FAIL policy is not allowed for withRecordings auto-deletion policy', - path: ['withRecordings'] - } - ), - config: RoomConfigSchema.optional().default({ - recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, - chat: { enabled: true }, - virtualBackground: { enabled: true }, - e2ee: { enabled: false } - }) - // maxParticipants: z - // .number() - // .positive('Max participants must be a positive integer') - // .nullable() - // .optional() - // .default(null) -}); - -const GetRoomFiltersSchema: z.ZodType = z.object({ - maxItems: z.coerce - .number() - .positive('maxItems must be a positive number') - .transform((val) => { - // Convert the value to a number - const intVal = Math.floor(val); - // Ensure it's not greater than 100 - return intVal > 100 ? 100 : intVal; - }) - .default(10), - nextPageToken: z.string().optional(), - roomName: z.string().optional(), - fields: z.string().optional() -}); - -const DeleteRoomQueryParamsSchema = z.object({ - withMeeting: RoomDeletionPolicyWithMeetingSchema.optional().default(MeetRoomDeletionPolicyWithMeeting.FAIL), - withRecordings: RoomDeletionPolicyWithRecordingsSchema.optional().default(MeetRoomDeletionPolicyWithRecordings.FAIL) -}); - -const BulkDeleteRoomsSchema = z.object({ - roomIds: z.preprocess( - (arg) => { - // First, convert input to array of strings - let roomIds: string[] = []; - - if (typeof arg === 'string') { - roomIds = arg - .split(',') - .map((s) => s.trim()) - .filter((s) => s !== ''); - } else if (Array.isArray(arg)) { - roomIds = arg.map((item) => String(item)).filter((s) => s !== ''); - } - - // Apply sanitization BEFORE validation and deduplicate - // This prevents identical IDs from being processed separately - const sanitizedIds = new Set(); - - // Pre-sanitize to check for duplicates that would become identical - for (const id of roomIds) { - const transformed = sanitizeRoomId(id); - - // Only add non-empty IDs - if (transformed !== '') { - sanitizedIds.add(transformed); - } - } - - return Array.from(sanitizedIds); - }, - z.array(z.string()).min(1, { - message: 'At least one valid roomId is required after sanitization' - }) - ), - withMeeting: RoomDeletionPolicyWithMeetingSchema.optional().default(MeetRoomDeletionPolicyWithMeeting.FAIL), - withRecordings: RoomDeletionPolicyWithRecordingsSchema.optional().default(MeetRoomDeletionPolicyWithRecordings.FAIL) -}); - -const UpdateRoomConfigSchema = z.object({ - config: RoomConfigSchema -}); - -const UpdateRoomStatusSchema = z.object({ - status: z.enum([MeetRoomStatus.OPEN, MeetRoomStatus.CLOSED]) -}); - -const RoomMemberTokenRequestSchema: z.ZodType = z - .object({ - secret: z.string().nonempty('Secret is required'), - grantJoinMeetingPermission: z.boolean().optional().default(false), - participantName: z.string().optional(), - participantIdentity: z.string().optional() - }) - .refine( - (data) => { - // If grantJoinMeetingPermission is true, participantName must be provided - return !data.grantJoinMeetingPermission || data.participantName; - }, - { - message: 'participantName is required when grantJoinMeetingPermission is true', - path: ['participantName'] - } - ); +import { + BulkDeleteRoomsSchema, + DeleteRoomQueryParamsSchema, + GetRoomFiltersSchema, + nonEmptySanitizedRoomId, + RoomMemberTokenRequestSchema, + RoomRequestOptionsSchema, + UpdateRoomConfigSchema, + UpdateRoomStatusSchema +} from '../../models/zod-schemas/index.js'; export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body); diff --git a/meet-ce/backend/src/middlewares/request-validators/user-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/user-validator.middleware.ts index 37167cb4..bdbe965c 100644 --- a/meet-ce/backend/src/middlewares/request-validators/user-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/user-validator.middleware.ts @@ -1,11 +1,6 @@ import { NextFunction, Request, Response } from 'express'; -import { z } from 'zod'; import { rejectUnprocessableRequest } from '../../models/error.model.js'; - -const ChangePasswordRequestSchema = z.object({ - currentPassword: z.string(), - newPassword: z.string().min(5, 'New password must be at least 5 characters long') -}); +import { ChangePasswordRequestSchema } from '../../models/zod-schemas/index.js'; export const validateChangePasswordRequest = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = ChangePasswordRequestSchema.safeParse(req.body); diff --git a/meet-ce/backend/src/migrations/api-key-migrations.ts b/meet-ce/backend/src/migrations/api-key-migrations.ts index 7957583a..4d96a8fd 100644 --- a/meet-ce/backend/src/migrations/api-key-migrations.ts +++ b/meet-ce/backend/src/migrations/api-key-migrations.ts @@ -1,5 +1,5 @@ import { ISchemaMigration } from '../models/migration.model.js'; -import { MeetApiKeyDocument } from '../repositories/schemas/api-key.schema.js'; +import { MeetApiKeyDocument } from '../models/mongoose-schemas/index.js'; /** * All migrations for the MeetApiKey collection in chronological order. diff --git a/meet-ce/backend/src/migrations/global-config-migrations.ts b/meet-ce/backend/src/migrations/global-config-migrations.ts index ea5a7c6f..aec2cbdb 100644 --- a/meet-ce/backend/src/migrations/global-config-migrations.ts +++ b/meet-ce/backend/src/migrations/global-config-migrations.ts @@ -1,5 +1,5 @@ import { ISchemaMigration } from '../models/migration.model.js'; -import { MeetGlobalConfigDocument } from '../repositories/schemas/global-config.schema.js'; +import { MeetGlobalConfigDocument } from '../models/mongoose-schemas/index.js'; /** * All migrations for the MeetGlobalConfig collection in chronological order. diff --git a/meet-ce/backend/src/migrations/migration-registry.ts b/meet-ce/backend/src/migrations/migration-registry.ts index 0e43b637..f26910ba 100644 --- a/meet-ce/backend/src/migrations/migration-registry.ts +++ b/meet-ce/backend/src/migrations/migration-registry.ts @@ -1,10 +1,17 @@ import { INTERNAL_CONFIG } from '../config/internal-config.js'; import { CollectionMigrationRegistry } from '../models/migration.model.js'; -import { MeetApiKeyModel, meetApiKeyCollectionName } from '../repositories/schemas/api-key.schema.js'; -import { MeetGlobalConfigModel, meetGlobalConfigCollectionName } from '../repositories/schemas/global-config.schema.js'; -import { MeetRecordingModel, meetRecordingCollectionName } from '../repositories/schemas/recording.schema.js'; -import { MeetRoomModel, meetRoomCollectionName } from '../repositories/schemas/room.schema.js'; -import { MeetUserModel, meetUserCollectionName } from '../repositories/schemas/user.schema.js'; +import { + MeetApiKeyModel, + MeetGlobalConfigModel, + MeetRecordingModel, + MeetRoomModel, + MeetUserModel, + meetApiKeyCollectionName, + meetGlobalConfigCollectionName, + meetRecordingCollectionName, + meetRoomCollectionName, + meetUserCollectionName +} from '../models/mongoose-schemas/index.js'; import { apiKeyMigrations } from './api-key-migrations.js'; import { globalConfigMigrations } from './global-config-migrations.js'; import { recordingMigrations } from './recording-migrations.js'; diff --git a/meet-ce/backend/src/migrations/recording-migrations.ts b/meet-ce/backend/src/migrations/recording-migrations.ts index ebbde3b7..510069be 100644 --- a/meet-ce/backend/src/migrations/recording-migrations.ts +++ b/meet-ce/backend/src/migrations/recording-migrations.ts @@ -1,5 +1,5 @@ import { ISchemaMigration } from '../models/migration.model.js'; -import { MeetRecordingDocument } from '../repositories/schemas/recording.schema.js'; +import { MeetRecordingDocument } from '../models/mongoose-schemas/index.js'; /** * All migrations for the MeetRecording collection in chronological order. diff --git a/meet-ce/backend/src/migrations/room-migrations.ts b/meet-ce/backend/src/migrations/room-migrations.ts index 033556e4..20816759 100644 --- a/meet-ce/backend/src/migrations/room-migrations.ts +++ b/meet-ce/backend/src/migrations/room-migrations.ts @@ -1,5 +1,5 @@ import { ISchemaMigration } from '../models/migration.model.js'; -import { MeetRoomDocument } from '../repositories/schemas/room.schema.js'; +import { MeetRoomDocument } from '../models/mongoose-schemas/index.js'; /** * All migrations for the MeetRoom collection in chronological order. diff --git a/meet-ce/backend/src/migrations/user-migrations.ts b/meet-ce/backend/src/migrations/user-migrations.ts index cc8a150a..c508349a 100644 --- a/meet-ce/backend/src/migrations/user-migrations.ts +++ b/meet-ce/backend/src/migrations/user-migrations.ts @@ -1,5 +1,5 @@ import { ISchemaMigration } from '../models/migration.model.js'; -import { MeetUserDocument } from '../repositories/schemas/user.schema.js'; +import { MeetUserDocument } from '../models/mongoose-schemas/index.js'; /** * All migrations for the MeetUser collection in chronological order. diff --git a/meet-ce/backend/src/models/db-pagination.model.ts b/meet-ce/backend/src/models/db-pagination.model.ts new file mode 100644 index 00000000..68f93c13 --- /dev/null +++ b/meet-ce/backend/src/models/db-pagination.model.ts @@ -0,0 +1,26 @@ +/** + * Options for paginated find operations. + */ +export interface PaginatedFindOptions { + maxItems?: number; + nextPageToken?: string; + sortField?: string; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Result of a paginated find operation. + */ +export interface PaginatedResult { + items: T[]; + isTruncated: boolean; + nextPageToken?: string; +} + +/** + * Pagination cursor structure. + */ +export interface PaginationCursor { + fieldValue: unknown; + id: string; +} diff --git a/meet-ce/backend/src/models/index.ts b/meet-ce/backend/src/models/index.ts index a5f6f98c..aba956a5 100644 --- a/meet-ce/backend/src/models/index.ts +++ b/meet-ce/backend/src/models/index.ts @@ -2,3 +2,7 @@ export * from './error.model.js'; export * from './redis.model.js'; export * from './distributed-event.model.js'; export * from './migration.model.js'; +export * from './ov-components-signal.model.js'; +export * from './db-pagination.model.js'; +export * from './task-scheduler.model.js'; +export * from './request-context.model.js'; diff --git a/meet-ce/backend/src/repositories/schemas/api-key.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/api-key.schema.ts similarity index 100% rename from meet-ce/backend/src/repositories/schemas/api-key.schema.ts rename to meet-ce/backend/src/models/mongoose-schemas/api-key.schema.ts diff --git a/meet-ce/backend/src/repositories/schemas/global-config.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/global-config.schema.ts similarity index 100% rename from meet-ce/backend/src/repositories/schemas/global-config.schema.ts rename to meet-ce/backend/src/models/mongoose-schemas/global-config.schema.ts diff --git a/meet-ce/backend/src/models/mongoose-schemas/index.ts b/meet-ce/backend/src/models/mongoose-schemas/index.ts new file mode 100644 index 00000000..0125f68f --- /dev/null +++ b/meet-ce/backend/src/models/mongoose-schemas/index.ts @@ -0,0 +1,6 @@ +export * from './migration.schema.js'; +export * from './global-config.schema.js'; +export * from './api-key.schema.js'; +export * from './user.schema.js'; +export * from './room.schema.js'; +export * from './recording.schema.js'; diff --git a/meet-ce/backend/src/repositories/schemas/migration.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/migration.schema.ts similarity index 98% rename from meet-ce/backend/src/repositories/schemas/migration.schema.ts rename to meet-ce/backend/src/models/mongoose-schemas/migration.schema.ts index a92a8c7e..cd75b170 100644 --- a/meet-ce/backend/src/repositories/schemas/migration.schema.ts +++ b/meet-ce/backend/src/models/mongoose-schemas/migration.schema.ts @@ -1,5 +1,5 @@ import { Document, model, Schema } from 'mongoose'; -import { MeetMigration, MigrationName, MigrationStatus } from '../../models/index.js'; +import { MeetMigration, MigrationName, MigrationStatus } from '../index.js'; /** * Mongoose Document interface for MeetMigration. diff --git a/meet-ce/backend/src/repositories/schemas/recording.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/recording.schema.ts similarity index 100% rename from meet-ce/backend/src/repositories/schemas/recording.schema.ts rename to meet-ce/backend/src/models/mongoose-schemas/recording.schema.ts diff --git a/meet-ce/backend/src/repositories/schemas/room.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts similarity index 100% rename from meet-ce/backend/src/repositories/schemas/room.schema.ts rename to meet-ce/backend/src/models/mongoose-schemas/room.schema.ts diff --git a/meet-ce/backend/src/repositories/schemas/user.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/user.schema.ts similarity index 100% rename from meet-ce/backend/src/repositories/schemas/user.schema.ts rename to meet-ce/backend/src/models/mongoose-schemas/user.schema.ts diff --git a/meet-ce/backend/src/models/ov-components-signal.model.ts b/meet-ce/backend/src/models/ov-components-signal.model.ts new file mode 100644 index 00000000..302816d5 --- /dev/null +++ b/meet-ce/backend/src/models/ov-components-signal.model.ts @@ -0,0 +1,31 @@ +export const enum OpenViduComponentsDataTopic { + CHAT = 'chat', + RECORDING_STARTING = 'recordingStarting', + RECORDING_STARTED = 'recordingStarted', + RECORDING_STOPPING = 'recordingStopping', + RECORDING_STOPPED = 'recordingStopped', + RECORDING_DELETED = 'recordingDeleted', + RECORDING_FAILED = 'recordingFailed', + ROOM_STATUS = 'roomStatus' +} + +export interface RecordingSignalPayload { + id: string; + roomName: string; + roomId: string; + status: string; + filename?: string; + startedAt?: number; + endedAt?: number; + duration?: number; + size?: number; + location?: string; + error?: string; +} + +export interface RoomStatusSignalPayload { + isRecordingStarted: boolean; + recordingList: RecordingSignalPayload[]; +} + +export type OpenViduComponentsSignalPayload = RecordingSignalPayload | RoomStatusSignalPayload; diff --git a/meet-ce/backend/src/models/request-context.model.ts b/meet-ce/backend/src/models/request-context.model.ts new file mode 100644 index 00000000..ca07daa3 --- /dev/null +++ b/meet-ce/backend/src/models/request-context.model.ts @@ -0,0 +1,9 @@ +import { MeetRoomMemberRoleAndPermissions, MeetUser } from '@openvidu-meet/typings'; + +/** + * Context information stored per HTTP request. + */ +export interface RequestContext { + user?: MeetUser; + roomMember?: MeetRoomMemberRoleAndPermissions; +} diff --git a/meet-ce/backend/src/models/task-scheduler.model.ts b/meet-ce/backend/src/models/task-scheduler.model.ts new file mode 100644 index 00000000..8c41df25 --- /dev/null +++ b/meet-ce/backend/src/models/task-scheduler.model.ts @@ -0,0 +1,10 @@ +import { StringValue } from 'ms'; + +export type TaskType = 'cron' | 'timeout'; + +export interface IScheduledTask { + name: string; + type: TaskType; + scheduleOrDelay: StringValue; + callback: () => Promise; +} diff --git a/meet-ce/backend/src/models/zod-schemas/auth.schema.ts b/meet-ce/backend/src/models/zod-schemas/auth.schema.ts new file mode 100644 index 00000000..e22170aa --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/auth.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const LoginRequestSchema = z.object({ + username: z.string().min(4, 'Username must be at least 4 characters long'), + password: z.string().min(4, 'Password must be at least 4 characters long') +}); diff --git a/meet-ce/backend/src/models/zod-schemas/global-config.schema.ts b/meet-ce/backend/src/models/zod-schemas/global-config.schema.ts new file mode 100644 index 00000000..d1b70f94 --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/global-config.schema.ts @@ -0,0 +1,61 @@ +import { + AuthenticationConfig, + AuthMode, + AuthType, + SecurityConfig, + SingleUserAuth, + ValidAuthMethod, + WebhookConfig +} from '@openvidu-meet/typings'; +import { z } from 'zod'; +import { AppearanceConfigSchema } from './room.schema.js'; + +export const WebhookConfigSchema: z.ZodType = z + .object({ + enabled: z.boolean(), + url: z + .string() + .url('Must be a valid URL') + .regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' }) + .optional() + }) + .refine( + (data) => { + // If webhooks are enabled, URL must be provided + return !data.enabled || Boolean(data.url); + }, + { + message: 'URL is required when webhooks are enabled', + path: ['url'] + } + ); + +export const WebhookTestSchema = z.object({ + url: z + .string() + .url('Must be a valid URL') + .regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' }) +}); + +const AuthModeSchema: z.ZodType = z.nativeEnum(AuthMode); + +const AuthTypeSchema: z.ZodType = z.nativeEnum(AuthType); + +const SingleUserAuthSchema: z.ZodType = z.object({ + type: AuthTypeSchema +}); + +const ValidAuthMethodSchema: z.ZodType = SingleUserAuthSchema; + +const AuthenticationConfigSchema: z.ZodType = z.object({ + authMethod: ValidAuthMethodSchema, + authModeToAccessRoom: AuthModeSchema +}); + +export const SecurityConfigSchema: z.ZodType = z.object({ + authentication: AuthenticationConfigSchema +}); + +export const RoomsAppearanceConfigSchema = z.object({ + appearance: AppearanceConfigSchema +}); diff --git a/meet-ce/backend/src/models/zod-schemas/index.ts b/meet-ce/backend/src/models/zod-schemas/index.ts new file mode 100644 index 00000000..ebc7a7ce --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/index.ts @@ -0,0 +1,6 @@ +export * from './global-config.schema.js'; +export * from './auth.schema.js'; +export * from './user.schema.js'; +export * from './room.schema.js'; +export * from './recording.schema.js'; +export * from './meeting.schema.js'; diff --git a/meet-ce/backend/src/models/zod-schemas/meeting.schema.ts b/meet-ce/backend/src/models/zod-schemas/meeting.schema.ts new file mode 100644 index 00000000..6e828396 --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/meeting.schema.ts @@ -0,0 +1,6 @@ +import { MeetRoomMemberRole } from '@openvidu-meet/typings'; +import { z } from 'zod'; + +export const UpdateParticipantRequestSchema = z.object({ + role: z.nativeEnum(MeetRoomMemberRole) +}); diff --git a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts new file mode 100644 index 00000000..3e79c7b4 --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts @@ -0,0 +1,136 @@ +import { MeetRecordingFilters } from '@openvidu-meet/typings'; +import { z } from 'zod'; +import { nonEmptySanitizedRoomId } from './room.schema.js'; + +export const nonEmptySanitizedRecordingId = (fieldName: string) => + z + .string() + .min(1, { message: `${fieldName} is required and cannot be empty` }) + .transform((val) => { + const sanitizedValue = val.trim(); + + // Verify the format of the recording ID + // The recording ID should be in the format 'roomId--EG_xxx--uid' + const parts = sanitizedValue.split('--'); + + // If the recording ID is not in the expected format, return the sanitized value + // The next validation will check if the format is correct + if (parts.length !== 3) return sanitizedValue; + + // If the recording ID is in the expected format, sanitize the roomId part + const { success, data } = nonEmptySanitizedRoomId('roomId').safeParse(parts[0]); + + if (!success) { + // If the roomId part is not valid, return the sanitized value + return sanitizedValue; + } + + return `${data}--${parts[1]}--${parts[2]}`; + }) + .refine((data) => data !== '', { + message: `${fieldName} cannot be empty after sanitization` + }) + .refine( + (data) => { + const parts = data.split('--'); + + if (parts.length !== 3) return false; + + if (parts[0].length === 0) return false; + + if (!parts[1].startsWith('EG_') || parts[1].length <= 3) return false; + + if (parts[2].length === 0) return false; + + return true; + }, + { + message: `${fieldName} does not follow the expected format` + } + ); + +export const StartRecordingRequestSchema = z.object({ + roomId: nonEmptySanitizedRoomId('roomId') +}); + +export const GetRecordingSchema = z.object({ + params: z.object({ + recordingId: nonEmptySanitizedRecordingId('recordingId') + }), + query: z.object({ + secret: z.string().optional() + }) +}); + +export const MultipleRecordingIdsSchema = z.object({ + recordingIds: z.preprocess( + (arg) => { + if (typeof arg === 'string') { + // If the argument is a string, it is expected to be a comma-separated list of recording IDs. + return arg + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + } + + return []; + }, + z + .array(nonEmptySanitizedRecordingId('recordingId')) + .nonempty({ message: 'recordingIds must contain at least one item' }) + ) +}); + +export const GetRecordingMediaSchema = z.object({ + params: z.object({ + recordingId: nonEmptySanitizedRecordingId('recordingId') + }), + query: z.object({ + secret: z.string().optional() + }), + headers: z + .object({ + range: z + .string() + .regex(/^bytes=\d+-\d*$/, { + message: 'Invalid range header format. Expected: bytes=start-end' + }) + .optional() + }) + .passthrough() // Allow other headers to pass through +}); + +export const GetRecordingsFiltersSchema: z.ZodType = z.object({ + maxItems: z.coerce + .number() + .positive('maxItems must be a positive number') + .transform((val) => { + // Convert the value to a number + const intVal = Math.floor(val); + // Ensure it's not greater than 100 + return intVal > 100 ? 100 : intVal; + }) + .default(10), + // status: z.string().optional(), + roomId: nonEmptySanitizedRoomId('roomId').optional(), + roomName: z.string().optional(), + nextPageToken: z.string().optional(), + fields: z.string().optional() +}); + +export const GetRecordingUrlSchema = z.object({ + params: z.object({ + recordingId: nonEmptySanitizedRecordingId('recordingId') + }), + query: z.object({ + privateAccess: z + .preprocess((val) => { + if (typeof val === 'string') { + return val.toLowerCase() === 'true'; + } + + return val; + }, z.boolean()) + .default(false) + }) +}); diff --git a/meet-ce/backend/src/models/zod-schemas/room.schema.ts b/meet-ce/backend/src/models/zod-schemas/room.schema.ts new file mode 100644 index 00000000..e2a2c578 --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/room.schema.ts @@ -0,0 +1,284 @@ +import { + MeetAppearanceConfig, + MeetChatConfig, + MeetE2EEConfig, + MeetPermissions, + MeetRecordingAccess, + MeetRecordingConfig, + MeetRoomAutoDeletionPolicy, + MeetRoomConfig, + MeetRoomDeletionPolicyWithMeeting, + MeetRoomDeletionPolicyWithRecordings, + MeetRoomFilters, + MeetRoomMemberRole, + MeetRoomMemberTokenMetadata, + MeetRoomMemberTokenOptions, + MeetRoomOptions, + MeetRoomStatus, + MeetRoomTheme, + MeetRoomThemeMode, + MeetVirtualBackgroundConfig +} from '@openvidu-meet/typings'; +import ms from 'ms'; +import { z } from 'zod'; +import { INTERNAL_CONFIG } from '../../config/internal-config.js'; +import { MeetRoomHelper } from '../../helpers/index.js'; + +export const nonEmptySanitizedRoomId = (fieldName: string) => + z + .string() + .min(1, { message: `${fieldName} is required and cannot be empty` }) + .max(100, { message: `${fieldName} cannot exceed 100 characters` }) + .transform(MeetRoomHelper.sanitizeRoomId) + .refine((data) => data !== '', { + message: `${fieldName} cannot be empty after sanitization` + }); + +const RecordingAccessSchema: z.ZodType = z.nativeEnum(MeetRecordingAccess); + +const RecordingConfigSchema: z.ZodType = z + .object({ + enabled: z.boolean(), + allowAccessTo: RecordingAccessSchema.optional() + }) + .refine( + (data) => { + // If recording is enabled, allowAccessTo must be provided + return !data.enabled || data.allowAccessTo !== undefined; + }, + { + message: 'allowAccessTo is required when recording is enabled', + path: ['allowAccessTo'] + } + ); + +const ChatConfigSchema: z.ZodType = z.object({ + enabled: z.boolean() +}); + +const VirtualBackgroundConfigSchema: z.ZodType = z.object({ + enabled: z.boolean() +}); + +const E2EEConfigSchema: z.ZodType = z.object({ + enabled: z.boolean() +}); + +const ThemeModeSchema: z.ZodType = z.nativeEnum(MeetRoomThemeMode); + +const hexColorSchema = z + .string() + .regex( + /^#([0-9A-Fa-f]{8}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{3})$/, + 'Must be a valid hex color code (with or without alpha)' + ); + +const RoomThemeSchema: z.ZodType = z.object({ + name: z + .string() + .min(1, 'Theme name cannot be empty') + .max(50, 'Theme name cannot exceed 50 characters') + .regex(/^[a-zA-Z0-9_-]+$/, 'Theme name can only contain letters, numbers, hyphens and underscores'), + enabled: z.boolean(), + baseTheme: ThemeModeSchema, + backgroundColor: hexColorSchema.optional(), + primaryColor: hexColorSchema.optional(), + secondaryColor: hexColorSchema.optional(), + accentColor: hexColorSchema.optional(), + surfaceColor: hexColorSchema.optional() +}); + +export const AppearanceConfigSchema: z.ZodType = z.object({ + themes: z.array(RoomThemeSchema).length(1, 'There must be exactly one theme defined') +}); + +const RoomConfigSchema: z.ZodType> = z + .object({ + recording: RecordingConfigSchema.optional().default({ + enabled: true, + allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER + }), + chat: ChatConfigSchema.optional().default({ enabled: true }), + virtualBackground: VirtualBackgroundConfigSchema.optional().default({ enabled: true }), + e2ee: E2EEConfigSchema.optional().default({ enabled: false }) + // appearance: AppearanceConfigSchema, + }) + .transform((data) => { + // Automatically disable recording when E2EE is enabled + if (data.e2ee.enabled && data.recording.enabled) { + return { + ...data, + recording: { + ...data.recording, + enabled: false + } + }; + } + + return data; + }); + +const RoomDeletionPolicyWithMeetingSchema: z.ZodType = z.nativeEnum( + MeetRoomDeletionPolicyWithMeeting +); + +const RoomDeletionPolicyWithRecordingsSchema: z.ZodType = z.nativeEnum( + MeetRoomDeletionPolicyWithRecordings +); + +const RoomAutoDeletionPolicySchema: z.ZodType = z.object({ + withMeeting: RoomDeletionPolicyWithMeetingSchema, + withRecordings: RoomDeletionPolicyWithRecordingsSchema +}); + +export const RoomRequestOptionsSchema: z.ZodType = z.object({ + roomName: z + .string() + .max(50, 'roomName cannot exceed 50 characters') + .transform(MeetRoomHelper.sanitizeRoomName) + .optional() + .default('Room'), + autoDeletionDate: z + .number() + .positive('autoDeletionDate must be a positive integer') + .refine( + (date) => date >= Date.now() + ms(INTERNAL_CONFIG.MIN_FUTURE_TIME_FOR_ROOM_AUTODELETION_DATE), + `autoDeletionDate must be at least ${INTERNAL_CONFIG.MIN_FUTURE_TIME_FOR_ROOM_AUTODELETION_DATE} in the future` + ) + .optional(), + autoDeletionPolicy: RoomAutoDeletionPolicySchema.optional() + .default({ + withMeeting: MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS, + withRecordings: MeetRoomDeletionPolicyWithRecordings.CLOSE + }) + .refine( + (policy) => { + return !policy || policy.withMeeting !== MeetRoomDeletionPolicyWithMeeting.FAIL; + }, + { + message: 'FAIL policy is not allowed for withMeeting auto-deletion policy', + path: ['withMeeting'] + } + ) + .refine( + (policy) => { + return !policy || policy.withRecordings !== MeetRoomDeletionPolicyWithRecordings.FAIL; + }, + { + message: 'FAIL policy is not allowed for withRecordings auto-deletion policy', + path: ['withRecordings'] + } + ), + config: RoomConfigSchema.optional().default({ + recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + chat: { enabled: true }, + virtualBackground: { enabled: true }, + e2ee: { enabled: false } + }) + // maxParticipants: z + // .number() + // .positive('Max participants must be a positive integer') + // .nullable() + // .optional() + // .default(null) +}); + +export const GetRoomFiltersSchema: z.ZodType = z.object({ + maxItems: z.coerce + .number() + .positive('maxItems must be a positive number') + .transform((val) => { + // Convert the value to a number + const intVal = Math.floor(val); + // Ensure it's not greater than 100 + return intVal > 100 ? 100 : intVal; + }) + .default(10), + nextPageToken: z.string().optional(), + roomName: z.string().optional(), + fields: z.string().optional() +}); + +export const DeleteRoomQueryParamsSchema = z.object({ + withMeeting: RoomDeletionPolicyWithMeetingSchema.optional().default(MeetRoomDeletionPolicyWithMeeting.FAIL), + withRecordings: RoomDeletionPolicyWithRecordingsSchema.optional().default(MeetRoomDeletionPolicyWithRecordings.FAIL) +}); + +export const BulkDeleteRoomsSchema = z.object({ + roomIds: z.preprocess( + (arg) => { + // First, convert input to array of strings + let roomIds: string[] = []; + + if (typeof arg === 'string') { + roomIds = arg + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + } else if (Array.isArray(arg)) { + roomIds = arg.map((item) => String(item)).filter((s) => s !== ''); + } + + // Apply sanitization BEFORE validation and deduplicate + // This prevents identical IDs from being processed separately + const sanitizedIds = new Set(); + + // Pre-sanitize to check for duplicates that would become identical + for (const id of roomIds) { + const transformed = MeetRoomHelper.sanitizeRoomId(id); + + // Only add non-empty IDs + if (transformed !== '') { + sanitizedIds.add(transformed); + } + } + + return Array.from(sanitizedIds); + }, + z.array(z.string()).min(1, { + message: 'At least one valid roomId is required after sanitization' + }) + ), + withMeeting: RoomDeletionPolicyWithMeetingSchema.optional().default(MeetRoomDeletionPolicyWithMeeting.FAIL), + withRecordings: RoomDeletionPolicyWithRecordingsSchema.optional().default(MeetRoomDeletionPolicyWithRecordings.FAIL) +}); + +export const UpdateRoomConfigSchema = z.object({ + config: RoomConfigSchema +}); + +export const UpdateRoomStatusSchema = z.object({ + status: z.enum([MeetRoomStatus.OPEN, MeetRoomStatus.CLOSED]) +}); + +export const RoomMemberTokenRequestSchema: z.ZodType = z + .object({ + secret: z.string().nonempty('Secret is required'), + grantJoinMeetingPermission: z.boolean().optional().default(false), + participantName: z.string().optional(), + participantIdentity: z.string().optional() + }) + .refine( + (data) => { + // If grantJoinMeetingPermission is true, participantName must be provided + return !data.grantJoinMeetingPermission || data.participantName; + }, + { + message: 'participantName is required when grantJoinMeetingPermission is true', + path: ['participantName'] + } + ); + +const MeetPermissionsSchema: z.ZodType = z.object({ + canRecord: z.boolean(), + canRetrieveRecordings: z.boolean(), + canDeleteRecordings: z.boolean(), + canChat: z.boolean(), + canChangeVirtualBackground: z.boolean() +}); + +export const RoomMemberTokenMetadataSchema: z.ZodType = z.object({ + livekitUrl: z.string().url('LiveKit URL must be a valid URL'), + role: z.nativeEnum(MeetRoomMemberRole), + permissions: MeetPermissionsSchema +}); diff --git a/meet-ce/backend/src/models/zod-schemas/user.schema.ts b/meet-ce/backend/src/models/zod-schemas/user.schema.ts new file mode 100644 index 00000000..43f1912b --- /dev/null +++ b/meet-ce/backend/src/models/zod-schemas/user.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const ChangePasswordRequestSchema = z.object({ + currentPassword: z.string(), + newPassword: z.string().min(5, 'New password must be at least 5 characters long') +}); diff --git a/meet-ce/backend/src/repositories/api-key.repository.ts b/meet-ce/backend/src/repositories/api-key.repository.ts index ca1abfb7..44018b1a 100644 --- a/meet-ce/backend/src/repositories/api-key.repository.ts +++ b/meet-ce/backend/src/repositories/api-key.repository.ts @@ -1,8 +1,8 @@ import { MeetApiKey } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; +import { MeetApiKeyDocument, MeetApiKeyModel } from '../models/mongoose-schemas/index.js'; import { LoggerService } from '../services/logger.service.js'; import { BaseRepository } from './base.repository.js'; -import { MeetApiKeyDocument, MeetApiKeyModel } from './schemas/api-key.schema.js'; /** * Repository for managing MeetApiKey entities in MongoDB. diff --git a/meet-ce/backend/src/repositories/base.repository.ts b/meet-ce/backend/src/repositories/base.repository.ts index 8fea7465..d72f8eee 100644 --- a/meet-ce/backend/src/repositories/base.repository.ts +++ b/meet-ce/backend/src/repositories/base.repository.ts @@ -1,34 +1,8 @@ import { inject, injectable, unmanaged } from 'inversify'; import { Document, FilterQuery, Model, UpdateQuery } from 'mongoose'; +import { PaginatedFindOptions, PaginatedResult, PaginationCursor } from '../models/index.js'; import { LoggerService } from '../services/logger.service.js'; -/** - * Options for paginated find operations. - */ -export interface PaginatedFindOptions { - maxItems?: number; - nextPageToken?: string; - sortField?: string; - sortOrder?: 'asc' | 'desc'; -} - -/** - * Result of a paginated find operation. - */ -export interface PaginatedResult { - items: T[]; - isTruncated: boolean; - nextPageToken?: string; -} - -/** - * Pagination cursor structure. - */ -interface PaginationCursor { - fieldValue: unknown; - id: string; -} - /** * Base repository providing common CRUD operations for MongoDB entities. * This class is meant to be extended by specific entity repositories. diff --git a/meet-ce/backend/src/repositories/global-config.repository.ts b/meet-ce/backend/src/repositories/global-config.repository.ts index 8fe5a5bf..2e0ea781 100644 --- a/meet-ce/backend/src/repositories/global-config.repository.ts +++ b/meet-ce/backend/src/repositories/global-config.repository.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; +import { MeetGlobalConfigDocument, MeetGlobalConfigModel } from '../models/mongoose-schemas/index.js'; import { LoggerService } from '../services/logger.service.js'; import { BaseRepository } from './base.repository.js'; -import { MeetGlobalConfigDocument, MeetGlobalConfigModel } from './schemas/global-config.schema.js'; /** * Repository for managing GlobalConfig in MongoDB. diff --git a/meet-ce/backend/src/repositories/migration.repository.ts b/meet-ce/backend/src/repositories/migration.repository.ts index b58aaa18..74e8c9ee 100644 --- a/meet-ce/backend/src/repositories/migration.repository.ts +++ b/meet-ce/backend/src/repositories/migration.repository.ts @@ -1,8 +1,8 @@ import { inject, injectable } from 'inversify'; import { MeetMigration, MigrationName, MigrationStatus } from '../models/index.js'; +import { MeetMigrationDocument, MeetMigrationModel } from '../models/mongoose-schemas/index.js'; import { LoggerService } from '../services/logger.service.js'; import { BaseRepository } from './base.repository.js'; -import { MeetMigrationDocument, MeetMigrationModel } from './schemas/migration.schema.js'; @injectable() export class MigrationRepository extends BaseRepository { diff --git a/meet-ce/backend/src/repositories/recording.repository.ts b/meet-ce/backend/src/repositories/recording.repository.ts index 6db22dfe..1c27c18b 100644 --- a/meet-ce/backend/src/repositories/recording.repository.ts +++ b/meet-ce/backend/src/repositories/recording.repository.ts @@ -1,9 +1,9 @@ import { MeetRecordingInfo } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; import { uid as secureUid } from 'uid/secure'; +import { MeetRecordingDocument, MeetRecordingModel } from '../models/mongoose-schemas/index.js'; import { LoggerService } from '../services/logger.service.js'; import { BaseRepository } from './base.repository.js'; -import { MeetRecordingDocument, MeetRecordingModel } from './schemas/recording.schema.js'; /** * Repository for managing recording entities in MongoDB. diff --git a/meet-ce/backend/src/repositories/room.repository.ts b/meet-ce/backend/src/repositories/room.repository.ts index 9657810d..1db40742 100644 --- a/meet-ce/backend/src/repositories/room.repository.ts +++ b/meet-ce/backend/src/repositories/room.repository.ts @@ -1,9 +1,9 @@ import { MeetRoom } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; +import { MeetRoomDocument, MeetRoomModel } from '../models/mongoose-schemas/index.js'; import { LoggerService } from '../services/logger.service.js'; import { getBaseUrl } from '../utils/url.utils.js'; import { BaseRepository } from './base.repository.js'; -import { MeetRoomDocument, MeetRoomModel } from './schemas/room.schema.js'; /** * Repository for managing MeetRoom entities in MongoDB. diff --git a/meet-ce/backend/src/repositories/user.repository.ts b/meet-ce/backend/src/repositories/user.repository.ts index 32130607..8267a0c3 100644 --- a/meet-ce/backend/src/repositories/user.repository.ts +++ b/meet-ce/backend/src/repositories/user.repository.ts @@ -1,8 +1,8 @@ import { MeetUser } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; +import { MeetUserDocument, MeetUserModel } from '../models/mongoose-schemas/index.js'; import { LoggerService } from '../services/logger.service.js'; import { BaseRepository } from './base.repository.js'; -import { MeetUserDocument, MeetUserModel } from './schemas/user.schema.js'; /** * Repository for managing MeetUser entities in MongoDB. diff --git a/meet-ce/backend/src/services/frontend-event.service.ts b/meet-ce/backend/src/services/frontend-event.service.ts index 5c0d7cf9..aaa586f8 100644 --- a/meet-ce/backend/src/services/frontend-event.service.ts +++ b/meet-ce/backend/src/services/frontend-event.service.ts @@ -9,7 +9,8 @@ import { } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; import { SendDataOptions } from 'livekit-server-sdk'; -import { OpenViduComponentsAdapterHelper, OpenViduComponentsSignalPayload } from '../helpers/index.js'; +import { OpenViduComponentsAdapterHelper } from '../helpers/index.js'; +import { OpenViduComponentsSignalPayload } from '../models/index.js'; import { LiveKitService, LoggerService } from './index.js'; /** diff --git a/meet-ce/backend/src/services/recording.service.ts b/meet-ce/backend/src/services/recording.service.ts index 674b0444..59319bfa 100644 --- a/meet-ce/backend/src/services/recording.service.ts +++ b/meet-ce/backend/src/services/recording.service.ts @@ -17,6 +17,7 @@ import { errorRecordingStartTimeout, errorRoomHasNoParticipants, errorRoomNotFound, + IScheduledTask, isErrorRecordingAlreadyStopped, isErrorRecordingCannotBeStoppedWhileStarting, isErrorRecordingNotFound, @@ -27,7 +28,6 @@ import { BlobStorageService, DistributedEventService, FrontendEventService, - IScheduledTask, LiveKitService, LoggerService, MutexService, diff --git a/meet-ce/backend/src/services/request-session.service.ts b/meet-ce/backend/src/services/request-session.service.ts index 00b354d0..2ee95296 100644 --- a/meet-ce/backend/src/services/request-session.service.ts +++ b/meet-ce/backend/src/services/request-session.service.ts @@ -1,21 +1,7 @@ +import { LiveKitPermissions, MeetPermissions, MeetRoomMemberRole, MeetUser } from '@openvidu-meet/typings'; import { AsyncLocalStorage } from 'async_hooks'; -import { - LiveKitPermissions, - MeetPermissions, - MeetRoomMemberRole, - MeetRoomMemberRoleAndPermissions, - MeetUser -} from '@openvidu-meet/typings'; import { injectable } from 'inversify'; - -/** - * Context stored per HTTP request using AsyncLocalStorage. - * This ensures that each concurrent request has its own isolated data. - */ -interface RequestContext { - user?: MeetUser; - roomMember?: MeetRoomMemberRoleAndPermissions; -} +import { RequestContext } from '../models/index.js'; /** * Service that manages request-scoped session data using Node.js AsyncLocalStorage. diff --git a/meet-ce/backend/src/services/room.service.ts b/meet-ce/backend/src/services/room.service.ts index d08e77e4..64eed5f5 100644 --- a/meet-ce/backend/src/services/room.service.ts +++ b/meet-ce/backend/src/services/room.service.ts @@ -26,10 +26,10 @@ import { internalError, OpenViduMeetError } from '../models/error.model.js'; +import { IScheduledTask } from '../models/index.js'; import { RoomRepository } from '../repositories/index.js'; import { FrontendEventService, - IScheduledTask, LiveKitService, LoggerService, RecordingService, diff --git a/meet-ce/backend/src/services/task-scheduler.service.ts b/meet-ce/backend/src/services/task-scheduler.service.ts index a050bb20..057cdbff 100644 --- a/meet-ce/backend/src/services/task-scheduler.service.ts +++ b/meet-ce/backend/src/services/task-scheduler.service.ts @@ -3,16 +3,8 @@ import { inject, injectable } from 'inversify'; import ms from 'ms'; import { INTERNAL_CONFIG } from '../config/internal-config.js'; import { MeetLock } from '../helpers/index.js'; -import { LoggerService, MutexService, DistributedEventService } from './index.js'; - -export type TaskType = 'cron' | 'timeout'; - -export interface IScheduledTask { - name: string; - type: TaskType; - scheduleOrDelay: ms.StringValue; - callback: () => Promise; -} +import { IScheduledTask } from '../models/index.js'; +import { DistributedEventService, LoggerService, MutexService } from './index.js'; @injectable() export class TaskSchedulerService {