Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
290 lines
18 KiB
Python
290 lines
18 KiB
Python
"""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<TOKEN>/...) — то же самое
|
||
# закрытие, что уже стоит в 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"])
|