fix(security): пер-юзер лимиты ИИ + SSE через одноразовый тикет (Спринт1 #5,#6)
#5 rate-limit (byUser) на дорогих LLM-эндпоинтах: /assistant/ask (20/мин), /assistant/flashcards (10/мин), /imggen (20/мин) — поверх cooldown/дневного лимита. Защита от «сжигания» бюджета провайдера одним аккаунтом. #6 SSE больше не таскает JWT в URL: добавлен authed /notifications/stream-ticket (одноразовый тикет, TTL 30с), клиент берёт тикет заголовком и подключается с ?ticket=. ?token= оставлен как временный фоллбэк для старых клиентов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1386,14 +1386,28 @@ async function markAllNotifsRead() { return req('POST', '/notifications/read-al
|
||||
/* ── SSE real-time notifications ─────────────────────────────────────────── */
|
||||
/* ── Shared SSE singleton — all listeners share one EventSource ─────── */
|
||||
let _sseShared = null;
|
||||
let _sseConnecting = false;
|
||||
let _sseRetryMs = 2000;
|
||||
let _sseEverConnected = false; // tracks whether SSE has successfully opened before
|
||||
const _sseListeners = new Set();
|
||||
|
||||
function _sseConnect() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
const url = `${API}/notifications/stream?token=${encodeURIComponent(token)}`;
|
||||
function _sseRetry() {
|
||||
const delay = Math.min(_sseRetryMs, 30000);
|
||||
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
|
||||
setTimeout(_sseConnect, delay);
|
||||
}
|
||||
|
||||
async function _sseConnect() {
|
||||
if (_sseShared || _sseConnecting) return;
|
||||
if (!getToken()) return;
|
||||
// Берём одноразовый тикет authed-запросом (Bearer в заголовке) — токен не попадает в URL.
|
||||
_sseConnecting = true;
|
||||
let ticket = null;
|
||||
try { const r = await req('GET', '/notifications/stream-ticket'); ticket = r && r.ticket; } catch (e) {}
|
||||
_sseConnecting = false;
|
||||
if (_sseShared) return; // подключились параллельно
|
||||
if (!ticket) { _sseRetry(); return; } // не вышло — повтор по backoff
|
||||
const url = `${API}/notifications/stream?ticket=${encodeURIComponent(ticket)}`;
|
||||
const es = new EventSource(url);
|
||||
_sseShared = es;
|
||||
es.onopen = () => {
|
||||
@@ -1412,9 +1426,7 @@ function _sseConnect() {
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
_sseShared = null;
|
||||
const delay = Math.min(_sseRetryMs, 30000);
|
||||
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
|
||||
setTimeout(_sseConnect, delay);
|
||||
_sseRetry();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user