diff --git a/src/backend/admin.py b/src/backend/admin.py index 42cccd1..19465cc 100644 --- a/src/backend/admin.py +++ b/src/backend/admin.py @@ -28,6 +28,7 @@ from dataclasses import dataclass from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import RedirectResponse from pydantic import BaseModel from schedule import ScheduleCache, ScheduleError @@ -245,6 +246,8 @@ def create_admin_router( ip = _client_ip(request) until = throttle.locked_until(ip) payload = cache.state() + # путь к панели здесь НЕ отдаём: он не должен попадать ни в JSON, ни в + # HTML — переход в панель идёт через /api/admin/goto (редирект) payload["throttle"] = { "max_attempts": config.max_attempts, "lockout_minutes": config.lockout_minutes, @@ -253,6 +256,20 @@ def create_admin_router( } return payload + @router.get("/goto", dependencies=[Depends(require_auth)]) + def goto() -> Response: + """Переход в панель. + + Так секретный путь не хранится в разметке главной страницы: кнопка + «Админка» дёргает этот адрес, и браузер уже получает редирект. + Без валидной сессии ответ — 401, то есть ботам тут ничего не видно. + """ + if not config.path: + raise HTTPException(status_code=500, detail="ADMIN_PATH не задан") + return RedirectResponse( + url=config.path, status_code=302, headers={"Cache-Control": "no-store"} + ) + @router.post("/refresh", dependencies=[Depends(require_auth)]) def refresh() -> dict: try: diff --git a/src/frontend/admin.html b/src/frontend/admin.html index bfdc8b6..c403290 100644 --- a/src/frontend/admin.html +++ b/src/frontend/admin.html @@ -85,7 +85,6 @@ } .admin-btn:hover { filter: brightness(1.06); } .admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); } - .admin-btn.danger { background: transparent; color: var(--accent-red-bright); border-color: var(--accent-red-bright); } .admin-btn-lg { font-size: 15px; padding: 11px 18px; } .admin-btn[disabled] { opacity: .55; cursor: default; filter: none; } @@ -163,6 +162,7 @@ + @@ -197,10 +197,8 @@
- открыть таблицу + открыть таблицу
-

Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними. - Значение хранится в файле настроек и имеет приоритет над .env.

@@ -216,8 +214,6 @@
Дата файла на Диске
—
Отпечаток файла
—
-

По интервалу таблица перечитывается, только если на Диске изменилась дата файла. - Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.

@@ -225,21 +221,8 @@
Последняя попытка
—
Последний успешный вход
—
-
Осталось попыток
—
-
-
- -
-

Каждая попытка запоминается: откуда, когда и чем закончилась. Порог и время блокировки задаются в .env.

-
- -
-

Данные расписания

-
-
Групп
—
-
Обновлено
—
-
Файл настроек
—
+
@@ -373,16 +356,13 @@ setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null); setText("auth-success", loginText(state.last_success)); const throttle = state.throttle || {}; + const lock = $("auth-lock"); if (throttle.locked_until) { - setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad"); - } else if (throttle.max_attempts) { - setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`); + lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`; + lock.classList.remove("hidden"); + } else { + lock.classList.add("hidden"); } - - // данные - setText("fact-groups", String(state.groups)); - setText("fact-updated", state.last_updated ? `${fmt(state.last_updated)} (${ago(state.last_updated)})` : "—"); - setText("fact-settings", state.settings_file || "—"); } function showLogin(show) { diff --git a/src/frontend/main.py b/src/frontend/main.py index 2c87180..40b2f1f 100644 --- a/src/frontend/main.py +++ b/src/frontend/main.py @@ -15,7 +15,7 @@ import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request, Response from fastapi.concurrency import run_in_threadpool -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles log = logging.getLogger(__name__) @@ -48,6 +48,32 @@ ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/") STATIC_DIR = Path(__file__).parent / "static" # страница админки вне static: прямой доступ по /admin.html закрыт ADMIN_PAGE = Path(__file__).parent / "admin.html" +INDEX_PAGE = STATIC_DIR / "index.html" +# проверка «это админ?» при отдаче главной — свой короткий таймаут +ADMIN_CHECK_TIMEOUT = 2.0 + + +def admin_session_active(request: Request) -> bool: + """Есть ли валидная админская сессия у запроса. + + Сам путь к панели при этом не читаем и никому не отдаём: главная страница + получает только булев флаг, по которому показывает кнопку «Админка». + """ + if not ADMIN_PATH: + return False + cookie = request.headers.get("cookie") + if not cookie: + return False + try: + resp = requests.get( + f"{SCHEDULE_API}/api/admin/state", + headers={"Cookie": cookie}, + timeout=ADMIN_CHECK_TIMEOUT, + ) + except requests.RequestException: + return False + return resp.status_code == 200 + app = FastAPI(title="Расписание — фронт") @@ -82,6 +108,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response data=body or None, headers=headers, timeout=PROXY_TIMEOUT, + # редирект /api/admin/goto отдаём браузеру как есть: он и уводит + # на страницу панели, чтобы путь не появлялся в разметке + allow_redirects=False, ) except requests.RequestException as exc: raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}") @@ -91,6 +120,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response media_type=resp.headers.get("content-type"), headers={"Cache-Control": "no-store"}, ) + location = resp.headers.get("location") + if location: + out.headers["location"] = location # Set-Cookie (вход/выход админки) передаём как есть raw_headers = getattr(resp.raw, "headers", None) for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []: @@ -177,8 +209,21 @@ async def api_admin(rest: str, request: Request): @app.get("/") -def index(): - return FileResponse(STATIC_DIR / "index.html") +def index(request: Request): + """Главная страница. + + Вошедшему админу добавляем только флаг (без пути!): по нему страница + покажет кнопку «Админка», которая ведёт на /api/admin/goto — сервер сам + ответит редиректом на секретный путь. Так путь не попадает ни в разметку, + ни в данные, и неавторизованный пользователь его нигде не увидит. + """ + if admin_session_active(request): + html = INDEX_PAGE.read_text(encoding="utf-8") + html = html.replace( + "", "\n ", 1 + ) + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) + return FileResponse(INDEX_PAGE, headers={"Cache-Control": "no-store"}) if ADMIN_PATH: @@ -187,7 +232,13 @@ if ADMIN_PATH: """Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет.""" return FileResponse( ADMIN_PAGE, - headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"}, + headers={ + "X-Robots-Tag": "noindex, nofollow, noarchive", + "Cache-Control": "no-store", + # путь не должен утекать во внешние ссылки через Referer + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + }, ) log.info("Страница админки включена по секретному пути") diff --git a/src/frontend/static/app.js b/src/frontend/static/app.js index 47a66cf..ad2bbd5 100644 --- a/src/frontend/static/app.js +++ b/src/frontend/static/app.js @@ -787,6 +787,24 @@ themeToggle.addEventListener("click", () => { initTheme(); +/* Кнопки админки в шапке: сервер подставляет путь, только если сессия валидна */ +const adminLink = document.getElementById("admin-link"); +const adminLogout = document.getElementById("admin-logout"); + +if (adminLink && adminLogout && window.__SCHEDULE_ADMIN__) { + // путь к панели в разметке не храним: сервер сам ответит редиректом + adminLink.classList.remove("hidden"); + adminLogout.classList.remove("hidden"); + adminLink.addEventListener("click", () => { window.location.href = "/api/admin/goto"; }); + adminLogout.addEventListener("click", async () => { + try { + await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }); + } catch (_) {} + adminLink.classList.add("hidden"); + adminLogout.classList.add("hidden"); + }); +} + document.querySelectorAll(".side-menu a").forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); diff --git a/src/frontend/static/index.html b/src/frontend/static/index.html index 9d0eafb..f566693 100644 --- a/src/frontend/static/index.html +++ b/src/frontend/static/index.html @@ -28,10 +28,14 @@ Факультет информатики и вычислительной техники - + + + +
diff --git a/src/frontend/static/robots.txt b/src/frontend/static/robots.txt new file mode 100644 index 0000000..d346249 --- /dev/null +++ b/src/frontend/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /api/ diff --git a/src/frontend/static/style.css b/src/frontend/static/style.css index 759ddbb..06aa5f7 100644 --- a/src/frontend/static/style.css +++ b/src/frontend/static/style.css @@ -151,6 +151,47 @@ a:hover { color: var(--accent-red); } } .theme-toggle:hover { color: var(--accent); border-color: var(--accent); } + +/* ---- кнопки в шапке: админка и выход ---- */ +.topbar-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } + +.topbar-admin { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 14px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--on-accent); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: filter .15s ease, transform .15s ease; +} +.topbar-admin:hover { filter: brightness(1.1); transform: translateY(-1px); } +.topbar-admin:active { transform: none; } + +/* красная кнопка выхода: при наведении темнеет, приподнимается и даёт тень */ +.topbar-logout { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 14px; + border: 1px solid var(--accent-red-bright); + background: var(--accent-red-bright); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, transform .15s ease; +} +.topbar-logout:hover { + background: var(--accent-red); + border-color: var(--accent-red); + box-shadow: 0 4px 12px color-mix(in srgb, var(--accent-red-bright) 35%, transparent); + transform: translateY(-1px); +} +.topbar-logout:active { transform: none; box-shadow: none; } .theme-toggle .icon { width: 18px; height: 18px; } .icon-sun { display: none; } diff --git a/tests/test_admin.py b/tests/test_admin.py index da7d35b..f1d72d2 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -75,6 +75,18 @@ def test_login_and_state(client, cache): assert state["groups"] == 2 assert state["error"] is None assert state["throttle"]["max_attempts"] == ADMIN.max_attempts + # путь к панели не должен утекать в JSON: переход идёт через /goto + assert "admin_path" not in state + assert ADMIN.path not in str(state) + + +def test_goto_redirects_to_panel_only_for_authorized(client): + """Переход в панель: без сессии 401, с сессией — редирект на секретный путь.""" + assert client.get("/api/admin/goto", follow_redirects=False).status_code == 401 + do_login(client) + resp = client.get("/api/admin/goto", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == ADMIN.path def test_logout(client):