upd
This commit is contained in:
188
src/api.py
188
src/api.py
@@ -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:
|
||||
# Отправляем начальный снимок состояния
|
||||
|
||||
Reference in New Issue
Block a user