From 26ad9b51945dfa735a0ea57addf279368199dccc Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 20:16:43 +0700 Subject: [PATCH] feat: guard admin import cooldown --- apps/web/src/pages/admin/index.astro | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/web/src/pages/admin/index.astro b/apps/web/src/pages/admin/index.astro index 69c269b..22e9438 100644 --- a/apps/web/src/pages/admin/index.astro +++ b/apps/web/src/pages/admin/index.astro @@ -25,11 +25,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const logout = document.querySelector("[data-admin-logout]"); let token = ""; let sessionTimer: ReturnType | undefined; + let importCooldownUntil = 0; + let importCooldownTimer: ReturnType | undefined; const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char)); const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; + const syncImportButton = () => { const button = content?.querySelector("[data-official-import]"); if (!button) return; const seconds = Math.max(0, Math.ceil((importCooldownUntil - Date.now()) / 1000)); button.disabled = seconds > 0; button.textContent = seconds > 0 ? `Повторить через ${seconds < 60 ? `${seconds} с` : `${Math.ceil(seconds / 60)} мин`}` : "Запустить импорт"; button.setAttribute("aria-disabled", String(seconds > 0)); }; + const startImportCooldown = (seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return; importCooldownUntil = Math.max(importCooldownUntil, Date.now() + Math.ceil(seconds) * 1000); if (importCooldownTimer) clearInterval(importCooldownTimer); syncImportButton(); importCooldownTimer = setInterval(() => { syncImportButton(); if (Date.now() >= importCooldownUntil && importCooldownTimer) { clearInterval(importCooldownTimer); importCooldownTimer = undefined; } }, 1000); }; async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); } async function loadDashboard() { if (!content) return; @@ -48,6 +52,24 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; } login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); + content?.addEventListener("click", async event => { + const button = (event.target as HTMLElement).closest("[data-official-import]"); + if (!button) return; + event.preventDefault(); event.stopImmediatePropagation(); + if (Date.now() < importCooldownUntil) { syncImportButton(); return; } + button.disabled = true; + try { + const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`} }); + if (!response.ok) { + const retryAfter = Number(response.headers.get("Retry-After") ?? 0); + if (response.status === 429 && retryAfter > 0) startImportCooldown(retryAfter); + if (adminEndsSession(response.status)) endSession(); + throw new Error(response.status === 429 ? "Источник на cooldown. Повторный запуск временно заблокирован." : response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт.")); + } + keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); + } catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); } + finally { syncImportButton(); } + }, true); content?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return; button.disabled = true;