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

Панель без публичного входа: страница живёт по ADMIN_PATH, логин/пароль —
ADMIN_LOGIN/ADMIN_PASSWORD из .env (пусто = админка выключена и маршруты
/api/admin/* не регистрируются, прод можно не трогать).

Умеет: посмотреть текущую ссылку и состояние кэша, вставить новую ссылку
(проверяется скачиванием и разбором до сохранения, при ошибке откат),
принудительно обновить таблицу вне интервала и без проверки modified,
задать интервал в минутах (1..1440).

Реализация:
- backend/admin.py — cookie-сессия на HMAC (ключ = пароль, 12 часов,
  HttpOnly/SameSite/Secure-за-https), сравнение за постоянное время;
  compare_digest по байтам — кириллица в логине не роняет сервер;
- schedule/settings.py — ссылка и интервал в JSON (SETTINGS_FILE), атомарная
  запись, терпимость к битому/недоступному файлу (тогда живёт в памяти);
- schedule/cache.py — интервал вместо TTL, apply_link с откатом,
  force_reload, state() для панели; кэш без ссылки больше не роняет старт;
- frontend: страница admin.html (вне static, прямой /admin.html закрыт) и
  прокси /api/admin/* с телом и Set-Cookie;
- docker-compose: том schedule_data для настроек, ADMIN_* в окружении;
- deploy/: nginx-конфиг и инструкция для стенда dev.schedule.our-place.ru.

Тесты: 138 (новые — админ-API, сессии, ссылка/интервал, force, хранилище).
This commit is contained in:
2026-09-28 12:06:25 +03:00
parent 0e62d5ca8d
commit cfa6a99639
16 changed files with 1285 additions and 83 deletions

157
src/backend/admin.py Normal file
View File

@@ -0,0 +1,157 @@
"""Админка расписания.
Отдельного входа на сайте нет: панель открывается по секретному пути из
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После
входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние
между запросами не хранится.
Что умеет администратор:
GET /api/admin/state — текущая ссылка, интервал, состояние кэша;
POST /api/admin/link — проверить и поставить новую ссылку;
POST /api/admin/interval — интервал обновления в минутах;
POST /api/admin/refresh — принудительно обновить расписание сейчас.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from schedule import ScheduleCache, ScheduleError
log = logging.getLogger(__name__)
COOKIE_NAME = "schedule_admin"
SESSION_TTL_SECONDS = 12 * 3600
@dataclass
class AdminConfig:
"""Настройки админки: секретный путь и учётные данные из .env."""
path: str = ""
login: str = ""
password: str = ""
@property
def enabled(self) -> bool:
return bool(self.login and self.password)
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _b64d(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _same(left: str, right: str) -> bool:
"""Сравнение строк за постоянное время (compare_digest не умеет не-ASCII)."""
return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str:
payload = json.dumps(
{"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")
).encode()
sig = hmac.new(password.encode(), payload, hashlib.sha256).digest()
return f"{_b64e(payload)}.{_b64e(sig)}"
def verify_token(token: str, password: str) -> bool:
try:
payload_b64, sig_b64 = token.split(".", 1)
payload = _b64d(payload_b64)
expected = hmac.new(password.encode(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(expected, _b64d(sig_b64)):
return False
return int(json.loads(payload).get("exp", 0)) > time.time()
except (ValueError, TypeError):
return False
class LoginBody(BaseModel):
login: str
password: str
class LinkBody(BaseModel):
link: str
class IntervalBody(BaseModel):
interval_minutes: int
def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
router = APIRouter(prefix="/api/admin", tags=["admin"])
def require_auth(request: Request) -> None:
token = request.cookies.get(COOKIE_NAME, "")
if not token or not verify_token(token, config.password):
raise HTTPException(status_code=401, detail="Требуется вход в админку")
def _secure(request: Request) -> bool:
proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme
return proto.split(",")[0].strip() == "https"
@router.post("/login")
def login(body: LoginBody, request: Request, response: Response) -> dict:
ok_login = _same(body.login.strip(), config.login)
ok_password = _same(body.password, config.password)
if not (ok_login and ok_password):
log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40])
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
response.set_cookie(
COOKIE_NAME,
make_token(config.login, config.password),
max_age=SESSION_TTL_SECONDS,
httponly=True,
samesite="lax",
secure=_secure(request),
path="/",
)
log.info("Вход в админку выполнен")
return {"ok": True}
@router.post("/logout")
def logout(response: Response) -> dict:
response.delete_cookie(COOKIE_NAME, path="/")
return {"ok": True}
@router.get("/state", dependencies=[Depends(require_auth)])
def state() -> dict:
return cache.state()
@router.post("/refresh", dependencies=[Depends(require_auth)])
def refresh() -> dict:
try:
return cache.force_reload()
except ScheduleError as exc:
raise HTTPException(status_code=502, detail=str(exc))
@router.post("/link", dependencies=[Depends(require_auth)])
def set_link(body: LinkBody) -> dict:
try:
return cache.apply_link(body.link)
except ScheduleError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/interval", dependencies=[Depends(require_auth)])
def set_interval(body: IntervalBody) -> dict:
try:
minutes = cache.set_interval_minutes(body.interval_minutes)
except ScheduleError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"interval_minutes": minutes}
return router