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

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

Безопасность пути:
- путь к панели не отдаётся ни в 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 datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from schedule import ScheduleCache, ScheduleError from schedule import ScheduleCache, ScheduleError
@@ -245,6 +246,8 @@ def create_admin_router(
ip = _client_ip(request) ip = _client_ip(request)
until = throttle.locked_until(ip) until = throttle.locked_until(ip)
payload = cache.state() payload = cache.state()
# путь к панели здесь НЕ отдаём: он не должен попадать ни в JSON, ни в
# HTML — переход в панель идёт через /api/admin/goto (редирект)
payload["throttle"] = { payload["throttle"] = {
"max_attempts": config.max_attempts, "max_attempts": config.max_attempts,
"lockout_minutes": config.lockout_minutes, "lockout_minutes": config.lockout_minutes,
@@ -253,6 +256,20 @@ def create_admin_router(
} }
return payload 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)]) @router.post("/refresh", dependencies=[Depends(require_auth)])
def refresh() -> dict: def refresh() -> dict:
try: try:

View File

@@ -85,7 +85,6 @@
} }
.admin-btn:hover { filter: brightness(1.06); } .admin-btn:hover { filter: brightness(1.06); }
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); } .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-lg { font-size: 15px; padding: 11px 18px; }
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; } .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-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> <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>
<button class="topbar-logout" id="logout" type="button">Выйти</button>
</div> </div>
</div> </div>
@@ -197,10 +197,8 @@
</label> </label>
<div class="admin-card-actions"> <div class="admin-card-actions">
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button> <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> </div>
<p class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
Значение хранится в файле настроек и <b>имеет приоритет над .env</b>.</p>
</article> </article>
<article class="admin-card"> <article class="admin-card">
@@ -216,8 +214,6 @@
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div> <div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div> <div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
</dl> </dl>
<p class="admin-hint">По интервалу таблица перечитывается, только если на Диске изменилась дата файла.
Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</p>
</article> </article>
<article class="admin-card"> <article class="admin-card">
@@ -225,21 +221,8 @@
<dl class="admin-facts"> <dl class="admin-facts">
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div> <div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</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> </dl>
<div id="auth-lock" class="admin-alert warn hidden" style="margin:10px 0 0"></div>
</article> </article>
</div> </div>
</section> </section>
@@ -373,16 +356,13 @@
setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null); setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
setText("auth-success", loginText(state.last_success)); setText("auth-success", loginText(state.last_success));
const throttle = state.throttle || {}; const throttle = state.throttle || {};
const lock = $("auth-lock");
if (throttle.locked_until) { if (throttle.locked_until) {
setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad"); lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`;
} else if (throttle.max_attempts) { lock.classList.remove("hidden");
setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`); } 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) { function showLogin(show) {

View File

@@ -15,7 +15,7 @@ import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool 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 from fastapi.staticfiles import StaticFiles
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -48,6 +48,32 @@ ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/")
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
# страница админки вне static: прямой доступ по /admin.html закрыт # страница админки вне static: прямой доступ по /admin.html закрыт
ADMIN_PAGE = Path(__file__).parent / "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="Расписание — фронт") app = FastAPI(title="Расписание — фронт")
@@ -82,6 +108,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
data=body or None, data=body or None,
headers=headers, headers=headers,
timeout=PROXY_TIMEOUT, timeout=PROXY_TIMEOUT,
# редирект /api/admin/goto отдаём браузеру как есть: он и уводит
# на страницу панели, чтобы путь не появлялся в разметке
allow_redirects=False,
) )
except requests.RequestException as exc: except requests.RequestException as exc:
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {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"), media_type=resp.headers.get("content-type"),
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
location = resp.headers.get("location")
if location:
out.headers["location"] = location
# Set-Cookie (вход/выход админки) передаём как есть # Set-Cookie (вход/выход админки) передаём как есть
raw_headers = getattr(resp.raw, "headers", None) raw_headers = getattr(resp.raw, "headers", None)
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []: 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("/") @app.get("/")
def index(): def index(request: Request):
return FileResponse(STATIC_DIR / "index.html") """Главная страница.
Вошедшему админу добавляем только флаг (без пути!): по нему страница
покажет кнопку «Админка», которая ведёт на /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: if ADMIN_PATH:
@@ -187,7 +232,13 @@ if ADMIN_PATH:
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет.""" """Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
return FileResponse( return FileResponse(
ADMIN_PAGE, 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("Страница админки включена по секретному пути") log.info("Страница админки включена по секретному пути")

View File

@@ -787,6 +787,24 @@ themeToggle.addEventListener("click", () => {
initTheme(); 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) => { document.querySelectorAll(".side-menu a").forEach((link) => {
link.addEventListener("click", (e) => { link.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();

View File

@@ -28,10 +28,14 @@
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</a> <a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</a>
</div> </div>
</div> </div>
<button id="theme-toggle" class="theme-toggle" title="Переключить тему"> <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-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> <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>
<button id="admin-logout" class="topbar-logout hidden" type="button">Выйти</button>
</div>
</div> </div>
<div class="layout"> <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); } .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; } .theme-toggle .icon { width: 18px; height: 18px; }
.icon-sun { display: none; } .icon-sun { display: none; }

View File

@@ -75,6 +75,18 @@ def test_login_and_state(client, cache):
assert state["groups"] == 2 assert state["groups"] == 2
assert state["error"] is None assert state["error"] is None
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts 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): def test_logout(client):