diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 60e8137c..d72e10eb 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -9,11 +9,12 @@ import asyncio import logging from typing import Annotated, Literal -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.core.db import get_db from app.services.geocoder import geocode from app.services.scrapers.avito import AvitoScraper @@ -27,6 +28,20 @@ logger = logging.getLogger(__name__) router = APIRouter() +def require_admin( + x_admin_token: Annotated[str | None, Header()] = None, +) -> None: + """Проверка admin-токена для /api/v1/admin/*. + + Если settings.admin_token не задан (dev) — пропускаем (открыто). + Если задан — требуем заголовок X-Admin-Token с этим значением. + """ + if settings.admin_token is None: + return # dev-режим: токен не настроен → эндпоинты открыты + if x_admin_token != settings.admin_token: + raise HTTPException(status_code=401, detail="Invalid or missing X-Admin-Token") + + class ScrapeRequest(BaseModel): lat: float lon: float @@ -52,7 +67,7 @@ class ScrapeResponse(BaseModel): by_source: list[ScrapeResult] -@router.post("/scrape", response_model=ScrapeResponse) +@router.post("/scrape", response_model=ScrapeResponse, dependencies=[Depends(require_admin)]) async def scrape_around( payload: ScrapeRequest, db: Annotated[Session, Depends(get_db)], @@ -121,7 +136,7 @@ def _clean_address_for_geocode(addr: str) -> str: return main or addr -@router.post("/geocode-missing") +@router.post("/geocode-missing", dependencies=[Depends(require_admin)]) async def geocode_missing( db: Annotated[Session, Depends(get_db)], limit: int = 100, diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 711797e7..25504b53 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -18,5 +18,9 @@ class Settings(BaseSettings): # Public URL — для QR-кода в PDF, shareable links, etc. public_url: str = "http://127.0.0.1:8080" + # Admin token — защищает /api/v1/admin/* (scrape, geocode-missing). + # Пусто = эндпоинты открыты (dev). В prod задаётся через env ADMIN_TOKEN. + admin_token: str | None = None + settings = Settings() diff --git a/tradein-mvp/deploy/cron-scrape.sh b/tradein-mvp/deploy/cron-scrape.sh index 04a75a3c..f71767bb 100755 --- a/tradein-mvp/deploy/cron-scrape.sh +++ b/tradein-mvp/deploy/cron-scrape.sh @@ -49,6 +49,10 @@ build_payload() { log "=== cron-scrape start: source=$SOURCE mode=$MODE container=$CONTAINER ===" +# X-Admin-Token заголовок — из ADMIN_TOKEN (.env.runtime). Если пусто — заголовок +# всё равно шлём, backend в dev-режиме (admin_token=None) его игнорирует. +ADMIN_HDR="X-Admin-Token: ${ADMIN_TOKEN:-}" + for anchor in "${ANCHORS[@]}"; do read -r lat lon name <<< "$anchor" payload=$(build_payload "$lat" "$lon" "$SOURCE" "$MODE") @@ -57,6 +61,7 @@ for anchor in "${ANCHORS[@]}"; do resp=$(docker exec "$CONTAINER" curl -s -X POST \ http://localhost:8000/api/v1/admin/scrape \ -H "Content-Type: application/json" \ + -H "$ADMIN_HDR" \ -d "$payload" \ -m 600 2>/dev/null || echo '{"error":"exec_or_timeout"}') log "← $name $resp" @@ -70,6 +75,7 @@ log "→ geocode-missing (чанками)" for gi in $(seq 1 15); do geo=$(docker exec "$CONTAINER" curl -s -X POST \ "http://localhost:8000/api/v1/admin/geocode-missing?limit=100" \ + -H "$ADMIN_HDR" \ -m 320 2>/dev/null || echo '{"error":"geocode_timeout","remaining":0}') log "← geocode чанк $gi: $geo" remaining=$(echo "$geo" | grep -oE '"remaining":[0-9]+' | grep -oE '[0-9]+' || echo 0) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 05716b6e..5cedfa3c 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -41,6 +41,8 @@ services: ENVIRONMENT: "production" CONTACT_EMAIL: "${TRADEIN_CONTACT_EMAIL:-tradein@gendsgn.ru}" YANDEX_GEOCODER_KEY: "${YANDEX_GEOCODER_KEY:-}" + # Защита /api/v1/admin/* — задаётся в .env.runtime. Пусто = открыто (dev). + ADMIN_TOKEN: "${ADMIN_TOKEN:-}" depends_on: postgres: condition: service_healthy