mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
bandwidth savings configs
This commit is contained in:
@@ -8,9 +8,19 @@ const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { setRole } = require('../roleService');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const {
|
||||
canUseExternalSpectatorAccess,
|
||||
getBandwidthSavingsPolicy,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
const {
|
||||
getFeatureState,
|
||||
getUserIdForSocket,
|
||||
} = require('../identityService');
|
||||
|
||||
const config = loadConfig();
|
||||
const admins = config.admins || [];
|
||||
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||
|
||||
function findAdmin(username) {
|
||||
return admins.find((admin) => admin.username === username);
|
||||
@@ -36,9 +46,44 @@ function isLockdownAdmin(socket) {
|
||||
return socket?.data?.role === 'lockdown';
|
||||
}
|
||||
|
||||
function hasExternalSpectatorGrant(socket) {
|
||||
const userId = getUserIdForSocket(socket);
|
||||
if (!userId) return false;
|
||||
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
|
||||
/*
|
||||
The identity database already owns per-user feature state. Keeping the grant
|
||||
as a tiny namespaced boolean avoids a new table and lets the existing admin
|
||||
database editor grant/revoke external spectator access immediately.
|
||||
*/
|
||||
return Boolean(state?.external);
|
||||
}
|
||||
|
||||
function canBecomeSpectator(socket) {
|
||||
const ip = getSocketIp(socket);
|
||||
const local = isLocalNetwork(ip);
|
||||
return canUseExternalSpectatorAccess({
|
||||
isLocal: local,
|
||||
isAdmin: isAdmin(socket),
|
||||
hasGrant: hasExternalSpectatorGrant(socket),
|
||||
});
|
||||
}
|
||||
|
||||
function externalSpectatorAccessError() {
|
||||
const mode = getBandwidthSavingsPolicy().externalSpectatorAccess;
|
||||
if (mode === 'admin') {
|
||||
return 'External spectator access requires admin approval for this identity.';
|
||||
}
|
||||
return 'External spectator access is disabled.';
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const requestedRole = socket.handshake?.query?.role;
|
||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
||||
/*
|
||||
Role is assigned before the browser's full identity heartbeat has completed.
|
||||
For admin-gated external spectators, fail closed here; the spectator page can
|
||||
identify the socket and then retry session:setRole once the grant exists.
|
||||
*/
|
||||
const initialRole = requestedRole === 'spectator' && canBecomeSpectator(socket) ? 'spectator' : 'user';
|
||||
setRole(socket, initialRole);
|
||||
logger.info('Socket connected with role', socket.id, initialRole);
|
||||
socket.emit('auth:role', { role: initialRole });
|
||||
@@ -63,6 +108,12 @@ io.on('connection', (socket) => {
|
||||
|
||||
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||
if (role === 'spectator' || role === 'user') {
|
||||
if (role === 'spectator' && !canBecomeSpectator(socket)) {
|
||||
const error = externalSpectatorAccessError();
|
||||
logger.info('Spectator role denied by bandwidth policy', socket.id, { error });
|
||||
cb({ error });
|
||||
return;
|
||||
}
|
||||
setRole(socket, role);
|
||||
socket.emit('auth:role', { role });
|
||||
logger.info('Role changed via client request', socket.id, role);
|
||||
|
||||
@@ -11,6 +11,10 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('ptzCamera');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
const { isVerified } = require('../verificationService');
|
||||
@@ -1291,7 +1295,26 @@ function canRequestLiveVideo(socket) {
|
||||
if (!enabled || !passesMode(socket)) return false;
|
||||
if (state.operatorSocketId === socket?.id) return true;
|
||||
if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
|
||||
return isLocalNetwork(getSocketIp(socket));
|
||||
const role = getRole(socket);
|
||||
const local = isLocalNetwork(getSocketIp(socket));
|
||||
if (role === 'spectator') {
|
||||
/*
|
||||
Spectator PTZ viewing follows the spectator bandwidth switch. LAN viewers
|
||||
stay live because they do not consume server upload; non-local spectators
|
||||
only get live PTZ when the external spectator video policy allows it.
|
||||
*/
|
||||
return local || !shouldUseSnapshotsForExternalSpectatorVideo();
|
||||
}
|
||||
if (canUsePtzFeature(socket) && !shouldUseSnapshotsForNonTurnVideo()) {
|
||||
/*
|
||||
Verified/VIP users who can queue or claim the camera are PTZ "turn"
|
||||
participants even before they become operator. When non-turn video is set
|
||||
to live, they may watch the live feed while waiting; camera movement still
|
||||
remains limited to the active operator by the command handlers.
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSnapshotPath() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('sessionService');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
||||
const { getMode, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { managerEvents } = roverManager;
|
||||
@@ -39,6 +39,14 @@ const { getAdminReason } = require('../adminReasonService');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const { getFeatureFlags } = require('../../helpers/features');
|
||||
const {
|
||||
canUseExternalSpectatorAccess,
|
||||
getBandwidthSavingsPolicy,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
const {
|
||||
getFeatureState,
|
||||
getUserIdForSocket,
|
||||
} = require('../identityService');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getButtonBoxState } = require('../buttonBoxService');
|
||||
@@ -62,6 +70,35 @@ logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configure
|
||||
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
|
||||
|
||||
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||
|
||||
function hasExternalSpectatorGrant(socket) {
|
||||
const userId = getUserIdForSocket(socket);
|
||||
if (!userId) return false;
|
||||
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
|
||||
return Boolean(state?.external);
|
||||
}
|
||||
|
||||
function buildBandwidthSavingsSessionState(socket) {
|
||||
const policy = getBandwidthSavingsPolicy();
|
||||
const local = isLocalNetwork(getSocketIp(socket));
|
||||
const granted = hasExternalSpectatorGrant(socket);
|
||||
return {
|
||||
...policy,
|
||||
/*
|
||||
These derived fields let browser routes make clear UI choices without
|
||||
re-implementing IP/admin/grant logic. The server still enforces the same
|
||||
decisions in auth and video services, so the UI remains advisory only.
|
||||
*/
|
||||
externalSpectatorGranted: granted,
|
||||
canUseExternalSpectatorAccess: canUseExternalSpectatorAccess({
|
||||
isLocal: local,
|
||||
isAdmin: isAdmin(socket),
|
||||
hasGrant: granted,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserEntry(socket) {
|
||||
if (!socket) return null;
|
||||
const role = getRole(socket);
|
||||
@@ -110,6 +147,7 @@ function buildSession(socket) {
|
||||
role: getRole(socket),
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket),
|
||||
/*
|
||||
Features is the single UI contract for optional server capabilities. A
|
||||
disabled feature should be absent from navigation/layout decisions even
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
} = require('../identityService');
|
||||
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
|
||||
|
||||
const verificationEvents = new EventEmitter();
|
||||
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
@@ -103,7 +104,7 @@ function identifySocket(socket, payload = {}) {
|
||||
nickname: incomingNickname || getNickname(socket) || '',
|
||||
});
|
||||
refreshSocketIdentityFlags(socket);
|
||||
enforceSingleUnverifiedSocketPerIdentity(socket);
|
||||
enforceSingleDriverSocketPerIdentity(socket);
|
||||
emitChange('identify', { socketId: socket.id, userId: result.userId });
|
||||
|
||||
return {
|
||||
@@ -132,13 +133,18 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
|
||||
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
||||
function enforceSingleDriverSocketPerIdentity(currentSocket) {
|
||||
const currentUserId = getUserIdForSocket(currentSocket);
|
||||
const currentRole = getRole(currentSocket);
|
||||
const enforceForCurrentSocket = shouldEnforceSingleDriverTab({
|
||||
isVerified: Boolean(currentSocket?.data?.isVerified),
|
||||
isAdmin: isAdminRole(currentRole),
|
||||
});
|
||||
if (
|
||||
!currentSocket?.id ||
|
||||
!currentUserId ||
|
||||
currentSocket.data?.isVerified ||
|
||||
currentSocket.data?.identitySurface !== 'driver'
|
||||
currentSocket.data?.identitySurface !== 'driver' ||
|
||||
!enforceForCurrentSocket
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -150,9 +156,21 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
||||
});
|
||||
|
||||
if (!duplicates.length) return;
|
||||
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
|
||||
const verifiedDuplicate = duplicates.find((candidate) => {
|
||||
/*
|
||||
verifiedOnly keeps the previous "verified tab wins" rule. In notAllowed
|
||||
mode, verified users are subject to the same single-driver-tab rule, so a
|
||||
verified duplicate should not protect the newer socket from enforcement.
|
||||
*/
|
||||
const candidateRole = getRole(candidate);
|
||||
const enforceForCandidate = shouldEnforceSingleDriverTab({
|
||||
isVerified: Boolean(candidate?.data?.isVerified),
|
||||
isAdmin: isAdminRole(candidateRole),
|
||||
});
|
||||
return candidate?.data?.isVerified && !enforceForCandidate;
|
||||
});
|
||||
if (verifiedDuplicate) {
|
||||
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', {
|
||||
logger.info('Disconnecting driver socket because its user is already active on an exempt verified socket', {
|
||||
socketId: currentSocket.id,
|
||||
retainedSocketId: verifiedDuplicate.id,
|
||||
userId: currentUserId,
|
||||
@@ -162,7 +180,7 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
||||
}
|
||||
|
||||
duplicates.forEach((duplicate) => {
|
||||
logger.info('Disconnecting older non-verified duplicate user socket', {
|
||||
logger.info('Disconnecting older duplicate driver socket', {
|
||||
socketId: duplicate.id,
|
||||
retainedSocketId: currentSocket.id,
|
||||
userId: currentUserId,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Video Auth Policy
|
||||
// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks.
|
||||
// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state.
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
|
||||
function createVideoAuthPolicy(deps) {
|
||||
const {
|
||||
getMode,
|
||||
@@ -63,7 +68,7 @@ function createVideoAuthPolicy(deps) {
|
||||
const isAudio = streamInfo.id?.endsWith('-audio');
|
||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||
const socketIp = getSocketIp(socket);
|
||||
if (!isLocalNetwork(socketIp)) {
|
||||
if (!isLocalNetwork(socketIp) && shouldUseSnapshotsForExternalSpectatorVideo()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +78,14 @@ function createVideoAuthPolicy(deps) {
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
return false;
|
||||
}
|
||||
if (!isAudio && shouldUseSnapshotsForNonTurnVideo() && !turnService.canDrive(roverId, socket)) {
|
||||
/*
|
||||
This mirrors videoSocketService's token gate. MediaMTX can ask auth
|
||||
after a token has been issued, so the active-turn bandwidth rule must
|
||||
be evaluated here too instead of trusting an older browser decision.
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -8,8 +8,13 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const roverManager = require('../roverManager');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const turnService = require('../turnService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const {
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
|
||||
const config = loadConfig();
|
||||
const mediaConfig = config.media || {};
|
||||
@@ -116,10 +121,25 @@ io.on('connection', (socket) => {
|
||||
const role = getRole(socket);
|
||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||
const ip = getSocketIp(socket);
|
||||
if (!isLocalNetwork(ip)) {
|
||||
if (!isLocalNetwork(ip) && shouldUseSnapshotsForExternalSpectatorVideo()) {
|
||||
throw new Error('Not authorized for video');
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isAudio &&
|
||||
role !== 'spectator' &&
|
||||
!isAdmin(socket) &&
|
||||
shouldUseSnapshotsForNonTurnVideo() &&
|
||||
!turnService.canDrive(baseId, socket)
|
||||
) {
|
||||
/*
|
||||
The browser also forces snapshots for non-active turn holders, but
|
||||
the socket token path must enforce the same rule. Otherwise a stale
|
||||
component or direct socket caller could still mint a MediaMTX token
|
||||
while the UI is showing snapshots.
|
||||
*/
|
||||
throw new Error('Live video is limited to the active turn');
|
||||
}
|
||||
} else if (target.type === 'room') {
|
||||
throw new Error('Room cameras now use the snapshot feed');
|
||||
} else if (target.type === 'ptz') {
|
||||
|
||||
Reference in New Issue
Block a user