diff --git a/server/src/api/record.ts b/server/src/api/record.ts index 05560487..b4014c3a 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -507,9 +507,9 @@ async function createWorkflowAndStoreMetadata(id: string, userId: string) { } } -async function readyForRunHandler(browserId: string, id: string) { +async function readyForRunHandler(browserId: string, id: string, userId: string){ try { - const result = await executeRun(id); + const result = await executeRun(id, userId); if (result && result.success) { logger.log('info', `Interpretation of ${id} succeeded`); @@ -517,14 +517,14 @@ async function readyForRunHandler(browserId: string, id: string) { return result.interpretationInfo; } else { logger.log('error', `Interpretation of ${id} failed`); - await destroyRemoteBrowser(browserId); + await destroyRemoteBrowser(browserId, userId); resetRecordingState(browserId, id); return null; } } catch (error: any) { logger.error(`Error during readyForRunHandler: ${error.message}`); - await destroyRemoteBrowser(browserId); + await destroyRemoteBrowser(browserId, userId); return null; } } @@ -546,7 +546,7 @@ function AddGeneratedFlags(workflow: WorkflowFile) { return copy; }; -async function executeRun(id: string) { +async function executeRun(id: string, userId: string) { try { const run = await Run.findOne({ where: { runId: id } }); if (!run) { @@ -568,7 +568,7 @@ async function executeRun(id: string) { plainRun.status = 'running'; - const browser = browserPool.getRemoteBrowser(plainRun.browserId); + const browser = browserPool.getRemoteBrowser(userId); if (!browser) { throw new Error('Could not access browser'); } @@ -586,7 +586,7 @@ async function executeRun(id: string) { const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); - await destroyRemoteBrowser(plainRun.browserId); + await destroyRemoteBrowser(plainRun.browserId, userId); const updatedRun = await run.update({ ...run, @@ -672,12 +672,12 @@ export async function handleRunRecording(id: string, userId: string) { rejectUnauthorized: false }); - socket.on('ready-for-run', () => readyForRunHandler(browserId, newRunId)); + socket.on('ready-for-run', () => readyForRunHandler(browserId, newRunId, userId)); logger.log('info', `Running Robot: ${id}`); socket.on('disconnect', () => { - cleanupSocketListeners(socket, browserId, newRunId); + cleanupSocketListeners(socket, browserId, newRunId, userId); }); // Return the runId immediately, so the client knows the run is started @@ -688,8 +688,8 @@ export async function handleRunRecording(id: string, userId: string) { } } -function cleanupSocketListeners(socket: Socket, browserId: string, id: string) { - socket.off('ready-for-run', () => readyForRunHandler(browserId, id)); +function cleanupSocketListeners(socket: Socket, browserId: string, id: string, userId: string) { + socket.off('ready-for-run', () => readyForRunHandler(browserId, id, userId)); logger.log('info', `Cleaned up listeners for browserId: ${browserId}, runId: ${id}`); } diff --git a/server/src/browser-management/classes/BrowserPool.ts b/server/src/browser-management/classes/BrowserPool.ts index cd4962a1..4bbc97af 100644 --- a/server/src/browser-management/classes/BrowserPool.ts +++ b/server/src/browser-management/classes/BrowserPool.ts @@ -15,6 +15,10 @@ interface BrowserPoolInfo { * @default false */ active: boolean, + /** + * The user ID that owns this browser instance. + */ + userId: string, } /** @@ -29,36 +33,101 @@ interface PoolDictionary { /** * A browser pool is a collection of remote browsers that are initialized and ready to be used. + * Enforces a "1 User - 1 Browser" policy, while allowing multiple users to have their own browser instances. * Adds the possibility to add, remove and retrieve remote browsers from the pool. - * It is possible to manage multiple browsers for creating or running a recording. * @category BrowserManagement */ export class BrowserPool { - /** * Holds all the instances of remote browsers. */ private pool: PoolDictionary = {}; /** - * Adds a remote browser instance to the pool indexed by the id. + * Maps user IDs to their browser IDs. + */ + private userToBrowserMap: Map = new Map(); + + /** + * Adds a remote browser instance to the pool for a specific user. + * If the user already has a browser, the existing browser will be closed and replaced. + * * @param id remote browser instance's id * @param browser remote browser instance + * @param userId the user ID that owns this browser instance * @param active states if the browser's instance is being actively used + * @returns true if a new browser was added, false if an existing browser was replaced */ - public addRemoteBrowser = (id: string, browser: RemoteBrowser, active: boolean = false): void => { - this.pool = { - ...this.pool, - [id]: { - browser, - active, - }, + public addRemoteBrowser = ( + id: string, + browser: RemoteBrowser, + userId: string, + active: boolean = false + ): boolean => { + // Check if user already has a browser + const existingBrowserId = this.userToBrowserMap.get(userId); + let replaced = false; + + if (existingBrowserId) { + // Close and remove the existing browser + if (existingBrowserId !== id) { + this.closeAndDeleteBrowser(existingBrowserId); + replaced = true; + } else { + // If it's the same browser ID, just update the info + this.pool[id] = { + browser, + active, + userId, + }; + logger.log('debug', `Updated existing browser with id: ${id} for user: ${userId}`); + return false; + } } - logger.log('debug', `Remote browser with id: ${id} added to the pool`); + + // Add the new browser to the pool + this.pool[id] = { + browser, + active, + userId, + }; + + // Update the user-to-browser mapping + this.userToBrowserMap.set(userId, id); + + logger.log('debug', `Remote browser with id: ${id} added to the pool for user: ${userId}`); + return !replaced; }; /** * Removes the remote browser instance from the pool. + * Note: This doesn't handle browser closing as RemoteBrowser doesn't expose a close method. + * The caller should ensure the browser is properly closed before calling this method. + * + * @param id remote browser instance's id + * @returns true if the browser was removed successfully, false otherwise + */ + public closeAndDeleteBrowser = (id: string): boolean => { + if (!this.pool[id]) { + logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`); + return false; + } + + // Remove the user-to-browser mapping + const userId = this.pool[id].userId; + if (this.userToBrowserMap.get(userId) === id) { + this.userToBrowserMap.delete(userId); + } + + // Remove from pool + delete this.pool[id]; + logger.log('debug', `Remote browser with id: ${id} removed from the pool`); + return true; + }; + + /** + * Removes the remote browser instance from the pool without attempting to close it. + * * @param id remote browser instance's id * @returns true if the browser was removed successfully, false otherwise */ @@ -67,13 +136,22 @@ export class BrowserPool { logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`); return false; } - delete (this.pool[id]); + + // Remove the user-to-browser mapping + const userId = this.pool[id].userId; + if (this.userToBrowserMap.get(userId) === id) { + this.userToBrowserMap.delete(userId); + } + + // Remove from pool + delete this.pool[id]; logger.log('debug', `Remote browser with id: ${id} deleted from the pool`); return true; }; /** * Returns the remote browser instance from the pool. + * * @param id remote browser instance's id * @returns remote browser instance or undefined if it does not exist in the pool */ @@ -83,18 +161,154 @@ export class BrowserPool { }; /** - * Returns the active browser's instance id from the pool. - * If there is no active browser, it returns undefined. - * If there are multiple active browsers, it returns the first one. - * @returns the first remote active browser instance's id from the pool + * Returns the active browser's instance id for a specific user. + * + * @param userId the user ID to find the browser for + * @returns the browser ID for the user, or null if no browser exists */ - public getActiveBrowserId = (): string | null => { + public getActiveBrowserId = (userId: string): string | null => { + const browserId = this.userToBrowserMap.get(userId); + if (!browserId) { + logger.log('debug', `No browser found for user: ${userId}`); + return null; + } + + // Verify the browser still exists in the pool + if (!this.pool[browserId]) { + this.userToBrowserMap.delete(userId); + logger.log('warn', `Browser mapping found for user: ${userId}, but browser doesn't exist in pool`); + return null; + } + console.log(`Browser Id ${browserId} found for user: ${userId}`); + return browserId; + }; + + /** + * Returns the user ID associated with a browser ID. + * + * @param browserId the browser ID to find the user for + * @returns the user ID for the browser, or null if the browser doesn't exist + */ + public getUserForBrowser = (browserId: string): string | null => { + if (!this.pool[browserId]) { + return null; + } + return this.pool[browserId].userId; + }; + + /** + * Sets the active state of a browser. + * + * @param id the browser ID + * @param active the new active state + * @returns true if successful, false if the browser wasn't found + */ + public setActiveBrowser = (id: string, active: boolean): boolean => { + if (!this.pool[id]) { + logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`); + return false; + } + + this.pool[id].active = active; + logger.log('debug', `Remote browser with id: ${id} set to ${active ? 'active' : 'inactive'}`); + return true; + }; + + /** + * Returns all browser instances for a specific user. + * Should only be one per the "1 User - 1 Browser" policy, but included for flexibility. + * + * @param userId the user ID to find browsers for + * @returns an array of browser IDs belonging to the user + */ + public getAllBrowserIdsForUser = (userId: string): string[] => { + const browserIds: string[] = []; + + // Normally this would just return the one browser from the map + const mappedBrowserId = this.userToBrowserMap.get(userId); + if (mappedBrowserId && this.pool[mappedBrowserId]) { + browserIds.push(mappedBrowserId); + } + + // But as a safeguard, also check the entire pool for any browsers assigned to this user + // This helps detect and fix any inconsistencies in the maps + for (const [id, info] of Object.entries(this.pool)) { + if (info.userId === userId && !browserIds.includes(id)) { + browserIds.push(id); + // Fix the map if it's inconsistent + if (!mappedBrowserId) { + this.userToBrowserMap.set(userId, id); + } + } + } + + return browserIds; + }; + + /** + * Returns the total number of browsers in the pool. + */ + public getPoolSize = (): number => { + return Object.keys(this.pool).length; + }; + + /** + * Returns the total number of active users (users with browsers). + */ + public getActiveUserCount = (): number => { + return this.userToBrowserMap.size; + }; + + /** + * Gets the current active browser for the system if there's only one active user. + * This is a migration helper to support code that hasn't been updated to the user-browser model yet. + * + * @param currentUserId The ID of the current user, which will be prioritized if multiple browsers exist + * @returns A browser ID if one can be determined, or null + */ + public getActiveBrowserForMigration = (currentUserId?: string): string | null => { + // If a current user ID is provided and they have a browser, return that + if (currentUserId) { + const browserForUser = this.getActiveBrowserId(currentUserId); + if (browserForUser) { + return browserForUser; + } + } + + // If only one user has a browser, return that + if (this.userToBrowserMap.size === 1) { + const userId = Array.from(this.userToBrowserMap.keys())[0]; + return this.userToBrowserMap.get(userId) || null; + } + + // Fall back to the first active browser if any for (const id of Object.keys(this.pool)) { if (this.pool[id].active) { return id; } } - logger.log('warn', `No active browser in the pool`); + + // If all else fails, return the first browser in the pool + const browserIds = Object.keys(this.pool); + return browserIds.length > 0 ? browserIds[0] : null; + }; + + /** + * Returns the first active browser's instance id from the pool. + * If there is no active browser, it returns null. + * If there are multiple active browsers, it returns the first one. + * + * @returns the first remote active browser instance's id from the pool + * @deprecated Use getBrowserIdForUser instead to enforce the 1 User - 1 Browser policy + */ + public getActiveBrowserIdLegacy = (): string | null => { + for (const id of Object.keys(this.pool)) { + if (this.pool[id].active) { + return id; + } + } + // Don't log a warning since this behavior is expected in the user-browser model + // logger.log('warn', `No active browser in the pool`); return null; }; -} +} \ No newline at end of file diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 8ff4f601..85d1928c 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -90,6 +90,12 @@ export class RemoteBrowser { maxRepeats: 1, }; + /** + * The user ID that owns this browser instance + * @private + */ + private userId: string; + private lastEmittedUrl: string | null = null; /** @@ -106,6 +112,7 @@ export class RemoteBrowser { private screenshotQueue: Buffer[] = []; private isProcessingScreenshot = false; private screencastInterval: NodeJS.Timeout | null = null + private isScreencastActive: boolean = false; /** * Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and @@ -113,8 +120,9 @@ export class RemoteBrowser { * @param socket socket.io socket instance used to communicate with the client side * @constructor */ - public constructor(socket: Socket) { + public constructor(socket: Socket, userId: string) { this.socket = socket; + this.userId = userId; this.interpreter = new WorkflowInterpreter(socket); this.generator = new WorkflowGenerator(socket); } @@ -193,7 +201,7 @@ export class RemoteBrowser { const currentUrl = page.url(); if (this.shouldEmitUrlChange(currentUrl)) { this.lastEmittedUrl = currentUrl; - this.socket.emit('urlChanged', currentUrl); + this.socket.emit('urlChanged', {url: currentUrl, userId: this.userId}); } } }); @@ -333,15 +341,40 @@ export class RemoteBrowser { * @returns void */ public registerEditorEvents = (): void => { - this.socket.on('rerender', async () => await this.makeAndEmitScreenshot()); - this.socket.on('settings', (settings) => this.interpreterSettings = settings); - this.socket.on('changeTab', async (tabIndex) => await this.changeTab(tabIndex)); - this.socket.on('addTab', async () => { + // For each event, include userId to make sure events are handled for the correct browser + logger.log('debug', `Registering editor events for user: ${this.userId}`); + + // Listen for specific events for this user + this.socket.on(`rerender:${this.userId}`, async () => { + logger.debug(`Rerender event received for user ${this.userId}`); + await this.makeAndEmitScreenshot(); + }); + + // For backward compatibility, also listen to the general event + this.socket.on('rerender', async () => { + logger.debug(`General rerender event received, checking if for user ${this.userId}`); + await this.makeAndEmitScreenshot(); + }); + + this.socket.on(`settings:${this.userId}`, (settings) => { + this.interpreterSettings = settings; + logger.debug(`Settings updated for user ${this.userId}`); + }); + + this.socket.on(`changeTab:${this.userId}`, async (tabIndex) => { + logger.debug(`Tab change to ${tabIndex} requested for user ${this.userId}`); + await this.changeTab(tabIndex); + }); + + this.socket.on(`addTab:${this.userId}`, async () => { + logger.debug(`New tab requested for user ${this.userId}`); await this.currentPage?.context().newPage(); const lastTabIndex = this.currentPage ? this.currentPage.context().pages().length - 1 : 0; await this.changeTab(lastTabIndex); }); - this.socket.on('closeTab', async (tabInfo) => { + + this.socket.on(`closeTab:${this.userId}`, async (tabInfo) => { + logger.debug(`Close tab ${tabInfo.index} requested for user ${this.userId}`); const page = this.currentPage?.context().pages()[tabInfo.index]; if (page) { if (tabInfo.isCurrent) { @@ -356,24 +389,52 @@ export class RemoteBrowser { await page.close(); logger.log( 'debug', - `${tabInfo.index} page was closed, new length of pages: ${this.currentPage?.context().pages().length}` - ) + `Tab ${tabInfo.index} was closed for user ${this.userId}, new tab count: ${this.currentPage?.context().pages().length}` + ); } else { - logger.log('error', `${tabInfo.index} index out of range of pages`) + logger.log('error', `Tab index ${tabInfo.index} out of range for user ${this.userId}`); } }); - this.socket.on('setViewportSize', async (data: { width: number, height: number }) => { + + this.socket.on(`setViewportSize:${this.userId}`, async (data: { width: number, height: number }) => { const { width, height } = data; - logger.log('debug', `Received viewport size: width=${width}, height=${height}`); + logger.log('debug', `Viewport size change to width=${width}, height=${height} requested for user ${this.userId}`); // Update the browser context's viewport dynamically if (this.context && this.browser) { this.context = await this.browser.newContext({ viewport: { width, height } }); - logger.log('debug', `Viewport size updated to width=${width}, height=${height} for the entire browser context`); + logger.log('debug', `Viewport size updated to width=${width}, height=${height} for user ${this.userId}`); } }); - } - + + // For backward compatibility, also register the standard events + this.socket.on('settings', (settings) => this.interpreterSettings = settings); + this.socket.on('changeTab', async (tabIndex) => await this.changeTab(tabIndex)); + this.socket.on('addTab', async () => { + await this.currentPage?.context().newPage(); + const lastTabIndex = this.currentPage ? this.currentPage.context().pages().length - 1 : 0; + await this.changeTab(lastTabIndex); + }); + this.socket.on('closeTab', async (tabInfo) => { + const page = this.currentPage?.context().pages()[tabInfo.index]; + if (page) { + if (tabInfo.isCurrent) { + if (this.currentPage?.context().pages()[tabInfo.index + 1]) { + await this.changeTab(tabInfo.index + 1); + } else { + await this.changeTab(tabInfo.index - 1); + } + } + await page.close(); + } + }); + this.socket.on('setViewportSize', async (data: { width: number, height: number }) => { + const { width, height } = data; + if (this.context && this.browser) { + this.context = await this.browser.newContext({ viewport: { width, height } }); + } + }); + }; /** * Subscribes the remote browser for a screencast session * on [CDP](https://chromedevtools.github.io/devtools-protocol/) level, @@ -382,16 +443,24 @@ export class RemoteBrowser { * @returns {Promise} */ public subscribeToScreencast = async (): Promise => { + logger.log('debug', `Starting screencast for user: ${this.userId}`); await this.startScreencast(); if (!this.client) { logger.log('warn', 'client is not initialized'); return; } + // Set flag to indicate screencast is active + this.isScreencastActive = true; + this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => { + // Only process if screencast is still active for this user + if (!this.isScreencastActive) { + return; + } this.emitScreenshot(Buffer.from(base64, 'base64')) setTimeout(async () => { try { - if (!this.client) { + if (!this.client || !this.isScreencastActive) { logger.log('warn', 'client is not initialized'); return; } @@ -410,6 +479,8 @@ export class RemoteBrowser { */ public async switchOff(): Promise { try { + this.isScreencastActive = false; + await this.interpreter.stopInterpretation(); if (this.screencastInterval) { @@ -553,7 +624,11 @@ export class RemoteBrowser { //await this.currentPage.setViewportSize({ height: 400, width: 900 }) this.client = await this.currentPage.context().newCDPSession(this.currentPage); - this.socket.emit('urlChanged', this.currentPage.url()); + // Include userId in the URL change event + this.socket.emit('urlChanged', { + url: this.currentPage.url(), + userId: this.userId + }); await this.makeAndEmitScreenshot(); await this.subscribeToScreencast(); } else { @@ -602,6 +677,8 @@ export class RemoteBrowser { await this.client.send('Page.startScreencast', { format: SCREENCAST_CONFIG.format, }); + // Set flag to indicate screencast is active + this.isScreencastActive = true; // Set up screencast frame handler this.client.on('Page.screencastFrame', async ({ data, sessionId }) => { @@ -627,6 +704,8 @@ export class RemoteBrowser { } try { + // Set flag to indicate screencast is active + this.isScreencastActive = false; await this.client.send('Page.stopScreencast'); this.screenshotQueue = []; this.isProcessingScreenshot = false; @@ -657,8 +736,11 @@ export class RemoteBrowser { const base64Data = optimizedScreenshot.toString('base64'); const dataWithMimeType = `data:image/jpeg;base64,${base64Data}`; - this.socket.emit('screencast', dataWithMimeType); - logger.debug('Screenshot emitted'); +// Emit with user context to ensure the frontend can identify which browser's screenshot this is +this.socket.emit('screencast', { + image: dataWithMimeType, + userId: this.userId +}); logger.debug('Screenshot emitted'); } catch (error) { logger.error('Screenshot emission failed:', error); } finally { diff --git a/server/src/browser-management/controller.ts b/server/src/browser-management/controller.ts index 24a677ce..e6619d77 100644 --- a/server/src/browser-management/controller.ts +++ b/server/src/browser-management/controller.ts @@ -21,23 +21,23 @@ import logger from "../logger"; * @category BrowserManagement-Controller */ export const initializeRemoteBrowserForRecording = (userId: string): string => { - const id = getActiveBrowserId() || uuid(); + const id = getActiveBrowserId(userId) || uuid(); createSocketConnection( io.of(id), async (socket: Socket) => { // browser is already active - const activeId = getActiveBrowserId(); + const activeId = getActiveBrowserId(userId); if (activeId) { const remoteBrowser = browserPool.getRemoteBrowser(activeId); remoteBrowser?.updateSocket(socket); await remoteBrowser?.makeAndEmitScreenshot(); } else { - const browserSession = new RemoteBrowser(socket); + const browserSession = new RemoteBrowser(socket, userId); browserSession.interpreter.subscribeToPausing(); await browserSession.initialize(userId); await browserSession.registerEditorEvents(); await browserSession.subscribeToScreencast(); - browserPool.addRemoteBrowser(id, browserSession, true); + browserPool.addRemoteBrowser(id, browserSession, userId); } socket.emit('loaded'); }); @@ -57,9 +57,9 @@ export const createRemoteBrowserForRun = (userId: string): string => { createSocketConnectionForRun( io.of(id), async (socket: Socket) => { - const browserSession = new RemoteBrowser(socket); + const browserSession = new RemoteBrowser(socket, userId); await browserSession.initialize(userId); - browserPool.addRemoteBrowser(id, browserSession, true); + browserPool.addRemoteBrowser(id, browserSession, userId); socket.emit('ready-for-run'); }); return id; @@ -72,7 +72,7 @@ export const createRemoteBrowserForRun = (userId: string): string => { * @returns {Promise} * @category BrowserManagement-Controller */ -export const destroyRemoteBrowser = async (id: string): Promise => { +export const destroyRemoteBrowser = async (id: string, userId: string): Promise => { const browserSession = browserPool.getRemoteBrowser(id); if (browserSession) { logger.log('debug', `Switching off the browser with id: ${id}`); @@ -88,8 +88,8 @@ export const destroyRemoteBrowser = async (id: string): Promise => { * @returns {string | null} * @category BrowserManagement-Controller */ -export const getActiveBrowserId = (): string | null => { - return browserPool.getActiveBrowserId(); +export const getActiveBrowserId = (userId: string): string | null => { + return browserPool.getActiveBrowserId(userId); }; /** @@ -98,7 +98,7 @@ export const getActiveBrowserId = (): string | null => { * @returns {string | undefined} * @category BrowserManagement-Controller */ -export const getRemoteBrowserCurrentUrl = (id: string): string | undefined => { +export const getRemoteBrowserCurrentUrl = (id: string, userId: string): string | undefined => { return browserPool.getRemoteBrowser(id)?.getCurrentPage()?.url(); }; @@ -108,7 +108,7 @@ export const getRemoteBrowserCurrentUrl = (id: string): string | undefined => { * @return {string[] | undefined} * @category BrowserManagement-Controller */ -export const getRemoteBrowserCurrentTabs = (id: string): string[] | undefined => { +export const getRemoteBrowserCurrentTabs = (id: string, userId: string): string[] | undefined => { return browserPool.getRemoteBrowser(id)?.getCurrentPage()?.context().pages() .map((page) => { const parsedUrl = new URL(page.url()); @@ -126,8 +126,8 @@ export const getRemoteBrowserCurrentTabs = (id: string): string[] | undefined => * @returns {Promise} * @category BrowserManagement-Controller */ -export const interpretWholeWorkflow = async () => { - const id = getActiveBrowserId(); +export const interpretWholeWorkflow = async (userId: string) => { + const id = getActiveBrowserId(userId); if (id) { const browser = browserPool.getRemoteBrowser(id); if (browser) { @@ -146,8 +146,8 @@ export const interpretWholeWorkflow = async () => { * @returns {Promise} * @category BrowserManagement-Controller */ -export const stopRunningInterpretation = async () => { - const id = getActiveBrowserId(); +export const stopRunningInterpretation = async (userId: string) => { + const id = getActiveBrowserId(userId); if (id) { const browser = browserPool.getRemoteBrowser(id); await browser?.stopCurrentInterpretation(); diff --git a/server/src/browser-management/inputHandlers.ts b/server/src/browser-management/inputHandlers.ts index bf365053..92c75e1d 100644 --- a/server/src/browser-management/inputHandlers.ts +++ b/server/src/browser-management/inputHandlers.ts @@ -4,6 +4,8 @@ * These functions are called by the client through socket communication. */ import { Socket } from 'socket.io'; +import { IncomingMessage } from 'http'; +import { JwtPayload } from 'jsonwebtoken'; import logger from "../logger"; import { Coordinates, ScrollDeltas, KeyboardInput, DatePickerEventData } from '../types'; @@ -13,6 +15,14 @@ import { Page } from "playwright"; import { throttle } from "../../../src/helpers/inputHelpers"; import { CustomActions } from "../../../src/shared/types"; +interface AuthenticatedIncomingMessage extends IncomingMessage { + user?: JwtPayload | string; +} + +interface AuthenticatedSocket extends Socket { + request: AuthenticatedIncomingMessage; +} + /** * A wrapper function for handling user input. * This function gets the active browser instance from the browser pool @@ -23,6 +33,7 @@ import { CustomActions } from "../../../src/shared/types"; * * @param handleCallback The callback handler to be called * @param args - arguments to be passed to the handler + * @param socket - socket with authenticated request * @category HelperFunctions */ const handleWrapper = async ( @@ -31,9 +42,21 @@ const handleWrapper = async ( page: Page, args?: any ) => Promise, - args?: any + args?: any, + socket?: AuthenticatedSocket, ) => { - const id = browserPool.getActiveBrowserId(); + if (!socket || !socket.request || !socket.request.user || typeof socket.request.user === 'string') { + logger.log('warn', `User not authenticated or invalid JWT payload`); + return; + } + + const userId = socket.request.user.id; + if (!userId) { + logger.log('warn', `User ID is missing in JWT payload`); + return; + } + + const id = browserPool.getActiveBrowserId(userId); if (id) { const activeBrowser = browserPool.getRemoteBrowser(id); if (activeBrowser?.interpreter.interpretationInProgress() && !activeBrowser.interpreter.interpretationIsPaused) { @@ -66,12 +89,13 @@ interface CustomActionEventData { /** * A wrapper function for handling custom actions. + * @param socket The socket connection * @param customActionEventData The custom action event data * @category HelperFunctions */ -const onGenerateAction = async (customActionEventData: CustomActionEventData) => { +const onGenerateAction = async (socket: AuthenticatedSocket, customActionEventData: CustomActionEventData) => { logger.log('debug', `Generating ${customActionEventData.action} action emitted from client`); - await handleWrapper(handleGenerateAction, customActionEventData); + await handleWrapper(handleGenerateAction, customActionEventData, socket); } /** @@ -89,12 +113,13 @@ const handleGenerateAction = /** * A wrapper function for handling mousedown event. + * @param socket The socket connection * @param coordinates - coordinates of the mouse click * @category HelperFunctions */ -const onMousedown = async (coordinates: Coordinates) => { +const onMousedown = async (socket: AuthenticatedSocket, coordinates: Coordinates) => { logger.log('debug', 'Handling mousedown event emitted from client'); - await handleWrapper(handleMousedown, coordinates); + await handleWrapper(handleMousedown, coordinates, socket); } /** @@ -139,12 +164,13 @@ const handleMousedown = async (generator: WorkflowGenerator, page: Page, { x, y /** * A wrapper function for handling the wheel event. + * @param socket The socket connection * @param scrollDeltas - the scroll deltas of the wheel event * @category HelperFunctions */ -const onWheel = async (scrollDeltas: ScrollDeltas) => { +const onWheel = async (socket: AuthenticatedSocket, scrollDeltas: ScrollDeltas) => { logger.log('debug', 'Handling scroll event emitted from client'); - await handleWrapper(handleWheel, scrollDeltas); + await handleWrapper(handleWheel, scrollDeltas, socket); }; /** @@ -165,12 +191,13 @@ const handleWheel = async (generator: WorkflowGenerator, page: Page, { deltaX, d /** * A wrapper function for handling the mousemove event. + * @param socket The socket connection * @param coordinates - the coordinates of the mousemove event * @category HelperFunctions */ -const onMousemove = async (coordinates: Coordinates) => { +const onMousemove = async (socket: AuthenticatedSocket, coordinates: Coordinates) => { logger.log('debug', 'Handling mousemove event emitted from client'); - await handleWrapper(handleMousemove, coordinates); + await handleWrapper(handleMousemove, coordinates, socket); } /** @@ -199,12 +226,13 @@ const handleMousemove = async (generator: WorkflowGenerator, page: Page, { x, y /** * A wrapper function for handling the keydown event. + * @param socket The socket connection * @param keyboardInput - the keyboard input of the keydown event * @category HelperFunctions */ -const onKeydown = async (keyboardInput: KeyboardInput) => { +const onKeydown = async (socket: AuthenticatedSocket, keyboardInput: KeyboardInput) => { logger.log('debug', 'Handling keydown event emitted from client'); - await handleWrapper(handleKeydown, keyboardInput); + await handleWrapper(handleKeydown, keyboardInput, socket); } /** @@ -235,49 +263,95 @@ const handleDateSelection = async (generator: WorkflowGenerator, page: Page, dat logger.log('debug', `Date ${data.value} selected`); } -const onDateSelection = async (data: DatePickerEventData) => { +/** + * A wrapper function for handling the date selection event. + * @param socket The socket connection + * @param data - the data of the date selection event + * @category HelperFunctions + */ +const onDateSelection = async (socket: AuthenticatedSocket, data: DatePickerEventData) => { logger.log('debug', 'Handling date selection event emitted from client'); - await handleWrapper(handleDateSelection, data); + await handleWrapper(handleDateSelection, data, socket); } +/** + * Handles the dropdown selection event. + * @param generator - the workflow generator {@link Generator} + * @param page - the active page of the remote browser + * @param data - the data of the dropdown selection event + * @category BrowserManagement + */ const handleDropdownSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => { await generator.onDropdownSelection(page, data); logger.log('debug', `Dropdown value ${data.value} selected`); } -const onDropdownSelection = async (data: { selector: string, value: string }) => { +/** + * A wrapper function for handling the dropdown selection event. + * @param socket The socket connection + * @param data - the data of the dropdown selection event + * @category HelperFunctions + */ +const onDropdownSelection = async (socket: AuthenticatedSocket, data: { selector: string, value: string }) => { logger.log('debug', 'Handling dropdown selection event emitted from client'); - await handleWrapper(handleDropdownSelection, data); + await handleWrapper(handleDropdownSelection, data, socket); } +/** + * Handles the time selection event. + * @param generator - the workflow generator {@link Generator} + * @param page - the active page of the remote browser + * @param data - the data of the time selection event + * @category BrowserManagement + */ const handleTimeSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => { await generator.onTimeSelection(page, data); logger.log('debug', `Time value ${data.value} selected`); } -const onTimeSelection = async (data: { selector: string, value: string }) => { +/** + * A wrapper function for handling the time selection event. + * @param socket The socket connection + * @param data - the data of the time selection event + * @category HelperFunctions + */ +const onTimeSelection = async (socket: AuthenticatedSocket, data: { selector: string, value: string }) => { logger.log('debug', 'Handling time selection event emitted from client'); - await handleWrapper(handleTimeSelection, data); + await handleWrapper(handleTimeSelection, data, socket); } +/** + * Handles the datetime-local selection event. + * @param generator - the workflow generator {@link Generator} + * @param page - the active page of the remote browser + * @param data - the data of the datetime-local selection event + * @category BrowserManagement + */ const handleDateTimeLocalSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => { await generator.onDateTimeLocalSelection(page, data); logger.log('debug', `DateTime Local value ${data.value} selected`); } -const onDateTimeLocalSelection = async (data: { selector: string, value: string }) => { +/** + * A wrapper function for handling the datetime-local selection event. + * @param socket The socket connection + * @param data - the data of the datetime-local selection event + * @category HelperFunctions + */ +const onDateTimeLocalSelection = async (socket: AuthenticatedSocket, data: { selector: string, value: string }) => { logger.log('debug', 'Handling datetime-local selection event emitted from client'); - await handleWrapper(handleDateTimeLocalSelection, data); + await handleWrapper(handleDateTimeLocalSelection, data, socket); } /** * A wrapper function for handling the keyup event. + * @param socket The socket connection * @param keyboardInput - the keyboard input of the keyup event * @category HelperFunctions */ -const onKeyup = async (keyboardInput: KeyboardInput) => { +const onKeyup = async (socket: AuthenticatedSocket, keyboardInput: KeyboardInput) => { logger.log('debug', 'Handling keyup event emitted from client'); - await handleWrapper(handleKeyup, keyboardInput); + await handleWrapper(handleKeyup, keyboardInput, socket); } /** @@ -296,12 +370,13 @@ const handleKeyup = async (generator: WorkflowGenerator, page: Page, key: string /** * A wrapper function for handling the url change event. + * @param socket The socket connection * @param url - the new url of the page * @category HelperFunctions */ -const onChangeUrl = async (url: string) => { +const onChangeUrl = async (socket: AuthenticatedSocket, url: string) => { logger.log('debug', 'Handling change url event emitted from client'); - await handleWrapper(handleChangeUrl, url); + await handleWrapper(handleChangeUrl, url, socket); } /** @@ -329,11 +404,12 @@ const handleChangeUrl = async (generator: WorkflowGenerator, page: Page, url: st /** * A wrapper function for handling the refresh event. + * @param socket The socket connection * @category HelperFunctions */ -const onRefresh = async () => { +const onRefresh = async (socket: AuthenticatedSocket) => { logger.log('debug', 'Handling refresh event emitted from client'); - await handleWrapper(handleRefresh); + await handleWrapper(handleRefresh, undefined, socket); } /** @@ -350,11 +426,12 @@ const handleRefresh = async (generator: WorkflowGenerator, page: Page) => { /** * A wrapper function for handling the go back event. + * @param socket The socket connection * @category HelperFunctions */ -const onGoBack = async () => { - logger.log('debug', 'Handling refresh event emitted from client'); - await handleWrapper(handleGoBack); +const onGoBack = async (socket: AuthenticatedSocket) => { + logger.log('debug', 'Handling go back event emitted from client'); + await handleWrapper(handleGoBack, undefined, socket); } /** @@ -372,11 +449,12 @@ const handleGoBack = async (generator: WorkflowGenerator, page: Page) => { /** * A wrapper function for handling the go forward event. + * @param socket The socket connection * @category HelperFunctions */ -const onGoForward = async () => { - logger.log('debug', 'Handling refresh event emitted from client'); - await handleWrapper(handleGoForward); +const onGoForward = async (socket: AuthenticatedSocket) => { + logger.log('debug', 'Handling go forward event emitted from client'); + await handleWrapper(handleGoForward, undefined, socket); } /** @@ -394,18 +472,7 @@ const handleGoForward = async (generator: WorkflowGenerator, page: Page) => { /** * Helper function for registering the handlers onto established websocket connection. - * Registers: - * - mousedownHandler - * - wheelHandler - * - mousemoveHandler - * - keydownHandler - * - keyupHandler - * - changeUrlHandler - * - refreshHandler - * - goBackHandler - * - goForwardHandler - * - onGenerateAction - * input handlers. + * Registers various input handlers. * * All these handlers first generates the workflow pair data * and then calls the corresponding playwright's function to emulate the input. @@ -415,21 +482,25 @@ const handleGoForward = async (generator: WorkflowGenerator, page: Page) => { * @returns void * @category BrowserManagement */ -const registerInputHandlers = (socket: Socket) => { - socket.on("input:mousedown", onMousedown); - socket.on("input:wheel", onWheel); - socket.on("input:mousemove", onMousemove); - socket.on("input:keydown", onKeydown); - socket.on("input:keyup", onKeyup); - socket.on("input:url", onChangeUrl); - socket.on("input:refresh", onRefresh); - socket.on("input:back", onGoBack); - socket.on("input:forward", onGoForward); - socket.on("input:date", onDateSelection); - socket.on("input:dropdown", onDropdownSelection); - socket.on("input:time", onTimeSelection); - socket.on("input:datetime-local", onDateTimeLocalSelection); - socket.on("action", onGenerateAction); +const registerInputHandlers = (socket: Socket) => { + // Cast to our authenticated socket type + const authSocket = socket as AuthenticatedSocket; + + // Register handlers with the socket + socket.on("input:mousedown", (data) => onMousedown(authSocket, data)); + socket.on("input:wheel", (data) => onWheel(authSocket, data)); + socket.on("input:mousemove", (data) => onMousemove(authSocket, data)); + socket.on("input:keydown", (data) => onKeydown(authSocket, data)); + socket.on("input:keyup", (data) => onKeyup(authSocket, data)); + socket.on("input:url", (data) => onChangeUrl(authSocket, data)); + socket.on("input:refresh", () => onRefresh(authSocket)); + socket.on("input:back", () => onGoBack(authSocket)); + socket.on("input:forward", () => onGoForward(authSocket)); + socket.on("input:date", (data) => onDateSelection(authSocket, data)); + socket.on("input:dropdown", (data) => onDropdownSelection(authSocket, data)); + socket.on("input:time", (data) => onTimeSelection(authSocket, data)); + socket.on("input:datetime-local", (data) => onDateTimeLocalSelection(authSocket, data)); + socket.on("action", (data) => onGenerateAction(authSocket, data)); }; -export default registerInputHandlers; +export default registerInputHandlers; \ No newline at end of file diff --git a/server/src/routes/record.ts b/server/src/routes/record.ts index 51d3ff92..495dda5d 100644 --- a/server/src/routes/record.ts +++ b/server/src/routes/record.ts @@ -76,26 +76,35 @@ router.post('/start', requireSignIn, (req: AuthenticatedRequest, res:Response) = * GET endpoint for terminating the remote browser recording session. * returns whether the termination was successful */ -router.get('/stop/:browserId', requireSignIn, async (req, res) => { - const success = await destroyRemoteBrowser(req.params.browserId); +router.get('/stop/:browserId', requireSignIn, async (req: AuthenticatedRequest, res) => { + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + const success = await destroyRemoteBrowser(req.params.browserId, req.user?.id); return res.send(success); }); /** * GET endpoint for getting the id of the active remote browser. */ -router.get('/active', requireSignIn, (req, res) => { - const id = getActiveBrowserId(); +router.get('/active', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + const id = getActiveBrowserId(req.user?.id); return res.send(id); }); /** * GET endpoint for getting the current url of the active remote browser. */ -router.get('/active/url', requireSignIn, (req, res) => { - const id = getActiveBrowserId(); +router.get('/active/url', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + const id = getActiveBrowserId(req.user?.id); if (id) { - const url = getRemoteBrowserCurrentUrl(id); + const url = getRemoteBrowserCurrentUrl(id, req.user?.id); return res.send(url); } return res.send(null); @@ -104,10 +113,13 @@ router.get('/active/url', requireSignIn, (req, res) => { /** * GET endpoint for getting the current tabs of the active remote browser. */ -router.get('/active/tabs', requireSignIn, (req, res) => { - const id = getActiveBrowserId(); +router.get('/active/tabs', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + const id = getActiveBrowserId(req.user?.id); if (id) { - const hosts = getRemoteBrowserCurrentTabs(id); + const hosts = getRemoteBrowserCurrentTabs(id, req.user?.id); return res.send(hosts); } return res.send([]); @@ -116,9 +128,12 @@ router.get('/active/tabs', requireSignIn, (req, res) => { /** * GET endpoint for starting an interpretation of the currently generated workflow. */ -router.get('/interpret', requireSignIn, async (req, res) => { +router.get('/interpret', requireSignIn, async (req: AuthenticatedRequest, res) => { try { - await interpretWholeWorkflow(); + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + await interpretWholeWorkflow(req.user?.id); return res.send('interpretation done'); } catch (e) { return res.send('interpretation failed'); @@ -128,7 +143,10 @@ router.get('/interpret', requireSignIn, async (req, res) => { /** * GET endpoint for stopping an ongoing interpretation of the currently generated workflow. */ -router.get('/interpret/stop', requireSignIn, async (req, res) => { - await stopRunningInterpretation(); +router.get('/interpret/stop', requireSignIn, async (req: AuthenticatedRequest, res) => { + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + await stopRunningInterpretation(req.user?.id); return res.send('interpretation stopped'); }); diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 5ab9ef7a..04c0341c 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -617,7 +617,7 @@ router.post('/runs/run/:id', requireSignIn, async (req: AuthenticatedRequest, re workflow, currentPage, (newPage: Page) => currentPage = newPage, plainRun.interpreterSettings); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); - await destroyRemoteBrowser(plainRun.browserId); + await destroyRemoteBrowser(plainRun.browserId, req.user?.id); await run.update({ ...run, status: 'success', @@ -900,8 +900,9 @@ router.delete('/schedule/:id', requireSignIn, async (req: AuthenticatedRequest, /** * POST endpoint for aborting a current interpretation of the run. */ -router.post('/runs/abort/:id', requireSignIn, async (req, res) => { +router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest, res) => { try { + if (!req.user) { return res.status(401).send({ error: 'Unauthorized' }); } const run = await Run.findOne({ where: { runId: req.params.id } }); if (!run) { return res.status(404).send(false); diff --git a/server/src/routes/workflow.ts b/server/src/routes/workflow.ts index 3c0e356b..3a117ce1 100644 --- a/server/src/routes/workflow.ts +++ b/server/src/routes/workflow.ts @@ -7,6 +7,7 @@ import logger from "../logger"; import { browserPool } from "../server"; import { requireSignIn } from '../middlewares/auth'; import Robot from '../models/Robot'; +import { AuthenticatedRequest } from './record'; export const router = Router(); @@ -46,8 +47,9 @@ router.get('/params/:browserId', requireSignIn, (req, res) => { /** * DELETE endpoint for deleting a pair from the generated workflow. */ -router.delete('/pair/:index', requireSignIn, (req, res) => { - const id = browserPool.getActiveBrowserId(); +router.delete('/pair/:index', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { return res.status(401).send('User not authenticated'); } + const id = browserPool.getActiveBrowserId(req.user?.id); if (id) { const browser = browserPool.getRemoteBrowser(id); if (browser) { @@ -62,8 +64,9 @@ router.delete('/pair/:index', requireSignIn, (req, res) => { /** * POST endpoint for adding a pair to the generated workflow. */ -router.post('/pair/:index', requireSignIn, (req, res) => { - const id = browserPool.getActiveBrowserId(); +router.post('/pair/:index', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { return res.status(401).send('User not authenticated'); } + const id = browserPool.getActiveBrowserId(req.user?.id); if (id) { const browser = browserPool.getRemoteBrowser(id); logger.log('debug', `Adding pair to workflow`); @@ -82,8 +85,9 @@ router.post('/pair/:index', requireSignIn, (req, res) => { /** * PUT endpoint for updating a pair in the generated workflow. */ -router.put('/pair/:index', requireSignIn, (req, res) => { - const id = browserPool.getActiveBrowserId(); +router.put('/pair/:index', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { return res.status(401).send('User not authenticated'); } + const id = browserPool.getActiveBrowserId(req.user?.id); if (id) { const browser = browserPool.getRemoteBrowser(id); logger.log('debug', `Updating pair in workflow`); diff --git a/server/src/socket-connection/connection.ts b/server/src/socket-connection/connection.ts index dac7a88e..9fad861b 100644 --- a/server/src/socket-connection/connection.ts +++ b/server/src/socket-connection/connection.ts @@ -1,6 +1,60 @@ import { Namespace, Socket } from 'socket.io'; +import { IncomingMessage } from 'http'; +import { verify, JwtPayload } from 'jsonwebtoken'; import logger from "../logger"; -import registerInputHandlers from '../browser-management/inputHandlers' +import registerInputHandlers from '../browser-management/inputHandlers'; + +interface AuthenticatedIncomingMessage extends IncomingMessage { + user?: JwtPayload | string; +} + +interface AuthenticatedSocket extends Socket { + request: AuthenticatedIncomingMessage; +} + +/** + * Socket.io middleware for authentication + * This is a socket.io specific auth handler that doesn't rely on Express middleware + */ +const socketAuthMiddleware = (socket: Socket, next: (err?: Error) => void) => { + const cookies = socket.handshake.headers.cookie; + if (!cookies) { + return next(new Error('Authentication required')); + } + + const tokenMatch = cookies.split(';').find(c => c.trim().startsWith('token=')); + if (!tokenMatch) { + return next(new Error('Authentication required')); + } + + const token = tokenMatch.split('=')[1]; + if (!token) { + return next(new Error('Authentication required')); + } + + const secret = process.env.JWT_SECRET; + if (!secret) { + return next(new Error('Server configuration error')); + } + + verify(token, secret, (err: any, user: any) => { + if (err) { + logger.log('warn', 'JWT verification error:', err); + return next(new Error('Authentication failed')); + } + + // Normalize payload key + if (user.userId && !user.id) { + user.id = user.userId; + delete user.userId; // temporary: del the old key for clarity + } + + // Attach user to socket request + const authSocket = socket as AuthenticatedSocket; + authSocket.request.user = user; + next(); + }); +}; /** * Opens a websocket canal for duplex data transfer and registers all handlers for this data for the recording session. @@ -13,6 +67,8 @@ export const createSocketConnection = ( io: Namespace, callback: (socket: Socket) => void, ) => { + io.use(socketAuthMiddleware); + const onConnection = async (socket: Socket) => { logger.log('info', "Client connected " + socket.id); registerInputHandlers(socket); @@ -34,6 +90,8 @@ export const createSocketConnectionForRun = ( io: Namespace, callback: (socket: Socket) => void, ) => { + io.use(socketAuthMiddleware); + const onConnection = async (socket: Socket) => { logger.log('info', "Client connected " + socket.id); socket.on('disconnect', () => logger.log('info', "Client disconnected " + socket.id)); @@ -41,4 +99,4 @@ export const createSocketConnectionForRun = ( } io.on('connection', onConnection); -}; +}; \ No newline at end of file diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index b4c72e96..8cdc233c 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -151,8 +151,8 @@ export class WorkflowGenerator { workflow: [], }); socket.on('activeIndex', (data) => this.generatedData.lastIndex = parseInt(data)); - socket.on('decision', async ({ pair, actionType, decision }) => { - const id = browserPool.getActiveBrowserId(); + socket.on('decision', async ({ pair, actionType, decision, userId }) => { + const id = browserPool.getActiveBrowserId(userId); if (id) { // const activeBrowser = browserPool.getRemoteBrowser(id); // const currentPage = activeBrowser?.getCurrentPage(); diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index ade7d969..b67e1ca0 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -92,7 +92,7 @@ function AddGeneratedFlags(workflow: WorkflowFile) { return copy; }; -async function executeRun(id: string) { +async function executeRun(id: string, userId: string) { try { const run = await Run.findOne({ where: { runId: id } }); if (!run) { @@ -114,7 +114,7 @@ async function executeRun(id: string) { plainRun.status = 'running'; - const browser = browserPool.getRemoteBrowser(plainRun.browserId); + const browser = browserPool.getRemoteBrowser(userId); if (!browser) { throw new Error('Could not access browser'); } @@ -132,7 +132,7 @@ async function executeRun(id: string) { const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); - await destroyRemoteBrowser(plainRun.browserId); + await destroyRemoteBrowser(plainRun.browserId, userId); await run.update({ ...run, @@ -207,22 +207,22 @@ async function executeRun(id: string) { } } -async function readyForRunHandler(browserId: string, id: string) { +async function readyForRunHandler(browserId: string, id: string, userId: string) { try { - const interpretation = await executeRun(id); + const interpretation = await executeRun(id, userId); if (interpretation) { logger.log('info', `Interpretation of ${id} succeeded`); } else { logger.log('error', `Interpretation of ${id} failed`); - await destroyRemoteBrowser(browserId); + await destroyRemoteBrowser(browserId, userId); } resetRecordingState(browserId, id); } catch (error: any) { logger.error(`Error during readyForRunHandler: ${error.message}`); - await destroyRemoteBrowser(browserId); + await destroyRemoteBrowser(browserId, userId); } } @@ -245,12 +245,12 @@ export async function handleRunRecording(id: string, userId: string) { rejectUnauthorized: false }); - socket.on('ready-for-run', () => readyForRunHandler(browserId, newRunId)); + socket.on('ready-for-run', () => readyForRunHandler(browserId, newRunId, userId)); logger.log('info', `Running robot: ${id}`); socket.on('disconnect', () => { - cleanupSocketListeners(socket, browserId, newRunId); + cleanupSocketListeners(socket, browserId, newRunId, userId); }); } catch (error: any) { @@ -258,8 +258,8 @@ export async function handleRunRecording(id: string, userId: string) { } } -function cleanupSocketListeners(socket: Socket, browserId: string, id: string) { - socket.off('ready-for-run', () => readyForRunHandler(browserId, id)); +function cleanupSocketListeners(socket: Socket, browserId: string, id: string, userId: string) { + socket.off('ready-for-run', () => readyForRunHandler(browserId, id, userId)); logger.log('info', `Cleaned up listeners for browserId: ${browserId}, runId: ${id}`); } diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index ffeb0df5..05c93c1e 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useSocketStore } from '../../context/socket'; import { Button } from '@mui/material'; import Canvas from "../recorder/canvas"; @@ -8,6 +8,7 @@ import { useActionContext } from '../../context/browserActions'; import { useBrowserSteps, TextStep } from '../../context/browserSteps'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { useTranslation } from 'react-i18next'; +import { AuthContext } from '../../context/auth'; interface ElementInfo { tagName: string; @@ -27,6 +28,12 @@ interface AttributeOption { value: string; } +interface ScreencastData { + image: string; + userId: string; +} + + const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null): AttributeOption[] => { if (!elementInfo) return []; switch (tagName.toLowerCase()) { @@ -72,6 +79,9 @@ export const BrowserWindow = () => { const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext(); const { addTextStep, addListStep } = useBrowserSteps(); + const { state } = useContext(AuthContext); + const { user } = state; + const onMouseMove = (e: MouseEvent) => { if (canvasRef && canvasRef.current && highlighterData) { const canvasRect = canvasRef.current.getBoundingClientRect(); @@ -99,9 +109,15 @@ export const BrowserWindow = () => { } }, [getList, resetListState]); - const screencastHandler = useCallback((data: string) => { - setScreenShot(data); - }, [screenShot]); + const screencastHandler = useCallback((data: string | ScreencastData) => { + if (typeof data === 'string') { + setScreenShot(data); + } else if (data && typeof data === 'object' && 'image' in data) { + if (!data.userId || data.userId === user?.id) { + setScreenShot(data.image); + } + } + }, [screenShot, user?.id]); useEffect(() => { if (socket) {