From b4ac4933bfda0ff302a13d7b46fce0b246187e8e Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Thu, 13 Mar 2025 12:38:08 +0100 Subject: [PATCH] frontend: Add session storage service and integrate moderator secret handling in room access validation --- .../src/lib/guards/index.ts | 1 + .../src/lib/guards/participant-name.guard.ts | 5 +- .../guards/replace-moderator-secret.guard.ts | 76 +++++++++++++++++ .../lib/guards/validate-room-access.guard.ts | 40 +++++---- .../src/lib/routes/base-routes.ts | 13 ++- .../src/lib/services/http/http.service.ts | 17 ++-- .../src/lib/services/index.ts | 1 + .../session-storage.service.spec.ts | 16 ++++ .../session-storage.service.ts | 81 +++++++++++++++++++ 9 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 frontend/projects/shared-meet-components/src/lib/guards/replace-moderator-secret.guard.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.ts diff --git a/frontend/projects/shared-meet-components/src/lib/guards/index.ts b/frontend/projects/shared-meet-components/src/lib/guards/index.ts index d3cf22e..9084fb1 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/index.ts @@ -3,3 +3,4 @@ export * from './extract-query-params.guard'; export * from './validate-room-access.guard'; export * from './application-mode.guard'; export * from './participant-name.guard'; +export * from './replace-moderator-secret.guard'; diff --git a/frontend/projects/shared-meet-components/src/lib/guards/participant-name.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/participant-name.guard.ts index 24d8da1..65b6437 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/participant-name.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/participant-name.guard.ts @@ -7,10 +7,11 @@ export const checkParticipantNameGuard: CanActivateFn = async (route, state) => const router = inject(Router); const contextService = inject(ContextService); const roomName = route.params['room-name']; + const hasParticipantName = !!contextService.getParticipantName(); // Check if participant name exists in the service - if (!contextService.getParticipantName()) { - // Redirect to a page where the user can input their participant name + if (!hasParticipantName) { + // Redirect to a page where the participant can input their participant name return router.navigate([`${roomName}/participant-name`], { queryParams: { originUrl: state.url, t: Date.now() }, skipLocationChange: true diff --git a/frontend/projects/shared-meet-components/src/lib/guards/replace-moderator-secret.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/replace-moderator-secret.guard.ts new file mode 100644 index 0000000..b6cd3b1 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/guards/replace-moderator-secret.guard.ts @@ -0,0 +1,76 @@ +import { inject } from '@angular/core'; +import { Location } from '@angular/common'; +import { CanActivateFn, NavigationEnd } from '@angular/router'; +import { Router } from '@angular/router'; +import { ContextService, HttpService, SessionStorageService } from '../services'; +import { filter, take } from 'rxjs'; + +/** + * Guard that replaces the moderator secret in the URL with the publisher secret. + * + * This guard checks if the current participant is a moderator. If so, it retrieves the moderator and publisher secrets + * for the current room and updates the session storage with the moderator secret. It then replaces the secret in the URL + * with the publisher secret. + * + * @param route - The activated route snapshot. + * @param state - The router state snapshot. + * @returns A promise that resolves to `true` if the operation is successful, otherwise `false`. + * + * @throws Will log an error and return `false` if an error occurs during the process. + */ +export const replaceModeratorSecretGuard: CanActivateFn = async (route, state) => { + const httpService = inject(HttpService); + const contextService = inject(ContextService); + const router = inject(Router); + const location: Location = inject(Location); + const sessionStorageService = inject(SessionStorageService); + + try { + router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + take(1) + ) + .subscribe(async () => { + if (contextService.isModeratorParticipant()) { + const roomName = contextService.getRoomName(); + const { moderatorSecret, publisherSecret } = await getUrlSecret(httpService, roomName); + + sessionStorageService.setModeratorSecret(roomName, moderatorSecret); + // Replace secret in URL by the publisher secret + const queryParams = { ...route.queryParams, secret: publisherSecret }; + const urlTree = router.createUrlTree([], { queryParams, queryParamsHandling: 'merge' }); + const newUrl = router.serializeUrl(urlTree); + + location.replaceState(newUrl); + } + }); + + return true; + } catch (error) { + console.error('error', error); + return false; + } +}; + +const getUrlSecret = async ( + httpService: HttpService, + roomName: string +): Promise<{ moderatorSecret: string; publisherSecret: string }> => { + const { moderatorRoomUrl, publisherRoomUrl } = await httpService.getRoom( + roomName, + 'moderatorRoomUrl,publisherRoomUrl' + ); + + const extractSecret = (urlString: string, type: string): string => { + const url = new URL(urlString); + const secret = url.searchParams.get('secret'); + if (!secret) throw new Error(`${type} secret not found`); + return secret; + }; + + const publisherSecret = extractSecret(publisherRoomUrl, 'Publisher'); + const moderatorSecret = extractSecret(moderatorRoomUrl, 'Moderator'); + + return { publisherSecret, moderatorSecret }; +}; diff --git a/frontend/projects/shared-meet-components/src/lib/guards/validate-room-access.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/validate-room-access.guard.ts index 4c38b4b..f492195 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/validate-room-access.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/validate-room-access.guard.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, CanActivateFn } from '@angular/router'; -import { ContextService, HttpService } from '../services'; +import { ContextService, HttpService, SessionStorageService } from '../services'; /** * Guard to validate the access to a room. @@ -12,30 +12,38 @@ export const validateRoomAccessGuard: CanActivateFn = async ( const httpService = inject(HttpService); const contextService = inject(ContextService); const router = inject(Router); + const sessionStorageService = inject(SessionStorageService); const { roomName, participantName, secret } = extractParams(route); - - if (contextService.isEmbeddedMode() && !contextService.getParticipantName()) { - await redirectToUnauthorized(router, 'invalid-participant'); - return false; - } + const storageSecret = sessionStorageService.getModeratorSecret(roomName); try { // Generate a participant token - const response = await httpService.generateParticipantToken({ roomName, participantName, secret }); + const response = await httpService.generateParticipantToken({ + roomName, + participantName, + secret: storageSecret || secret + }); contextService.setToken(response.token); return true; } catch (error: any) { - if (error.status === 409) { - // Participant already exists - // Redirect to a page where the user can input their participant name - await router.navigate([`${roomName}/participant-name`], { - queryParams: { originUrl: _state.url, accessError: 'participant-exists', t: Date.now() }, - skipLocationChange: true - }); - return false; + console.error('Error generating participant token:', error); + switch (error.status) { + case 409: + // Participant already exists. + // Send a timestamp to force update the query params and show the error message in participant name input form + await router.navigate([`${roomName}/participant-name`], { + queryParams: { originUrl: _state.url, accessError: 'participant-exists', t: Date.now() }, + skipLocationChange: true + }); + break; + case 406: + await redirectToUnauthorized(router, 'unauthorized-participant'); + break; + default: + await redirectToUnauthorized(router, 'invalid-room'); } - return await redirectToUnauthorized(router, 'invalid-room'); + return false; } }; diff --git a/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts b/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts index 3c08fb6..15bf212 100644 --- a/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts +++ b/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts @@ -7,7 +7,8 @@ import { validateRoomAccessGuard, applicationModeGuard, extractQueryParamsGuard, - checkParticipantNameGuard + checkParticipantNameGuard, + replaceModeratorSecretGuard } from '../guards'; import { AboutComponent, @@ -77,11 +78,17 @@ export const baseRoutes: Routes = [ { path: ':room-name', component: VideoRoomComponent, - canActivate: [applicationModeGuard, extractQueryParamsGuard, checkParticipantNameGuard, validateRoomAccessGuard], + canActivate: [ + applicationModeGuard, + extractQueryParamsGuard, + checkParticipantNameGuard, + validateRoomAccessGuard, + replaceModeratorSecretGuard + ] }, { path: ':room-name/participant-name', - component: ParticipantNameFormComponent, + component: ParticipantNameFormComponent }, // Redirect all other routes to home diff --git a/frontend/projects/shared-meet-components/src/lib/services/http/http.service.ts b/frontend/projects/shared-meet-components/src/lib/services/http/http.service.ts index 2dde157..326e114 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/http/http.service.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/http/http.service.ts @@ -23,13 +23,20 @@ export class HttpService { return this.deleteRequest(`${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`); } - listRooms(): Promise { - //TODO: Add 'fields' query param for filtering rooms by fields - return this.getRequest(`${this.pathPrefix}/${this.apiVersion}/rooms`); + listRooms(fields?: string): Promise { + let path = `${this.pathPrefix}/${this.apiVersion}/rooms/`; + if (fields) { + path += `?fields=${encodeURIComponent(fields)}`; + } + return this.getRequest(path); } - getRoom(roomName: string): Promise { - return this.getRequest(`${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`); + getRoom(roomName: string, fields?: string): Promise { + let path = `${this.pathPrefix}/${this.apiVersion}/rooms/${roomName}`; + if (fields) { + path += `?fields=${encodeURIComponent(fields)}`; + } + return this.getRequest(path); } generateParticipantToken(tokenOptions: TokenOptions): Promise<{ token: string }> { diff --git a/frontend/projects/shared-meet-components/src/lib/services/index.ts b/frontend/projects/shared-meet-components/src/lib/services/index.ts index 1a91ed4..66a680f 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/index.ts @@ -5,3 +5,4 @@ export * from './global-preferences/global-preferences.service'; export * from './room/room.service'; export * from './notification/notification.service'; export * from './webcomponent-manager/webcomponent-manager.service'; +export * from './session-storage/session-storage.service'; diff --git a/frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.spec.ts b/frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.spec.ts new file mode 100644 index 0000000..78a1335 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SessionStorageService } from './session-storage.service'; + +describe('SessionStorageService', () => { + let service: SessionStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SessionStorageService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.ts b/frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.ts new file mode 100644 index 0000000..42e32a0 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Service for managing session storage operations. + * Provides methods to store, retrieve, and remove data from sessionStorage. + */ +export class SessionStorageService { + constructor() {} + + /** + * Stores a moderator secret for a specific room. + * + * @param roomName The room name. + * @param secret The secret string. + */ + public setModeratorSecret(roomName: string, secret: string): void { + this.set(`moderator_secret_${roomName}`, secret); + } + + /** + * Retrieves the moderator secret for a specific room. + * + * @param roomName The room name. + * @returns The stored secret or null if not found. + */ + public getModeratorSecret(roomName: string): string | null { + return this.get(`moderator_secret_${roomName}`) ?? null; + } + + /** + * Removes the moderator secret for a specific room. + * + * @param roomName The room name. + */ + public removeModeratorSecret(roomName: string): void { + this.remove(`moderator_secret_${roomName}`); + } + + /** + * Clears all data stored in sessionStorage. + */ + public clear(): void { + sessionStorage.clear(); + } + + /** + * Stores a value in sessionStorage. + * The value is converted to a JSON string before saving. + * + * @param key The key under which the value will be stored. + * @param value The value to be stored (any type). + */ + protected set(key: string, value: any): void { + const jsonValue = JSON.stringify(value); + sessionStorage.setItem(key, jsonValue); + } + + /** + * Retrieves a value from sessionStorage. + * The value is parsed from JSON back to its original type. + * + * @param key The key of the item to retrieve. + * @returns The stored value or null if the key does not exist. + */ + protected get(key: string): T | null { + const jsonValue = sessionStorage.getItem(key); + return jsonValue ? (JSON.parse(jsonValue) as T) : null; + } + + /** + * Removes a specific item from sessionStorage. + * + * @param key The key of the item to remove. + */ + protected remove(key: string): void { + sessionStorage.removeItem(key); + } +}