feat(tradein): auth на admin-эндпоинтах (#2) #383

Merged
lekss361 merged 1 commit from feat/tradein-admin-auth into main 2026-05-21 16:47:18 +00:00
4 changed files with 30 additions and 3 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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)

View file

@ -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