fix(mera/b2c): семь дефектов публичного периметра, найденных состязательным ревью
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
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 → падает тест маршрутов.
This commit is contained in:
parent
208173d816
commit
56194c606a
12 changed files with 667 additions and 52 deletions
|
|
@ -65,6 +65,30 @@ jobs:
|
|||
python3 scripts/check-workflow-ports.py --selftest
|
||||
python3 scripts/check-workflow-ports.py
|
||||
|
||||
- name: "Guard: Caddyfile синтаксически валиден"
|
||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
||||
#
|
||||
# ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один
|
||||
# workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При
|
||||
# этом deploy.yml применяет его не через `reload` (тот отказался бы
|
||||
# принять битый конфиг и оставил бы старый работать), а через
|
||||
# `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер
|
||||
# в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru,
|
||||
# obsidian, status. То есть цена опечатки в этом файле — полный
|
||||
# даунтайм, а гейта на неё не было.
|
||||
#
|
||||
# `docker run`, а не установка caddy в раннер: тот же образ `caddy:2`,
|
||||
# что стоит в docker-compose.prod.yml — проверяем ровно тем парсером,
|
||||
# который будет читать конфиг на проде. Docker на раннере есть (им же
|
||||
# поднимается Postgres в ci-tradein.yml).
|
||||
#
|
||||
# Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую
|
||||
# строку — это нормально, синтаксис от их значений не зависит.
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/etc/caddy" caddy:2 \
|
||||
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
|
||||
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
|
||||
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
||||
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
||||
|
|
|
|||
55
Caddyfile
55
Caddyfile
|
|
@ -278,6 +278,10 @@ meraocenka.ru {
|
|||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||||
# «/», и запрос закольцуется.
|
||||
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
|
||||
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
|
||||
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
|
||||
# когда вариант выберут и он станет корнем.
|
||||
@meraPages path /estimate /oferta /refund /privacy /v3
|
||||
handle @meraPages {
|
||||
rewrite * /trade-in/mera-public{path}
|
||||
|
|
@ -286,6 +290,17 @@ meraocenka.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Тот же адрес со слэшем на конце → 301 на канонический вид без слэша.
|
||||
# Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер
|
||||
# `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы
|
||||
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
||||
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
||||
# со слэшем и здесь нормализуются.
|
||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$
|
||||
handle @meraShortSlash {
|
||||
redir * /{re.shortslash.1} permanent
|
||||
}
|
||||
|
||||
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
|
||||
# страницы, а не два работающих.
|
||||
#
|
||||
|
|
@ -300,10 +315,6 @@ meraocenka.ru {
|
|||
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
|
||||
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
|
||||
#
|
||||
# Query-строка при редиректе не переносится: ни одна из этих страниц
|
||||
# параметров не принимает (форма проверки шлёт данные телом POST, а
|
||||
# черновик с лэндинга едет через sessionStorage — специально чтобы адрес
|
||||
# квартиры не попал в access-лог).
|
||||
# `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он
|
||||
# начинается со слэша, Caddy разбирает как inline path-matcher — то есть
|
||||
# `redir / permanent` означает «для пути / редиректить на permanent», а не
|
||||
|
|
@ -315,18 +326,30 @@ meraocenka.ru {
|
|||
redir * / permanent
|
||||
}
|
||||
|
||||
# `([^/].*)`, а не `(.+)` — страховка от протокол-относительной цели.
|
||||
# Захват, начинающийся со слэша, дал бы `redir` цель вида `//evil.example`,
|
||||
# которую браузер резолвит как ЧУЖОЙ ХОСТ (открытый редирект с нашего
|
||||
# домена). Проверено на живом Caddy: сегодня это недостижимо и без
|
||||
# ограничения — Caddy нормализует путь ДО матчинга, схлопывая повторные
|
||||
# слэши, и `//evil.example` (как и `/%2Fevil.example`) уже приезжает сюда
|
||||
# одним слэшем, то есть редирект остаётся на нашем хосте. Ограничение
|
||||
# оставлено намеренно: оно стоит ноль, а полагаться на нормализацию как на
|
||||
# единственный барьер для дыры такого класса не хочется.
|
||||
@meraLongSub path_regexp meralong ^/trade-in/mera-public/([^/].*)$
|
||||
handle @meraLongSub {
|
||||
redir * /{re.meralong.1} permanent
|
||||
# Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы
|
||||
# (со слэшем на конце и без) — не шаблоном и не регекспом.
|
||||
#
|
||||
# ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант
|
||||
# `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый
|
||||
# редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
|
||||
# `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`,
|
||||
# а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это
|
||||
# готовая фишинговая заготовка с домена, который напечатан внутри оферты и
|
||||
# уходит модератору эквайера. Проверено на живом Caddy, воспроизводится.
|
||||
# С поимённым списком такой путь просто не матчится и падает в 404 ниже.
|
||||
#
|
||||
# ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке.
|
||||
# `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками
|
||||
# после редиректа не теряют атрибуцию. Обёртка `route` обязательна —
|
||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||
# (бесконечный цикл; поймано на локальном стенде).
|
||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
|
||||
handle @meraLongPages {
|
||||
route {
|
||||
uri strip_prefix /trade-in/mera-public
|
||||
redir * {uri} permanent
|
||||
}
|
||||
}
|
||||
|
||||
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS`
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -67,11 +68,20 @@ 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()
|
||||
|
||||
# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес
|
||||
|
|
@ -79,13 +89,47 @@ router = APIRouter()
|
|||
# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг
|
||||
# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь,
|
||||
# нажал ещё раз».
|
||||
_SUGGEST_LIMIT = 40
|
||||
_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 бюджета. Попытку регистрируем ДО работы ручки.
|
||||
|
|
@ -148,9 +192,42 @@ async def public_suggest(
|
|||
стоить внешнего вызова.
|
||||
"""
|
||||
_enforce(_suggest_limiter, request, "suggest")
|
||||
return await suggest_addresses(
|
||||
q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint
|
||||
)
|
||||
|
||||
# Суточный потолок — ПОСЛЕ 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=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint
|
||||
)
|
||||
finally:
|
||||
_suggest_slots.release()
|
||||
|
||||
|
||||
@router.post("/coverage", response_model=CoverageProbeResponse)
|
||||
|
|
|
|||
97
tradein-mvp/backend/app/core/public_request.py
Normal file
97
tradein-mvp/backend/app/core/public_request.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие:
|
||||
адрес, который ввёл аноним, не попадает в журналы.
|
||||
|
||||
ЗАЧЕМ ЭТО СУЩЕСТВУЕТ
|
||||
--------------------
|
||||
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый
|
||||
адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки
|
||||
только читают), а по журналам — не было: геокодер логирует запрос открытым
|
||||
текстом на каждый вызов, например
|
||||
|
||||
INFO app.services.dadata: dadata suggest: 'онуфриева 24' → 5 вариантов
|
||||
|
||||
Прод пишет stdout контейнеров в journald с persistent-хранилищем
|
||||
(`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт
|
||||
там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же
|
||||
меткой времени — то есть адрес квартиры фактически сохранён и сопоставим с
|
||||
человеком. Ровно то, что публичная страница обещает не делать.
|
||||
|
||||
Найдено состязательным ревью PR публичного периметра (16.08.2026) и
|
||||
воспроизведено на проде, а не выведено из чтения кода.
|
||||
|
||||
ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger
|
||||
------------------------------------------------
|
||||
Мест, где адрес попадает в лог, много (`app/services/dadata.py`,
|
||||
`app/services/geocoder.py` — успех, пустая выдача, сетевая ошибка, таймаут,
|
||||
кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно
|
||||
зависеть от того, вспомнил ли автор следующей правки про эту страницу.
|
||||
Фильтр — единственная точка, которая закрывает и уже написанное, и будущее.
|
||||
|
||||
ПОЧЕМУ contextvar
|
||||
-----------------
|
||||
Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же
|
||||
`suggest()` вызывают оба. Различить их можно только по текущему запросу.
|
||||
`ContextVar` — то, что переживает `await` и копируется в `asyncio.to_thread`
|
||||
(им геокодер уходит в синхронный кадастровый тир), в отличие от глобального
|
||||
флага, который в конкурентной обработке принадлежал бы соседнему запросу.
|
||||
|
||||
Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние — они
|
||||
нужны, чтобы разбирать жалобы пилотов на подсказки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
#: Истинно, пока обрабатывается запрос анонимной публичной формы.
|
||||
is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False)
|
||||
|
||||
#: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним
|
||||
#: по-прежнему видно, что вызов был и чем закончился.
|
||||
REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>"
|
||||
|
||||
#: Логгеры, чьи записи могут содержать введённый адрес.
|
||||
ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def public_request_scope() -> Iterator[None]:
|
||||
"""Помечает текущий запрос публичным на время работы блока."""
|
||||
token = is_public_request.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
is_public_request.reset(token)
|
||||
|
||||
|
||||
class RedactPublicAddressFilter(logging.Filter):
|
||||
"""Заменяет сообщение целиком, пока обрабатывается публичный запрос.
|
||||
|
||||
Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с
|
||||
безобидными аргументами (`"%r → %d вариантов"`), и отличить их друг от
|
||||
друга внутри фильтра нельзя. Терять текст сообщения на публичном пути
|
||||
дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if is_public_request.get():
|
||||
record.msg = REDACTED_MESSAGE
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def install_address_log_redaction() -> None:
|
||||
"""Вешает фильтр на логгеры, видящие адрес. Идемпотентно.
|
||||
|
||||
Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры
|
||||
логгера применяются к записям этого логгера, а не ко всему, что через
|
||||
хендлер проходит, — то есть посторонние сообщения (пул соединений, старт
|
||||
приложения) во время публичного запроса не пострадают.
|
||||
"""
|
||||
for name in ADDRESS_LOGGERS:
|
||||
logger = logging.getLogger(name)
|
||||
if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters):
|
||||
logger.addFilter(RedactPublicAddressFilter())
|
||||
|
|
@ -71,6 +71,7 @@ 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,
|
||||
)
|
||||
|
||||
|
|
@ -93,6 +94,13 @@ if settings.glitchtip_dsn:
|
|||
(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]
|
||||
|
|
|
|||
|
|
@ -241,6 +241,61 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
|
|||
return event
|
||||
|
||||
|
||||
_PUBLIC_API_URL_SEGMENT = "/api/public/"
|
||||
|
||||
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
|
||||
_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru")
|
||||
|
||||
_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$")
|
||||
|
||||
|
||||
def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||
"""Убрать введённый анонимом адрес из события GlitchTip.
|
||||
|
||||
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес
|
||||
нигде не сохраняется. GlitchTip — внешний сервис, и до этой правки адрес
|
||||
доезжал туда двумя путями (оба воспроизведены состязательным ревью
|
||||
16.08.2026, не выведены из чтения кода):
|
||||
|
||||
1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса,
|
||||
а `send_default_pii=False` этот путь не гейтит — он про куки и IP, не
|
||||
про тело. Тело публичной ручки — это ровно `{"q": "<адрес>"}`.
|
||||
Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет
|
||||
имена вроде `client_phone`, а поле здесь называется `q`.
|
||||
2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration`
|
||||
кладёт URL целиком, а адрес там в query (`?q=Малышева+30`).
|
||||
|
||||
Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а
|
||||
убирать целиком — состав полей задаёт не только наш код (у геокодеров свои
|
||||
параметры), поэтому перечислить безопасное заранее нельзя.
|
||||
|
||||
Композировать с остальными шагами, а не вместо них.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
|
||||
request = event.get("request")
|
||||
if isinstance(request, dict):
|
||||
url = request.get("url")
|
||||
if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower():
|
||||
request.pop("data", None)
|
||||
|
||||
crumbs = event.get("breadcrumbs")
|
||||
values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs
|
||||
if isinstance(values, list):
|
||||
for crumb in values:
|
||||
if not isinstance(crumb, dict):
|
||||
continue
|
||||
data = crumb.get("data")
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
url = data.get("url")
|
||||
if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS):
|
||||
data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _redact_strings(obj: Any) -> Any:
|
||||
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
|
||||
строке (не только по конкретным ключам) — токен может оказаться в locals
|
||||
|
|
|
|||
|
|
@ -246,3 +246,107 @@ def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None:
|
|||
above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15})
|
||||
assert at_ceiling.status_code == 200, at_ceiling.text
|
||||
assert above.status_code == 422, above.text
|
||||
|
||||
|
||||
# ── 5. Обещание «адрес нигде не сохраняется» ─────────────────────────────────
|
||||
#
|
||||
# Три пути утечки, найденные состязательным ревью 16.08.2026 и воспроизведённые
|
||||
# на живом коде. Тесты сформулированы от обещания, а не от реализации: пока на
|
||||
# публичной странице и в политике ПДн написано «не сохраняется», эти проверки
|
||||
# обязаны быть зелёными.
|
||||
|
||||
|
||||
def test_address_is_redacted_from_logs_inside_public_scope(caplog) -> None:
|
||||
"""Геокодер логирует введённую строку открытым текстом на каждый вызов, а
|
||||
прод пишет stdout контейнеров в persistent journald — то есть адрес ложился
|
||||
на диск рядом с IP того же запроса в access-логе Caddy."""
|
||||
import logging
|
||||
|
||||
from app.core.public_request import install_address_log_redaction, public_request_scope
|
||||
|
||||
install_address_log_redaction()
|
||||
dadata_logger = logging.getLogger("app.services.dadata")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
with public_request_scope():
|
||||
dadata_logger.info("dadata suggest: %r → %d вариантов", "онуфриева 24", 5)
|
||||
# Вне публичного контура логи прежние — они нужны для разбора жалоб
|
||||
# пилотов на подсказки.
|
||||
dadata_logger.info("dadata suggest: %r → %d вариантов", "малышева 51", 3)
|
||||
|
||||
text = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "онуфриева" not in text.lower(), "адрес анонима попал в журнал"
|
||||
assert "малышева" in text.lower(), "редакция протекла на закрытый контур"
|
||||
|
||||
|
||||
def test_public_scope_is_not_leaked_after_request() -> None:
|
||||
"""Флаг обязан сниматься: иначе первый же публичный запрос заглушил бы логи
|
||||
процесса до перезапуска."""
|
||||
from app.core.public_request import is_public_request, public_request_scope
|
||||
|
||||
assert is_public_request.get() is False
|
||||
with public_request_scope():
|
||||
assert is_public_request.get() is True
|
||||
assert is_public_request.get() is False
|
||||
|
||||
|
||||
def test_sentry_scrub_drops_public_request_body_and_geocoder_query() -> None:
|
||||
"""sentry_sdk кладёт в событие полное тело запроса, а `send_default_pii=False`
|
||||
этот путь не гейтит (он про куки и IP). Тело публичной ручки — ровно
|
||||
`{"q": "<адрес>"}`; брэдкрамб httpx несёт тот же адрес в query."""
|
||||
from app.observability.sentry_scrub import scrub_public_address
|
||||
|
||||
event = {
|
||||
"request": {
|
||||
"url": "https://meraocenka.ru/trade-in/api/public/mera/suggest",
|
||||
"data": {"q": "Малышева 30"},
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"values": [
|
||||
{
|
||||
"category": "httpx",
|
||||
"data": {"url": "https://nominatim.openstreetmap.org/search?q=Малышева+30"},
|
||||
},
|
||||
{"category": "httpx", "data": {"url": "https://example.com/x?a=1"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
scrubbed = scrub_public_address(event, {})
|
||||
assert scrubbed is not None
|
||||
assert "data" not in scrubbed["request"], "тело запроса с адресом уехало в GlitchTip"
|
||||
|
||||
crumbs = scrubbed["breadcrumbs"]["values"]
|
||||
assert "Малышева" not in crumbs[0]["data"]["url"], "адрес уехал в брэдкрамбе геокодера"
|
||||
# Посторонние URL не трогаем — иначе разбирать чужие ошибки станет нечем.
|
||||
assert crumbs[1]["data"]["url"] == "https://example.com/x?a=1"
|
||||
|
||||
|
||||
def test_sentry_scrub_keeps_closed_contour_body() -> None:
|
||||
"""Редакция узкая: тела запросов закрытого контура нужны для разбора."""
|
||||
from app.observability.sentry_scrub import scrub_public_address
|
||||
|
||||
event = {
|
||||
"request": {"url": "https://gendsgn.ru/trade-in/api/v1/trade-in/estimate", "data": {"x": 1}}
|
||||
}
|
||||
scrubbed = scrub_public_address(event, {})
|
||||
assert scrubbed is not None
|
||||
assert scrubbed["request"]["data"] == {"x": 1}
|
||||
|
||||
|
||||
# ── 6. Бюджет внешнего геокодера ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_daily_suggest_budget_protects_shared_geocoder_quota(client: TestClient) -> None:
|
||||
"""Per-IP окна ограничивают одного клиента, но не сумму: 20/мин с адреса —
|
||||
это 28 800 в сутки при общем бесплатном тире DaData в 10 000, ОБЩЕМ с
|
||||
закрытым контуром. Без суточного потолка один скрипт оставлял бы без
|
||||
подсказок платящих пилотов."""
|
||||
assert public_mera._DAILY_SUGGEST_BUDGET < 10_000, (
|
||||
"суточный потолок публичных подсказок обязан быть заметно меньше всего "
|
||||
"тира геокодера — иначе публичная форма может навредить закрытому контуру"
|
||||
)
|
||||
assert public_mera._SUGGEST_LIMIT * 60 * 24 > public_mera._DAILY_SUGGEST_BUDGET, (
|
||||
"если per-IP лимит сам по себе не может исчерпать суточный бюджет, "
|
||||
"потолок бессмысленен — проверь, что тест сторожит реальный сценарий"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft";
|
||||
|
||||
/**
|
||||
* Черновик, который лэндинг передаёт на экран проверки.
|
||||
*
|
||||
* Тесты — на две вещи, каждая из которых уже ломалась при ревью:
|
||||
* 1. поле «Комнат» на лэндинге свободное, и его значение нельзя подставлять
|
||||
* в селект как есть;
|
||||
* 2. черновик не должен переживать свой единственный переход.
|
||||
*/
|
||||
|
||||
const ROOMS = ["0", "1", "2", "3", "4", "5"];
|
||||
|
||||
describe("normalizeDraftRooms", () => {
|
||||
it("пропускает то, что уже совпадает с вариантом селекта", () => {
|
||||
expect(normalizeDraftRooms("2", ROOMS)).toBe("2");
|
||||
expect(normalizeDraftRooms("0", ROOMS)).toBe("0");
|
||||
});
|
||||
|
||||
it("понимает, как люди пишут на самом деле", () => {
|
||||
expect(normalizeDraftRooms("2 комнаты", ROOMS)).toBe("2");
|
||||
expect(normalizeDraftRooms("3-комн.", ROOMS)).toBe("3");
|
||||
expect(normalizeDraftRooms(" 1к ", ROOMS)).toBe("1");
|
||||
expect(normalizeDraftRooms("Студия", ROOMS)).toBe("0");
|
||||
expect(normalizeDraftRooms("студию", ROOMS)).toBe("0");
|
||||
});
|
||||
|
||||
it("возвращает null вместо мусора — иначе селект покажет пустоту, а сервер получит null", () => {
|
||||
// Именно этот путь и давал 422 с текстом «сломалось на нашей стороне»:
|
||||
// селект пустой, parseInt → NaN, rooms: null улетает на бэкенд.
|
||||
expect(normalizeDraftRooms("много", ROOMS)).toBeNull();
|
||||
expect(normalizeDraftRooms("", ROOMS)).toBeNull();
|
||||
expect(normalizeDraftRooms(undefined, ROOMS)).toBeNull();
|
||||
expect(normalizeDraftRooms("9", ROOMS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("черновик", () => {
|
||||
beforeEach(() => window.sessionStorage.clear());
|
||||
|
||||
it("переживает ровно один переход", () => {
|
||||
saveDraft({ address: "Малышева 51", rooms: "2" });
|
||||
expect(takeDraft()?.address).toBe("Малышева 51");
|
||||
// Второй раз — уже пусто: иначе возврат на /estimate через неделю в той же
|
||||
// вкладке подставил бы чужой по смыслу адрес.
|
||||
expect(takeDraft()).toBeNull();
|
||||
});
|
||||
|
||||
it("не падает на мусоре в хранилище", () => {
|
||||
window.sessionStorage.setItem("mera:estimate-draft", "{это не json");
|
||||
expect(takeDraft()).toBeNull();
|
||||
|
||||
window.sessionStorage.setItem("mera:estimate-draft", JSON.stringify({ rooms: "2" }));
|
||||
expect(takeDraft()).toBeNull();
|
||||
});
|
||||
|
||||
it("не тащит поля неожиданных типов", () => {
|
||||
window.sessionStorage.setItem(
|
||||
"mera:estimate-draft",
|
||||
JSON.stringify({ address: "Ленина 1", rooms: 2, area: null }),
|
||||
);
|
||||
const draft = takeDraft();
|
||||
expect(draft?.address).toBe("Ленина 1");
|
||||
expect(draft?.rooms).toBeUndefined();
|
||||
expect(draft?.area).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -72,18 +72,62 @@ describe("короткие адреса публичного домена", () =
|
|||
expect(meraBlock.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
/**
|
||||
* Разрешённый «лишний» путь: временное превью второго варианта дизайна.
|
||||
* Ссылок на него нет, страница noindex; строка удаляется вместе с выбором
|
||||
* варианта. Держим здесь, чтобы проверка ниже была ДВУСТОРОННЕЙ.
|
||||
*/
|
||||
const PREVIEW_ONLY = ["/v3"];
|
||||
|
||||
const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/);
|
||||
|
||||
it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => {
|
||||
for (const route of Object.values(PUBLIC_ROUTES)) {
|
||||
// Корень — отдельным `handle /`, остальные перечислены в матчере @meraPages.
|
||||
const served =
|
||||
route === "/"
|
||||
? /handle\s+\/\s*\{/.test(meraBlock)
|
||||
: new RegExp(`@meraPages path[^\\n]*\\s${route}(\\s|$)`, "m").test(meraBlock);
|
||||
: (meraPagesLine?.[1].split(/\s+/) ?? []).includes(route);
|
||||
expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Обратное направление. Односторонняя проверка (каждый роут есть в Caddy)
|
||||
* пропускает противоположную ошибку: путь, открытый на боевом домене, о
|
||||
* котором приложение не знает. Так на публичный домен уже уехало `/v3` —
|
||||
* черновой лэндинг с маркетинговыми плейсхолдерами вместо посчитанных чисел.
|
||||
* Теперь любой такой путь обязан быть либо в PUBLIC_ROUTES, либо в списке
|
||||
* превью выше — то есть названным вслух.
|
||||
*/
|
||||
it("на публичном домене не открыто ничего сверх известных страниц", () => {
|
||||
const served = meraPagesLine?.[1].split(/\s+/).filter(Boolean) ?? [];
|
||||
const known = new Set([...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]);
|
||||
const unexpected = served.filter((p) => !known.has(p));
|
||||
expect(
|
||||
unexpected,
|
||||
`открыты наружу, но не объявлены ни в PUBLIC_ROUTES, ни как превью: ${unexpected}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Длинные адреса обязаны редиректить на короткие — иначе разосланные ссылки
|
||||
* и закладки превращаются в 404. Список тоже поимённый (регексп с захватом
|
||||
* хвоста здесь был бы открытым редиректом — см. комментарий в Caddyfile),
|
||||
* поэтому он так же легко расходится с набором страниц.
|
||||
*/
|
||||
it("для каждой страницы есть 301 с длинного адреса", () => {
|
||||
const longLine = meraBlock.match(/@meraLongPages path ([^\n]+)/)?.[1] ?? "";
|
||||
for (const route of [...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]) {
|
||||
if (route === "/") continue; // корень ловит отдельный @meraLongRoot
|
||||
expect(
|
||||
longLine.includes(`/trade-in/mera-public${route}`),
|
||||
`нет 301 с длинного адреса на ${route} — старые ссылки станут 404`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("basePath не протекает в публичные ссылки", () => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import type { FormEvent, KeyboardEvent } from "react";
|
|||
import { COVERED_CITIES, PRIMARY_CITY } from "../../content";
|
||||
import { describeCoverage } from "../../coverage-copy";
|
||||
import type { CoverageVerdict } from "../../coverage-copy";
|
||||
import { takeDraft } from "../../estimate-draft";
|
||||
import { normalizeDraftRooms, takeDraft } from "../../estimate-draft";
|
||||
import {
|
||||
PublicApiError,
|
||||
fetchAddressSuggestions,
|
||||
|
|
@ -40,6 +40,8 @@ import styles from "../../landing-v3.module.css";
|
|||
/** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */
|
||||
const SUGGEST_DEBOUNCE_MS = 300;
|
||||
const SUGGEST_MIN_CHARS = 3;
|
||||
/** Потолок ожидания пробы покрытия. Сам SQL укладывается в ~80 мс. */
|
||||
const COVERAGE_TIMEOUT_MS = 15_000;
|
||||
|
||||
const ROOM_OPTIONS = [
|
||||
{ value: 0, label: "Студия" },
|
||||
|
|
@ -59,6 +61,12 @@ type Phase =
|
|||
const FORM: Phase = { kind: "form" };
|
||||
|
||||
function failureCopy(error: unknown): { title: string; text: string } {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return {
|
||||
title: "Проверка заняла слишком долго",
|
||||
text: "Мы прервали запрос, чтобы не держать вас в неизвестности. Нажмите «Проверить мой дом» ещё раз — введённое сохранилось.",
|
||||
};
|
||||
}
|
||||
if (error instanceof PublicApiError && error.kind === "rate-limited") {
|
||||
const wait = error.retryAfterS ? `${error.retryAfterS} сек.` : "минуту";
|
||||
return {
|
||||
|
|
@ -96,58 +104,88 @@ export function EstimateFlow() {
|
|||
const [area, setArea] = useState("");
|
||||
const [phase, setPhase] = useState<Phase>(FORM);
|
||||
const [fieldError, setFieldError] = useState<"address" | "area" | null>(null);
|
||||
const [suggestFailed, setSuggestFailed] = useState(false);
|
||||
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const areaRef = useRef<HTMLInputElement>(null);
|
||||
const suggestAbort = useRef<AbortController | null>(null);
|
||||
const coverageAbort = useRef<AbortController | null>(null);
|
||||
|
||||
// Незавершённый запрос покрытия при уходе со страницы отменяем — иначе
|
||||
// setState прилетает в размонтированный компонент.
|
||||
useEffect(() => () => coverageAbort.current?.abort(), []);
|
||||
|
||||
// Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз
|
||||
// на монтировании; координат в нём нет (и быть не может — на лэндинге нет
|
||||
// автокомплита), поэтому дом всё равно придётся выбрать из подсказок.
|
||||
//
|
||||
// Каждое поле ВАЛИДИРУЕТСЯ, а не подставляется как есть. На лэндинге
|
||||
// «Комнат» — свободный текст, туда пишут «студия» или «2 комнаты»; такое
|
||||
// значение не совпадает ни с одним <option>, селект молча показывает
|
||||
// пустоту, а на сабмите `parseInt` даёт NaN и на сервер уходит `rooms: null`
|
||||
// → 422, который форма покажет как «сломалось на нашей стороне». Лучше
|
||||
// оставить значение по умолчанию: оно хотя бы видно и его можно поправить.
|
||||
useEffect(() => {
|
||||
const draft = takeDraft();
|
||||
if (!draft) return;
|
||||
setQuery(draft.address);
|
||||
if (draft.rooms) setRooms(draft.rooms);
|
||||
if (draft.area) setArea(draft.area);
|
||||
if (draft.city && COVERED_CITIES.includes(draft.city)) setCity(draft.city);
|
||||
|
||||
const normalized = normalizeDraftRooms(
|
||||
draft.rooms,
|
||||
ROOM_OPTIONS.map((o) => String(o.value)),
|
||||
);
|
||||
if (normalized) setRooms(normalized);
|
||||
}, []);
|
||||
|
||||
// Подсказки: debounce + отмена предыдущего запроса. Без отмены ответы
|
||||
// приходят в произвольном порядке, и в списке может осесть выдача по
|
||||
// ПРЕДЫДУЩЕЙ строке — человек видит подсказки не к тому, что набрал.
|
||||
// Подсказки: debounce + отмена предыдущего запроса.
|
||||
//
|
||||
// Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в
|
||||
// cleanup эффекта. Прежний вариант откладывал отмену до следующего такта
|
||||
// debounce, и она не наступала вовсе, если человек переставал печатать:
|
||||
// ответ по старой строке долетал и молча ложился в список. Дальше он мог
|
||||
// выбрать дом «Ленина 5», отредактировать номер на 50 — и увидеть под новым
|
||||
// текстом подсказки к старому, с координатами не того дома.
|
||||
//
|
||||
// Список НЕ очищается на каждом такте: раньше он схлопывался на каждое
|
||||
// нажатие клавиши, и клик по уже намеченному пункту промахивался в пустоту.
|
||||
// Старая выдача висит, пока не пришла новая, — она относится к более
|
||||
// короткому префиксу той же строки и остаётся осмысленной.
|
||||
useEffect(() => {
|
||||
if (picked || query.trim().length < SUGGEST_MIN_CHARS) {
|
||||
setSuggestions([]);
|
||||
setSuggestFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
suggestAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
suggestAbort.current = controller;
|
||||
|
||||
fetchAddressSuggestions(query.trim(), {
|
||||
cityHint: city,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((items) => {
|
||||
setSuggestions(items);
|
||||
setSuggestFailed(false);
|
||||
setHighlighted(-1);
|
||||
})
|
||||
.catch(() => {
|
||||
// Молча: подсказки — удобство. Отвалились (в т.ч. по лимиту) —
|
||||
// человек всё ещё может выбрать дом, набрав адрес точнее. Ронять
|
||||
// на этом весь экран было бы хуже, чем остаться без списка.
|
||||
.catch((error) => {
|
||||
// Отменённый нами же запрос — не сбой.
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
// Раньше ошибка глушилась в пустой список, и человек попадал в
|
||||
// тупик: подсказок нет, объяснения нет, а сабмит требует выбрать
|
||||
// дом из подсказок. Теперь говорим прямо.
|
||||
setSuggestions([]);
|
||||
setSuggestFailed(true);
|
||||
});
|
||||
}, SUGGEST_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, city, picked]);
|
||||
|
||||
useEffect(() => () => suggestAbort.current?.abort(), []);
|
||||
|
||||
const choose = useCallback((item: AddressSuggestion) => {
|
||||
setPicked(item);
|
||||
setQuery(item.full_address);
|
||||
|
|
@ -168,7 +206,10 @@ export function EstimateFlow() {
|
|||
event.preventDefault();
|
||||
choose(suggestions[highlighted]);
|
||||
} else if (event.key === "Escape") {
|
||||
// Сбрасываем и подсветку: иначе aria-activedescendant указывает на
|
||||
// элемент, которого в DOM уже нет, и скринридер читает пустоту.
|
||||
setSuggestions([]);
|
||||
setHighlighted(-1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,14 +237,27 @@ export function EstimateFlow() {
|
|||
setFieldError(null);
|
||||
setPhase({ kind: "loading" });
|
||||
|
||||
fetchCoverage({
|
||||
lat: picked.lat,
|
||||
lon: picked.lon,
|
||||
rooms: Number.parseInt(rooms, 10),
|
||||
area_m2: areaValue,
|
||||
})
|
||||
// Таймаут обязателен: без него оборванное соединение оставляет кнопку в
|
||||
// «Смотрим данные…» навсегда — ни результата, ни ошибки, ни возможности
|
||||
// повторить. Выход был только через перезагрузку страницы, а она вдобавок
|
||||
// теряет черновик (он забирается «на вынос» при монтировании).
|
||||
coverageAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
coverageAbort.current = controller;
|
||||
const timeout = setTimeout(() => controller.abort(), COVERAGE_TIMEOUT_MS);
|
||||
|
||||
fetchCoverage(
|
||||
{
|
||||
lat: picked.lat,
|
||||
lon: picked.lon,
|
||||
rooms: Number.parseInt(rooms, 10),
|
||||
area_m2: areaValue,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((probe) => setPhase({ kind: "result", verdict: describeCoverage(probe) }))
|
||||
.catch((error) => setPhase({ kind: "failed", ...failureCopy(error) }));
|
||||
.catch((error) => setPhase({ kind: "failed", ...failureCopy(error) }))
|
||||
.finally(() => clearTimeout(timeout));
|
||||
}
|
||||
|
||||
const showList = suggestions.length > 0 && !picked;
|
||||
|
|
@ -230,7 +284,19 @@ export function EstimateFlow() {
|
|||
</select>
|
||||
</label>
|
||||
|
||||
<div className={styles.estAutocomplete}>
|
||||
{/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и
|
||||
физически перекрывает поля «Комнат» и «Площадь», в которые человек
|
||||
как раз собрался попасть. onBlur на контейнере, а не на инпуте, —
|
||||
чтобы клик по самой подсказке не считался уходом. */}
|
||||
<div
|
||||
className={styles.estAutocomplete}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setSuggestions([]);
|
||||
setHighlighted(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className={styles.heroField} htmlFor={addressId}>
|
||||
<span className={styles.heroFieldLabel}>Улица и дом</span>
|
||||
<input
|
||||
|
|
@ -268,6 +334,11 @@ export function EstimateFlow() {
|
|||
type="button"
|
||||
id={`${listboxId}-${index}`}
|
||||
role="option"
|
||||
// Из списка нельзя «выпасть» табом: иначе после семи
|
||||
// подсказок до поля «Комнат» нужно семь нажатий Tab.
|
||||
// Управление списком — только стрелками, как и положено
|
||||
// комбобоксу.
|
||||
tabIndex={-1}
|
||||
aria-selected={index === highlighted}
|
||||
className={`${styles.estSuggestItem} ${index === highlighted ? styles.estSuggestItemActive : ""}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
|
|
@ -355,6 +426,15 @@ export function EstimateFlow() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{suggestFailed && phase.kind !== "result" && (
|
||||
<div className={styles.heroFormFeedback}>
|
||||
<p className={styles.heroFormFeedbackText}>
|
||||
Подсказки адреса сейчас не отвечают. Это не значит, что вашего
|
||||
дома нет в базе — попробуйте набрать заново через минуту.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === "result" && <CoverageResult verdict={phase.verdict} />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,3 +63,26 @@ export function takeDraft(): EstimateDraft | null {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Привести «Комнат» из черновика к значению, которое реально есть в селекте.
|
||||
*
|
||||
* На лэндинге это поле — свободный текст: туда пишут «студия», «2 комнаты»,
|
||||
* «2-к». Такая строка не совпадает ни с одним `<option>`, селект молча
|
||||
* показывает пустоту (человек этого может не заметить), а на сабмите
|
||||
* `parseInt` даёт NaN → на сервер уходит `rooms: null` → 422, который форма
|
||||
* покажет как «сломалось на нашей стороне». Возвращаем null, если распознать
|
||||
* не удалось: вызывающий оставит значение по умолчанию — оно хотя бы видно и
|
||||
* его можно поправить.
|
||||
*/
|
||||
export function normalizeDraftRooms(
|
||||
raw: string | undefined,
|
||||
allowed: readonly string[],
|
||||
): string | null {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (!value) return null;
|
||||
if (allowed.includes(value)) return value;
|
||||
if (/^студи/i.test(value)) return allowed.includes("0") ? "0" : null;
|
||||
const digits = value.match(/\d+/)?.[0];
|
||||
return digits && allowed.includes(digits) ? digits : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,21 @@ import { SiteHeader } from "../_components/SiteHeader";
|
|||
* это было правдой, пока форма ничего не отправляла. С открытием
|
||||
* бесплатной проверки (`/estimate` → `/api/public/mera/*`) адрес уходит на
|
||||
* сервер, поэтому утверждение заменено на точное: передаётся, используется
|
||||
* однократно, в базах не сохраняется. Последнее проверено по коду, а не
|
||||
* предположено: `suggest()` работает без кэша (докстринг
|
||||
* `services/geocoder.py`), проба покрытия — один SELECT, а `RequestAudit`
|
||||
* пишет строку только при наличии username, которого у анонима нет; в
|
||||
* любом случае адрес едет телом запроса и в аудит не попадает.
|
||||
* однократно, нигде не сохраняется. Каждая половина этого утверждения
|
||||
* обеспечена кодом, а не надеждой:
|
||||
* - БАЗЫ. `suggest()` работает без кэша (докстринг
|
||||
* `services/geocoder.py`), проба покрытия — один SELECT, `RequestAudit`
|
||||
* пишет строку только при наличии username, которого у анонима нет.
|
||||
* - ЖУРНАЛЫ. Геокодер печатал введённую строку открытым текстом, а прод
|
||||
* пишет stdout контейнеров в persistent journald — то есть адрес
|
||||
* ложился на диск рядом с IP того же запроса в access-логе Caddy.
|
||||
* Закрыто фильтром логов на время публичного запроса
|
||||
* (`app/core/public_request.py`).
|
||||
* - МОНИТОРИНГ. sentry_sdk кладёт в событие полное тело запроса, а
|
||||
* брэдкрамб httpx несёт адрес в query внешнего геокодера. Закрыто
|
||||
* `scrub_public_address` (`app/observability/sentry_scrub.py`).
|
||||
* Две последние утечки найдены состязательным ревью 16.08.2026 и
|
||||
* воспроизведены на живом коде — не выведены из чтения.
|
||||
* - Пункт про ПЛАТНЫЙ расчёт остался условным по `PUBLIC_ESTIMATE_ENABLED`:
|
||||
* вот он действительно хранит результат, и его текст обязан меняться
|
||||
* вместе с флагом, а не после.
|
||||
|
|
@ -215,7 +225,8 @@ export default function MeraPublicPrivacyPage() {
|
|||
для формирования подсказок адреса и поиска сопоставимых объявлений и
|
||||
не сохраняется: указанные операции выполняются в режиме чтения и не
|
||||
создают записей об адресе в базах данных Оператора. Адрес передаётся
|
||||
телом запроса и не записывается в журналы веб-сервера. Для
|
||||
телом запроса и не записывается ни в журналы веб-сервера, ни в
|
||||
технические журналы сервиса, ни в систему мониторинга ошибок. Для
|
||||
формирования подсказок адрес может передаваться внешним сервисам
|
||||
геокодирования.
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue