mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
add identity to socket auth hopefully will fix a lot of probem
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-KnOn2V1J.js"></script>
|
<script type="module" crossorigin src="/assets/index-gn8wwSnH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ function registerVerificationHooks(deps) {
|
|||||||
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
const result = identifySocket(socket, payload || {});
|
const result = identifySocket(socket, payload || {});
|
||||||
socket.data.lastClientIdentifyAt = Date.now();
|
|
||||||
cb({ success: true, ...result });
|
cb({ success: true, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ const {
|
|||||||
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
|
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
|
||||||
|
|
||||||
const verificationEvents = new EventEmitter();
|
const verificationEvents = new EventEmitter();
|
||||||
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
|
||||||
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
|
|
||||||
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
|
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
|
||||||
const DETERRED_DISCONNECT_DELAY_MS = 250;
|
const DETERRED_DISCONNECT_DELAY_MS = 250;
|
||||||
|
|
||||||
@@ -157,9 +155,9 @@ function identifySocket(socket, payload = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Spectator-style pages send identity heartbeats too, but only the driver
|
Spectator-style pages carry identity too, but only the driver surface
|
||||||
surface participates in duplicate-driver enforcement. This flag remains on
|
participates in duplicate-driver enforcement. This flag remains on the
|
||||||
the socket because it is connection-specific, not person-specific.
|
socket because it is connection-specific, not person-specific.
|
||||||
*/
|
*/
|
||||||
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
||||||
|
|
||||||
@@ -644,16 +642,41 @@ function getVerificationStatus(socket) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Namespace middleware completes before Socket.IO emits `connection` to any
|
||||||
|
service. Attaching canonical identity here guarantees that authorization,
|
||||||
|
session construction, queues, chat, and media handlers never observe the old
|
||||||
|
intermediate state where a transport existed but session:identify had not
|
||||||
|
arrived yet. Missing keys remain valid: the canonical service creates the
|
||||||
|
first portable key and exposes it through the initial session sync.
|
||||||
|
*/
|
||||||
|
io.use((socket, next) => {
|
||||||
|
try {
|
||||||
|
socket.data = socket.data || {};
|
||||||
|
socket.data.connectedAt = Date.now();
|
||||||
|
identifySocket(socket, socket.handshake?.auth || {});
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Rejected socket with invalid handshake identity', {
|
||||||
|
socketId: socket.id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
next(new Error(err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.data = socket.data || {};
|
|
||||||
socket.data.connectedAt = Date.now();
|
|
||||||
installDeterredSocketGuard(socket);
|
installDeterredSocketGuard(socket);
|
||||||
identifySocket(socket, {});
|
/*
|
||||||
|
Duplicate-driver enforcement emits a normal socket event before disconnecting
|
||||||
|
the losing tab, so it runs after middleware admits the namespace connection.
|
||||||
|
Identity itself is already present before this point.
|
||||||
|
*/
|
||||||
|
enforceSingleDriverSocketPerIdentity(socket);
|
||||||
|
|
||||||
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
socket.on('session:identify', (payload = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
const result = identifySocket(socket, payload || {});
|
const result = identifySocket(socket, payload || {});
|
||||||
socket.data.lastClientIdentifyAt = Date.now();
|
|
||||||
cb({ success: true, ...result });
|
cb({ success: true, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
@@ -712,27 +735,6 @@ identityEvents.on('change', ({ userId, reason } = {}) => {
|
|||||||
emitChange('identity_change', { userId, reason });
|
emitChange('identity_change', { userId, reason });
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
io.sockets.sockets.forEach((socket) => {
|
|
||||||
if (!socket?.id) return;
|
|
||||||
const role = getRole(socket);
|
|
||||||
if (role === 'admin' || role === 'lockdown' || role === 'spectator') return;
|
|
||||||
const connectedAt = Number(socket?.data?.connectedAt || 0);
|
|
||||||
const lastClientIdentifyAt = Number(socket?.data?.lastClientIdentifyAt || 0);
|
|
||||||
const referenceTs = lastClientIdentifyAt || connectedAt;
|
|
||||||
if (!referenceTs) return;
|
|
||||||
if (now - referenceTs < IDENTITY_TIMEOUT_MS) return;
|
|
||||||
logger.info('Disconnecting socket due to stale identity heartbeat', {
|
|
||||||
socketId: socket.id,
|
|
||||||
role,
|
|
||||||
ageMs: now - referenceTs,
|
|
||||||
hadClientIdentify: Boolean(lastClientIdentifyAt),
|
|
||||||
});
|
|
||||||
socket.disconnect(true);
|
|
||||||
});
|
|
||||||
}, IDENTITY_SWEEP_INTERVAL_MS);
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
identifySocket,
|
identifySocket,
|
||||||
getVerificationStatus,
|
getVerificationStatus,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Hook: useUserIdentitySync
|
// Hook: useUserIdentitySync
|
||||||
// Purpose: Keeps local identity state synchronized with server session/auth updates. Scope: Handles identity hydration, change propagation, and persistence touch points.
|
// Purpose: Keeps local identity state synchronized with server session/auth updates. Scope: Handles identity hydration, change propagation, and persistence touch points.
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import { useSocket } from '../context/SocketContext.jsx';
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
import { getBrowserFingerprintId } from '../lib/browserFingerprint.js';
|
import { getBrowserFingerprintId } from '../lib/browserFingerprint.js';
|
||||||
@@ -9,6 +9,7 @@ import { useSettingsNamespace } from '../settings/index.js';
|
|||||||
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
|
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const connected = useSessionSelector((state) => state.connected);
|
const connected = useSessionSelector((state) => state.connected);
|
||||||
|
const serverCookieUserId = useSessionSelector((state) => state.session?.identity?.cookieUserId || '');
|
||||||
const { identifySession } = useSessionActions();
|
const { identifySession } = useSessionActions();
|
||||||
const { value: identity, status: identityStatus, save: saveIdentity } = useSettingsNamespace('identity', {
|
const { value: identity, status: identityStatus, save: saveIdentity } = useSettingsNamespace('identity', {
|
||||||
cookieUserId: '',
|
cookieUserId: '',
|
||||||
@@ -19,9 +20,6 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastAckSocketRef = useRef(null);
|
|
||||||
const fingerprintRef = useRef('');
|
|
||||||
|
|
||||||
const ready =
|
const ready =
|
||||||
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
|
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
|
||||||
const cookieUserId = (identity?.cookieUserId || '').trim();
|
const cookieUserId = (identity?.cookieUserId || '').trim();
|
||||||
@@ -32,23 +30,15 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
const sendIdentify = useCallback(async () => {
|
const sendIdentify = useCallback(async () => {
|
||||||
if (!ready || !connected || !socket?.id) return;
|
if (!ready || !connected || !socket?.id) return;
|
||||||
try {
|
try {
|
||||||
if (!fingerprintRef.current) {
|
|
||||||
/*
|
|
||||||
The portable cookie key is still the cross-device identity signal.
|
|
||||||
Thumbmark adds a same-device signal that survives cookie clearing, so
|
|
||||||
both are sent together whenever the heartbeat identifies this socket.
|
|
||||||
*/
|
|
||||||
fingerprintRef.current = await getBrowserFingerprintId();
|
|
||||||
}
|
|
||||||
/*
|
/*
|
||||||
Every route shares the same persisted identity key, but only the main
|
Handshake auth establishes identity before connection handlers run.
|
||||||
driver page should trigger duplicate-tab prevention. Sending the surface
|
This event remains the live-update path for settings that change while
|
||||||
with the heartbeat lets the server make that decision before spectator
|
the current transport stays connected, so existing callers and server
|
||||||
pages finish their role switch.
|
behavior do not need a second update contract.
|
||||||
*/
|
*/
|
||||||
const resp = await identifySession({
|
const resp = await identifySession({
|
||||||
cookieUserId,
|
cookieUserId,
|
||||||
fingerprintId: fingerprintRef.current,
|
fingerprintId: await getBrowserFingerprintId(),
|
||||||
nickname,
|
nickname,
|
||||||
overseerEnabled,
|
overseerEnabled,
|
||||||
identitySurface: normalizedIdentitySurface,
|
identitySurface: normalizedIdentitySurface,
|
||||||
@@ -57,12 +47,11 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
if (nextKey && nextKey !== cookieUserId) {
|
if (nextKey && nextKey !== cookieUserId) {
|
||||||
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
|
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
|
||||||
}
|
}
|
||||||
lastAckSocketRef.current = socket.id;
|
|
||||||
} catch {
|
} catch {
|
||||||
/*
|
/*
|
||||||
The permanent heartbeat below is the retry mechanism. A failed or
|
A failed live update must not replace Socket.IO's connection lifecycle
|
||||||
half-open request must not create separate timer state that can stop
|
with custom retry state. The next reconnect reads the latest persisted
|
||||||
future identity sends or disappear during a socket transition.
|
settings through handshake auth and re-establishes the complete identity.
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -78,46 +67,25 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !connected || !socket?.id) return;
|
/*
|
||||||
if (lastAckSocketRef.current === socket.id) return;
|
This runs once when the route's settings become ready and again only when
|
||||||
sendIdentify();
|
an identity value changes. Reconnects receive the same data from the auth
|
||||||
}, [connected, ready, sendIdentify, socket?.id]);
|
callback, so there is intentionally no timer, visibility retry, or online
|
||||||
|
retry here.
|
||||||
useEffect(() => {
|
*/
|
||||||
if (!ready || !connected || !socket?.id) return;
|
if (!ready || !connected || !socket?.id) return;
|
||||||
sendIdentify();
|
sendIdentify();
|
||||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleOnline = () => {
|
|
||||||
if (!socket?.connected) return;
|
|
||||||
sendIdentify();
|
|
||||||
};
|
|
||||||
const handleVisibility = () => {
|
|
||||||
if (typeof document === 'undefined' || document.visibilityState !== 'visible') return;
|
|
||||||
if (!socket?.connected) return;
|
|
||||||
sendIdentify();
|
|
||||||
};
|
|
||||||
window.addEventListener('online', handleOnline);
|
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('online', handleOnline);
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
|
||||||
};
|
|
||||||
}, [sendIdentify, socket?.connected]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
Keep this interval installed whenever persisted identity settings are
|
A first-time browser has no portable key to send in its handshake, so the
|
||||||
ready, including while Socket.IO is reconnecting. Each tick checks the
|
canonical identity service creates one. Session sync is authoritative for
|
||||||
current connection before sending, so reconnects resume heartbeats without
|
that generated value; persisting it here makes every later handshake carry
|
||||||
depending on an acknowledgement or another effect recreating the timer.
|
the same user key without depending on a session:identify acknowledgement.
|
||||||
*/
|
*/
|
||||||
if (!ready) return undefined;
|
const nextKey = String(serverCookieUserId || '').trim();
|
||||||
const timer = setInterval(() => {
|
if (!nextKey || nextKey === cookieUserId) return;
|
||||||
if (!socket?.connected) return;
|
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
|
||||||
sendIdentify();
|
}, [cookieUserId, saveIdentity, serverCookieUserId]);
|
||||||
}, 2000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [ready, sendIdentify, socket]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Socket Library Helper
|
// Socket Library Helper
|
||||||
// Purpose: Builds and exports the browser Socket.IO client with shared defaults. Scope: Centralizes connection URL/options so all modules use consistent socket behavior.
|
// Purpose: Builds and exports the browser Socket.IO client with shared defaults. Scope: Centralizes connection URL/options so all modules use consistent socket behavior.
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import { getBrowserFingerprintId } from './browserFingerprint.js';
|
||||||
import { loadSettings } from '../settings/persistence.js';
|
import { loadSettings } from '../settings/persistence.js';
|
||||||
|
|
||||||
const configured = import.meta.env.VITE_ROVERD_URL?.trim();
|
const configured = import.meta.env.VITE_ROVERD_URL?.trim();
|
||||||
@@ -9,8 +10,48 @@ console.info('[socket] connecting to', resolvedUrl);
|
|||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
const transportPref = settings?.page?.connectionTransport || 'websocket';
|
const transportPref = settings?.page?.connectionTransport || 'websocket';
|
||||||
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
|
||||||
|
|
||||||
|
function getIdentitySurface() {
|
||||||
|
/*
|
||||||
|
Duplicate-driver protection is connection-specific, so the route must be
|
||||||
|
known during the handshake rather than waiting for a React route component
|
||||||
|
to mount. The main driver and dedicated PTZ controller are the two active
|
||||||
|
control surfaces; every other route is passive identity-wise.
|
||||||
|
*/
|
||||||
|
return window.location.pathname === '/' || window.location.pathname === '/ptz'
|
||||||
|
? 'driver'
|
||||||
|
: 'passive';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSocketIdentity() {
|
||||||
|
/*
|
||||||
|
Socket.IO invokes this auth callback again for every reconnection. Reading
|
||||||
|
persistence here, instead of reusing the module-level settings snapshot,
|
||||||
|
ensures that nickname, preferences, imported settings, and a server-issued
|
||||||
|
identity key are current whenever a new socket is accepted.
|
||||||
|
*/
|
||||||
|
const currentSettings = loadSettings();
|
||||||
|
const fingerprintId = await getBrowserFingerprintId();
|
||||||
|
return {
|
||||||
|
cookieUserId: String(currentSettings?.identity?.cookieUserId || '').trim(),
|
||||||
|
fingerprintId,
|
||||||
|
nickname: String(currentSettings?.profile?.nickname || '').trim(),
|
||||||
|
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||||
|
identitySurface: getIdentitySurface(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const socket = io(resolvedUrl, {
|
export const socket = io(resolvedUrl, {
|
||||||
transports,
|
transports,
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
|
/*
|
||||||
|
The callback form lets the existing asynchronous fingerprint finish before
|
||||||
|
Socket.IO sends its namespace CONNECT packet. Server connection middleware
|
||||||
|
can therefore attach the canonical identity before any feature service sees
|
||||||
|
the socket, eliminating the previous connected-but-unidentified window.
|
||||||
|
*/
|
||||||
|
auth: (callback) => {
|
||||||
|
buildSocketIdentity().then(callback);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
|
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
|
||||||
|
|||||||
Reference in New Issue
Block a user