"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки. ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* ------------------------------------------------------- На `meraocenka.ru` действует allowlist-by-default: Caddy проксирует поимённо перечисленные пути, всё остальное — 404 (см. корневой Caddyfile, site-блок meraocenka.ru; регресс — scripts/smoke-mera-perimeter.sh). Чтобы открыть там API, нужно было выбрать одно из двух: (а) пробросить `/trade-in/api/v1/trade-in/coverage` и `.../geocode/suggest` поимённо — периметр остаётся узким, но одна опечатка в matcher'е (`/trade-in/api/*` вместо точного пути) открывает наружу ВЕСЬ v1: ~20 ручек, включая PDF расчётов, фотографии объектов, историю и админку; (б) завести отдельный префикс, под которым по определению не может лежать ничего закрытого, и пробрасывать его целиком. Выбрано (б). Разница не в удобстве, а в цене ошибки: при (а) безопасность периметра держится на аккуратности матчера, при (б) — на структуре кода. Добавить сюда ручку с приватными данными нужно СПЕЦИАЛЬНО (положить файл в `app/api/public/`), случайно — нельзя. Тот же принцип, что уже применён на фронте: публичный лэндинг вынесен в `app/mera-public/` с guard-скриптом на граф импортов, а не помечен флагом внутри общего дерева. АНОНИМНОСТЬ ----------- `rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — не префиксом: множество там — frozenset с проверкой `path in ...`, и добавление префиксной ветки ради двух путей расширило бы механизм, которым пользуется весь бэкенд, ради одной фичи. ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ ----------------------- Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` — чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не случайность, а условие, при котором публичная форма может работать ДО того, как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица попадает в `trade_in_estimates` раньше любого согласия, а пути удаления данных в бэкенде нет). Платный расчёт, который писать будет, открывается отдельно и только вместе с этим контуром. БЮДЖЕТЫ ------- Общий `RateLimitMiddleware` (300/60с на IP) здесь недостаточен: `/suggest` через DaData-тир геокодера — платный внешний вызов, то есть абуз стоит денег, а не только CPU. Поэтому у каждой ручки свой, заведомо более узкий per-IP бюджет поверх общего — тот же приём, что у анонимного чата поддержки (app/api/v1/support.py, `_anon_ip_limiter`). Лимитеры in-process: при нескольких репликах бэкенда бюджет умножится на их число. Сейчас реплика одна (docker-compose, tradein-backend), что и делает допущение верным; при масштабировании — выносить в Redis (issue заводить тогда же, не раньше: преждевременный вынос добавит зависимость без выигрыша). """ from __future__ import annotations import asyncio import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.api.v1.geocode import SuggestResponse, suggest_addresses from app.api.v1.trade_in import coverage_probe from app.core.db import get_db from app.core.public_request import install_address_log_redaction, public_request_scope from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse logger = logging.getLogger(__name__) # Публичная форма обещает, что введённый адрес нигде не сохраняется. По базам # это так, по журналам не было — геокодер печатал запрос открытым текстом, а # прод пишет stdout в persistent journald. Ставим редакцию логов в момент # импорта модуля (его импортирует app/main.py) — то есть ровно тогда, когда # публичные ручки вообще появляются в приложении. Разбор — в # app/core/public_request.py. install_address_log_redaction() router = APIRouter() # Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес # с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает # на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг # осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь, # нажал ещё раз». _SUGGEST_LIMIT = 20 _COVERAGE_LIMIT = 15 _WINDOW_S = 60.0 _suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) _coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) # ── Общий суточный потолок публичных подсказок ────────────────────────────── # # Per-IP окна одного клиента ограничивают, но не ограничивают СУММУ. Считаем: # 20 запросов/мин с одного адреса — это 28 800 в сутки, а весь бесплатный тир # DaData у проекта — 10 000 в сутки И ОН ОБЩИЙ с закрытым контуром. То есть без # этого потолка один настойчивый клиент (или один скрипт) за несколько часов # выедает квоту, и подсказки перестают работать у ПЛАТЯЩИХ пилотов, а не только # у него. Найдено состязательным ревью и подтверждено на проде: достаточно # упомянуть в запросе не-екатеринбургский город, чтобы локальный кадастровый # тир отключился и запрос гарантированно ушёл во внешний сервис. # # 2000/сутки — заведомо меньше десятой доли тира: публичная форма не должна # уметь навредить закрытому контуру в принципе. Порог достижим только абузом # (живой посетитель тратит единицы запросов на адрес), поэтому исчерпание — # сигнал, а не штатный режим: логируем ошибкой. _DAILY_SUGGEST_BUDGET = 2000 _daily_suggest_limiter = SlidingWindowLimiter(limit=_DAILY_SUGGEST_BUDGET, window_s=86_400.0) _GLOBAL_KEY = "public-suggest" # ── Потолок одновременных подсказок ───────────────────────────────────────── # # Кадастровый тир геокодера уходит в FDW-скан ЧУЖОЙ базы (gendesign) и на # коротком вводе занимает около секунды, всё это время удерживая соединение из # пула. Пул общий с закрытым контуром и невелик (дефолт SQLAlchemy 5+10), так # что полтора десятка одновременных публичных подсказок способны положить # B2B-запросы в том же процессе — при том, что per-IP лимиты каждого из них # формально соблюдены. # # Ждём слот недолго и отвечаем 429, а не копим очередь: очередь под нагрузкой # превращается в те же занятые соединения плюс растущий таймаут у клиента. _SUGGEST_CONCURRENCY = 4 _SUGGEST_SLOT_WAIT_S = 2.0 _suggest_slots = asyncio.Semaphore(_SUGGEST_CONCURRENCY) def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None: """429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки. В отличие от отправки сообщения в поддержку (там `record()` только на успех, чтобы неудача не съедала бюджет), здесь считаем каждую попытку: внешний вызов геокодера тратится и на запросе, который вернёт пусто, — иначе перебор мусорными строками не стоил бы атакующему ничего. """ ip = _client_ip(request) retry_after = limiter.retry_after(ip) if retry_after is not None: logger.info("public mera %s rate-limited for %s", what, ip) raise HTTPException( status_code=429, detail="Слишком много запросов. Попробуйте через минуту.", headers={"Retry-After": str(int(retry_after) + 1)}, ) limiter.record(ip) class PublicSuggestInput(BaseModel): """Вход публичного автокомплита. Телом, а не query-параметрами — см. `public_suggest`. """ q: str = Field(min_length=2, max_length=200) limit: int = Field(default=8, ge=1, le=10) city_hint: str | None = Field(default=None, max_length=100) def _fold(text: str) -> str: """ёЁ→еЕ + casefold — та же нормализация, что у городов в trade_in.py.""" return text.translate(str.maketrans("ёЁ", "ее")).casefold() def _query_with_city(query: str, city_hint: str | None) -> str: """Подставить выбранный город В САМУ СТРОКУ запроса. ЗАЧЕМ. `city_hint` доезжает до геокодера, но НА ВЫДАЧУ ПОДСКАЗОК НЕ ВЛИЯЕТ: его использует только екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает. Замер на проде 16.08.2026: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает совсем чужой дом — ровно тот баг #2576, ради которого город и спрашивают. С городом в строке («Серов Ленина 1») выдача становится серовской целиком — проверено там же. Для Екатеринбурга подстановка безвредна: три разных адреса дали побайтово тот же результат с префиксом и без (кадастровый тир парсит улицу и дом одинаково). Поэтому правило одно на все города, без исключения для основного трафика — исключение пришлось бы поддерживать. Чинится ЗДЕСЬ, а не в геокодере: там от `city_hint` зависит поведение закрытого контура (`target_city_ambiguous`), и менять его смысл ради публичной формы значит трогать чужой контракт. """ if not city_hint: return query if _fold(city_hint) in _fold(query): return query return f"{city_hint}, {query}" @router.post("/suggest", response_model=SuggestResponse) async def public_suggest( request: Request, payload: PublicSuggestInput, db: Annotated[Session, Depends(get_db)], ) -> SuggestResponse: """Автокомплит адреса для публичной формы (Свердловская область). ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а он пишет URI целиком — то есть адрес квартиры лёг бы в файл рядом с IP посетителя. Мы публично обещаем на этой же странице, что введённый адрес нигде не сохраняем; лог — это сохранение. Тело запроса в лог не попадает, поэтому обещание остаётся правдой без правки конфигурации логирования (её легко потерять при следующем рефакторинге Caddyfile — а тип запроса потерять нельзя, сломается сразу и заметно). Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а не через query-параметры (frontend `estimate-draft.ts`). Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран (`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику: публичная форма обязана резолвить адрес ровно так же, как платный расчёт, иначе аноним выберет дом, которого потом «не окажется». Отличие от v1 ровно одно — потолок `limit` 10 вместо 15: выдача сверх десятка в публичном UI не показывается, а каждый лишний кандидат может стоить внешнего вызова. """ _enforce(_suggest_limiter, request, "suggest") # Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его # собственным лимитом, и только оставшееся считаем в общий бюджет. daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY) if daily_retry is not None: logger.error( "публичные подсказки исчерпали суточный бюджет (%d) — квота геокодера " "защищена, но форма на лэндинге сейчас без автокомплита", _DAILY_SUGGEST_BUDGET, ) raise HTTPException( status_code=429, detail="Подсказки адреса временно недоступны. Введите адрес полностью.", headers={"Retry-After": str(int(daily_retry) + 1)}, ) _daily_suggest_limiter.record(_GLOBAL_KEY) try: await asyncio.wait_for(_suggest_slots.acquire(), timeout=_SUGGEST_SLOT_WAIT_S) except TimeoutError: raise HTTPException( status_code=429, detail="Сервис сейчас занят. Попробуйте ещё раз через несколько секунд.", headers={"Retry-After": "5"}, ) from None try: # Пометка публичного запроса нужна ровно здесь: внутри `suggest_addresses` # геокодер логирует введённую строку, а публичная форма обещает, что # адрес не попадает в журналы. with public_request_scope(): return await suggest_addresses( q=_query_with_city(payload.q, payload.city_hint), limit=payload.limit, db=db, city_hint=payload.city_hint, ) finally: _suggest_slots.release() @router.post("/coverage", response_model=CoverageProbeResponse) def public_coverage( request: Request, payload: CoverageProbeInput, db: Annotated[Session, Depends(get_db)], ) -> CoverageProbeResponse: """Бесплатная проба покрытия (issue #2894) для публичной формы. Делегирует в `app.api.v1.trade_in.coverage_probe` — ту же функцию, что вызывает закрытый контур. Копии SQL здесь нет намеренно: разбор #2894 показал, что стоит когорте пробы разойтись с когортой платного расчёта — проба честно отвечает «есть данные» там, где расчёт увидит ноль. Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) — бесплатный шаг доказывает наличие данных, цену продаёт платный. """ _enforce(_coverage_limiter, request, "coverage") return coverage_probe(payload=payload, db=db)