Панель без публичного входа: страница живёт по 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, хранилище).
177 lines
5.6 KiB
Python
177 lines
5.6 KiB
Python
"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки.
|
||
|
||
Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py):
|
||
сам путь не публикуется, входа с сайта нет.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import requests
|
||
import uvicorn
|
||
from dotenv import load_dotenv
|
||
from fastapi import FastAPI, HTTPException, Request, Response
|
||
from fastapi.concurrency import run_in_threadpool
|
||
from fastapi.responses import FileResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
log = logging.getLogger(__name__)
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||
|
||
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="Расписание — фронт")
|
||
|
||
|
||
@app.get("/healthz")
|
||
def healthz() -> dict:
|
||
"""Живость процесса; не зависит от бэкенда."""
|
||
return {"ok": True}
|
||
|
||
|
||
def _proxy(path: str, params: dict) -> object:
|
||
try:
|
||
resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=PROXY_TIMEOUT)
|
||
except requests.RequestException as exc:
|
||
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||
if resp.status_code != 200:
|
||
try:
|
||
detail = resp.json().get("detail", resp.text)
|
||
except Exception:
|
||
detail = resp.text
|
||
raise HTTPException(status_code=resp.status_code, detail=detail)
|
||
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-маршруты -----------------------------------------------------------
|
||
|
||
|
||
@app.get("/api/groups")
|
||
def api_groups():
|
||
return _proxy("groups", {})
|
||
|
||
|
||
@app.get("/api/schedule/{group}")
|
||
def api_schedule(group: str):
|
||
return _proxy(f"schedule/{group}", {})
|
||
|
||
|
||
@app.get("/api/teachers")
|
||
def api_teachers():
|
||
return _proxy("teachers", {})
|
||
|
||
|
||
@app.get("/api/rooms")
|
||
def api_rooms():
|
||
return _proxy("rooms", {})
|
||
|
||
|
||
@app.get("/api/teacher/{name}")
|
||
def api_teacher(name: str):
|
||
return _proxy(f"teacher/{name}", {})
|
||
|
||
|
||
@app.get("/api/room/{label}")
|
||
def api_room(label: str):
|
||
return _proxy(f"room/{label}", {})
|
||
|
||
|
||
@app.get("/api/rooms-free")
|
||
def api_rooms_free(day: str, period: int):
|
||
return _proxy("rooms-free", {"day": day, "period": period})
|
||
|
||
|
||
@app.get("/api/rooms-day")
|
||
def api_rooms_day(day: str):
|
||
return _proxy("rooms-day", {"day": day})
|
||
|
||
|
||
@app.get("/api/week")
|
||
def api_week():
|
||
return _proxy("week", {})
|
||
|
||
|
||
@app.get("/api/status")
|
||
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
|
||
)
|
||
|
||
|
||
# --- статики -----------------------------------------------------------------
|
||
|
||
|
||
@app.get("/")
|
||
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")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
uvicorn.run("main:app", host="127.0.0.1", port=8080, reload=True)
|