AAAHHHJ RATE LIMITTT

This commit is contained in:
legop3
2025-12-01 22:56:06 -05:00
parent 29bb16ba1a
commit 335fa4e21d
4 changed files with 64 additions and 14 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,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" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CvHOwJu1.js"></script> <script type="module" crossorigin src="/assets/index-Bm6FTwrt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css"> <link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css">
</head> </head>
<body> <body>
@@ -5,6 +5,9 @@ const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const { getRoomCamera } = require('./roomCameraService'); const { getRoomCamera } = require('./roomCameraService');
const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnapshotService'); const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnapshotService');
const SUBSCRIBE_LIMIT = 20;
const SUBSCRIBE_WINDOW_MS = 1000;
function passesMode(socket) { function passesMode(socket) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.LOCKDOWN) { if (mode === MODES.LOCKDOWN) {
@@ -23,6 +26,7 @@ function canViewRoomCamera(socket) {
const cameraSubscribers = new Map(); // id -> Set(socketId) const cameraSubscribers = new Map(); // id -> Set(socketId)
const socketSubscriptions = new Map(); // socketId -> Set(id) const socketSubscriptions = new Map(); // socketId -> Set(id)
const subscribeBuckets = new Map(); // socketId -> { start, count }
function addSubscription(socket, cameraId) { function addSubscription(socket, cameraId) {
if (!cameraSubscribers.has(cameraId)) { if (!cameraSubscribers.has(cameraId)) {
@@ -59,6 +63,17 @@ function removeAllSubscriptions(socketId) {
bucket.forEach((cameraId) => removeSubscription(socketId, cameraId)); bucket.forEach((cameraId) => removeSubscription(socketId, cameraId));
} }
function allowSubscribe(socketId) {
const now = Date.now();
let bucket = subscribeBuckets.get(socketId);
if (!bucket || now - bucket.start >= SUBSCRIBE_WINDOW_MS) {
bucket = { start: now, count: 0 };
}
bucket.count += 1;
subscribeBuckets.set(socketId, bucket);
return bucket.count <= SUBSCRIBE_LIMIT;
}
function sendFrame(socket, cameraId, payload, buffer) { function sendFrame(socket, cameraId, payload, buffer) {
socket.emit('roomCamera:frame', { id: cameraId, ...payload }, buffer); socket.emit('roomCamera:frame', { id: cameraId, ...payload }, buffer);
} }
@@ -91,6 +106,10 @@ io.on('connection', (socket) => {
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => { socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
const cameraId = payload.roomCameraId || payload.id; const cameraId = payload.roomCameraId || payload.id;
try { try {
if (!allowSubscribe(socket.id)) {
cb({ error: 'Rate limited' });
return;
}
if (!cameraId) { if (!cameraId) {
throw new Error('roomCameraId required'); throw new Error('roomCameraId required');
} }
@@ -122,5 +141,6 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => { socket.on('disconnect', () => {
removeAllSubscriptions(socket.id); removeAllSubscriptions(socket.id);
subscribeBuckets.delete(socket.id);
}); });
}); });
+35 -5
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
const RETRY_DELAY_MS = 3000;
function normalizeCamera(entry) { function normalizeCamera(entry) {
if (!entry) return null; if (!entry) return null;
if (typeof entry === 'string') { if (typeof entry === 'string') {
@@ -29,11 +31,14 @@ export function useRoomCameraSnapshots(sourceList = []) {
const objectUrls = useRef(new Map()); const objectUrls = useRef(new Map());
const normalizedEntries = useMemo(() => dedupe(sourceList.map(normalizeCamera).filter(Boolean)), [sourceList]); const normalizedEntries = useMemo(() => dedupe(sourceList.map(normalizeCamera).filter(Boolean)), [sourceList]);
const entriesKey = useMemo(() => normalizedEntries.map((e) => e.key).join('|'), [normalizedEntries]); const entriesKey = useMemo(() => normalizedEntries.map((e) => e.key).join('|'), [normalizedEntries]);
const retryTimers = useRef(new Map());
useEffect(() => { useEffect(() => {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear(); objectUrls.current.clear();
setFeeds({}); setFeeds({});
retryTimers.current.forEach((timer) => clearTimeout(timer));
retryTimers.current.clear();
}, [entriesKey]); }, [entriesKey]);
useEffect(() => { useEffect(() => {
@@ -42,6 +47,23 @@ export function useRoomCameraSnapshots(sourceList = []) {
} }
let cancelled = false; let cancelled = false;
const clearRetry = (id) => {
const timer = retryTimers.current.get(id);
if (timer) {
clearTimeout(timer);
retryTimers.current.delete(id);
}
};
const scheduleRetry = (id) => {
clearRetry(id);
const timer = setTimeout(() => {
retryTimers.current.delete(id);
requestSubscribe(id);
}, RETRY_DELAY_MS);
retryTimers.current.set(id, timer);
};
const handleFrame = (meta = {}, buffer) => { const handleFrame = (meta = {}, buffer) => {
if (cancelled || !meta.id || !buffer) return; if (cancelled || !meta.id || !buffer) return;
const blob = new Blob([buffer], { type: 'image/jpeg' }); const blob = new Blob([buffer], { type: 'image/jpeg' });
@@ -78,18 +100,24 @@ export function useRoomCameraSnapshots(sourceList = []) {
})); }));
}; };
socket.on('roomCamera:frame', handleFrame); const requestSubscribe = (id) => {
socket.on('roomCamera:status', handleStatus); const entry = normalizedEntries.find((e) => e.id === id);
if (!entry) return;
normalizedEntries.forEach((entry) => {
socket.emit('roomCamera:subscribe', { roomCameraId: entry.id }, (resp = {}) => { socket.emit('roomCamera:subscribe', { roomCameraId: entry.id }, (resp = {}) => {
if (resp.error) { if (resp.error) {
handleStatus({ id: entry.id, error: resp.error }); handleStatus({ id: entry.id, error: resp.error });
scheduleRetry(entry.id);
} else { } else {
clearRetry(entry.id);
handleStatus({ id: entry.id, stale: true }); handleStatus({ id: entry.id, stale: true });
} }
}); });
}); };
socket.on('roomCamera:frame', handleFrame);
socket.on('roomCamera:status', handleStatus);
normalizedEntries.forEach((entry) => requestSubscribe(entry.id));
return () => { return () => {
cancelled = true; cancelled = true;
@@ -100,6 +128,8 @@ export function useRoomCameraSnapshots(sourceList = []) {
socket.off('roomCamera:status', handleStatus); socket.off('roomCamera:status', handleStatus);
objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear(); objectUrls.current.clear();
retryTimers.current.forEach((timer) => clearTimeout(timer));
retryTimers.current.clear();
}; };
}, [socket, normalizedEntries, entriesKey]); }, [socket, normalizedEntries, entriesKey]);