frontend: move room and recording parameter extraction guards to its corresponding domain folder and refactor code

This commit is contained in:
juancarmore 2026-02-10 11:10:35 +01:00
parent 5bb9a2f3e1
commit 9f46d03646
24 changed files with 177 additions and 255 deletions

View File

@ -0,0 +1,47 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { NavigationService } from '../../../shared/services/navigation.service';
import { SessionStorageService } from '../../../shared/services/session-storage.service';
import { extractParams, handleLeaveRedirectUrl } from '../../../shared/utils/url-params.utils';
import { RoomMemberContextService } from '../../room-members/services/room-member-context.service';
import { MeetingContextService } from '../services/meeting-context.service';
export const extractRoomParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService);
const meetingContextService = inject(MeetingContextService);
const roomMemberContextService = inject(RoomMemberContextService);
const sessionStorageService = inject(SessionStorageService);
const {
roomId,
secret: querySecret,
participantName,
leaveRedirectUrl,
showOnlyRecordings,
e2eeKey: queryE2eeKey
} = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret();
const e2eeKey = queryE2eeKey || sessionStorageService.getE2EEKey();
// Handle leave redirect URL logic
handleLeaveRedirectUrl(leaveRedirectUrl);
// Save parameters in the meeting context and room member context services
meetingContextService.setRoomId(roomId);
if (secret) {
meetingContextService.setRoomSecret(secret, true);
}
if (e2eeKey) {
meetingContextService.setE2eeKey(e2eeKey);
}
if (participantName) {
roomMemberContextService.setParticipantName(participantName);
}
// If the showOnlyRecordings flag is set, redirect to the recordings page for the room
if (showOnlyRecordings === 'true') {
return navigationService.createRedirectionTo(`room/${roomId}/recordings`, { secret });
}
return true;
};

View File

@ -0,0 +1,2 @@
export * from './extract-params.guard';
export * from './validate-room-access.guard';

View File

@ -1,10 +1,9 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { NavigationErrorReason } from '../../../shared/models/navigation.model';
import { RoomMemberContextService } from '../../room-members/services/room-member-context.service';
import { NavigationService } from '../../../shared/services/navigation.service';
import { MeetingContextService } from '../../meeting/services/meeting-context.service';
import { RoomMemberContextService } from '../../room-members/services/room-member-context.service';
import { MeetingContextService } from '../services/meeting-context.service';
/**
* Guard to validate access to a room by generating a room member token.
@ -38,16 +37,12 @@ const validateRoomAccessInternal = async (pageUrl: string, validateRecordingPerm
const navigationService = inject(NavigationService);
const meetingContextService = inject(MeetingContextService);
const secret = meetingContextService.roomSecret();
const roomId = meetingContextService.roomId();
if (!roomId) {
console.error('Cannot validate room access: room ID is undefined');
return navigationService.redirectToErrorPage(NavigationErrorReason.INVALID_ROOM);
}
const secret = meetingContextService.roomSecret();
if (!secret) {
console.error('Cannot validate room access: room secret is undefined');
return navigationService.redirectToErrorPage(NavigationErrorReason.MISSING_ROOM_SECRET);
}
try {
await roomMemberService.generateToken(roomId, {
@ -59,13 +54,14 @@ const validateRoomAccessInternal = async (pageUrl: string, validateRecordingPerm
if (validateRecordingPermissions) {
if (!roomMemberService.hasPermission('canRetrieveRecordings')) {
// If the user does not have permission to retrieve recordings, redirect to the error page
return navigationService.redirectToErrorPage(NavigationErrorReason.UNAUTHORIZED_RECORDING_ACCESS);
return navigationService.redirectToErrorPage(NavigationErrorReason.FORBIDDEN_RECORDING_ACCESS);
}
}
return true;
} catch (error: any) {
console.error('Error generating room member token:', error);
const message = error?.error?.message || error.message || 'Unknown error';
switch (error.status) {
case 400:
// Invalid secret
@ -74,8 +70,17 @@ const validateRoomAccessInternal = async (pageUrl: string, validateRecordingPerm
// Unauthorized access
// Redirect to the login page with query param to redirect back to the page
return navigationService.redirectToLoginPage(pageUrl);
case 403:
// Insufficient permissions or anonymous access disabled
if (message.includes('Anonymous access')) {
return navigationService.redirectToErrorPage(NavigationErrorReason.ANONYMOUS_ACCESS_DISABLED);
}
return navigationService.redirectToErrorPage(NavigationErrorReason.FORBIDDEN_ROOM_ACCESS);
case 404:
// Room not found
// Room or member not found
if (message.includes('Room member')) {
return navigationService.redirectToErrorPage(NavigationErrorReason.INVALID_MEMBER);
}
return navigationService.redirectToErrorPage(NavigationErrorReason.INVALID_ROOM);
default:
return navigationService.redirectToErrorPage(NavigationErrorReason.INTERNAL_ERROR);

View File

@ -1,6 +1,6 @@
export * from './components';
export * from './customization';
export * from './guards';
export * from './models';
export * from './pages';
export * from './providers';
export * from './services';

View File

@ -1 +0,0 @@
export * from './meeting-context-adapter.provider';

View File

@ -1,12 +0,0 @@
import { Provider } from '@angular/core';
import { MEETING_CONTEXT_ADAPTER } from '../../../shared/adapters';
import { MeetingContextService } from '../services/meeting-context.service';
/**
* Provides the MeetingContextAdapter using the existing MeetingContextService.
* This allows shared guards to use the adapter interface without depending on domain services.
*/
export const MEETING_CONTEXT_ADAPTER_PROVIDER: Provider = {
provide: MEETING_CONTEXT_ADAPTER,
useExisting: MeetingContextService
};

View File

@ -1,9 +1,9 @@
import { WebComponentProperty } from '@openvidu-meet/typings';
import { extractRoomQueryParamsGuard } from '../../../shared/guards/extract-query-params.guard';
import { removeQueryParamsGuard } from '../../../shared/guards/remove-query-params.guard';
import { runGuardsSerially } from '../../../shared/guards/run-serially.guard';
import { DomainRouteConfig } from '../../../shared/models/domain-routes.model';
import { validateRoomAccessGuard } from '../../rooms/guards/room-validate-access.guard';
import { extractRoomParamsGuard } from '../guards/extract-params.guard';
import { validateRoomAccessGuard } from '../guards/validate-room-access.guard';
/**
* Meeting domain route configurations
@ -15,7 +15,7 @@ export const meetingDomainRoutes: DomainRouteConfig[] = [
loadComponent: () => import('../pages/meeting/meeting.component').then((m) => m.MeetingComponent),
canActivate: [
runGuardsSerially(
extractRoomQueryParamsGuard,
extractRoomParamsGuard,
validateRoomAccessGuard,
removeQueryParamsGuard(['secret', WebComponentProperty.E2EE_KEY])
)

View File

@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { SessionStorageService } from '../../../shared/services/session-storage.service';
import { extractParams } from '../../../shared/utils/url-params.utils';
import { MeetingContextService } from '../../meeting/services/meeting-context.service';
export const extractRoomRecordingsParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const meetingContextService = inject(MeetingContextService);
const sessionStorageService = inject(SessionStorageService);
const { roomId, secret: querySecret } = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret();
// Save parameters in the meeting context service
meetingContextService.setRoomId(roomId);
if (secret) {
meetingContextService.setRoomSecret(secret, true);
}
return true;
};

View File

@ -1 +1,2 @@
export * from './extract-params.guard';
export * from './recording-validate-access.guard';

View File

@ -17,11 +17,6 @@ export const validateRecordingAccessGuard: CanActivateFn = async (
const recordingId = route.params['recording-id'];
const secret = route.queryParams['secret'];
if (!secret) {
// If no secret is provided, redirect to the error page
return navigationService.redirectToErrorPage(NavigationErrorReason.MISSING_RECORDING_SECRET);
}
try {
// Attempt to access the recording to check if the secret is valid
await recordingService.getRecording(recordingId, secret);

View File

@ -1,9 +1,8 @@
import { WebComponentProperty } from '@openvidu-meet/typings';
import { extractRecordingQueryParamsGuard } from '../../../shared/guards/extract-query-params.guard';
import { removeQueryParamsGuard } from '../../../shared/guards/remove-query-params.guard';
import { runGuardsSerially } from '../../../shared/guards/run-serially.guard';
import { DomainRouteConfig } from '../../../shared/models/domain-routes.model';
import { validateRoomRecordingsAccessGuard } from '../../rooms/guards/room-validate-access.guard';
import { validateRoomRecordingsAccessGuard } from '../../meeting/guards/validate-room-access.guard';
import { extractRoomRecordingsParamsGuard } from '../guards/extract-params.guard';
import { validateRecordingAccessGuard } from '../guards/recording-validate-access.guard';
/**
@ -19,9 +18,9 @@ export const recordingsDomainRoutes: DomainRouteConfig[] = [
),
canActivate: [
runGuardsSerially(
extractRecordingQueryParamsGuard,
extractRoomRecordingsParamsGuard,
validateRoomRecordingsAccessGuard,
removeQueryParamsGuard(['secret', WebComponentProperty.E2EE_KEY])
removeQueryParamsGuard(['secret'])
)
]
}

View File

@ -1,3 +1,2 @@
export * from './interceptor-handlers';
export * from './providers';
export * from './services';

View File

@ -1 +0,0 @@
export * from './room-member-adapter.provider';

View File

@ -1,12 +0,0 @@
import { Provider } from '@angular/core';
import { ROOM_MEMBER_ADAPTER } from '../../../shared/adapters';
import { RoomMemberContextService } from '../../room-members/services/room-member-context.service';
/**
* Provides the RoomMemberAdapter using the existing RoomMemberService.
* This allows shared guards to use the adapter interface without depending on domain services.
*/
export const ROOM_MEMBER_ADAPTER_PROVIDER: Provider = {
provide: ROOM_MEMBER_ADAPTER,
useExisting: RoomMemberContextService
};

View File

@ -1,2 +1 @@
export * from './room-edit-check.guard';
export * from './room-validate-access.guard';

View File

@ -1,3 +0,0 @@
export * from './meeting-context.adapter';
export * from './room-member.adapter';

View File

@ -1,27 +0,0 @@
import { InjectionToken } from '@angular/core';
/**
* Adapter interface for meeting context operations.
* This allows shared guards to interact with meeting context without directly depending on domain services.
*/
export interface MeetingContextAdapter {
/**
* Sets the room ID for the current meeting context
*/
setRoomId(roomId: string): void;
/**
* Sets the room secret for the current meeting context
*/
setRoomSecret(secret: string, persistInStorage?: boolean): void;
/**
* Sets the E2EE encryption key for the current meeting
*/
setE2eeKey(key: string): void;
}
/**
* Injection token for the MeetingContextAdapter
*/
export const MEETING_CONTEXT_ADAPTER = new InjectionToken<MeetingContextAdapter>('MEETING_CONTEXT_ADAPTER');

View File

@ -1,17 +0,0 @@
import { InjectionToken } from '@angular/core';
/**
* Adapter interface for room member operations.
* This allows shared guards to interact with room member context without directly depending on domain services.
*/
export interface RoomMemberAdapter {
/**
* Sets the participant name for the current room member
*/
setParticipantName(name: string): void;
}
/**
* Injection token for the RoomMemberAdapter
*/
export const ROOM_MEMBER_ADAPTER = new InjectionToken<RoomMemberAdapter>('ROOM_MEMBER_ADAPTER');

View File

@ -1,148 +0,0 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { WebComponentProperty } from '@openvidu-meet/typings';
import { MEETING_CONTEXT_ADAPTER, ROOM_MEMBER_ADAPTER } from '../adapters';
import { NavigationErrorReason } from '../models/navigation.model';
import { AppDataService } from '../services/app-data.service';
import { NavigationService } from '../services/navigation.service';
import { SessionStorageService } from '../services/session-storage.service';
export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService);
const meetingContextAdapter = inject(MEETING_CONTEXT_ADAPTER);
const roomMemberAdapter = inject(ROOM_MEMBER_ADAPTER);
const sessionStorageService = inject(SessionStorageService);
const {
roomId,
secret: querySecret,
participantName,
leaveRedirectUrl,
showOnlyRecordings,
e2eeKey: queryE2eeKey
} = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret();
const e2eeKey = queryE2eeKey || sessionStorageService.getE2EEKey();
// Handle leave redirect URL logic
handleLeaveRedirectUrl(leaveRedirectUrl);
if (!secret) {
// If no secret is provided, redirect to the error page
return navigationService.redirectToErrorPage(NavigationErrorReason.MISSING_ROOM_SECRET);
}
meetingContextAdapter.setRoomId(roomId);
meetingContextAdapter.setRoomSecret(secret, true);
if (e2eeKey) {
meetingContextAdapter.setE2eeKey(e2eeKey);
}
if (participantName) {
roomMemberAdapter.setParticipantName(participantName);
}
if (showOnlyRecordings === 'true') {
// Redirect to the room recordings page
return navigationService.createRedirectionTo(`room/${roomId}/recordings`, { secret });
}
return true;
};
export const extractRecordingQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService);
const meetingContextAdapter = inject(MEETING_CONTEXT_ADAPTER);
const sessionStorageService = inject(SessionStorageService);
const { roomId, secret: querySecret } = extractParams(route);
const secret = querySecret || sessionStorageService.getRoomSecret();
if (!secret) {
// If no secret is provided, redirect to the error page
return navigationService.redirectToErrorPage(NavigationErrorReason.MISSING_ROOM_SECRET);
}
meetingContextAdapter.setRoomId(roomId);
meetingContextAdapter.setRoomSecret(secret, true);
return true;
};
const extractParams = ({ params, queryParams }: ActivatedRouteSnapshot) => ({
roomId: params['room-id'] as string,
secret: queryParams['secret'] as string,
participantName: queryParams[WebComponentProperty.PARTICIPANT_NAME] as string,
leaveRedirectUrl: queryParams[WebComponentProperty.LEAVE_REDIRECT_URL] as string,
showOnlyRecordings: (queryParams[WebComponentProperty.SHOW_ONLY_RECORDINGS] as string) || 'false',
e2eeKey: queryParams[WebComponentProperty.E2EE_KEY] as string
});
/**
* Handles the leave redirect URL logic with automatic referrer detection
*/
const handleLeaveRedirectUrl = (leaveRedirectUrl: string | undefined) => {
const navigationService = inject(NavigationService);
const appDataService = inject(AppDataService);
const isEmbeddedMode = appDataService.isEmbeddedMode();
// Explicit valid URL provided - use as is
if (leaveRedirectUrl && isValidUrl(leaveRedirectUrl)) {
navigationService.setLeaveRedirectUrl(leaveRedirectUrl);
return;
}
// Absolute path provided in embedded mode - construct full URL based on parent origin
if (isEmbeddedMode && leaveRedirectUrl?.startsWith('/')) {
const parentUrl = document.referrer;
const parentOrigin = new URL(parentUrl).origin;
navigationService.setLeaveRedirectUrl(parentOrigin + leaveRedirectUrl);
return;
}
// Auto-detect from referrer (only if no explicit URL provided and not embedded)
if (!leaveRedirectUrl && !isEmbeddedMode) {
const autoRedirectUrl = getAutoRedirectUrl();
if (autoRedirectUrl) {
navigationService.setLeaveRedirectUrl(autoRedirectUrl);
}
}
};
/**
* Automatically detects if user came from another domain and returns appropriate redirect URL
*/
const getAutoRedirectUrl = (): string | null => {
try {
const referrer = document.referrer;
// No referrer means user typed URL directly or came from bookmark
if (!referrer) {
return null;
}
const referrerUrl = new URL(referrer);
const currentUrl = new URL(window.location.href);
// Check if referrer is from a different domain
if (referrerUrl.origin !== currentUrl.origin) {
console.log(`Auto-configuring leave redirect to referrer: ${referrer}`);
return referrer;
}
return null;
} catch (error) {
console.warn('Error detecting auto redirect URL:', error);
return null;
}
};
const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch (error) {
return false;
}
};

View File

@ -1,4 +1,2 @@
export * from './extract-query-params.guard';
export * from './remove-query-params.guard';
export * from './run-serially.guard';

View File

@ -1,4 +1,3 @@
export * from './adapters';
export * from './components';
export * from './guards';
export * from './interceptors';

View File

@ -1,4 +1,4 @@
export * from './array.utils';
export * from './format.utils';
export * from './token.utils';
export * from './url-params.utils';

View File

@ -0,0 +1,82 @@
import { ActivatedRouteSnapshot } from "@angular/router";
import { WebComponentProperty } from "@openvidu-meet/typings";
import { NavigationService } from "../services/navigation.service";
import { AppDataService } from "../services/app-data.service";
import { inject } from "@angular/core";
export const extractParams = ({ params, queryParams }: ActivatedRouteSnapshot) => ({
roomId: params['room-id'] as string,
secret: queryParams['secret'] as string | undefined,
participantName: queryParams[WebComponentProperty.PARTICIPANT_NAME] as string | undefined,
leaveRedirectUrl: queryParams[WebComponentProperty.LEAVE_REDIRECT_URL] as string | undefined,
showOnlyRecordings: (queryParams[WebComponentProperty.SHOW_ONLY_RECORDINGS] as string) || 'false',
e2eeKey: queryParams[WebComponentProperty.E2EE_KEY] as string | undefined
});
/**
* Handles the leave redirect URL logic with automatic referrer detection
*/
export const handleLeaveRedirectUrl = (leaveRedirectUrl: string | undefined) => {
const navigationService = inject(NavigationService);
const appDataService = inject(AppDataService);
const isEmbeddedMode = appDataService.isEmbeddedMode();
// Explicit valid URL provided - use as is
if (leaveRedirectUrl && isValidUrl(leaveRedirectUrl)) {
navigationService.setLeaveRedirectUrl(leaveRedirectUrl);
return;
}
// Absolute path provided in embedded mode - construct full URL based on parent origin
if (isEmbeddedMode && leaveRedirectUrl?.startsWith('/')) {
const parentUrl = document.referrer;
const parentOrigin = new URL(parentUrl).origin;
navigationService.setLeaveRedirectUrl(parentOrigin + leaveRedirectUrl);
return;
}
// Auto-detect from referrer (only if no explicit URL provided and not embedded)
if (!leaveRedirectUrl && !isEmbeddedMode) {
const autoRedirectUrl = getAutoRedirectUrl();
if (autoRedirectUrl) {
navigationService.setLeaveRedirectUrl(autoRedirectUrl);
}
}
};
/**
* Automatically detects if user came from another domain and returns appropriate redirect URL
*/
const getAutoRedirectUrl = (): string | null => {
try {
const referrer = document.referrer;
// No referrer means user typed URL directly or came from bookmark
if (!referrer) {
return null;
}
const referrerUrl = new URL(referrer);
const currentUrl = new URL(window.location.href);
// Check if referrer is from a different domain
if (referrerUrl.origin !== currentUrl.origin) {
console.log(`Auto-configuring leave redirect to referrer: ${referrer}`);
return referrer;
}
return null;
} catch (error) {
console.warn('Error detecting auto redirect URL:', error);
return null;
}
};
const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch (error) {
return false;
}
};

View File

@ -15,9 +15,7 @@ import {
AuthInterceptorErrorHandlerService,
CustomParticipantModel,
httpInterceptor,
MEETING_CONTEXT_ADAPTER_PROVIDER,
MeetingLayoutService,
ROOM_MEMBER_ADAPTER_PROVIDER,
RoomMemberInterceptorErrorHandlerService,
ThemeService
} from '@openvidu-meet/shared-components';
@ -40,8 +38,6 @@ export const appConfig: ApplicationConfig = {
provideAppInitializer(() => inject(RoomMemberInterceptorErrorHandlerService).init()),
importProvidersFrom(OpenViduComponentsModule.forRoot(ovComponentsconfig)),
{ provide: LayoutService, useClass: MeetingLayoutService },
MEETING_CONTEXT_ADAPTER_PROVIDER,
ROOM_MEMBER_ADAPTER_PROVIDER,
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(ceRoutes),
provideAnimationsAsync(),