Админка: кнопки в шапке сайта, красный выход и путь, который нигде не утекает
Интерфейс: - после входа на главной появляются «Админка» и «Выйти» — в шапке, перед переключателем темы и после него соответственно (порядок: Админка, тема, Выйти); - «Выйти» теперь красная и заметно реагирует на наведение (темнеет, приподнимается, тень) — и на сайте, и в админке (одна кнопка в шапке, из карточки убрана); - в админке убраны «осталось попыток», путь к файлу настроек и карточка «Данные расписания» (дублировала шапку); блокировка показывается отдельной плашкой только когда действительно активна. Безопасность пути: - путь к панели не отдаётся ни в 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:
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("Страница админки включена по секретному пути")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -28,10 +28,14 @@
|
||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
||||
</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-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 class="layout">
|
||||
|
||||
2
src/frontend/static/robots.txt
Normal file
2
src/frontend/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /api/
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user