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

Панель без публичного входа: страница живёт по 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

View File

@@ -4,17 +4,18 @@ from __future__ import annotations
import logging
import os
import threading
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException
from admin import AdminConfig, create_admin_router
from schedule import ScheduleCache, ScheduleError
from schedule import parsing
from schedule.settings import SettingsStore, interval_from_env
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
@@ -37,40 +38,36 @@ _dotenv_path = _find_dotenv()
if _dotenv_path:
load_dotenv(_dotenv_path)
def _normalize_admin_path(raw: str | None) -> str:
"""Секретный путь панели: всегда с ведущим слэшем и без хвостового."""
path = (raw or "").strip()
if not path:
return ""
if not path.startswith("/"):
path = "/" + path
return path.rstrip("/") or "/"
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "900"))
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip()
ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH"))
ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip()
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or ""
def _default_cache() -> ScheduleCache:
try:
return ScheduleCache(SCHEDULE_LINK, ttl_seconds=CACHE_TTL, retry_after=RETRY_AFTER)
except ScheduleError:
log.error("SCHEDULE_LINK не задан — API данных работать не будет")
return _BrokenCache()
def default_settings() -> SettingsStore:
return SettingsStore(
link=SCHEDULE_LINK,
interval_minutes=interval_from_env(),
path=SETTINGS_FILE or None,
)
class _BrokenCache(ScheduleCache):
"""Кэш без ссылки: все запросы данных отвечают понятной ошибкой."""
def __init__(self) -> None:
self._link = None
self._ttl = CACHE_TTL
self._retry_after = RETRY_AFTER
self._lock = threading.RLock()
self._data = None
self._weeks = []
self._index = None
self._day_times = {}
self._teacher_full = {}
self._room_info = {}
self._loaded_at = 0.0
self._modified = None
self._error = "Не задана переменная окружения SCHEDULE_LINK"
self._last_failed = 0.0
def _ensure_fresh(self) -> None:
raise ScheduleError(self._error)
def default_cache(settings: SettingsStore | None = None) -> ScheduleCache:
settings = settings or default_settings()
return ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
def _day_arg(day: str) -> str:
@@ -83,8 +80,14 @@ def _day_arg(day: str) -> str:
return normalized
def create_app(cache: ScheduleCache | None = None) -> FastAPI:
cache = cache or _default_cache()
def create_app(
cache: ScheduleCache | None = None,
admin: AdminConfig | None = None,
) -> FastAPI:
cache = cache or default_cache()
admin = admin if admin is not None else AdminConfig(
path=ADMIN_PATH, login=ADMIN_LOGIN, password=ADMIN_PASSWORD
)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -97,6 +100,16 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI:
lifespan=lifespan,
)
app.state.cache = cache
app.state.admin = admin
if admin.enabled:
app.include_router(create_admin_router(cache, admin))
if admin.path:
log.info("Админка включена (секретный путь задан)")
else:
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
else:
log.info("Админка выключена: не заданы ADMIN_LOGIN/ADMIN_PASSWORD")
def _data() -> tuple[dict, list[dict]]:
try: