Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
- Значение хранится в файле настроек и имеет приоритет над .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 @@
Факультет информатики и вычислительной техники
-