Админка в master: секретный путь, ссылка, интервал, force, блокировка входа, история
Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы ADMIN_LOGIN/ADMIN_PASSWORD: маршруты /api/admin/* не регистрируются, страницы нет — обычная установка ничего не замечает. Панель (открывается по секретному пути ADMIN_PATH, кнопки входа на сайте нет, вход по логину/паролю): - состояние: группы, когда обновлялось, дата файла на Диске, изменился ли файл при последнем чтении (sha256), ошибки, путь файла настроек; - новая ссылка на таблицу: проверяется скачиванием, при ошибке откат; - интервал обновления (TTL) в минутах; - «Обновить сейчас»: принудительно перечитывает таблицу (качает файл всегда, интервал — только если на Диске изменилась дата); - история входов: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы. Защита входа: ADMIN_MAX_ATTEMPTS неудач (по умолчанию 3), затем блокировка на ADMIN_LOCKOUT_MINUTES минут (по умолчанию 15) — всё из .env. Реализация: - backend/admin.py: cookie-сессия на HMAC (ключ — пароль, 12 ч, HttpOnly, SameSite, Secure за https), сравнение за постоянное время, реальный IP из X-Forwarded-For, логирование каждой попытки; - schedule/settings.py: ссылка, интервал и история входов в JSON; путь по умолчанию data/settings.json (в docker — том schedule_data), при недоступности файла настройки живут в памяти и панель предупреждает; - cache.py: apply_link (с проверкой и откатом), force_reload, state(); - frontend: страница админки по ADMIN_PATH (вне static, noindex) и прокси /api/admin/* с телом и Set-Cookie; - install.sh пишет переменные админки в .env, Dockerfile копирует admin.py. Тесты: 156. Проверено локально (страница, 3 попытки -> блокировка, история) и в собранном docker-образе (том настроек, запись истории).
This commit is contained in:
278
src/backend/admin.py
Normal file
278
src/backend/admin.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Админка расписания.
|
||||
|
||||
Отдельного входа на сайте нет: панель открывается по секретному пути из
|
||||
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они
|
||||
пусты, админка выключена и маршруты /api/admin/* не регистрируются.
|
||||
|
||||
После нескольких неудачных попыток вход блокируется на время из
|
||||
ADMIN_LOCKOUT_MINUTES (по умолчанию: 3 попытки и 15 минут). Каждая попытка
|
||||
запоминается (откуда, когда, успешна ли) — это видно в панели и пишется в лог.
|
||||
|
||||
Что умеет администратор:
|
||||
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 threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from schedule import ScheduleCache, ScheduleError
|
||||
from schedule.settings import SettingsStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
COOKIE_NAME = "schedule_admin"
|
||||
SESSION_TTL_SECONDS = 12 * 3600
|
||||
|
||||
DEFAULT_MAX_ATTEMPTS = 3
|
||||
DEFAULT_LOCKOUT_MINUTES = 15
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminConfig:
|
||||
"""Настройки админки: секретный путь, учётные данные и защита от перебора."""
|
||||
|
||||
path: str = ""
|
||||
login: str = ""
|
||||
password: str = ""
|
||||
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
||||
lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES
|
||||
|
||||
@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 LoginThrottle:
|
||||
"""Счётчик неудачных попыток: N промахов — пауза на lockout_minutes."""
|
||||
|
||||
def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS, lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES) -> None:
|
||||
self.max_attempts = max(1, int(max_attempts))
|
||||
self.lockout_seconds = max(1, int(lockout_minutes)) * 60
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, int] = {}
|
||||
self._blocked_until: dict[str, float] = {}
|
||||
|
||||
def locked_until(self, key: str) -> float | None:
|
||||
with self._lock:
|
||||
until = self._blocked_until.get(key)
|
||||
if until is None:
|
||||
return None
|
||||
if until <= time.time():
|
||||
self._blocked_until.pop(key, None)
|
||||
self._failures.pop(key, None)
|
||||
return None
|
||||
return until
|
||||
|
||||
def attempts_left(self, key: str) -> int:
|
||||
with self._lock:
|
||||
return max(0, self.max_attempts - self._failures.get(key, 0))
|
||||
|
||||
def register_failure(self, key: str) -> None:
|
||||
with self._lock:
|
||||
failures = self._failures.get(key, 0) + 1
|
||||
if failures >= self.max_attempts:
|
||||
self._blocked_until[key] = time.time() + self.lockout_seconds
|
||||
self._failures[key] = 0
|
||||
else:
|
||||
self._failures[key] = failures
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
self._blocked_until.pop(key, None)
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
login: str
|
||||
password: str
|
||||
|
||||
|
||||
class LinkBody(BaseModel):
|
||||
link: str
|
||||
|
||||
|
||||
class IntervalBody(BaseModel):
|
||||
interval_minutes: int
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Реальный адрес клиента: за nginx он приходит в X-Forwarded-For."""
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip() or "неизвестно"
|
||||
return request.client.host if request.client else "неизвестно"
|
||||
|
||||
|
||||
def _human_dt(iso: str | None) -> str:
|
||||
if not iso:
|
||||
return "—"
|
||||
try:
|
||||
return datetime.fromisoformat(iso).strftime("%d.%m.%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return iso
|
||||
|
||||
|
||||
def create_admin_router(
|
||||
cache: ScheduleCache,
|
||||
settings: SettingsStore | None,
|
||||
config: AdminConfig,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
throttle = LoginThrottle(config.max_attempts, config.lockout_minutes)
|
||||
|
||||
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"
|
||||
|
||||
def _remember(ip: str, ok: bool) -> None:
|
||||
if settings is not None:
|
||||
settings.record_login(ip, ok)
|
||||
|
||||
@router.post("/login")
|
||||
def login(body: LoginBody, request: Request, response: Response) -> dict:
|
||||
ip = _client_ip(request)
|
||||
until = throttle.locked_until(ip)
|
||||
if until is not None:
|
||||
minutes = max(1, round((until - time.time()) / 60))
|
||||
log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Слишком много неудачных попыток. Вход заблокирован ещё на ~{minutes} мин.",
|
||||
)
|
||||
ok_login = _same(body.login.strip(), config.login)
|
||||
ok_password = _same(body.password, config.password)
|
||||
if not (ok_login and ok_password):
|
||||
throttle.register_failure(ip)
|
||||
_remember(ip, False)
|
||||
left = throttle.attempts_left(ip)
|
||||
if throttle.locked_until(ip) is not None:
|
||||
log.warning(
|
||||
"Неудачная попытка входа (%s): исчерпаны попытки, пауза %d мин",
|
||||
ip,
|
||||
config.lockout_minutes,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Неверный логин или пароль. Попытки исчерпаны — вход заблокирован на {config.lockout_minutes} мин.",
|
||||
)
|
||||
log.warning("Неудачная попытка входа (%s), осталось попыток: %d", ip, left)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Неверный логин или пароль. Осталось попыток: {left}.",
|
||||
)
|
||||
throttle.reset(ip)
|
||||
previous = settings.last_attempt if settings is not None else None
|
||||
_remember(ip, True)
|
||||
if previous:
|
||||
log.info(
|
||||
"Успешный вход в админку (%s). Предыдущая попытка: %s, %s, %s",
|
||||
ip,
|
||||
_human_dt(previous.get("at")),
|
||||
previous.get("ip"),
|
||||
"успешная" if previous.get("ok") else "неудачная",
|
||||
)
|
||||
else:
|
||||
log.info("Успешный вход в админку (%s)", ip)
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
make_token(config.login, config.password),
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=_secure(request),
|
||||
path="/",
|
||||
)
|
||||
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(request: Request) -> dict:
|
||||
ip = _client_ip(request)
|
||||
until = throttle.locked_until(ip)
|
||||
payload = cache.state()
|
||||
payload["throttle"] = {
|
||||
"max_attempts": config.max_attempts,
|
||||
"lockout_minutes": config.lockout_minutes,
|
||||
"attempts_left": throttle.attempts_left(ip),
|
||||
"locked_until": datetime.fromtimestamp(until).astimezone().isoformat() if until else None,
|
||||
}
|
||||
return payload
|
||||
|
||||
@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
|
||||
@@ -1,4 +1,9 @@
|
||||
"""API-приложение расписания (FastAPI)."""
|
||||
"""API-приложение расписания (FastAPI).
|
||||
|
||||
Кроме публичных данных здесь живёт админка (backend/admin.py): она включается
|
||||
только если заданы ADMIN_LOGIN и ADMIN_PASSWORD, и открывается по секретному
|
||||
пути ADMIN_PATH. Все параметры — переменные окружения, см. .env.example.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,15 +17,17 @@ import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
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")
|
||||
|
||||
|
||||
def _find_dotenv() -> Path | None:
|
||||
"""Ищем .env вверх от каталога модуля (локальная разработка)."""
|
||||
"""Ищем .env вверх от каталога модуля (общий .env в корне проекта)."""
|
||||
path = Path(__file__).resolve().parent
|
||||
for _ in range(5):
|
||||
env = path / ".env"
|
||||
@@ -36,36 +43,58 @@ _dotenv_path = _find_dotenv()
|
||||
if _dotenv_path:
|
||||
load_dotenv(_dotenv_path)
|
||||
|
||||
DEFAULT_INTERVAL_MINUTES = 15
|
||||
|
||||
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 "/"
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = (os.getenv(name) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
log.warning("%s=%r не число, беру %d", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
|
||||
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 ""
|
||||
ADMIN_MAX_ATTEMPTS = _int_env("ADMIN_MAX_ATTEMPTS", 3)
|
||||
ADMIN_LOCKOUT_MINUTES = _int_env("ADMIN_LOCKOUT_MINUTES", 15)
|
||||
|
||||
|
||||
def interval_seconds() -> float:
|
||||
"""Интервал обновления: SCHEDULE_INTERVAL_MINUTES, иначе старый SCHEDULE_CACHE_TTL (сек)."""
|
||||
raw_minutes = (os.getenv("SCHEDULE_INTERVAL_MINUTES") or "").strip()
|
||||
if raw_minutes:
|
||||
try:
|
||||
return max(1.0, float(raw_minutes)) * 60
|
||||
except ValueError:
|
||||
log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру значение по умолчанию", raw_minutes)
|
||||
raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip()
|
||||
if raw_ttl:
|
||||
try:
|
||||
return max(1.0, float(raw_ttl))
|
||||
except ValueError:
|
||||
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl)
|
||||
return DEFAULT_INTERVAL_MINUTES * 60
|
||||
|
||||
|
||||
def default_cache() -> ScheduleCache:
|
||||
return ScheduleCache(
|
||||
def default_settings() -> SettingsStore:
|
||||
return SettingsStore(
|
||||
link=SCHEDULE_LINK,
|
||||
ttl_seconds=interval_seconds(),
|
||||
retry_after=RETRY_AFTER,
|
||||
interval_minutes=interval_from_env(),
|
||||
path=SETTINGS_FILE or None,
|
||||
)
|
||||
|
||||
|
||||
def default_cache(settings: SettingsStore | None = None) -> ScheduleCache:
|
||||
settings = settings or default_settings()
|
||||
if SCHEDULE_LINK and settings.link and settings.link != SCHEDULE_LINK:
|
||||
log.warning(
|
||||
"Ссылка в настройках (%s) отличается от SCHEDULE_LINK из окружения (%s); "
|
||||
"работаем по ссылке из настроек — её видно и можно поменять в админке",
|
||||
settings.link,
|
||||
SCHEDULE_LINK,
|
||||
)
|
||||
return ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
|
||||
|
||||
|
||||
def _day_arg(day: str) -> str:
|
||||
normalized = day.strip().lower()
|
||||
if normalized not in parsing.DAYS:
|
||||
@@ -76,8 +105,21 @@ 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,
|
||||
settings: SettingsStore | None = None,
|
||||
) -> FastAPI:
|
||||
if cache is None:
|
||||
settings = settings or default_settings()
|
||||
cache = ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
|
||||
admin = admin if admin is not None else AdminConfig(
|
||||
path=ADMIN_PATH,
|
||||
login=ADMIN_LOGIN,
|
||||
password=ADMIN_PASSWORD,
|
||||
max_attempts=ADMIN_MAX_ATTEMPTS,
|
||||
lockout_minutes=ADMIN_LOCKOUT_MINUTES,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -90,6 +132,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, settings, admin))
|
||||
if admin.path:
|
||||
log.info("Админка включена (путь задан в ADMIN_PATH)")
|
||||
else:
|
||||
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
|
||||
else:
|
||||
log.info("Админка выключена (не заданы ADMIN_LOGIN/ADMIN_PASSWORD)")
|
||||
|
||||
def _data() -> tuple[dict, list[dict]]:
|
||||
try:
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
- однопоточная перезагрузка (lock): параллельные запросы во время загрузки
|
||||
ждут один download вместо N скачиваний;
|
||||
- интервал обновления: по его истечении сверяем modified таблицы — если файл
|
||||
не менялся, скачивание не повторяется;
|
||||
не менялся, скачивание не повторяется; принудительное обновление из админки
|
||||
(force_reload) перечитывает таблицу всегда;
|
||||
- stale-while-error: если таблица перестала скачиваться, отдаём последнее
|
||||
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
||||
(вместо скачивания на каждый запрос).
|
||||
(вместо скачивания на каждый запрос);
|
||||
- настройки администратора (ссылка, интервал) живут в SettingsStore и
|
||||
применяются на лету: смена ссылки проверяется до сохранения.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,12 +22,13 @@ 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 = "Ссылка на расписание не задана (SCHEDULE_LINK)"
|
||||
NO_LINK_ERROR = "Ссылка на расписание не задана"
|
||||
|
||||
|
||||
class ScheduleCache:
|
||||
@@ -33,8 +37,15 @@ class ScheduleCache:
|
||||
link: str | None = None,
|
||||
ttl_seconds: float = 900.0,
|
||||
retry_after: float = 60.0,
|
||||
settings: SettingsStore | None = None,
|
||||
) -> None:
|
||||
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._ttl = float(ttl_seconds)
|
||||
self._retry_after = float(retry_after)
|
||||
self._lock = threading.RLock()
|
||||
@@ -64,6 +75,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:
|
||||
@@ -84,20 +100,21 @@ class ScheduleCache:
|
||||
return self._day_times
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Состояние кэша (публичный /status): без ссылки и без админских данных."""
|
||||
with self._lock:
|
||||
return {
|
||||
"cache_ttl_seconds": self._ttl,
|
||||
"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,
|
||||
# при последнем чтении файла: изменилось ли его содержимое
|
||||
"changed": self._changed,
|
||||
"changed_at": self._iso(self._changed_at),
|
||||
"content_hash": (self._content_hash or "")[:12],
|
||||
}
|
||||
return self._status()
|
||||
|
||||
def state(self) -> dict:
|
||||
"""Состояние для админки: то же плюс текущая ссылка и настройки."""
|
||||
with self._lock:
|
||||
state = self._status()
|
||||
state["link"] = self._link
|
||||
if self._settings is not None:
|
||||
state["settings_file"] = str(self._settings.path) if self._settings.path else None
|
||||
state["settings_save_error"] = self._settings.save_error
|
||||
state["last_attempt"] = self._settings.last_attempt
|
||||
state["last_success"] = self._settings.last_success
|
||||
return state
|
||||
|
||||
def warmup(self) -> None:
|
||||
"""Первая загрузка данных на старте приложения (ошибка не фатальна)."""
|
||||
@@ -106,6 +123,56 @@ class ScheduleCache:
|
||||
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
|
||||
@@ -114,6 +181,20 @@ class ScheduleCache:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
|
||||
|
||||
def _status(self) -> dict:
|
||||
return {
|
||||
"cache_ttl_seconds": self._ttl,
|
||||
"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,
|
||||
"changed": self._changed,
|
||||
"changed_at": self._iso(self._changed_at),
|
||||
"content_hash": (self._content_hash or "")[:12],
|
||||
}
|
||||
|
||||
def _ensure_fresh(self) -> None:
|
||||
now = time.time()
|
||||
fresh = (
|
||||
@@ -137,6 +218,9 @@ class ScheduleCache:
|
||||
if not self._link:
|
||||
self._fail(ScheduleError(NO_LINK_ERROR), now)
|
||||
return
|
||||
# Обновление по интервалу: сначала сверяем modified — если файл не
|
||||
# менялся, скачивание не повторяем. Принудительное обновление (кнопка
|
||||
# в админке) идёт через force_reload и качает файл всегда.
|
||||
modified = fetch_table_modified(self._link)
|
||||
if (
|
||||
self._data is not None
|
||||
|
||||
221
src/backend/schedule/settings.py
Normal file
221
src/backend/schedule/settings.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Настройки, которые правит администратор.
|
||||
|
||||
Хранятся в JSON-файле (`SETTINGS_FILE`, по умолчанию `data/settings.json` в
|
||||
корне проекта), поэтому переживают перезапуск службы: ссылка на таблицу,
|
||||
интервал обновления и история входов в админку. Если файл недоступен для
|
||||
записи — настройки живут в памяти, а сервис продолжает работать (об этом
|
||||
пишется предупреждение в лог и в состояние админки).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
def _default_settings_path() -> Path:
|
||||
"""`<корень проекта>/data/settings.json`.
|
||||
|
||||
В репозитории это три уровня вверх от пакета, а в docker-образе файлы лежат
|
||||
плоско (/app/...), поэтому ищем корень по маркеру — requirements.txt.
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
if (parent / "requirements.txt").is_file():
|
||||
return parent / "data" / "settings.json"
|
||||
return here.parent / "data" / "settings.json"
|
||||
|
||||
|
||||
DEFAULT_SETTINGS_PATH = _default_settings_path()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
link: str = ""
|
||||
interval_minutes: int = DEFAULT_INTERVAL_MINUTES
|
||||
last_attempt: dict | None = None # {"at": iso, "ip": str, "ok": bool}
|
||||
last_success: dict | None = None # {"at": iso, "ip": str}
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"link": self.link,
|
||||
"interval_minutes": self.interval_minutes,
|
||||
"last_attempt": self.last_attempt,
|
||||
"last_success": self.last_success,
|
||||
}
|
||||
|
||||
|
||||
def clamp_interval(minutes: int) -> int:
|
||||
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
|
||||
|
||||
|
||||
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 settings_path_from_env() -> Path | None:
|
||||
raw = (os.getenv("SETTINGS_FILE") or "").strip()
|
||||
return Path(raw) if raw else DEFAULT_SETTINGS_PATH
|
||||
|
||||
|
||||
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._save_error: str | None = None
|
||||
self._settings = Settings(
|
||||
link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes)
|
||||
)
|
||||
self._load()
|
||||
|
||||
# --- чтение -----------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
return self._path
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def last_attempt(self) -> dict | None:
|
||||
with self._lock:
|
||||
return dict(self._settings.last_attempt) if self._settings.last_attempt else None
|
||||
|
||||
@property
|
||||
def last_success(self) -> dict | None:
|
||||
with self._lock:
|
||||
return dict(self._settings.last_success) if self._settings.last_success else None
|
||||
|
||||
@property
|
||||
def save_error(self) -> str | None:
|
||||
"""Текст ошибки, если настройки не удаётся сохранить на диск."""
|
||||
with self._lock:
|
||||
return self._save_error
|
||||
|
||||
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 record_login(self, ip: str, ok: bool) -> dict:
|
||||
"""Запомнить попытку входа: откуда, когда и чем закончилась."""
|
||||
now = datetime.now().astimezone().replace(microsecond=0)
|
||||
attempt = {"at": now.isoformat(), "ip": ip or "неизвестно", "ok": bool(ok)}
|
||||
with self._lock:
|
||||
self._settings.last_attempt = attempt
|
||||
if ok:
|
||||
self._settings.last_success = {"at": attempt["at"], "ip": attempt["ip"]}
|
||||
self._save()
|
||||
return attempt
|
||||
|
||||
# --- файл -------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
for key in ("last_attempt", "last_success"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, dict) and value.get("at"):
|
||||
setattr(self._settings, key, value)
|
||||
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)
|
||||
self._save_error = None
|
||||
except OSError as exc:
|
||||
self._save_error = str(exc)
|
||||
log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc)
|
||||
356
src/frontend/admin.html
Normal file
356
src/frontend/admin.html
Normal file
@@ -0,0 +1,356 @@
|
||||
<!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: 760px; 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-warn { color: #b8860b; font-size: 13px; margin-top: 10px; word-break: break-word; }
|
||||
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
|
||||
.hidden { display: none !important; }
|
||||
.bad { color: var(--accent-red-bright); }
|
||||
.good { color: #2f9e5f; }
|
||||
|
||||
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
|
||||
#toast-wrap {
|
||||
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
|
||||
z-index: 9999; display: flex; flex-direction: column; gap: 8px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: var(--surface); border: 1px solid var(--border-2);
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
padding: 12px 14px; font-size: 14px; color: var(--text);
|
||||
white-space: pre-line; animation: toast-in .18s ease-out;
|
||||
}
|
||||
.toast.ok { border-left-color: #2f9e5f; }
|
||||
.toast.error { border-left-color: var(--accent-red-bright); }
|
||||
.toast.hide { opacity: 0; transition: opacity .3s ease; }
|
||||
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.toast .toast-close {
|
||||
flex: 0 0 auto; border: none; background: none; color: var(--muted);
|
||||
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px;
|
||||
}
|
||||
.toast .toast-close:hover { color: var(--text); }
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
</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 id="state-warn" class="admin-warn hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>Входы в админку</h2>
|
||||
<div class="admin-status" id="auth-chips"></div>
|
||||
<div class="admin-hint">Каждая попытка входа запоминается: откуда, когда и чем закончилась. После нескольких неудач вход временно блокируется.</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">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
|
||||
Значение хранится в файле настроек и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</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>
|
||||
|
||||
<div id="toast-wrap"></div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const TOAST_MS = 15000;
|
||||
|
||||
/** Всплывающее уведомление: само скрывается через 15 секунд. */
|
||||
function toast(message, kind = "info", ms = TOAST_MS) {
|
||||
const wrap = $("toast-wrap");
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast " + kind;
|
||||
const text = document.createElement("div");
|
||||
text.className = "toast-text";
|
||||
text.textContent = message;
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toast-close";
|
||||
close.textContent = "✕";
|
||||
close.setAttribute("aria-label", "Закрыть");
|
||||
el.append(text, close);
|
||||
wrap.appendChild(el);
|
||||
const hide = () => {
|
||||
clearTimeout(timer);
|
||||
el.classList.add("hide");
|
||||
setTimeout(() => el.remove(), 300);
|
||||
};
|
||||
const timer = setTimeout(hide, ms);
|
||||
close.addEventListener("click", hide);
|
||||
return hide;
|
||||
}
|
||||
|
||||
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, cls) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "admin-chip";
|
||||
div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`;
|
||||
const v = div.querySelector(".v");
|
||||
v.textContent = value;
|
||||
if (cls) v.classList.add(cls);
|
||||
return div;
|
||||
}
|
||||
|
||||
function loginText(info) {
|
||||
if (!info) return "нет данных";
|
||||
// у успешного входа поля ok нет — там итог и так известен
|
||||
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
|
||||
return `${fmt(info.at)} · ${info.ip}${status}`;
|
||||
}
|
||||
|
||||
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(
|
||||
"Файл при последнем обновлении",
|
||||
state.changed ? "изменился" : "без изменений",
|
||||
state.changed ? "good" : null,
|
||||
));
|
||||
chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified)));
|
||||
chips.appendChild(chip("Групп в расписании", String(state.groups)));
|
||||
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
|
||||
chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
|
||||
if (state.settings_file) chips.appendChild(chip("Файл настроек", state.settings_file));
|
||||
|
||||
const auth = $("auth-chips");
|
||||
auth.innerHTML = "";
|
||||
const attempt = state.last_attempt;
|
||||
auth.appendChild(chip(
|
||||
"Последняя попытка входа",
|
||||
loginText(attempt),
|
||||
attempt ? (attempt.ok ? "good" : "bad") : null,
|
||||
));
|
||||
auth.appendChild(chip("Последний успешный вход", loginText(state.last_success)));
|
||||
const throttle = state.throttle || {};
|
||||
if (throttle.locked_until) {
|
||||
auth.appendChild(chip("Вход заблокирован до", fmt(throttle.locked_until), "bad"));
|
||||
} else if (throttle.max_attempts) {
|
||||
auth.appendChild(chip(
|
||||
"Осталось попыток",
|
||||
`${throttle.attempts_left} из ${throttle.max_attempts}`,
|
||||
));
|
||||
}
|
||||
|
||||
$("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);
|
||||
|
||||
const warn = $("state-warn");
|
||||
warn.textContent = state.settings_save_error
|
||||
? "Настройки не сохраняются на диск: " + state.settings_save_error +
|
||||
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env."
|
||||
: "";
|
||||
warn.classList.toggle("hidden", !state.settings_save_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) {
|
||||
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
|
||||
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 = "";
|
||||
toast("Вход выполнен.", "ok", 5000);
|
||||
await loadState();
|
||||
});
|
||||
|
||||
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
|
||||
const r = await post("/link", { link: $("link").value });
|
||||
if (!r.ok) {
|
||||
toast("Ссылка не принята: " + ((r.data && r.data.detail) || r.status), "error");
|
||||
return;
|
||||
}
|
||||
renderState(r.data);
|
||||
toast("Ссылка сохранена, расписание загружено.", "ok");
|
||||
}));
|
||||
|
||||
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
|
||||
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
|
||||
if (!r.ok) {
|
||||
toast("Интервал не сохранён: " + ((r.data && r.data.detail) || r.status), "error");
|
||||
return;
|
||||
}
|
||||
toast(`Интервал обновления: ${r.data.interval_minutes} мин.`, "ok");
|
||||
await loadState();
|
||||
}));
|
||||
|
||||
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
||||
const r = await post("/refresh");
|
||||
if (!r.ok) {
|
||||
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
|
||||
} else if (r.data) {
|
||||
renderState(r.data);
|
||||
toast(
|
||||
r.data.changed
|
||||
? "Готово: в файле обнаружены изменения — расписание обновлено."
|
||||
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
|
||||
r.data.changed ? "ok" : "info",
|
||||
);
|
||||
}
|
||||
await loadState();
|
||||
}));
|
||||
|
||||
$("logout").addEventListener("click", async () => {
|
||||
await post("/logout");
|
||||
showLogin(true);
|
||||
});
|
||||
|
||||
loadState();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Фронтенд расписания: статики + прокси к API бэкенда."""
|
||||
"""Фронтенд расписания: статики, прокси к API и страница админки.
|
||||
|
||||
Страница админки отдаётся по секретному пути из ADMIN_PATH; сам API админки —
|
||||
/api/admin/* (проксируется в backend). Если ADMIN_PATH не задан, страницы нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +13,8 @@ from pathlib import Path
|
||||
import requests
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -39,7 +44,10 @@ if _dotenv_path:
|
||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
||||
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="Расписание — фронт")
|
||||
|
||||
@@ -65,6 +73,31 @@ def _proxy(path: str, params: dict) -> object:
|
||||
return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
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"),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
# 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-маршруты -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -118,7 +151,29 @@ 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
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
headers["X-Forwarded-For"] = forwarded
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
if forwarded_proto:
|
||||
headers["X-Forwarded-Proto"] = forwarded_proto
|
||||
return await run_in_threadpool(
|
||||
_proxy_admin, request.method, f"api/admin/{rest}", body, headers
|
||||
)
|
||||
|
||||
|
||||
# --- статики и админка --------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -126,6 +181,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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user