From d5b67f5d97ac683c5e8d7f1d8780c56a0788e7f5 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sun, 25 Jan 2026 16:50:00 +0000 Subject: [PATCH] fix(simulator): move inline script to external file for helmet CSP compliance --- Backend/public/mobile-sim.html | 586 +-------------------------------- Backend/public/mobile-sim.js | 583 ++++++++++++++++++++++++++++++++ 2 files changed, 584 insertions(+), 585 deletions(-) create mode 100644 Backend/public/mobile-sim.js diff --git a/Backend/public/mobile-sim.html b/Backend/public/mobile-sim.html index 043b76c..dc90da5 100644 --- a/Backend/public/mobile-sim.html +++ b/Backend/public/mobile-sim.html @@ -354,590 +354,6 @@ - + diff --git a/Backend/public/mobile-sim.js b/Backend/public/mobile-sim.js new file mode 100644 index 0000000..03a7193 --- /dev/null +++ b/Backend/public/mobile-sim.js @@ -0,0 +1,583 @@ + const state = { + session: null, + device: null, + deviceToken: null, + socket: null, + currentScreen: 'auth', + lastMotionEventId: null, + lastStreamSessionId: null, + lastRecordingId: null, + latestPushNotificationId: null, + }; + + const screens = ['auth', 'onboarding', 'home-client', 'home-camera', 'activity', 'account']; + + const $ = (id) => document.getElementById(id); + + const safeJson = (value) => JSON.stringify(value, null, 2); + + const log = (message, payload) => { + const ts = new Date().toISOString(); + const line = `[${ts}] ${message}`; + const next = `${line}${payload ? `\n${safeJson(payload)}` : ''}\n\n${$('eventLog').textContent}`; + $('eventLog').textContent = next; + }; + + const saveLocal = () => { + localStorage.setItem('mobileSimDevice', JSON.stringify({ device: state.device, deviceToken: state.deviceToken })); + }; + + const loadLocal = () => { + const raw = localStorage.getItem('mobileSimDevice'); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + state.device = parsed.device ?? null; + state.deviceToken = parsed.deviceToken ?? null; + } catch { + state.device = null; + state.deviceToken = null; + } + }; + + const setScreen = (name) => { + state.currentScreen = name; + for (const screen of screens) { + $(`screen-${screen}`).classList.toggle('active', screen === name); + } + }; + + const updateTop = () => { + const signedIn = Boolean(state.session?.session); + $('authChip').textContent = signedIn ? 'signed in' : 'signed out'; + $('authChip').className = `chip ${signedIn ? 'online' : 'offline'}`; + + const socketConnected = Boolean(state.socket?.connected); + $('socketChip').textContent = socketConnected ? 'online' : 'offline'; + $('socketChip').className = `chip ${socketConnected ? 'online' : 'offline'}`; + + if (!signedIn) { + $('topSubtitle').textContent = 'Sign in to continue'; + return; + } + + if (!state.device) { + $('topSubtitle').textContent = 'Set up this phone as camera or client'; + return; + } + + $('topSubtitle').textContent = `${state.device.role} mode · ${state.device.name || state.device.id}`; + }; + + const renderState = () => { + $('activityState').textContent = safeJson({ + device: state.device, + lastStreamSessionId: state.lastStreamSessionId, + lastRecordingId: state.lastRecordingId, + latestPushNotificationId: state.latestPushNotificationId, + }); + + $('accountState').textContent = safeJson({ + session: state.session, + deviceTokenPresent: Boolean(state.deviceToken), + }); + }; + + const updateFlow = () => { + const signedIn = Boolean(state.session?.session); + + if (!signedIn) { + $('bottomNav').classList.add('hidden'); + setScreen('auth'); + updateTop(); + renderState(); + return; + } + + if (!state.device || !state.deviceToken) { + $('bottomNav').classList.add('hidden'); + setScreen('onboarding'); + updateTop(); + renderState(); + return; + } + + $('bottomNav').classList.remove('hidden'); + + if (!['home-client', 'home-camera', 'activity', 'account'].includes(state.currentScreen)) { + setScreen(state.device.role === 'camera' ? 'home-camera' : 'home-client'); + } + + updateTop(); + renderState(); + }; + + const authFetch = async (url, options = {}) => { + const response = await fetch(url, { + credentials: 'include', + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(payload.message || response.statusText); + } + + return payload; + }; + + const deviceFetch = async (url, options = {}) => { + if (!state.deviceToken) { + throw new Error('No device token'); + } + + return authFetch(url, { + ...options, + headers: { + Authorization: `Bearer ${state.deviceToken}`, + ...(options.headers || {}), + }, + }); + }; + + const getAuthPayload = () => ({ + name: $('authName').value.trim() || undefined, + email: $('authEmail').value.trim(), + password: $('authPassword').value, + }); + + const connectSocket = () => { + if (!state.deviceToken) throw new Error('Register device first'); + + if (state.socket) { + state.socket.disconnect(); + } + + state.socket = io({ auth: { token: state.deviceToken } }); + + state.socket.on('connect', () => { + updateTop(); + log('socket connected'); + }); + + state.socket.on('disconnect', () => { + updateTop(); + log('socket disconnected'); + }); + + state.socket.on('connected', (payload) => log('connected', payload)); + state.socket.on('command:status', (payload) => log('command:status', payload)); + state.socket.on('stream:requested', (payload) => { + state.lastStreamSessionId = payload.streamSessionId; + renderState(); + log('stream:requested', payload); + }); + state.socket.on('stream:started', (payload) => { + state.lastStreamSessionId = payload.streamSessionId; + renderState(); + log('stream:started', payload); + if (state.device?.role === 'client') { + deviceFetch(`/streams/${payload.streamSessionId}/subscribe-credentials`) + .then((x) => log('auto subscribe credentials', x)) + .catch((e) => log('auto subscribe credentials failed', { error: e.message })); + } + }); + state.socket.on('stream:ended', (payload) => log('stream:ended', payload)); + state.socket.on('motion:detected', (payload) => log('motion:detected', payload)); + state.socket.on('motion:ended', (payload) => log('motion:ended', payload)); + + state.socket.on('command:received', async (payload) => { + log('command:received', payload); + + try { + if (payload.commandType === 'start_stream' && payload.payload?.streamSessionId) { + await deviceFetch(`/streams/${payload.payload.streamSessionId}/accept`, { + method: 'POST', + body: JSON.stringify({}), + }); + + const creds = await deviceFetch(`/streams/${payload.payload.streamSessionId}/publish-credentials`); + log('auto publish credentials', creds); + } + + if (payload.commandType === 'stop_stream' && payload.payload?.streamSessionId) { + await deviceFetch(`/streams/${payload.payload.streamSessionId}/end`, { + method: 'POST', + body: JSON.stringify({ reason: 'completed' }), + }); + } + + state.socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' }); + } catch (error) { + state.socket.emit('command:ack', { + commandId: payload.commandId, + status: 'rejected', + error: error.message, + }); + } + }); + }; + + $('signUpBtn').addEventListener('click', async () => { + try { + const payload = await authFetch('/api/auth/sign-up/email', { + method: 'POST', + body: JSON.stringify(getAuthPayload()), + }); + log('sign up', payload); + $('sessionBtn').click(); + } catch (error) { + log('sign up failed', { error: error.message }); + } + }); + + $('signInBtn').addEventListener('click', async () => { + try { + const authPayload = getAuthPayload(); + const payload = await authFetch('/api/auth/sign-in/email', { + method: 'POST', + body: JSON.stringify({ email: authPayload.email, password: authPayload.password }), + }); + log('sign in', payload); + $('sessionBtn').click(); + } catch (error) { + log('sign in failed', { error: error.message }); + } + }); + + $('sessionBtn').addEventListener('click', async () => { + try { + const payload = await authFetch('/api/auth/get-session'); + state.session = payload?.session ? payload : null; + log('session', payload); + } catch (error) { + state.session = null; + log('session check failed', { error: error.message }); + } + updateFlow(); + }); + + $('signOutBtn').addEventListener('click', async () => { + try { + await authFetch('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }); + } catch (error) { + log('sign out failed', { error: error.message }); + } + + state.session = null; + state.device = null; + state.deviceToken = null; + state.socket?.disconnect(); + state.socket = null; + localStorage.removeItem('mobileSimDevice'); + updateFlow(); + }); + + $('registerBtn').addEventListener('click', async () => { + try { + const payload = await authFetch('/devices/register', { + method: 'POST', + body: JSON.stringify({ + role: $('role').value, + name: $('deviceName').value.trim() || undefined, + platform: 'web', + appVersion: 'sim-ux-1', + pushToken: $('pushToken').value.trim() || undefined, + }), + }); + + state.device = payload.device; + state.deviceToken = payload.deviceToken; + saveLocal(); + log('device registered', payload); + updateFlow(); + } catch (error) { + log('register failed', { error: error.message }); + } + }); + + $('loadSavedBtn').addEventListener('click', async () => { + loadLocal(); + + try { + if (state.device?.id && $('pushToken').value.trim()) { + await authFetch(`/devices/${state.device.id}`, { + method: 'PATCH', + body: JSON.stringify({ pushToken: $('pushToken').value.trim() }), + }); + } + } catch (error) { + log('push token update failed', { error: error.message }); + } + + updateFlow(); + }); + + const bindConnectButtons = (connectId, disconnectId) => { + $(connectId).addEventListener('click', () => { + try { + connectSocket(); + } catch (error) { + log('connect failed', { error: error.message }); + } + }); + + $(disconnectId).addEventListener('click', () => { + state.socket?.disconnect(); + }); + }; + + bindConnectButtons('connectBtn', 'disconnectBtn'); + bindConnectButtons('connectBtnCam', 'disconnectBtnCam'); + + $('linkBtn').addEventListener('click', async () => { + try { + if (!state.device?.id || state.device.role !== 'client') throw new Error('Current phone is not in client mode'); + + const cameraDeviceId = $('targetCameraId').value.trim(); + if (!cameraDeviceId) throw new Error('Enter camera device id'); + + const payload = await authFetch('/device-links', { + method: 'POST', + body: JSON.stringify({ cameraDeviceId, clientDeviceId: state.device.id }), + }); + log('camera linked', payload); + } catch (error) { + log('link failed', { error: error.message }); + } + }); + + $('requestStreamBtn').addEventListener('click', async () => { + try { + const cameraDeviceId = $('targetCameraId').value.trim(); + if (!cameraDeviceId) throw new Error('Enter camera device id'); + + const payload = await deviceFetch('/streams/request', { + method: 'POST', + body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }), + }); + + state.lastStreamSessionId = payload.streamSession.id; + renderState(); + log('stream requested', payload); + } catch (error) { + log('stream request failed', { error: error.message }); + } + }); + + $('fetchSubscribeBtn').addEventListener('click', async () => { + try { + if (!state.lastStreamSessionId) throw new Error('No stream session yet'); + const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/subscribe-credentials`); + log('subscribe credentials', payload); + } catch (error) { + log('subscribe credentials failed', { error: error.message }); + } + }); + + $('fetchPlaybackBtn').addEventListener('click', async () => { + try { + if (!state.lastStreamSessionId) throw new Error('No stream session yet'); + const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/playback-token`); + log('playback token', payload); + } catch (error) { + log('playback token failed', { error: error.message }); + } + }); + + $('listRecordingsBtn').addEventListener('click', async () => { + try { + const payload = await deviceFetch('/recordings/me/list'); + if (payload.recordings?.length) { + state.lastRecordingId = payload.recordings[0].id; + } + renderState(); + log('recordings', payload); + } catch (error) { + log('recordings list failed', { error: error.message }); + } + }); + + $('downloadLatestRecordingBtn').addEventListener('click', async () => { + try { + if (!state.lastRecordingId) throw new Error('No recording selected'); + const payload = await deviceFetch(`/recordings/${state.lastRecordingId}/download-url`); + log('recording download url', payload); + } catch (error) { + log('recording download failed', { error: error.message }); + } + }); + + $('startMotionBtn').addEventListener('click', async () => { + try { + const payload = await deviceFetch('/events/motion/start', { + method: 'POST', + body: JSON.stringify({ title: 'Motion from simulator', triggeredBy: 'motion' }), + }); + state.lastMotionEventId = payload.event.id; + renderState(); + log('motion started', payload); + } catch (error) { + log('motion start failed', { error: error.message }); + } + }); + + $('endMotionBtn').addEventListener('click', async () => { + try { + if (!state.lastMotionEventId) throw new Error('No active motion event id'); + const payload = await deviceFetch(`/events/${state.lastMotionEventId}/motion/end`, { + method: 'POST', + body: JSON.stringify({ status: 'completed' }), + }); + log('motion ended', payload); + } catch (error) { + log('motion end failed', { error: error.message }); + } + }); + + $('fetchPublishBtn').addEventListener('click', async () => { + try { + if (!state.lastStreamSessionId) throw new Error('No stream session yet'); + const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/publish-credentials`); + log('publish credentials', payload); + } catch (error) { + log('publish credentials failed', { error: error.message }); + } + }); + + $('finalizeRecordingBtn').addEventListener('click', async () => { + try { + if (!state.lastStreamSessionId) throw new Error('No stream session yet'); + const list = await deviceFetch('/recordings/me/list'); + const target = list.recordings?.find((r) => r.streamSessionId === state.lastStreamSessionId) ?? list.recordings?.[0]; + if (!target) throw new Error('No recording placeholder found'); + + state.lastRecordingId = target.id; + renderState(); + + const payload = await deviceFetch(`/recordings/${target.id}/finalize`, { + method: 'POST', + body: JSON.stringify({ + objectKey: `recordings/${target.id}.mp4`, + bucket: 'videos', + durationSeconds: 12, + sizeBytes: 8 * 1024 * 1024, + }), + }); + log('recording finalized', payload); + } catch (error) { + log('recording finalize failed', { error: error.message }); + } + }); + + $('pollPushInboxBtn').addEventListener('click', async () => { + try { + const payload = await deviceFetch('/push-notifications/me'); + if (payload.notifications?.length) { + state.latestPushNotificationId = payload.notifications[0].id; + } + renderState(); + log('push inbox', payload); + } catch (error) { + log('push inbox failed', { error: error.message }); + } + }); + + $('markLatestPushReadBtn').addEventListener('click', async () => { + try { + if (!state.latestPushNotificationId) throw new Error('No push notification selected'); + const payload = await deviceFetch(`/push-notifications/${state.latestPushNotificationId}/read`, { + method: 'POST', + body: JSON.stringify({}), + }); + log('push marked read', payload); + } catch (error) { + log('mark push read failed', { error: error.message }); + } + }); + + $('dispatchPushWorkerBtn').addEventListener('click', async () => { + try { + const payload = await deviceFetch('/push-notifications/worker/dispatch', { + method: 'POST', + body: JSON.stringify({}), + }); + log('push worker dispatch', payload); + } catch (error) { + log('push worker dispatch failed', { error: error.message }); + } + }); + + $('fetchAuditBtn').addEventListener('click', async () => { + try { + const payload = await deviceFetch('/audit/device'); + log('audit logs', payload); + } catch (error) { + log('audit fetch failed', { error: error.message }); + } + }); + + $('checkLiveBtn').addEventListener('click', async () => { + try { + const payload = await authFetch('/ops/live'); + log('ops live', payload); + } catch (error) { + log('ops live failed', { error: error.message }); + } + }); + + $('checkReadyBtn').addEventListener('click', async () => { + try { + const payload = await authFetch('/ops/ready'); + log('ops ready', payload); + } catch (error) { + log('ops ready failed', { error: error.message }); + } + }); + + $('checkMetricsBtn').addEventListener('click', async () => { + try { + const payload = await authFetch('/ops/metrics'); + log('ops metrics', payload); + } catch (error) { + log('ops metrics failed', { error: error.message }); + } + }); + + $('navHome').addEventListener('click', () => { + setScreen(state.device?.role === 'camera' ? 'home-camera' : 'home-client'); + updateFlow(); + }); + + $('navActivity').addEventListener('click', () => { + setScreen('activity'); + updateFlow(); + }); + + $('navAccount').addEventListener('click', () => { + setScreen('account'); + updateFlow(); + }); + + const init = async () => { + loadLocal(); + try { + const payload = await authFetch('/api/auth/get-session'); + state.session = payload?.session ? payload : null; + } catch { + state.session = null; + } + + updateFlow(); + log('simulator initialized', { + hasSession: Boolean(state.session), + hasSavedDevice: Boolean(state.device), + }); + }; + + init();