From 7adb7cff6625b97554e7864cc2d2d40c1b894d1e Mon Sep 17 00:00:00 2001 From: TradeIn Deploy Date: Thu, 21 May 2026 19:47:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20auth=20=D0=BD=D0=B0=20admin-?= =?UTF-8?q?=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/v1/admin/scrape и geocode-missing закрыты X-Admin-Token. settings.admin_token (env ADMIN_TOKEN). Пусто = открыто (dev), задано = требуем заголовок. cron шлёт токен из .env.runtime. --- tradein-mvp/backend/app/api/v1/admin.py | 21 ++++++++++++++++++--- tradein-mvp/backend/app/core/config.py | 4 ++++ tradein-mvp/deploy/cron-scrape.sh | 6 ++++++ tradein-mvp/docker-compose.prod.yml | 2 ++ 4 files changed, 30 insertions(+), 3 deletions(-) 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