Админка: секретный путь, смена ссылки, интервал и принудительное обновление
Панель без публичного входа: страница живёт по 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:
157
src/backend/admin.py
Normal file
157
src/backend/admin.py
Normal 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
|
||||
Reference in New Issue
Block a user