This commit is contained in:
2026-04-30 17:45:16 +03:00
parent 77592c9a55
commit 88bf301b60
6 changed files with 477 additions and 28 deletions

View File

@@ -2,14 +2,18 @@
FastAPI веб-сервер: REST API + WebSocket для мониторинга загрузок манги.
"""
import asyncio
import hashlib
import hmac as _hmac
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from croniter import croniter
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
from loguru import logger
@@ -20,8 +24,45 @@ from .exporter import patch_meta, MangaMeta
OUTPUT_DIR = Path("/app/output")
FRONTEND_DIR = Path("/app/frontend")
# ── Авторизация ───────────────────────────────
AUTH_LOGIN = os.getenv("AUTH_LOGIN", "")
AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "")
AUTH_ENABLED = bool(AUTH_LOGIN and AUTH_PASSWORD)
COOKIE_NAME = "manga_session"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
def _compute_token() -> str:
"""Стабильный токен сессии, производный от credentials."""
return _hmac.new(
AUTH_PASSWORD.encode(),
AUTH_LOGIN.encode(),
hashlib.sha256,
).hexdigest()
_VALID_TOKEN: str = _compute_token() if AUTH_ENABLED else ""
# Пути, доступные без авторизации
_AUTH_EXEMPT = {"/api/login", "/api/auth/check", "/api/logout"}
app = FastAPI(title="Manga Downloader API")
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Проверяет авторизацию для всех /api/* эндпоинтов."""
if not AUTH_ENABLED:
return await call_next(request)
path = request.url.path
# Пропускаем статику и исключения
if not path.startswith("/api") or path in _AUTH_EXEMPT:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
if token != _VALID_TOKEN:
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
return await call_next(request)
# ── WebSocket менеджер ────────────────────────
class ConnectionManager:
@@ -145,16 +186,99 @@ async def startup_event():
db.close()
def _parse_schedule() -> Optional[str]:
"""
Читает расписание из переменных окружения.
Приоритет: UPDATE_SCHEDULE (cron-строка) → UPDATE_INTERVAL_HOURS (число часов, legacy).
Возвращает cron-строку или None если планировщик отключён.
"""
schedule = os.getenv("UPDATE_SCHEDULE", "").strip()
if schedule:
# Валидируем cron-выражение
if croniter.is_valid(schedule):
return schedule
logger.error("UPDATE_SCHEDULE='{}' — невалидное cron-выражение, планировщик отключён", schedule)
return None
# Обратная совместимость: UPDATE_INTERVAL_HOURS → конвертируем в cron
hours_raw = os.getenv("UPDATE_INTERVAL_HOURS", "").strip()
if not hours_raw:
return None
try:
hours = float(hours_raw)
if hours <= 0:
return None
# Конвертируем в cron: каждые N часов (если целое и делит 24) или фиксированное время
h = int(hours)
if h == hours and 24 % h == 0:
cron = f"0 */{h} * * *"
else:
# Нецелое или не делит 24 — берём ближайшее целое число часов
h = max(1, round(hours))
cron = f"0 */{h} * * *" if 24 % h == 0 else f"0 0/{h} * * *"
logger.info("UPDATE_INTERVAL_HOURS={} → cron: '{}'", hours_raw, cron)
return cron
except ValueError:
logger.error("UPDATE_INTERVAL_HOURS='{}' — не число, планировщик отключён", hours_raw)
return None
async def update_scheduler():
"""Периодически проверяет новые главы для манг с auto_update=1."""
interval_hours = float(os.getenv("UPDATE_INTERVAL_HOURS", "6"))
interval_sec = interval_hours * 3600
logger.info("Планировщик обновлений: каждые {} ч", interval_hours)
# Первый запуск — через 5 минут после старта
"""
Планировщик авто-обновлений на основе cron-расписания.
При любой ошибке — 3 попытки с интервалом 5 мин, затем ждёт следующего слота.
Цикл никогда не прерывается.
"""
cron_expr = _parse_schedule()
if not cron_expr:
logger.info("Планировщик обновлений отключён (UPDATE_SCHEDULE и UPDATE_INTERVAL_HOURS не заданы)")
return
logger.info("Планировщик обновлений запущен: '{}'", cron_expr)
# Первый запуск — через 5 минут после старта (не сразу, чтобы не мешать инициализации)
await asyncio.sleep(300)
while True:
await _run_auto_updates()
await asyncio.sleep(interval_sec)
# Вычисляем время до следующего запуска
now_utc = datetime.now(timezone.utc)
now_naive = now_utc.replace(tzinfo=None) # croniter работает с naive datetime
cron = croniter(cron_expr, now_naive)
next_run: datetime = cron.get_next(datetime)
wait_sec = max(0.0, (next_run - now_naive).total_seconds())
logger.info("Следующая проверка обновлений: {} UTC (через {:.0f} мин)",
next_run.strftime("%Y-%m-%d %H:%M"), wait_sec / 60)
await asyncio.sleep(wait_sec)
# Запускаем с retry-логикой
await _run_auto_updates_with_retry()
async def _run_auto_updates_with_retry():
"""Запускает _run_auto_updates с тремя попытками при ошибке."""
max_attempts = 3
retry_delay = 300 # 5 минут между попытками
for attempt in range(1, max_attempts + 1):
try:
await _run_auto_updates()
return # успех
except asyncio.CancelledError:
raise # не перехватываем отмену
except Exception as e:
if attempt < max_attempts:
logger.warning(
"Авто-обновление: попытка {}/{} завершилась ошибкой: {}. "
"Повтор через {} сек.", attempt, max_attempts, e, retry_delay
)
await asyncio.sleep(retry_delay)
else:
logger.error(
"Авто-обновление: все {} попытки исчерпаны. "
"Последняя ошибка: {}. Ждём следующего слота по расписанию.",
max_attempts, e
)
async def _run_auto_updates():
@@ -176,7 +300,6 @@ async def _run_auto_updates():
new_chapters = await check_for_updates(url, on_event=ws_manager.broadcast)
if new_chapters:
logger.info("Новых глав для {}: {}", url, len(new_chapters))
# Добавляем в очередь с флагом is_update
db2 = StateDB()
try:
status = db2.get_manga(url)
@@ -333,6 +456,47 @@ class AddMangaRequest(BaseModel):
format: str = "cbz"
# ── Auth API ─────────────────────────────────
class LoginRequest(BaseModel):
login: str
password: str
@app.get("/api/auth/check")
async def auth_check(request: Request):
"""Проверить, авторизован ли пользователь."""
if not AUTH_ENABLED:
return {"authenticated": True, "auth_enabled": False}
ok = request.cookies.get(COOKIE_NAME) == _VALID_TOKEN
return {"authenticated": ok, "auth_enabled": True}
@app.post("/api/login")
async def login(body: LoginRequest, response: Response):
if not AUTH_ENABLED:
return {"ok": True}
if body.login != AUTH_LOGIN or body.password != AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="Неверный логин или пароль")
response.set_cookie(
key=COOKIE_NAME,
value=_VALID_TOKEN,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=False, # включите True если HTTPS
)
return {"ok": True}
@app.post("/api/logout")
async def logout(response: Response):
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
# ── REST API ──────────────────────────────────
@app.get("/api/mangas")
async def list_mangas():
db = StateDB()
@@ -857,6 +1021,10 @@ async def global_stats():
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
# Проверяем авторизацию по cookie
if AUTH_ENABLED and ws.cookies.get(COOKIE_NAME) != _VALID_TOKEN:
await ws.close(code=4401)
return
await ws_manager.connect(ws)
try:
# Отправляем начальный снимок состояния