Админка: кнопки в шапке сайта, красный выход и путь, который нигде не утекает

Интерфейс:
- после входа на главной появляются «Админка» и «Выйти» — в шапке, перед
  переключателем темы и после него соответственно (порядок: Админка, тема,
  Выйти);
- «Выйти» теперь красная и заметно реагирует на наведение (темнеет,
  приподнимается, тень) — и на сайте, и в админке (одна кнопка в шапке,
  из карточки убрана);
- в админке убраны «осталось попыток», путь к файлу настроек и карточка
  «Данные расписания» (дублировала шапку); блокировка показывается отдельной
  плашкой только когда действительно активна.

Безопасность пути:
- путь к панели не отдаётся ни в HTML, ни в JSON (из /api/admin/state убран),
  ни в JS: на главной для вошедшего только флаг, а кнопка «Админка» ведёт на
  служебный /api/admin/goto, который отвечает 302 на секретный путь;
- без сессии /api/admin/goto и /api/admin/state отвечают 401, неизвестные пути
  дают 404 — перебором путь не находится;
- страница панели: noindex/nofollow/noarchive, Referrer-Policy: no-referrer,
  X-Frame-Options: DENY, Cache-Control: no-store;
- robots.txt без пути (только Disallow: /api/), внешние ссылки в панели —
  rel=noopener noreferrer.

Тесты: 161 (состояние без пути, /goto: 401 без входа и 302 с ним).
This commit is contained in:
2026-10-10 12:54:54 +03:00
parent 71f83a27dd
commit 34a46b6832
8 changed files with 159 additions and 34 deletions

View File

@@ -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:

View File

@@ -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 @@
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="topbar-logout" id="logout" type="button">Выйти</button>
</div>
</div>
@@ -197,10 +197,8 @@
</label>
<div class="admin-card-actions">
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener">открыть таблицу</a>
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener noreferrer">открыть таблицу</a>
</div>
<p class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
Значение хранится в файле настроек и <b>имеет приоритет над .env</b>.</p>
</article>
<article class="admin-card">
@@ -216,8 +214,6 @@
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
</dl>
<p class="admin-hint">По интервалу таблица перечитывается, только если на Диске изменилась дата файла.
Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</p>
</article>
<article class="admin-card">
@@ -225,21 +221,8 @@
<dl class="admin-facts">
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
<div><dt>Осталось попыток</dt><dd id="auth-throttle">—</dd></div>
</dl>
<div class="admin-card-actions" style="margin-top:auto">
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
</div>
<p class="admin-hint">Каждая попытка запоминается: откуда, когда и чем закончилась. Порог и время блокировки задаются в .env.</p>
</article>
<article class="admin-card">
<h2>Данные расписания</h2>
<dl class="admin-facts">
<div><dt>Групп</dt><dd id="fact-groups">—</dd></div>
<div><dt>Обновлено</dt><dd id="fact-updated">—</dd></div>
<div><dt>Файл настроек</dt><dd id="fact-settings" class="admin-mono">—</dd></div>
</dl>
<div id="auth-lock" class="admin-alert warn hidden" style="margin:10px 0 0"></div>
</article>
</div>
</section>
@@ -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) {

View File

@@ -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(
"<body>", "<body>\n <script>window.__SCHEDULE_ADMIN__ = true;</script>", 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("Страница админки включена по секретному пути")

View File

@@ -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();

View File

@@ -28,10 +28,14 @@
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</a>
</div>
</div>
<div class="topbar-actions">
<button id="admin-link" class="topbar-admin hidden" type="button">Админка</button>
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button id="admin-logout" class="topbar-logout hidden" type="button">Выйти</button>
</div>
</div>
<div class="layout">

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /api/

View File

@@ -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; }

View File

@@ -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):