"""Trade-In MVP — FastAPI entry point. Standalone версия, выделена из основного gendesign repo для локальной разработки. Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет. """ from __future__ import annotations import logging import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import sentry_sdk from fastapi import FastAPI, Response from fastapi.middleware.cors import CORSMiddleware from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.httpx import HttpxIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration from app.api.public import mera as public_mera from app.api.v1 import ( admin, audit, auth, brand, buildings, geocode, glitchtip, lead, me, privacy_admin, search, support, team, trade_in, version, ) from app.core.auth_db import get_auth_engine from app.core.config import settings from app.core.db import SessionLocal from app.core.fdw import ensure_fdw_user_mapping from app.core.ratelimit import RateLimitMiddleware from app.core.rbac import rbac_guard from app.core.request_audit import RequestAuditMiddleware from app.observability.sentry_scrub import scrub_pii_event logger = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) # #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую # (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не # только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL, # включая токен в пути (https://api.telegram.org/bot/...) — то же самое # закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь. logging.getLogger("httpx").setLevel(logging.WARNING) # Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). # DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP. # Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs), # Logging (logger.error → events). БЕЗ CeleryIntegration — prod не гоняет celery # worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/ # frontend), отдельного broker нет → мониторить нечего. if settings.glitchtip_dsn: from app.observability.sentry_scrub import ( redact_telegram_bot_token, scrub_payment_request_body, scrub_public_address, stabilize_retry_error_fingerprint, ) def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None: """Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction + RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) — см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а include_local_variables=False ниже — первый рубеж защиты. PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные — режет `request.data` целиком только для `/payments/*`, остальные пути (extra/contexts/traceback) по-прежнему проходят ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ каналам ниже (before_send и before_send_transaction) — вчерашний баг в Птице закрыл только error-канал, transaction-канал остался вообще без обработчика. RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см. sentry_scrub.stabilize_retry_error_fingerprint.""" scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type] if scrubbed is None: return None # Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а # брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная # страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний # сервис, значит обещание распространяется и на него. scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type] if scrubbed is None: return None scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type] if scrubbed is None: return None detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type] if detokened is None: return None return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value] sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=0.0, # только ошибки, без performance-трейсов send_default_pii=False, # не шлём client_name / client_phone в отчёты include_local_variables=False, # #tgsupport-web: TelegramClient._request # держит base URL с токеном в локальных переменных стек-фрейма — default # sentry_sdk (True) приложил бы их к traceback открытым текстом. before_send=_before_send, # PR-D2: тот же обработчик на transaction-канал — traces_sample_rate=0.0 # сегодня не шлёт трейсы вообще, но это belt-and-suspenders на случай, # если трейсинг когда-нибудь включат (см. docstring _before_send выше). before_send_transaction=_before_send, integrations=[ StarletteIntegration(), FastApiIntegration(), SqlalchemyIntegration(), HttpxIntegration(), LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), ], ) logging.getLogger("app.main").info("GlitchTip monitoring enabled") @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # #2213 defense-in-depth: если общий секрет не задан — trusted-header auth # уязвим к подделке X-Authenticated-User изнутри docker-сети gendesign_shared. # Один явный WARNING на старте, чтобы это не осталось незамеченным в проде. if not settings.tradein_internal_auth_secret: logger.warning( "defense-in-depth НЕ активен: X-Authenticated-User принимается без " "проверки внутреннего секрета — задай TRADEIN_INTERNAL_AUTH_SECRET в " ".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)" ) # #2552: session_secret зарезервирован на будущее (напр. подписанные токены) — # opaque session-токены (secrets.token_urlsafe, см. app.services.auth_session) # НЕ требуют подписи, их валидность проверяется исключительно наличием строки # в tradein_sessions + expires_at/is_active. Пустой session_secret НЕ должен # ронять старт контейнера (не startup-fail) — только громкий WARNING, чтобы # прод не остался без него незамеченно до момента, когда он реально понадобится. if not settings.session_secret: logger.warning( "SESSION_SECRET пуст — не блокирует старт (opaque session-токены не " "требуют подписи), но задай его в .env.runtime до появления фич, " "которым подпись реально нужна" ) # Эпик «единый вход»: при IDENTITY_STORE=auth реестр людей обязан быть # СКОНФИГУРИРОВАН — иначе стартуем сломанными. Ошибка DSN не похожа на «БД # недоступна»: продуктовая БД жива, приложение полностью работоспособно и # может так работать сутками, а rbac_guard ловит AuthDatabaseNotConfiguredError # вместе с любым другим сбоем резолва сессии и падает в legacy # trusted-header ветку (auth_mode='dual'). То есть любой, кого пропустил # Caddy basic_auth, молча получал бы права из roles.yaml — даже аккаунт с # access_state='disabled'/'trial_expired' в реестре. Пусть лучше сломанный # деплой не поднимется вообще, чем сутки раздаёт доступ мимо реестра. # # На ДЕФОЛТНЫЙ режим не влияет: при identity_store="tradein" (прод сегодня) # ветка не выполняется, engine БД `auth` не создаётся, пустой # AUTH_DATABASE_URL по-прежнему не ошибка. if settings.identity_store == "auth": # Наружу летит AuthDatabaseNotConfiguredError с внятным текстом # (app.core.auth_db); create_engine к серверу не ходит, так что это # проверка КОНФИГУРАЦИИ, а не доступности БД — недоступный сервер # по-прежнему не мешает старту. get_auth_engine() logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован") # Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис # ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то # включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт # TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще, # чем молча остаться без терминала/пароля на боевом трафике оплат. if settings.payments_enabled and ( not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value() ): raise RuntimeError( "PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — " "задай оба в .env.runtime до включения платёжного контура" ) # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: with SessionLocal() as db: ensure_fdw_user_mapping(db) except Exception: logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail") # #2397 Part C: legacy in-app scheduler launch removed — scheduling lives exclusively # in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler). # Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml); # this API process never actually launched scheduler_loop() in production. yield app = FastAPI( title="Trade-In MVP API", description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501 version="0.1.0", lifespan=lifespan, ) # RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User # (см. app/core/auth.py + auth/roles.yaml). Правила: # 1) Любой non-public path требует X-Authenticated-User — иначе 401. # 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего # не видит» — decided 2026-05-25). # 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy # `uri strip_prefix /trade-in`) — только role=admin, иначе 403. # Guard body живёт в app/core/rbac.py (без DB/lifespan side effects), чтобы # тесты могли импортировать РЕАЛЬНЫЙ guard вместо hand-maintained копии. app.middleware("http")(rbac_guard) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза. app.add_middleware(RateLimitMiddleware) # Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation). app.add_middleware(RequestAuditMiddleware) @app.get("/health") def health() -> dict[str, str]: return {"status": "ok", "environment": settings.environment} # FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от # raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health # отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только # /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой # docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам — # только pg_isready для postgres) — маршрут пока используется лишь тестами. # Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder # (backend/app/main.py, за Caddyfile `handle /health`), не здесь. # media_type="application/json" — Content-Type совпадает с GET; Content-Length # сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload) # — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для # HEAD, требует совпадения только заголовков представления (Content-Type). # include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это # инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим # симметрично, чтобы схема двух бэкендов не разъезжалась. @app.head("/health", include_in_schema=False) def health_head() -> Response: return Response(status_code=200, media_type="application/json") app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"]) app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"]) app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(team.router, prefix="/api/v1/team", tags=["team"]) # Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно — # на публичном домене он проксируется целиком, поэтому под ним по определению # не должно лежать ничего закрытого; разбор — в app/api/public/mera.py. # Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками. app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"])