backend: update security preferences structure to remove unused attributes and refactor associated code
This commit is contained in:
parent
dd3a2939e4
commit
55bc8726d0
@ -1,4 +1,4 @@
|
|||||||
import { SecurityPreferencesDTO, UpdateSecurityPreferencesDTO } from '@typings-ce';
|
import { SecurityPreferences } from '@typings-ce';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { container } from '../../config/index.js';
|
import { container } from '../../config/index.js';
|
||||||
import { handleError } from '../../models/error.model.js';
|
import { handleError } from '../../models/error.model.js';
|
||||||
@ -9,29 +9,15 @@ export const updateSecurityPreferences = async (req: Request, res: Response) =>
|
|||||||
const globalPrefService = container.get(MeetStorageService);
|
const globalPrefService = container.get(MeetStorageService);
|
||||||
|
|
||||||
logger.verbose(`Updating security preferences: ${JSON.stringify(req.body)}`);
|
logger.verbose(`Updating security preferences: ${JSON.stringify(req.body)}`);
|
||||||
const securityPreferences = req.body as UpdateSecurityPreferencesDTO;
|
const securityPreferences = req.body as SecurityPreferences;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const globalPreferences = await globalPrefService.getGlobalPreferences();
|
const globalPreferences = await globalPrefService.getGlobalPreferences();
|
||||||
|
|
||||||
if (securityPreferences.roomCreationPolicy) {
|
|
||||||
globalPreferences.securityPreferences.roomCreationPolicy = {
|
|
||||||
allowRoomCreation: securityPreferences.roomCreationPolicy.allowRoomCreation,
|
|
||||||
requireAuthentication:
|
|
||||||
securityPreferences.roomCreationPolicy.requireAuthentication === undefined
|
|
||||||
? globalPreferences.securityPreferences.roomCreationPolicy.requireAuthentication
|
|
||||||
: securityPreferences.roomCreationPolicy.requireAuthentication
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (securityPreferences.authentication) {
|
|
||||||
const currentAuth = globalPreferences.securityPreferences.authentication;
|
const currentAuth = globalPreferences.securityPreferences.authentication;
|
||||||
const newAuth = securityPreferences.authentication;
|
const newAuth = securityPreferences.authentication;
|
||||||
|
|
||||||
currentAuth.authMode = newAuth.authMode;
|
currentAuth.authMethod = newAuth.authMethod;
|
||||||
currentAuth.method.type = newAuth.method.type;
|
currentAuth.authModeToAccessRoom = newAuth.authModeToAccessRoom;
|
||||||
}
|
|
||||||
|
|
||||||
await globalPrefService.saveGlobalPreferences(globalPreferences);
|
await globalPrefService.saveGlobalPreferences(globalPreferences);
|
||||||
|
|
||||||
return res.status(200).json({ message: 'Security preferences updated successfully' });
|
return res.status(200).json({ message: 'Security preferences updated successfully' });
|
||||||
@ -48,19 +34,8 @@ export const getSecurityPreferences = async (_req: Request, res: Response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const preferences = await preferenceService.getGlobalPreferences();
|
const preferences = await preferenceService.getGlobalPreferences();
|
||||||
|
|
||||||
// Convert the preferences to the DTO format by removing credentials
|
|
||||||
const securityPreferences = preferences.securityPreferences;
|
const securityPreferences = preferences.securityPreferences;
|
||||||
const securityPreferencesDTO: SecurityPreferencesDTO = {
|
return res.status(200).json(securityPreferences);
|
||||||
roomCreationPolicy: securityPreferences.roomCreationPolicy,
|
|
||||||
authentication: {
|
|
||||||
authMode: securityPreferences.authentication.authMode,
|
|
||||||
method: {
|
|
||||||
type: securityPreferences.authentication.method.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return res.status(200).json(securityPreferencesDTO);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(res, error, 'getting security preferences');
|
handleError(res, error, 'getting security preferences');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,22 +25,22 @@ export const configureParticipantTokenAuth = async (req: Request, res: Response,
|
|||||||
return handleError(res, error, 'getting room role by secret');
|
return handleError(res, error, 'getting room role by secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
let authMode: AuthMode;
|
let authModeToAccessRoom: AuthMode;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { securityPreferences } = await globalPrefService.getGlobalPreferences();
|
const { securityPreferences } = await globalPrefService.getGlobalPreferences();
|
||||||
authMode = securityPreferences.authentication.authMode;
|
authModeToAccessRoom = securityPreferences.authentication.authModeToAccessRoom;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(res, error, 'checking authentication preferences');
|
return handleError(res, error, 'checking authentication preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authValidators = [];
|
const authValidators = [];
|
||||||
|
|
||||||
if (authMode === AuthMode.NONE) {
|
if (authModeToAccessRoom === AuthMode.NONE) {
|
||||||
authValidators.push(allowAnonymous);
|
authValidators.push(allowAnonymous);
|
||||||
} else {
|
} else {
|
||||||
const isModeratorsOnlyMode = authMode === AuthMode.MODERATORS_ONLY && role === ParticipantRole.MODERATOR;
|
const isModeratorsOnlyMode = authModeToAccessRoom === AuthMode.MODERATORS_ONLY && role === ParticipantRole.MODERATOR;
|
||||||
const isAllUsersMode = authMode === AuthMode.ALL_USERS;
|
const isAllUsersMode = authModeToAccessRoom === AuthMode.ALL_USERS;
|
||||||
|
|
||||||
if (isModeratorsOnlyMode || isAllUsersMode) {
|
if (isModeratorsOnlyMode || isAllUsersMode) {
|
||||||
authValidators.push(tokenAndRoleValidator(UserRole.USER));
|
authValidators.push(tokenAndRoleValidator(UserRole.USER));
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
AuthenticationPreferencesDTO,
|
AuthenticationPreferences,
|
||||||
AuthMode,
|
AuthMode,
|
||||||
AuthType,
|
AuthType,
|
||||||
RoomCreationPolicy,
|
SecurityPreferences,
|
||||||
SingleUserAuthDTO,
|
SingleUserAuth,
|
||||||
UpdateSecurityPreferencesDTO,
|
ValidAuthMethod,
|
||||||
ValidAuthMethodDTO,
|
|
||||||
WebhookPreferences
|
WebhookPreferences
|
||||||
} from '@typings-ce';
|
} from '@typings-ce';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
@ -36,41 +35,20 @@ const AuthModeSchema: z.ZodType<AuthMode> = z.enum([AuthMode.NONE, AuthMode.MODE
|
|||||||
|
|
||||||
const AuthTypeSchema: z.ZodType<AuthType> = z.enum([AuthType.SINGLE_USER]);
|
const AuthTypeSchema: z.ZodType<AuthType> = z.enum([AuthType.SINGLE_USER]);
|
||||||
|
|
||||||
const SingleUserAuthDTOSchema: z.ZodType<SingleUserAuthDTO> = z.object({
|
const SingleUserAuthSchema: z.ZodType<SingleUserAuth> = z.object({
|
||||||
type: AuthTypeSchema
|
type: AuthTypeSchema
|
||||||
});
|
});
|
||||||
|
|
||||||
const ValidAuthMethodDTOSchema: z.ZodType<ValidAuthMethodDTO> = SingleUserAuthDTOSchema;
|
const ValidAuthMethodSchema: z.ZodType<ValidAuthMethod> = SingleUserAuthSchema;
|
||||||
|
|
||||||
const AuthenticationPreferencesDTOSchema: z.ZodType<AuthenticationPreferencesDTO> = z.object({
|
const AuthenticationPreferencesSchema: z.ZodType<AuthenticationPreferences> = z.object({
|
||||||
authMode: AuthModeSchema,
|
authMethod: ValidAuthMethodSchema,
|
||||||
method: ValidAuthMethodDTOSchema
|
authModeToAccessRoom: AuthModeSchema
|
||||||
});
|
});
|
||||||
|
|
||||||
const RoomCreationPolicySchema: z.ZodType<RoomCreationPolicy> = z
|
const SecurityPreferencesSchema: z.ZodType<SecurityPreferences> = z.object({
|
||||||
.object({
|
authentication: AuthenticationPreferencesSchema
|
||||||
allowRoomCreation: z.boolean(),
|
});
|
||||||
requireAuthentication: z.boolean().optional()
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// If allowRoomCreation is true, requireAuthentication must be provided
|
|
||||||
return !data.allowRoomCreation || data.requireAuthentication !== undefined;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'requireAuthentication is required when allowRoomCreation is true',
|
|
||||||
path: ['requireAuthentication']
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const UpdateSecurityPreferencesDTOSchema: z.ZodType<UpdateSecurityPreferencesDTO> = z
|
|
||||||
.object({
|
|
||||||
authentication: AuthenticationPreferencesDTOSchema.optional(),
|
|
||||||
roomCreationPolicy: RoomCreationPolicySchema.optional()
|
|
||||||
})
|
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
|
||||||
message: 'At least one field must be provided for the update'
|
|
||||||
});
|
|
||||||
|
|
||||||
export const validateWebhookPreferences = (req: Request, res: Response, next: NextFunction) => {
|
export const validateWebhookPreferences = (req: Request, res: Response, next: NextFunction) => {
|
||||||
const { success, error, data } = WebhookPreferencesSchema.safeParse(req.body);
|
const { success, error, data } = WebhookPreferencesSchema.safeParse(req.body);
|
||||||
@ -84,7 +62,7 @@ export const validateWebhookPreferences = (req: Request, res: Response, next: Ne
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const validateSecurityPreferences = (req: Request, res: Response, next: NextFunction) => {
|
export const validateSecurityPreferences = (req: Request, res: Response, next: NextFunction) => {
|
||||||
const { success, error, data } = UpdateSecurityPreferencesDTOSchema.safeParse(req.body);
|
const { success, error, data } = SecurityPreferencesSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return rejectUnprocessableRequest(res, error);
|
return rejectUnprocessableRequest(res, error);
|
||||||
|
|||||||
@ -8,39 +8,7 @@ import {
|
|||||||
rejectRequestFromMeetError
|
rejectRequestFromMeetError
|
||||||
} from '../models/error.model.js';
|
} from '../models/error.model.js';
|
||||||
import { MeetStorageService, RoomService } from '../services/index.js';
|
import { MeetStorageService, RoomService } from '../services/index.js';
|
||||||
import { allowAnonymous, apiKeyValidator, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
|
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware that configures authentication for creating a room based on global settings.
|
|
||||||
*
|
|
||||||
* - Admin role and API key authentication methods are always allowed.
|
|
||||||
* - If room creation is allowed and requires authentication, the user must have a valid token.
|
|
||||||
* - If room creation is allowed and does not require authentication, anonymous users are allowed.
|
|
||||||
*/
|
|
||||||
export const configureCreateRoomAuth = async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const globalPrefService = container.get(MeetStorageService);
|
|
||||||
let allowRoomCreation: boolean;
|
|
||||||
let requireAuthentication: boolean | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { securityPreferences } = await globalPrefService.getGlobalPreferences();
|
|
||||||
({ allowRoomCreation, requireAuthentication } = securityPreferences.roomCreationPolicy);
|
|
||||||
} catch (error) {
|
|
||||||
return handleError(res, error, 'checking room creation policy');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authValidators = [apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)];
|
|
||||||
|
|
||||||
if (allowRoomCreation) {
|
|
||||||
if (requireAuthentication) {
|
|
||||||
authValidators.push(tokenAndRoleValidator(UserRole.USER));
|
|
||||||
} else {
|
|
||||||
authValidators.push(allowAnonymous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return withAuth(...authValidators)(req, res, next);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware that configures authorization for accessing a specific room.
|
* Middleware that configures authorization for accessing a specific room.
|
||||||
@ -79,7 +47,7 @@ export const configureRoomAuthorization = async (req: Request, res: Response, ne
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to configure authentication based on participant role and authentication mode
|
* Middleware to configure authentication based on participant role and authentication mode to access a room
|
||||||
* for generating a token for retrieving/deleting recordings.
|
* for generating a token for retrieving/deleting recordings.
|
||||||
*
|
*
|
||||||
* - If the authentication mode is MODERATORS_ONLY and the participant role is MODERATOR, configure user authentication.
|
* - If the authentication mode is MODERATORS_ONLY and the participant role is MODERATOR, configure user authentication.
|
||||||
@ -114,22 +82,22 @@ export const configureRecordingTokenAuth = async (req: Request, res: Response, n
|
|||||||
return handleError(res, error, 'getting room role by secret');
|
return handleError(res, error, 'getting room role by secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
let authMode: AuthMode;
|
let authModeToAccessRoom: AuthMode;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { securityPreferences } = await storageService.getGlobalPreferences();
|
const { securityPreferences } = await storageService.getGlobalPreferences();
|
||||||
authMode = securityPreferences.authentication.authMode;
|
authModeToAccessRoom = securityPreferences.authentication.authModeToAccessRoom;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(res, error, 'checking authentication preferences');
|
return handleError(res, error, 'checking authentication preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authValidators = [];
|
const authValidators = [];
|
||||||
|
|
||||||
if (authMode === AuthMode.NONE) {
|
if (authModeToAccessRoom === AuthMode.NONE) {
|
||||||
authValidators.push(allowAnonymous);
|
authValidators.push(allowAnonymous);
|
||||||
} else {
|
} else {
|
||||||
const isModeratorsOnlyMode = authMode === AuthMode.MODERATORS_ONLY && role === ParticipantRole.MODERATOR;
|
const isModeratorsOnlyMode = authModeToAccessRoom === AuthMode.MODERATORS_ONLY && role === ParticipantRole.MODERATOR;
|
||||||
const isAllUsersMode = authMode === AuthMode.ALL_USERS;
|
const isAllUsersMode = authModeToAccessRoom === AuthMode.ALL_USERS;
|
||||||
|
|
||||||
if (isModeratorsOnlyMode || isAllUsersMode) {
|
if (isModeratorsOnlyMode || isAllUsersMode) {
|
||||||
authValidators.push(tokenAndRoleValidator(UserRole.USER));
|
authValidators.push(tokenAndRoleValidator(UserRole.USER));
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import bodyParser from 'body-parser';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as roomCtrl from '../controllers/room.controller.js';
|
import * as roomCtrl from '../controllers/room.controller.js';
|
||||||
import {
|
import {
|
||||||
|
allowAnonymous,
|
||||||
apiKeyValidator,
|
apiKeyValidator,
|
||||||
configureCreateRoomAuth,
|
|
||||||
configureRecordingTokenAuth,
|
configureRecordingTokenAuth,
|
||||||
configureRoomAuthorization,
|
configureRoomAuthorization,
|
||||||
participantTokenValidator,
|
participantTokenValidator,
|
||||||
@ -24,7 +24,12 @@ roomRouter.use(bodyParser.urlencoded({ extended: true }));
|
|||||||
roomRouter.use(bodyParser.json());
|
roomRouter.use(bodyParser.json());
|
||||||
|
|
||||||
// Room Routes
|
// Room Routes
|
||||||
roomRouter.post('/', configureCreateRoomAuth, withValidRoomOptions, roomCtrl.createRoom);
|
roomRouter.post(
|
||||||
|
'/',
|
||||||
|
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
|
||||||
|
withValidRoomOptions,
|
||||||
|
roomCtrl.createRoom
|
||||||
|
);
|
||||||
roomRouter.get(
|
roomRouter.get(
|
||||||
'/',
|
'/',
|
||||||
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
|
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
|
||||||
@ -56,7 +61,6 @@ export const internalRoomRouter = Router();
|
|||||||
internalRoomRouter.use(bodyParser.urlencoded({ extended: true }));
|
internalRoomRouter.use(bodyParser.urlencoded({ extended: true }));
|
||||||
internalRoomRouter.use(bodyParser.json());
|
internalRoomRouter.use(bodyParser.json());
|
||||||
|
|
||||||
// Room preferences
|
|
||||||
internalRoomRouter.put(
|
internalRoomRouter.put(
|
||||||
'/:roomId',
|
'/:roomId',
|
||||||
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
|
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
|
||||||
@ -64,7 +68,6 @@ internalRoomRouter.put(
|
|||||||
withValidRoomPreferences,
|
withValidRoomPreferences,
|
||||||
roomCtrl.updateRoomPreferences
|
roomCtrl.updateRoomPreferences
|
||||||
);
|
);
|
||||||
|
|
||||||
internalRoomRouter.post(
|
internalRoomRouter.post(
|
||||||
'/:roomId/recording-token',
|
'/:roomId/recording-token',
|
||||||
configureRecordingTokenAuth,
|
configureRecordingTokenAuth,
|
||||||
@ -72,7 +75,15 @@ internalRoomRouter.post(
|
|||||||
withValidRoomSecret,
|
withValidRoomSecret,
|
||||||
roomCtrl.generateRecordingToken
|
roomCtrl.generateRecordingToken
|
||||||
);
|
);
|
||||||
|
internalRoomRouter.get(
|
||||||
// Roles and permissions
|
'/:roomId/roles',
|
||||||
internalRoomRouter.get('/:roomId/roles', withValidRoomId, roomCtrl.getRoomRolesAndPermissions);
|
withAuth(allowAnonymous),
|
||||||
internalRoomRouter.get('/:roomId/roles/:secret', withValidRoomId, roomCtrl.getRoomRoleAndPermissions);
|
withValidRoomId,
|
||||||
|
roomCtrl.getRoomRolesAndPermissions
|
||||||
|
);
|
||||||
|
internalRoomRouter.get(
|
||||||
|
'/:roomId/roles/:secret',
|
||||||
|
withAuth(allowAnonymous),
|
||||||
|
withValidRoomId,
|
||||||
|
roomCtrl.getRoomRoleAndPermissions
|
||||||
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export interface AuthenticationPreferences {
|
export interface AuthenticationPreferences {
|
||||||
authMode: AuthMode;
|
authMethod: ValidAuthMethod;
|
||||||
method: ValidAuthMethod;
|
authModeToAccessRoom: AuthMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +33,6 @@ export const enum AuthType {
|
|||||||
*/
|
*/
|
||||||
export interface SingleUserAuth extends AuthMethod {
|
export interface SingleUserAuth extends AuthMethod {
|
||||||
type: AuthType.SINGLE_USER;
|
type: AuthType.SINGLE_USER;
|
||||||
credentials: SingleUserCredentials;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,14 +56,6 @@ export interface SingleUserAuth extends AuthMethod {
|
|||||||
*/
|
*/
|
||||||
export type ValidAuthMethod = SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
|
export type ValidAuthMethod = SingleUserAuth /* | MultiUserAuth | OAuthOnlyAuth */;
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for a single user login method.
|
|
||||||
*/
|
|
||||||
export interface SingleUserCredentials {
|
|
||||||
username: string;
|
|
||||||
passwordHash: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for OAuth authentication.
|
* Configuration for OAuth authentication.
|
||||||
*/
|
*/
|
||||||
@ -82,13 +73,3 @@ export interface SingleUserCredentials {
|
|||||||
// GOOGLE = 'google',
|
// GOOGLE = 'google',
|
||||||
// GITHUB = 'github'
|
// GITHUB = 'github'
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// DTOs
|
|
||||||
export interface AuthenticationPreferencesDTO {
|
|
||||||
authMode: AuthMode;
|
|
||||||
method: ValidAuthMethodDTO;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ValidAuthMethodDTO = SingleUserAuthDTO;
|
|
||||||
|
|
||||||
export type SingleUserAuthDTO = Omit<SingleUserAuth, 'credentials'>;
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { AuthenticationPreferences, AuthenticationPreferencesDTO } from './auth-preferences.js';
|
import { AuthenticationPreferences } from './auth-preferences.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents global preferences for OpenVidu Meet.
|
* Represents global preferences for OpenVidu Meet.
|
||||||
@ -18,19 +18,4 @@ export interface WebhookPreferences {
|
|||||||
|
|
||||||
export interface SecurityPreferences {
|
export interface SecurityPreferences {
|
||||||
authentication: AuthenticationPreferences;
|
authentication: AuthenticationPreferences;
|
||||||
roomCreationPolicy: RoomCreationPolicy;
|
|
||||||
// e2eEncryption: {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomCreationPolicy {
|
|
||||||
allowRoomCreation: boolean;
|
|
||||||
requireAuthentication?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DTOs
|
|
||||||
export interface SecurityPreferencesDTO {
|
|
||||||
authentication: AuthenticationPreferencesDTO;
|
|
||||||
roomCreationPolicy: RoomCreationPolicy;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateSecurityPreferencesDTO = Partial<SecurityPreferencesDTO>;
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user