backend: refactor code to centralize all TS interfaces, types, enums and schemas under the models directory
This commit is contained in:
parent
b711840349
commit
69df748002
@ -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() {
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<WebhookConfig> = 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<AuthMode> = z.nativeEnum(AuthMode);
|
||||
|
||||
const AuthTypeSchema: z.ZodType<AuthType> = z.nativeEnum(AuthType);
|
||||
|
||||
const SingleUserAuthSchema: z.ZodType<SingleUserAuth> = z.object({
|
||||
type: AuthTypeSchema
|
||||
});
|
||||
|
||||
const ValidAuthMethodSchema: z.ZodType<ValidAuthMethod> = SingleUserAuthSchema;
|
||||
|
||||
const AuthenticationConfigSchema: z.ZodType<AuthenticationConfig> = z.object({
|
||||
authMethod: ValidAuthMethodSchema,
|
||||
authModeToAccessRoom: AuthModeSchema
|
||||
});
|
||||
|
||||
const SecurityConfigSchema: z.ZodType<SecurityConfig> = 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);
|
||||
|
||||
@ -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<MeetPermissions> = z.object({
|
||||
canRecord: z.boolean(),
|
||||
canRetrieveRecordings: z.boolean(),
|
||||
canDeleteRecordings: z.boolean(),
|
||||
canChat: z.boolean(),
|
||||
canChangeVirtualBackground: z.boolean()
|
||||
});
|
||||
|
||||
const RoomMemberTokenMetadataSchema: z.ZodType<MeetRoomMemberTokenMetadata> = 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);
|
||||
|
||||
@ -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<MeetRecordingFilters> = 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);
|
||||
|
||||
@ -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<MeetRecordingAccess> = z.nativeEnum(MeetRecordingAccess);
|
||||
|
||||
const RecordingConfigSchema: z.ZodType<MeetRecordingConfig> = 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<MeetChatConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const VirtualBackgroundConfigSchema: z.ZodType<MeetVirtualBackgroundConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const E2EEConfigSchema: z.ZodType<MeetE2EEConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const ThemeModeSchema: z.ZodType<MeetRoomThemeMode> = 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<MeetRoomTheme> = 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<MeetAppearanceConfig> = z.object({
|
||||
themes: z.array(RoomThemeSchema).length(1, 'There must be exactly one theme defined')
|
||||
});
|
||||
|
||||
const RoomConfigSchema: z.ZodType<Partial<MeetRoomConfig>> = 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<MeetRoomDeletionPolicyWithMeeting> = z.nativeEnum(
|
||||
MeetRoomDeletionPolicyWithMeeting
|
||||
);
|
||||
|
||||
const RoomDeletionPolicyWithRecordingsSchema: z.ZodType<MeetRoomDeletionPolicyWithRecordings> = z.nativeEnum(
|
||||
MeetRoomDeletionPolicyWithRecordings
|
||||
);
|
||||
|
||||
const RoomAutoDeletionPolicySchema: z.ZodType<MeetRoomAutoDeletionPolicy> = z.object({
|
||||
withMeeting: RoomDeletionPolicyWithMeetingSchema,
|
||||
withRecordings: RoomDeletionPolicyWithRecordingsSchema
|
||||
});
|
||||
|
||||
const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = 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<MeetRoomFilters> = 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<MeetRoomMemberTokenOptions> = 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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
26
meet-ce/backend/src/models/db-pagination.model.ts
Normal file
26
meet-ce/backend/src/models/db-pagination.model.ts
Normal file
@ -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<T> {
|
||||
items: T[];
|
||||
isTruncated: boolean;
|
||||
nextPageToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination cursor structure.
|
||||
*/
|
||||
export interface PaginationCursor {
|
||||
fieldValue: unknown;
|
||||
id: string;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
6
meet-ce/backend/src/models/mongoose-schemas/index.ts
Normal file
6
meet-ce/backend/src/models/mongoose-schemas/index.ts
Normal file
@ -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';
|
||||
@ -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.
|
||||
31
meet-ce/backend/src/models/ov-components-signal.model.ts
Normal file
31
meet-ce/backend/src/models/ov-components-signal.model.ts
Normal file
@ -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;
|
||||
9
meet-ce/backend/src/models/request-context.model.ts
Normal file
9
meet-ce/backend/src/models/request-context.model.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MeetRoomMemberRoleAndPermissions, MeetUser } from '@openvidu-meet/typings';
|
||||
|
||||
/**
|
||||
* Context information stored per HTTP request.
|
||||
*/
|
||||
export interface RequestContext {
|
||||
user?: MeetUser;
|
||||
roomMember?: MeetRoomMemberRoleAndPermissions;
|
||||
}
|
||||
10
meet-ce/backend/src/models/task-scheduler.model.ts
Normal file
10
meet-ce/backend/src/models/task-scheduler.model.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { StringValue } from 'ms';
|
||||
|
||||
export type TaskType = 'cron' | 'timeout';
|
||||
|
||||
export interface IScheduledTask {
|
||||
name: string;
|
||||
type: TaskType;
|
||||
scheduleOrDelay: StringValue;
|
||||
callback: () => Promise<void>;
|
||||
}
|
||||
6
meet-ce/backend/src/models/zod-schemas/auth.schema.ts
Normal file
6
meet-ce/backend/src/models/zod-schemas/auth.schema.ts
Normal file
@ -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')
|
||||
});
|
||||
@ -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<WebhookConfig> = 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<AuthMode> = z.nativeEnum(AuthMode);
|
||||
|
||||
const AuthTypeSchema: z.ZodType<AuthType> = z.nativeEnum(AuthType);
|
||||
|
||||
const SingleUserAuthSchema: z.ZodType<SingleUserAuth> = z.object({
|
||||
type: AuthTypeSchema
|
||||
});
|
||||
|
||||
const ValidAuthMethodSchema: z.ZodType<ValidAuthMethod> = SingleUserAuthSchema;
|
||||
|
||||
const AuthenticationConfigSchema: z.ZodType<AuthenticationConfig> = z.object({
|
||||
authMethod: ValidAuthMethodSchema,
|
||||
authModeToAccessRoom: AuthModeSchema
|
||||
});
|
||||
|
||||
export const SecurityConfigSchema: z.ZodType<SecurityConfig> = z.object({
|
||||
authentication: AuthenticationConfigSchema
|
||||
});
|
||||
|
||||
export const RoomsAppearanceConfigSchema = z.object({
|
||||
appearance: AppearanceConfigSchema
|
||||
});
|
||||
6
meet-ce/backend/src/models/zod-schemas/index.ts
Normal file
6
meet-ce/backend/src/models/zod-schemas/index.ts
Normal file
@ -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';
|
||||
6
meet-ce/backend/src/models/zod-schemas/meeting.schema.ts
Normal file
6
meet-ce/backend/src/models/zod-schemas/meeting.schema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { MeetRoomMemberRole } from '@openvidu-meet/typings';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UpdateParticipantRequestSchema = z.object({
|
||||
role: z.nativeEnum(MeetRoomMemberRole)
|
||||
});
|
||||
136
meet-ce/backend/src/models/zod-schemas/recording.schema.ts
Normal file
136
meet-ce/backend/src/models/zod-schemas/recording.schema.ts
Normal file
@ -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<MeetRecordingFilters> = 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)
|
||||
})
|
||||
});
|
||||
284
meet-ce/backend/src/models/zod-schemas/room.schema.ts
Normal file
284
meet-ce/backend/src/models/zod-schemas/room.schema.ts
Normal file
@ -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<MeetRecordingAccess> = z.nativeEnum(MeetRecordingAccess);
|
||||
|
||||
const RecordingConfigSchema: z.ZodType<MeetRecordingConfig> = 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<MeetChatConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const VirtualBackgroundConfigSchema: z.ZodType<MeetVirtualBackgroundConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const E2EEConfigSchema: z.ZodType<MeetE2EEConfig> = z.object({
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
const ThemeModeSchema: z.ZodType<MeetRoomThemeMode> = 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<MeetRoomTheme> = 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<MeetAppearanceConfig> = z.object({
|
||||
themes: z.array(RoomThemeSchema).length(1, 'There must be exactly one theme defined')
|
||||
});
|
||||
|
||||
const RoomConfigSchema: z.ZodType<Partial<MeetRoomConfig>> = 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<MeetRoomDeletionPolicyWithMeeting> = z.nativeEnum(
|
||||
MeetRoomDeletionPolicyWithMeeting
|
||||
);
|
||||
|
||||
const RoomDeletionPolicyWithRecordingsSchema: z.ZodType<MeetRoomDeletionPolicyWithRecordings> = z.nativeEnum(
|
||||
MeetRoomDeletionPolicyWithRecordings
|
||||
);
|
||||
|
||||
const RoomAutoDeletionPolicySchema: z.ZodType<MeetRoomAutoDeletionPolicy> = z.object({
|
||||
withMeeting: RoomDeletionPolicyWithMeetingSchema,
|
||||
withRecordings: RoomDeletionPolicyWithRecordingsSchema
|
||||
});
|
||||
|
||||
export const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = 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<MeetRoomFilters> = 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<MeetRoomMemberTokenOptions> = 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<MeetPermissions> = z.object({
|
||||
canRecord: z.boolean(),
|
||||
canRetrieveRecordings: z.boolean(),
|
||||
canDeleteRecordings: z.boolean(),
|
||||
canChat: z.boolean(),
|
||||
canChangeVirtualBackground: z.boolean()
|
||||
});
|
||||
|
||||
export const RoomMemberTokenMetadataSchema: z.ZodType<MeetRoomMemberTokenMetadata> = z.object({
|
||||
livekitUrl: z.string().url('LiveKit URL must be a valid URL'),
|
||||
role: z.nativeEnum(MeetRoomMemberRole),
|
||||
permissions: MeetPermissionsSchema
|
||||
});
|
||||
6
meet-ce/backend/src/models/zod-schemas/user.schema.ts
Normal file
6
meet-ce/backend/src/models/zod-schemas/user.schema.ts
Normal file
@ -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')
|
||||
});
|
||||
@ -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.
|
||||
|
||||
@ -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<T> {
|
||||
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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<MeetMigration, MeetMigrationDocument> {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
errorRecordingStartTimeout,
|
||||
errorRoomHasNoParticipants,
|
||||
errorRoomNotFound,
|
||||
IScheduledTask,
|
||||
isErrorRecordingAlreadyStopped,
|
||||
isErrorRecordingCannotBeStoppedWhileStarting,
|
||||
isErrorRecordingNotFound,
|
||||
@ -27,7 +28,6 @@ import {
|
||||
BlobStorageService,
|
||||
DistributedEventService,
|
||||
FrontendEventService,
|
||||
IScheduledTask,
|
||||
LiveKitService,
|
||||
LoggerService,
|
||||
MutexService,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<void>;
|
||||
}
|
||||
import { IScheduledTask } from '../models/index.js';
|
||||
import { DistributedEventService, LoggerService, MutexService } from './index.js';
|
||||
|
||||
@injectable()
|
||||
export class TaskSchedulerService {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user