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

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

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:

View File

@@ -2,11 +2,14 @@
- однопоточная перезагрузка (lock): параллельные запросы во время загрузки
ждут один download вместо N скачиваний;
- TTL, а также проверка modified таблицы: если таблица не менялась,
скачивание не повторяется;
- TTL (интервал обновления), а также проверка modified таблицы: если таблица
не менялась, скачивание не повторяется;
- stale-while-error: если таблица перестала скачиваться, отдаём последнее
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
(вместо скачивания на каждый запрос).
(вместо скачивания на каждый запрос);
- настройки администратора (ссылка, интервал) живут в SettingsStore и
применяются на лету: смена ссылки проверяется до сохранения, принудительное
обновление идёт вне расписания.
"""
from __future__ import annotations
@@ -18,24 +21,32 @@ from datetime import datetime
from . import parsing
from .errors import ScheduleError
from .settings import SettingsStore
from .source import download_schedule, fetch_table_modified
from .xlsx import parse_workbook
log = logging.getLogger(__name__)
NO_LINK_ERROR = "Ссылка на расписание не задана"
class ScheduleCache:
def __init__(
self,
link: str | None,
link: str | None = None,
ttl_seconds: float = 900.0,
retry_after: float = 60.0,
settings: SettingsStore | None = None,
) -> None:
if not link:
self._settings = settings
if settings is not None:
link = settings.link or link or ""
ttl_seconds = settings.interval_minutes * 60
self._link = (link or "").strip()
if not self._link and settings is None:
raise ScheduleError("Не задана переменная окружения SCHEDULE_LINK")
self._link = link
self._ttl = ttl_seconds
self._retry_after = retry_after
self._ttl = float(ttl_seconds)
self._retry_after = float(retry_after)
self._lock = threading.RLock()
self._data: dict | None = None
@@ -46,7 +57,7 @@ class ScheduleCache:
self._room_info: dict = {}
self._loaded_at: float = 0.0
self._modified: str | None = None
self._error: str | None = None
self._error: str | None = None if self._link else NO_LINK_ERROR
self._last_failed: float = 0.0
# --- публичное API ---------------------------------------------------------
@@ -59,6 +70,11 @@ class ScheduleCache:
def room_info(self) -> dict:
return self._room_info
@property
def interval_minutes(self) -> int:
with self._lock:
return max(1, round(self._ttl / 60))
def get(self) -> tuple[dict, list[dict]]:
"""(data, weeks); бросает ScheduleError, если данных нет совсем."""
with self._lock:
@@ -82,18 +98,74 @@ class ScheduleCache:
with self._lock:
return {
"cache_ttl_seconds": self._ttl,
"interval_minutes": self.interval_minutes,
"last_updated": self._iso(self._loaded_at),
"table_modified": self._modified,
"error": self._error,
}
def state(self) -> dict:
"""Полное состояние для админки."""
with self._lock:
return self._state()
def warmup(self) -> None:
"""Первый запуск данных на старте приложения (ошибка не фатальна)."""
"""Первый загруз данных на старте приложения (ошибка не фатальна)."""
try:
self.get()
except ScheduleError as exc:
log.warning("Начальная загрузка расписания не удалась: %s", exc)
# --- действия администратора ------------------------------------------------
def force_reload(self) -> dict:
"""Принудительно обновить расписание вне интервала (кнопка в админке)."""
with self._lock:
now = time.time()
modified = fetch_table_modified(self._link) if self._link else None
try:
self._load(now, modified)
except ScheduleError as exc:
self._error = str(exc)
self._last_failed = now
log.warning("Принудительное обновление не удалось: %s", exc)
raise
return self._state()
def apply_link(self, link: str) -> dict:
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
link = (link or "").strip()
if not link:
raise ScheduleError("Ссылка на расписание не может быть пустой")
with self._lock:
previous_link, previous_modified = self._link, self._modified
self._link = link
self._modified = None
now = time.time()
modified = fetch_table_modified(link)
try:
self._load(now, modified)
except ScheduleError as exc:
# ссылка не подошла — возвращаемся к прежней, данные не теряем
self._link = previous_link
self._modified = previous_modified
self._last_failed = now
if self._data is None:
self._error = str(exc)
raise ScheduleError(f"Ссылка не подошла: {exc}") from exc
if self._settings is not None:
self._settings.set_link(link)
return self._state()
def set_interval_minutes(self, minutes: int | str) -> int:
if self._settings is None:
raise ScheduleError("Настройки не подключены: интервал задаётся через .env")
value = self._settings.set_interval_minutes(minutes)
with self._lock:
self._ttl = value * 60
log.info("Интервал обновления расписания: %d мин", value)
return value
# --- internals --------------------------------------------------------------
@staticmethod
@@ -102,6 +174,17 @@ class ScheduleCache:
return None
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
def _state(self) -> dict:
return {
"link": self._link,
"interval_minutes": max(1, round(self._ttl / 60)),
"last_updated": self._iso(self._loaded_at),
"table_modified": self._modified,
"error": self._error,
"groups": len(self._data) if self._data else 0,
"has_data": self._data is not None,
}
def _ensure_fresh(self) -> None:
now = time.time()
fresh = (
@@ -122,6 +205,9 @@ class ScheduleCache:
and now - self._last_failed < self._retry_after
):
return
if not self._link:
self._fail(ScheduleError(NO_LINK_ERROR), now)
return
modified = fetch_table_modified(self._link)
if (
self._data is not None
@@ -133,31 +219,35 @@ class ScheduleCache:
self._error = None
return
try:
log.info("Загружаю расписание по ссылке %s ...", self._link)
started = time.time()
xlsx = download_schedule(self._link)
sheets = parse_workbook(xlsx)
data = parsing.parse_schedule(sheets)
weeks = parsing.extract_week_ranges(sheets)
index = parsing.build_teacher_room_index(data)
self._data = data
self._weeks = weeks
self._index = index
self._day_times = parsing.build_day_times(data)
self._teacher_full = parsing.parse_teachers_full(sheets)
self._room_info = parsing.parse_rooms_info(sheets)
self._modified = modified
self._loaded_at = now
self._error = None
log.info(
"Расписание загружено: %d групп за %.1f с", len(data), time.time() - started
)
self._load(now, modified)
except ScheduleError as exc:
self._fail(exc, now)
except Exception as exc: # непредвиденное в парсинге/сети
log.exception("Ошибка при загрузке расписания")
self._fail(ScheduleError(f"Не удалось обработать таблицу: {exc}"), now)
def _load(self, now: float, modified: str | None) -> None:
"""Скачать и разобрать таблицу, заменить состояние. Бросает ScheduleError."""
if not self._link:
raise ScheduleError(NO_LINK_ERROR)
log.info("Загружаю расписание по ссылке %s ...", self._link)
started = time.time()
xlsx = download_schedule(self._link)
sheets = parse_workbook(xlsx)
data = parsing.parse_schedule(sheets)
weeks = parsing.extract_week_ranges(sheets)
index = parsing.build_teacher_room_index(data)
self._data = data
self._weeks = weeks
self._index = index
self._day_times = parsing.build_day_times(data)
self._teacher_full = parsing.parse_teachers_full(sheets)
self._room_info = parsing.parse_rooms_info(sheets)
self._modified = modified
self._loaded_at = now
self._error = None
log.info("Расписание загружено: %d групп за %.1f с", len(data), time.time() - started)
def _fail(self, exc: ScheduleError, now: float) -> None:
self._error = str(exc)
self._last_failed = now

View File

@@ -0,0 +1,153 @@
"""Настройки расписания, которые правит администратор.
Хранятся в JSON-файле (если задан путь) — так ссылка и интервал переживают
перезапуск контейнера. Если путь не задан, настройки живут только в памяти
(поведение прежних версий, прод можно не трогать).
"""
from __future__ import annotations
import json
import logging
import os
import threading
from dataclasses import asdict, dataclass
from pathlib import Path
from .errors import ScheduleError
log = logging.getLogger(__name__)
DEFAULT_INTERVAL_MINUTES = 15
MIN_INTERVAL_MINUTES = 1
MAX_INTERVAL_MINUTES = 24 * 60
@dataclass
class Settings:
link: str = ""
interval_minutes: int = DEFAULT_INTERVAL_MINUTES
def as_dict(self) -> dict:
return asdict(self)
def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
"""Интервал из SCHEDULE_INTERVAL_MINUTES, иначе из старого SCHEDULE_CACHE_TTL (сек)."""
raw_minutes = (os.getenv("SCHEDULE_INTERVAL_MINUTES") or "").strip()
if raw_minutes:
try:
return clamp_interval(int(float(raw_minutes)))
except ValueError:
log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру значение по умолчанию", raw_minutes)
raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip()
if raw_ttl:
try:
return clamp_interval(round(float(raw_ttl) / 60) or 1)
except ValueError:
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl)
return default
def clamp_interval(minutes: int) -> int:
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
class SettingsStore:
"""Потокобезопасное хранилище настроек администратора."""
def __init__(
self,
link: str = "",
interval_minutes: int = DEFAULT_INTERVAL_MINUTES,
path: str | os.PathLike | None = None,
) -> None:
self._path = Path(path) if path else None
self._lock = threading.RLock()
self._settings = Settings(
link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes)
)
self._load()
# --- чтение -----------------------------------------------------------------
@property
def link(self) -> str:
with self._lock:
return self._settings.link
@property
def interval_minutes(self) -> int:
with self._lock:
return self._settings.interval_minutes
def as_dict(self) -> dict:
with self._lock:
return self._settings.as_dict()
# --- запись -----------------------------------------------------------------
def set_link(self, link: str) -> str:
link = (link or "").strip()
if not link:
raise ScheduleError("Ссылка на расписание не может быть пустой")
with self._lock:
self._settings.link = link
self._save()
return link
def set_interval_minutes(self, minutes: int | str) -> int:
try:
value = int(minutes)
except (TypeError, ValueError):
raise ScheduleError("Интервал должен быть целым числом минут") from None
if not MIN_INTERVAL_MINUTES <= value <= MAX_INTERVAL_MINUTES:
raise ScheduleError(
f"Интервал должен быть от {MIN_INTERVAL_MINUTES} до {MAX_INTERVAL_MINUTES} минут"
)
with self._lock:
self._settings.interval_minutes = value
self._save()
return value
# --- файл -------------------------------------------------------------------
def _load(self) -> None:
if not self._path or not self._path.is_file():
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
log.warning("Не читается файл настроек %s: %s (остаюсь на значениях из окружения)", self._path, exc)
return
if not isinstance(raw, dict):
log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path)
return
link = str(raw.get("link") or "").strip()
if link:
self._settings.link = link
if raw.get("interval_minutes") is not None:
try:
self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"]))
except (TypeError, ValueError):
log.warning("interval_minutes в %s не число, игнорирую", self._path)
log.info(
"Настройки из %s: интервал %s мин, ссылка %s",
self._path,
self._settings.interval_minutes,
"задана" if self._settings.link else "пустая",
)
def _save(self) -> None:
if not self._path:
return
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(
json.dumps(self._settings.as_dict(), ensure_ascii=False, indent=1),
encoding="utf-8",
)
tmp.replace(self._path)
except OSError as exc:
log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc)

225
src/frontend/admin.html Normal file
View File

@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Админка расписания</title>
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
<link rel="stylesheet" href="/style.css">
<style>
.admin-wrap { max-width: 720px; margin: 0 auto; padding: 26px 18px 60px; }
.admin-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.admin-head img { width: 40px; height: 40px; }
.admin-title { font-size: 22px; font-weight: 700; color: var(--text); }
.admin-sub { color: var(--muted); font-size: 14px; margin-bottom: 18px; }
.admin-card {
background: var(--surface); border: 1px solid var(--border);
padding: 16px 18px; margin-bottom: 14px;
}
.admin-card h2 { font-size: 15px; margin-bottom: 10px; color: var(--text); }
.admin-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.admin-field { display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0; }
.admin-field label { font-size: 13px; color: var(--muted); }
.admin-field input {
font-size: 14px; padding: 8px 10px; border: 1px solid var(--border-2);
background: var(--surface-2); color: var(--text); width: 100%; min-width: 0;
}
.admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
.admin-btn {
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: #fff;
}
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
.admin-btn.danger { background: var(--accent-red-bright); border-color: var(--accent-red-bright); }
.admin-btn[disabled] { opacity: .55; cursor: default; }
.admin-status { display: flex; flex-wrap: wrap; gap: 10px; }
.admin-chip {
background: var(--surface-2); border: 1px solid var(--border); padding: 8px 12px; min-width: 0;
}
.admin-chip .k { font-size: 12px; color: var(--muted); }
.admin-chip .v { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; overflow-wrap: anywhere; }
.admin-error { color: var(--accent-red-bright); font-size: 13px; margin-top: 10px; word-break: break-word; overflow-wrap: anywhere; }
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
.hidden { display: none !important; }
abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
</style>
</head>
<body>
<div class="admin-wrap">
<div class="admin-head">
<img src="/img/owl.svg" alt="">
<div class="admin-title">Админка расписания</div>
</div>
<div class="admin-sub">Управление источником данных и обновлением. Страница доступна только по секретной ссылке.</div>
<section id="login-card" class="admin-card hidden">
<h2>Вход</h2>
<form id="login-form" autocomplete="off">
<div class="admin-field"><label for="login">Логин</label><input id="login" required></div>
<div class="admin-field" style="margin-top:10px"><label for="password">Пароль</label><input id="password" type="password" required></div>
<div class="admin-row" style="margin-top:12px">
<button class="admin-btn" type="submit">Войти</button>
</div>
</form>
<div id="login-error" class="admin-error hidden"></div>
</section>
<section id="panel" class="hidden">
<div class="admin-card">
<h2>Состояние</h2>
<div class="admin-status" id="status-chips"></div>
<div id="state-error" class="admin-error hidden"></div>
</div>
<div class="admin-card">
<h2>Ссылка на расписание</h2>
<div class="admin-row">
<div class="admin-field">
<label for="link">Публичная ссылка Яндекс.Диска</label>
<input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/...">
</div>
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
</div>
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.</div>
</div>
<div class="admin-card">
<h2>Обновление</h2>
<div class="admin-row">
<div class="admin-field" style="flex:0 1 200px">
<label for="interval">Интервал, минут</label>
<input id="interval" type="number" min="1" max="1440" step="1">
</div>
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
</div>
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.</div>
</div>
<div class="admin-card">
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
</div>
</section>
</div>
<script>
const $ = (id) => document.getElementById(id);
async function api(path, options = {}) {
let resp;
try {
resp = await fetch("/api/admin" + path, { credentials: "same-origin", ...options });
} catch (err) {
return { ok: false, status: 0, data: { detail: "Сеть недоступна: " + err.message } };
}
let data = null;
try { data = await resp.json(); } catch (_) {}
return { ok: resp.ok, status: resp.status, data };
}
function post(path, payload) {
return api(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
function fmt(value) {
if (!value) return "—";
const d = new Date(value);
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
}
function chip(key, value) {
const div = document.createElement("div");
div.className = "admin-chip";
div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`;
div.querySelector(".v").textContent = value;
return div;
}
function renderState(state) {
const chips = $("status-chips");
chips.innerHTML = "";
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
chips.appendChild(chip("Таблица изменена", fmt(state.table_modified)));
chips.appendChild(chip("Групп в расписании", String(state.groups)));
chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
$("link").value = state.link || "";
$("interval").value = state.interval_minutes;
const err = $("state-error");
err.textContent = state.error ? "Ошибка загрузки: " + state.error : "";
err.classList.toggle("hidden", !state.error);
}
function showLogin(show) {
$("login-card").classList.toggle("hidden", !show);
$("panel").classList.toggle("hidden", show);
if (show) $("login").focus();
}
function busy(button, text) {
const original = button.textContent;
button.disabled = true;
button.textContent = text;
return () => { button.disabled = false; button.textContent = original; };
}
async function loadState() {
const r = await api("/state");
if (r.status === 401) { showLogin(true); return; }
if (!r.ok) { alert("Ошибка: " + ((r.data && r.data.detail) || r.status)); return; }
showLogin(false);
renderState(r.data);
}
async function withButton(button, text, fn) {
const done = busy(button, text);
try { await fn(); } finally { done(); }
}
$("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const error = $("login-error");
error.classList.add("hidden");
const r = await post("/login", { login: $("login").value, password: $("password").value });
if (!r.ok) {
error.textContent = (r.data && r.data.detail) || "Не удалось войти";
error.classList.remove("hidden");
return;
}
$("password").value = "";
await loadState();
});
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
const r = await post("/link", { link: $("link").value });
if (!r.ok) { alert("Ссылка не принята: " + ((r.data && r.data.detail) || r.status)); return; }
renderState(r.data);
alert("Ссылка сохранена, расписание загружено.");
}));
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
if (!r.ok) { alert("Не получилось: " + ((r.data && r.data.detail) || r.status)); return; }
await loadState();
}));
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
const r = await post("/refresh");
if (!r.ok) { alert("Обновить не удалось: " + ((r.data && r.data.detail) || r.status)); }
await loadState();
}));
$("logout").addEventListener("click", async () => {
await post("/logout");
showLogin(true);
});
loadState();
</script>
</body>
</html>

View File

@@ -1,15 +1,20 @@
"""Фронтенд расписания: статики + прокси к API бэкенда."""
"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки.
Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py):
сам путь не публикуется, входа с сайта нет.
"""
from __future__ import annotations
import os
import logging
import os
from pathlib import Path
import requests
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -21,7 +26,10 @@ load_dotenv(Path(__file__).parent / ".env")
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
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"
app = FastAPI(title="Расписание — фронт")
@@ -46,6 +54,30 @@ def _proxy(path: str, params: dict) -> object:
return resp.json()
def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response:
"""Проксирует админ-запрос как есть: метод, тело, куки и Set-Cookie."""
try:
resp = requests.request(
method,
f"{SCHEDULE_API}/{path}",
data=body or None,
headers=headers,
timeout=PROXY_TIMEOUT,
)
except requests.RequestException as exc:
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
out = Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type"),
)
# Set-Cookie (вход/выход админки) передаём как есть
raw_headers = getattr(resp.raw, "headers", None)
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []:
out.headers.append("set-cookie", cookie)
return out
# --- API-маршруты -----------------------------------------------------------
@@ -99,6 +131,22 @@ def api_status():
return _proxy("status", {})
@app.api_route("/api/admin/{rest:path}", methods=["GET", "POST"])
async def api_admin(rest: str, request: Request):
"""Админ-API: тело и куки передаём в бэкенд, ответ — обратно как есть."""
body = await request.body()
headers = {}
content_type = request.headers.get("content-type")
if content_type:
headers["Content-Type"] = content_type
cookie = request.headers.get("cookie")
if cookie:
headers["Cookie"] = cookie
return await run_in_threadpool(
_proxy_admin, request.method, f"api/admin/{rest}", body, headers
)
# --- статики -----------------------------------------------------------------
@@ -107,6 +155,20 @@ def index():
return FileResponse(STATIC_DIR / "index.html")
if ADMIN_PATH:
@app.get(ADMIN_PATH, include_in_schema=False)
def admin_page():
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
return FileResponse(
ADMIN_PAGE,
headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"},
)
log.info("Страница админки включена по секретному пути")
else:
log.info("ADMIN_PATH не задан — страница админки выключена")
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")