From d046eaada24067b1597304be123dacfdbc13b57e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 22:32:53 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(mera/b2c):=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20API=20=D0=9C=D0=95=D0=A0=D0=AB?= =?UTF-8?q?=20=E2=80=94=20/api/public/mera/{suggest,coverage}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого контура. ## Отдельный префикс, а не проброс кусков /api/v1/* На meraocenka.ru действует allowlist-by-default. Открыть там API можно было двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под которым по определению не лежит ничего закрытого. Выбран второй: при первом одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена ошибки, а не удобство. Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в app/api/public/. Случайно нельзя. ## Ноль записей в БД Обе ручки только читают: /coverage — один SELECT, /suggest — прокси автокомплита. Это условие, при котором публичная форма работает ДО контура согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать будет, открывается только вместе с ним. ## Делегирование, а не копии Обе ручки вызывают те же функции, что обслуживают закрытый контур (v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894 показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там, где платный расчёт видит ноль. Публичный ответ переиспользует CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля». ## Бюджеты Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом тратит единицы на адрес), 15/мин на пробу. ## Проверено 11 тестов, из них структурные: набор ручек под /api/public проверяется на РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака. Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт снят целиком. Мутации: убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed снять бюджет с /coverage → 2 failed / 9 passed откат → 11 passed Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid configuration, ruff чист. Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404. Зелёная только первая проверка = API открыт целиком, а тест этого не заметил. Refs #2894, #2895 --- Caddyfile | 32 ++- scripts/smoke-mera-perimeter.sh | 33 +++ .../backend/app/api/public/__init__.py | 7 + tradein-mvp/backend/app/api/public/mera.py | 149 +++++++++++ tradein-mvp/backend/app/core/rbac.py | 14 ++ tradein-mvp/backend/app/main.py | 7 + .../backend/tests/test_public_mera_api.py | 235 ++++++++++++++++++ 7 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/app/api/public/__init__.py create mode 100644 tradein-mvp/backend/app/api/public/mera.py create mode 100644 tradein-mvp/backend/tests/test_public_mera_api.py diff --git a/Caddyfile b/Caddyfile index 8624f14f..1de54f2e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -317,8 +317,38 @@ meraocenka.ru { } } + # Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до + # бэкенда. Под /api/public/ по определению не лежит ничего закрытого: + # гарантию даёт структура пакета app/api/public/, а не аккуратность этого + # матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий: + # /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже. + # + # ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*. + # Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же + # страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru + # корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь + # потребовал бы там ВТОРОГО handle, выигрывающего у существующего по + # специфичности — то есть работоспособность публичной формы зависела бы от + # порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in + # запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь + # нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools + # публичного домена; он там и так виден на всех чанках Next (basePath). + # + # strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не + # часть маршрута FastAPI. + # + # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130): + # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с + # анонимного домена — расширять доверие без нужды. + handle /trade-in/api/public/* { + uri strip_prefix /trade-in + reverse_proxy tradein-backend:8000 { + header_up -X-Authenticated-User + } + } + # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, - # /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется. + # /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется. handle { respond 404 } diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 99e53ef5..c2b091ea 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -43,6 +43,19 @@ check() { fi } +check_post() { + local desc="$1" url="$2" body="$3" expected="$4" + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ + -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null) + if [ "$code" = "$expected" ]; then + echo "PASS: $desc ($url -> $code)" + else + echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" + fail=1 + fi +} + echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" # 1. Публичный домен отдаёт 200 анонимно. @@ -79,6 +92,26 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API) # Ловит расширение матчера обратно до `/trade-in/_next/*`. check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 +# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* +# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене +# по-прежнему 404. +# +# Пара проверок ниже неразделима: первая доказывает, что форма вообще +# работает, вторая — что новый handle не расширил периметр до +# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это +# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный +# префикс, а не поимённый проброс v1-путей). +check "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 +check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200 +check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 404 +check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404 + +# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда +# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он +# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит +# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile. +check "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 + # 3. B2B-данные trade-in по-прежнему закрыты анониму. # # ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200. diff --git a/tradein-mvp/backend/app/api/public/__init__.py b/tradein-mvp/backend/app/api/public/__init__.py new file mode 100644 index 00000000..6a5ab986 --- /dev/null +++ b/tradein-mvp/backend/app/api/public/__init__.py @@ -0,0 +1,7 @@ +"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru. + +Отдельный пакет, а не ещё один модуль в `app/api/v1/`, — намеренно: всё, что +лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по +дереву каталогов, а не по списку исключений в middleware. Разбор — в +`app/api/public/mera.py`. +""" diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py new file mode 100644 index 00000000..818459f4 --- /dev/null +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -0,0 +1,149 @@ +"""Публичный 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 logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +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.ratelimit import SlidingWindowLimiter, _client_ip +from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес +# с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает +# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг +# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь, +# нажал ещё раз». +_SUGGEST_LIMIT = 40 +_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) + + +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) + + +@router.get("/suggest", response_model=SuggestResponse) +async def public_suggest( + request: Request, + q: Annotated[str, Query(min_length=2, max_length=200)], + db: Annotated[Session, Depends(get_db)], + limit: Annotated[int, Query(ge=1, le=10)] = 8, + city_hint: Annotated[str | None, Query(max_length=100)] = None, +) -> SuggestResponse: + """Автокомплит адреса для публичной формы (Свердловская область). + + Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран + (`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику: + публичная форма обязана резолвить адрес ровно так же, как платный расчёт, + иначе аноним выберет дом, которого потом «не окажется». + + Отличие от v1 ровно одно — потолок `limit` 10 вместо 15: выдача сверх + десятка в публичном UI не показывается, а каждый лишний кандидат может + стоить внешнего вызова. + """ + _enforce(_suggest_limiter, request, "suggest") + return await suggest_addresses(q=q, limit=limit, db=db, city_hint=city_hint) + + +@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) diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 0dd654ff..bf7e7ead 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -86,6 +86,20 @@ _PUBLIC_PATHS = frozenset( # не секрет, читает только process env — быстрая справка для клиента/ # поддержки/смоук-теста, не должна требовать сессию. "/api/v1/trade-in/version", + # Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга + # идентичности нет и не будет — Caddy на этом домене вообще без + # basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не + # пишут в БД адрес физлица, поэтому открываются ДО контура согласия + # 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор + # периметра и бюджетов — app/api/public/mera.py. + # + # Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как + # `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради + # двух путей значит расширить механизм, от которого зависит auth-гейт + # всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого» + # держится на структуре пакета app/api/public/, а не на матчере. + "/api/public/mera/suggest", + "/api/public/mera/coverage", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 8b76910e..8b5dbab2 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -20,6 +20,7 @@ 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, @@ -255,3 +256,9 @@ app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["building 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"]) diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py new file mode 100644 index 00000000..71d3e966 --- /dev/null +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -0,0 +1,235 @@ +"""Публичный B2C-API МЕРЫ — /api/public/mera/* (issue #2911). + +Что здесь запинено и почему именно это: + + 1. ПЕРИМЕТР. Набор маршрутов под /api/public/ проверяется на РАВЕНСТВО, а не + на вхождение. Третья ручка, добавленная сюда без правки этого теста, + уронит его — а решение «эта ручка доступна анониму» не должно приниматься + мимоходом, добавлением файла в каталог. + + 2. СВЯЗКА С rbac. Публичность даёт не расположение файла, а строка в + `rbac._PUBLIC_PATHS`; тест сверяет, что для каждого маршрута она есть, и + наоборот — что `_PUBLIC_PATHS` не содержит /api/public/ путей, которых + больше нет в роутере (иначе освободившийся путь остался бы открытым для + любого, кто его потом займёт). + + 3. УЗОСТЬ ИСКЛЮЧЕНИЯ. Соседний non-public путь в том же приложении обязан + по-прежнему отдавать 401 анониму. Без этой проверки тест «аноним получает + 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт + снят целиком. + + 4. БЮДЖЕТ. 429 после исчерпания per-IP окна — иначе внешний (платный) + геокодер открыт наружу без ограничений. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# Settings требует DATABASE_URL на конструирование — stub до любого app-импорта +# (тот же паттерн, что в остальных tests/*.py). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint (GTK) в CI/Windows нет, а app.api.v1.trade_in тянет его на импорте — +# public/mera.py делегирует туда, значит стаб нужен и здесь. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.api.public import mera as public_mera # noqa: E402 +from app.api.v1.geocode import SuggestResponse # noqa: E402 +from app.core.db import get_db # noqa: E402 +from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402 +from app.schemas.trade_in import CoverageProbeResponse # noqa: E402 + +# Внешний префикс монтирования — ровно тот, что в app/main.py. +PREFIX = "/api/public/mera" + +_COVERAGE_BODY = {"lat": 56.838, "lon": 60.597, "rooms": 2, "area_m2": 54.0} + +_FAKE_COVERAGE = CoverageProbeResponse( + status="ok", + n_listings=34, + median_listing_age_days=44, + n_with_age=6, + radius_m=1000, + city="Екатеринбург", + threshold=10, +) + + +@pytest.fixture(autouse=True) +def _reset_limiters(): + """Лимитеры — состояние МОДУЛЯ, переживающее тест. + + Без сброса порядок тестов начинает влиять на результат: тест бюджета + выбирает окно, а следующий за ним «аноним получает 200» ловит 429 и падает + не по своей вине. + """ + public_mera._suggest_limiter._hits.clear() + public_mera._coverage_limiter._hits.clear() + yield + public_mera._suggest_limiter._hits.clear() + public_mera._coverage_limiter._hits.clear() + + +@pytest.fixture() +def client() -> TestClient: + """Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что регистрирует app/main.py. + + Рядом с публичным роутером намеренно висит закрытый маршрут-двойник: он и + делает проверку 3 (узость исключения) возможной внутри одного приложения. + """ + app = FastAPI() + app.middleware("http")(rbac_guard) + app.include_router(public_mera.router, prefix=PREFIX) + + @app.get("/api/v1/trade-in/coverage") + def _closed_twin() -> dict[str, str]: # pragma: no cover — до хендлера не доходит + return {"ok": "should never be reachable anonymously"} + + def _override_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = _override_db + return TestClient(app) + + +# ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── + + +def test_public_router_exposes_exactly_two_routes() -> None: + paths = {r.path for r in public_mera.router.routes} + assert paths == {"/suggest", "/coverage"}, ( + "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " + "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " + "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." + ) + + +def test_every_public_route_is_exempt_in_rbac() -> None: + for route in public_mera.router.routes: + full = f"{PREFIX}{route.path}" + assert full in _PUBLIC_PATHS, ( + f"{full} смонтирован в публичном пакете, но rbac его не пропускает — " + "анониму прилетит 401, форма на лэндинге молча сломается" + ) + + +def test_rbac_has_no_stale_public_mera_exemptions() -> None: + """Обратное направление: в `_PUBLIC_PATHS` нет /api/public/ путей-призраков. + + Удалённая ручка, чья строка осталась в исключениях, открывает этот путь + заранее — для любого маршрута, который потом займёт то же место. + """ + live = {f"{PREFIX}{r.path}" for r in public_mera.router.routes} + stale = {p for p in _PUBLIC_PATHS if p.startswith("/api/public/")} - live + assert not stale, f"в rbac._PUBLIC_PATHS остались несуществующие пути: {stale}" + + +# ── 3. Аноним проходит, но исключение узкое ────────────────────────────────── + + +def test_anonymous_gets_coverage(client: TestClient) -> None: + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 200, resp.text + assert resp.json()["city"] == "Екатеринбург" + + +def test_anonymous_gets_suggest(client: TestClient) -> None: + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + resp = client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) + assert resp.status_code == 200, resp.text + assert resp.json() == {"items": []} + + +def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None: + """Исключение точечное, а не «auth-гейт снят». + + Без этой проверки тесты выше остаются зелёными и в случае, когда + rbac_guard перестал требовать идентичность вообще. + """ + resp = client.get("/api/v1/trade-in/coverage") + assert resp.status_code == 401, resp.text + + +# ── 4. Бюджеты ─────────────────────────────────────────────────────────────── + + +def test_coverage_rate_limited_per_ip(client: TestClient) -> None: + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + codes = [ + client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY).status_code + for _ in range(public_mera._COVERAGE_LIMIT + 2) + ] + assert codes[: public_mera._COVERAGE_LIMIT] == [200] * public_mera._COVERAGE_LIMIT + assert codes[-1] == 429, f"бюджет не сработал: {codes}" + + +def test_rate_limited_response_carries_retry_after(client: TestClient) -> None: + """429 без Retry-After — это «попробуйте когда-нибудь»: клиент начнёт + долбить сразу же, а лимит существует ровно чтобы этого не было.""" + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + for _ in range(public_mera._COVERAGE_LIMIT): + client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 429 + assert int(resp.headers["Retry-After"]) > 0 + + +def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> None: + """Общий лимитер на две ручки означал бы, что набор адреса (частые + подсказки) выедает бюджет пробы — человек не смог бы нажать кнопку.""" + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + for _ in range(public_mera._SUGGEST_LIMIT): + client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) + + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 200, resp.text + + +# ── Контракт ответа ────────────────────────────────────────────────────────── + + +def test_public_coverage_reuses_v1_response_model(client: TestClient) -> None: + """Публичная ручка отдаёт ТОТ ЖЕ тип, что и v1. + + Это не косметика: на `CoverageProbeResponse` уже стоит гейт «ни одного + price-подобного поля» (tests/test_coverage_probe_endpoint.py). Заведи здесь + отдельную модель — и публичный ответ окажется вне того гейта, а именно он + держит продуктовое правило «бесплатный шаг не отдаёт цену». + """ + route = next(r for r in public_mera.router.routes if r.path == "/coverage") + assert route.response_model is CoverageProbeResponse + + +def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None: + """Потолок limit публичной ручки — 10 (у v1 15). Каждый лишний кандидат + может стоить внешнего (платного) вызова, а публичный UI больше и не + показывает.""" + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + at_ceiling = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 10}) + above = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 15}) + assert at_ceiling.status_code == 200, at_ceiling.text + assert above.status_code == 422, above.text From 7424c283d59914fd3a7774228e002e0587dfe5fd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 16 Aug 2026 09:27:04 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(mera/b2c):=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20=D0=BD=D0=B0=20meraoc?= =?UTF-8?q?enka.ru=20=D0=B8=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Экран проверки — /estimate Проверка квартиры вынесена на собственный адрес: там у автокомплита есть место под список подсказок, а у результата — место рядом с полями. Форма в герое лэндинга осталась входной точкой и уводит сюда, донося набранное через sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом с IP посетителя, а мы на той же странице обещаем ничего не хранить). Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и покрыты тестами: подпись под возрастом обязана говорить «объявление», а не «продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что поменять, — директива «никогда не блокировать вывод». ## Короткие адреса Человек больше не видит /trade-in/mera-public/... — только /, /estimate, /oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы один канонический адрес, старые ссылки живы. Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru. Ссылки эмитятся обычным (PublicLink) — next/link подставляет basePath, и href="/estimate" уехал бы на несуществующий /trade-in/estimate. ## Три дефекта, найденных на живом сайте 1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации — лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест: каждая использованная в CSS переменная обязана быть объявлена. 2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой. Ровно туда вела «Главная» в подвале. 3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря, которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны. ## Периметр и приватность Подсказки переведены на POST: access-лог публичного домена пишет URI целиком, то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает. Метод запинен тестом — это часть обещания, а не стиль. П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно — передаётся, используется однократно, в базах не сохраняется. Последнее проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до платного шага (бесплатная проба не хранит ничего, платный расчёт хранит). ## Проверено vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard 40 файлов, backend 75 passed, caddy validate = Valid configuration. Мутации структурных гейтов: убрать --b2c-accent-text из b2cVars → падает тест палитры убрать /estimate из @meraPages → падает тест маршрутов добавить импорт next/link → падает тест basePath откат → 14 passed Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт. Refs #2894, #2895 --- .forgejo/workflows/ci-tradein.yml | 9 + Caddyfile | 73 ++-- scripts/smoke-mera-perimeter.sh | 40 +- tradein-mvp/backend/app/api/public/mera.py | 36 +- .../backend/tests/test_public_mera_api.py | 21 +- .../__tests__/coverage-copy.test.ts | 95 +++++ .../__tests__/public-perimeter.test.ts | 109 +++++ .../mera-public/_components/AddressForm.tsx | 39 +- .../mera-public/_components/PublicLink.tsx | 38 ++ .../mera-public/_components/SiteFooter.tsx | 31 +- .../_components/estimate/EstimateFlow.tsx | 403 ++++++++++++++++++ .../_components/estimate/EstimateHeader.tsx | 40 ++ .../mera-public/_components/v3/FooterV3.tsx | 44 +- .../_components/v3/FreeCheckCard.tsx | 61 +-- .../mera-public/_components/v3/HeaderV3.tsx | 66 ++- .../_components/v3/StickyCtaV3.tsx | 19 +- .../frontend/src/app/mera-public/content.ts | 89 ++-- .../src/app/mera-public/coverage-copy.ts | 130 ++++++ .../src/app/mera-public/estimate-draft.ts | 65 +++ .../src/app/mera-public/estimate/page.tsx | 61 +++ .../src/app/mera-public/landing-v3.module.css | 127 ++++++ .../frontend/src/app/mera-public/layout.tsx | 4 +- .../src/app/mera-public/oferta/page.tsx | 19 +- .../src/app/mera-public/privacy/page.tsx | 44 +- .../src/app/mera-public/public-api.ts | 150 +++++++ .../src/app/mera-public/refund/page.tsx | 14 +- .../frontend/src/app/mera-public/theme.ts | 48 +++ 27 files changed, 1630 insertions(+), 245 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/PublicLink.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateHeader.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/public-api.ts diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 0fdc2732..ed60c692 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -54,6 +54,15 @@ jobs: - '.forgejo/workflows/ci-tradein.yml' frontend: - 'tradein-mvp/frontend/**' + # Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в + # КОРНЕ репы, но его читает фронтовый тест + # (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет, + # что каждый маршрут публичного сайта действительно раздаётся на + # meraocenka.ru. Без этой строки правка одного лишь Caddyfile не + # запускала бы НИ ОДИН гейт, и удаление короткого адреса из + # allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить» + # стала бы ссылкой в 404. + - 'Caddyfile' - '.forgejo/workflows/ci-tradein.yml' browser: # Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml diff --git a/Caddyfile b/Caddyfile index 1de54f2e..6ae40a86 100644 --- a/Caddyfile +++ b/Caddyfile @@ -251,45 +251,68 @@ meraocenka.ru { } } - # Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ - # самих документов (оферта ссылается на meraocenka.ru/refund, политика - # возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому - # обязаны резолвиться сами по себе, а не только длинным - # /trade-in/mera-public/. Обратное направление тоже рабочее: длинный - # путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что - # то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где - # короткого /oferta нет. + # Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ + # юридических документов (оферта ссылается на meraocenka.ru/refund, + # политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, + # поэтому обязаны резолвиться сами по себе. + # + # ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца, + # 15.08.2026). Раньше навигация внутри сайта ходила по длинным + # /trade-in/mera-public/... — так короткие адреса и длинные существовали + # параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а + # ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во + # фронте — обычный , потому что next/link подставляет basePath). # # `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким # — модератор эквайера открывает ссылку из заявки и видит ровно тот URL, # который в ней указан. Каноничность для поисковиков задана отдельно, через - # `alternates.canonical` на каждой из трёх страниц. + # `alternates.canonical` на каждой странице. # # Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого # site-блока — часть периметра (#2545), и превращать его в «любой корневой - # путь проксируется» ради трёх страниц нельзя. - @meraLegalDocs path /oferta /refund /privacy - handle @meraLegalDocs { + # путь проксируется» нельзя. Новая публичная страница = новая строка здесь + # (и проверка в scripts/smoke-mera-perimeter.sh). + # + # NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина + # техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а + # для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при + # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант + # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на + # «/», и запрос закольцуется. + @meraPages path /estimate /oferta /refund /privacy /v3 + handle @meraPages { rewrite * /trade-in/mera-public{path} reverse_proxy tradein-frontend:3000 { header_up -X-Authenticated-User } } - # Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается - # на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с - # basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle - # ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ - # документ был бы недоступен с публичной страницы. + # Длинные адреса поддерева → 301 на короткие. Один канонический адрес у + # страницы, а не два работающих. # - # Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*. - # B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history) - # под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это — - # в scripts/smoke-mera-perimeter.sh. - handle /trade-in/mera-public/* { - reverse_proxy tradein-frontend:3000 { - header_up -X-Authenticated-User - } + # Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал + # и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301 + # (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что + # канонический адрес один. + # + # ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со + # слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста) + # под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела + # ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде + # 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без. + # + # Query-строка при редиректе не переносится: ни одна из этих страниц + # параметров не принимает (форма проверки шлёт данные телом POST, а + # черновик с лэндинга едет через sessionStorage — специально чтобы адрес + # квартиры не попал в access-лог). + @meraLongRoot path /trade-in/mera-public /trade-in/mera-public/ + handle @meraLongRoot { + redir / permanent + } + + @meraLongSub path_regexp meralong ^/trade-in/mera-public/(.+)$ + handle @meraLongSub { + redir /{re.meralong.1} permanent } # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index c2b091ea..6c433b67 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -3,10 +3,16 @@ # # Проверяет инварианты периметра (см. корневой Caddyfile): # 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). -# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 — -# политика ПДн, на которую ссылается футер. +# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий +# (у страницы один канонический адрес, старые ссылки не ломаются). # 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL # напечатаны внутри самих юридических документов и уходят эквайеру. +# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга. +# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ +# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале +# вела в 404). +# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на +# публичном домене по-прежнему 404. # 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 — # allowlist-by-default, НЕ были случайно проброшены на B2B-дерево # tradein-frontend. Проверяются обе формы — с basePath-префиксом и без. @@ -61,11 +67,13 @@ echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" # 1. Публичный домен отдаёт 200 анонимно. check "meraocenka.ru root — public 200" "$BASE_MERA/" 200 -# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер. -# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно -# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile, -# обязательный по 152-ФЗ документ станет недоступен с публичной страницы. -check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200 +# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а +# не 200: с 15.08.2026 у публичной страницы один канонический адрес. +# Проверка осталась именно здесь, потому что раньше она сторожила +# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при +# переходе на короткие адреса длинные не превратились в 404 (тогда бы +# сломались уже разосланные ссылки). +check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301 # 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно # эти три URL напечатаны внутри самих документов и уходят в заявку @@ -76,6 +84,18 @@ check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200 check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200 check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200 +# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки +# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404. +check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200 + +# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один +# канонический адрес, а старые ссылки и закладки продолжают работать. +# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер +# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в +# подвале v3 вела в 404. +check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301 +check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301 + # 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 @@ -101,16 +121,16 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти # `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это # пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный # префикс, а не поимённый проброс v1-путей). -check "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 +check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200 -check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 404 +check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404 check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404 # Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда # (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он # проходит через уже существующий `handle /trade-in/api/*` — проверка ловит # регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile. -check "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 +check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 # 3. B2B-данные trade-in по-прежнему закрыты анониму. # diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 818459f4..21af11d0 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -60,7 +60,8 @@ from __future__ import annotations import logging from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query, Request +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 @@ -106,16 +107,37 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None limiter.record(ip) -@router.get("/suggest", response_model=SuggestResponse) +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) + + +@router.post("/suggest", response_model=SuggestResponse) async def public_suggest( request: Request, - q: Annotated[str, Query(min_length=2, max_length=200)], + payload: PublicSuggestInput, db: Annotated[Session, Depends(get_db)], - limit: Annotated[int, Query(ge=1, le=10)] = 8, - city_hint: Annotated[str | None, Query(max_length=100)] = None, ) -> 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`), а не повторяет её логику: публичная форма обязана резолвить адрес ровно так же, как платный расчёт, @@ -126,7 +148,9 @@ async def public_suggest( стоить внешнего вызова. """ _enforce(_suggest_limiter, request, "suggest") - return await suggest_addresses(q=q, limit=limit, db=db, city_hint=city_hint) + return await suggest_addresses( + q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint + ) @router.post("/coverage", response_model=CoverageProbeResponse) diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 71d3e966..06b5b031 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -150,11 +150,24 @@ def test_anonymous_gets_suggest(client: TestClient) -> None: "suggest_addresses", AsyncMock(return_value=SuggestResponse(items=[])), ): - resp = client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) + resp = client.post(f"{PREFIX}/suggest", json={"q": "Малышева"}) assert resp.status_code == 200, resp.text assert resp.json() == {"items": []} +def test_suggest_is_post_so_address_never_lands_in_access_log() -> None: + """Адрес едет ТЕЛОМ, а не в query. + + На публичном домене включён access-лог Caddy, и он пишет URI целиком — + `GET /suggest?q=Малышева+51` сохранил бы адрес квартиры в файл рядом с IP + посетителя. На той же странице мы обещаем, что введённый адрес нигде не + сохраняем. Метод — часть этого обещания, поэтому запинен: вернуть GET + «для каноничности» нельзя, не сломав обещание. + """ + route = next(r for r in public_mera.router.routes if r.path == "/suggest") + assert route.methods == {"POST"} + + def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None: """Исключение точечное, а не «auth-гейт снят». @@ -198,7 +211,7 @@ def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> Non AsyncMock(return_value=SuggestResponse(items=[])), ): for _ in range(public_mera._SUGGEST_LIMIT): - client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) + client.post(f"{PREFIX}/suggest", json={"q": "Малышева"}) with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) @@ -229,7 +242,7 @@ def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None: "suggest_addresses", AsyncMock(return_value=SuggestResponse(items=[])), ): - at_ceiling = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 10}) - above = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 15}) + at_ceiling = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 10}) + 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 diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts new file mode 100644 index 00000000..60ea1434 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { describeCoverage } from "../coverage-copy"; +import type { CoverageProbe } from "../public-api"; + +/** + * Что именно мы утверждаем о своих данных перед человеком, который пришёл + * узнать цену квартиры. Здесь не форматирование — здесь честность, поэтому + * тесты сформулированы через утверждения, а не через строки разметки. + */ + +function probe(over: Partial = {}): CoverageProbe { + return { + status: "ok", + n_listings: 34, + median_listing_age_days: 44, + n_with_age: 6, + radius_m: 1000, + city: "Екатеринбург", + threshold: 10, + ...over, + }; +} + +describe("describeCoverage", () => { + it("не молчит ни при одном статусе — директива «никогда не блокировать вывод»", () => { + for (const status of ["ok", "thin", "not_covered"] as const) { + const verdict = describeCoverage(probe({ status, n_listings: status === "not_covered" ? 0 : 34 })); + expect(verdict.title.length, `пустой заголовок при ${status}`).toBeGreaterThan(10); + expect(verdict.lines.length, `нет объяснения при ${status}`).toBeGreaterThan(0); + } + }); + + it("при неизвестном возрасте объявлений плитки нет — ноль вместо «неизвестно» хуже всего", () => { + const verdict = describeCoverage(probe({ median_listing_age_days: null, n_with_age: 2 })); + expect(verdict.tiles).toHaveLength(1); + expect(JSON.stringify(verdict.tiles)).not.toContain("дн."); + }); + + it("подпись под возрастом говорит про объявление, а не про срок продажи", () => { + // Выборка цензурирована: эти квартиры ЕЩЁ продаются, поэтому «за столько + // продаются» — прямая ложь. Формулировка запинена намеренно. + const [, ageTile] = describeCoverage(probe()).tiles; + expect(ageTile.label).toContain("объявление"); + expect(ageTile.label).not.toMatch(/продаётся|продаются|срок продажи|уходят/i); + }); + + it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => { + const verdict = describeCoverage(probe({ status: "not_covered", city: "", n_listings: 0 })); + expect(verdict.offerPaidStep).toBe(false); + expect(verdict.title).toMatch(/вне области/i); + }); + + it("пустая когорта в НАШЕМ городе — это про рынок, а не «сервис не работает»", () => { + const verdict = describeCoverage( + probe({ status: "not_covered", city: "Берёзовский", n_listings: 0 }), + ); + expect(verdict.offerPaidStep).toBe(false); + expect(verdict.lines.join(" ")).toContain("Берёзовский"); + // Подсказка, что делать дальше, обязана быть — иначе это тупик. + expect(verdict.lines.join(" ")).toMatch(/площадь|комнат/i); + }); + + it("тонкая выборка не скрывается: называем и найденное, и порог", () => { + const verdict = describeCoverage(probe({ status: "thin", n_listings: 4, threshold: 10 })); + expect(verdict.offerPaidStep).toBe(true); + expect(verdict.lines.join(" ")).toContain("4"); + expect(verdict.lines.join(" ")).toContain("10"); + }); + + it("склоняет существительное по числу — «1 похожая квартира», не «1 похожих квартир»", () => { + const one = describeCoverage(probe({ n_listings: 1 })).tiles[0].label; + const two = describeCoverage(probe({ n_listings: 2 })).tiles[0].label; + const five = describeCoverage(probe({ n_listings: 5 })).tiles[0].label; + const eleven = describeCoverage(probe({ n_listings: 11 })).tiles[0].label; + + expect(one).toContain("похожая квартира"); + expect(two).toContain("похожие квартиры"); + expect(five).toContain("похожих квартир"); + // 11-14 — исключение из правила последней цифры. + expect(eleven).toContain("похожих квартир"); + }); + + it("радиус показывается в километрах, когда он кратен тысяче метров", () => { + expect(describeCoverage(probe()).tiles[0].label).toContain("1 км"); + expect(describeCoverage(probe({ radius_m: 750 })).tiles[0].label).toContain("750 м"); + }); + + it("ни в одном ответе нет рублей — цену продаёт платный шаг", () => { + for (const status of ["ok", "thin", "not_covered"] as const) { + const verdict = describeCoverage(probe({ status })); + expect(JSON.stringify(verdict)).not.toMatch(/₽|руб/i); + } + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts new file mode 100644 index 00000000..c99f060c --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -0,0 +1,109 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { PUBLIC_ROUTES } from "../content"; +import { b2cVars } from "../theme"; + +/** + * Структурные гейты публичного B2C-периметра. Каждый заведён на РЕАЛЬНО + * случившийся дефект — все три нашёл владелец на живом сайте, а не сборка. + * + * Проверки читают исходники с диска намеренно: то, что здесь ломается, — это + * связь между файлами (CSS ↔ токены, роуты ↔ Caddyfile, компоненты ↔ basePath), + * а не поведение одной функции. Такую связь юнит-тест на модуле не видит. + */ + +const MERA_PUBLIC = path.resolve(__dirname, ".."); +const REPO_ROOT = path.resolve(MERA_PUBLIC, "../../../../.."); + +function collectFiles(dir: string, exts: readonly string[]): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...collectFiles(full, exts)); + } else if (exts.includes(path.extname(full))) { + out.push(full); + } + } + return out; +} + +describe("палитра v3 доезжает до CSS", () => { + /** + * Дефект 15.08.2026: `b2c-tokens.ts` завёл палитру макета, `landing-v3.module.css` + * начал ссылаться на `var(--b2c-*)` — а моста между ними не появилось. + * Переменные не объявлял никто, каскад молча пропускал такие декларации, и + * лэндинг v3 на проде отдавал 200 бесцветным: карточки без фона, кнопки без + * заливки. Ни сборка, ни линт, ни тайпчек этого не видят — заметил владелец. + */ + const css = readFileSync(path.join(MERA_PUBLIC, "landing-v3.module.css"), "utf8"); + const used = new Set( + [...css.matchAll(/var\(\s*(--b2c-[a-z0-9-]+)/g)].map((m) => m[1]), + ); + + it("в CSS вообще есть ссылки на переменные (иначе тест бесполезен)", () => { + expect(used.size).toBeGreaterThan(5); + }); + + it("каждая использованная --b2c-* переменная объявлена в b2cVars", () => { + const declared = new Set(Object.keys(b2cVars)); + const missing = [...used].filter((name) => !declared.has(name)); + expect(missing, `не объявлены — на странице будут пустые значения: ${missing}`).toEqual([]); + }); +}); + +describe("короткие адреса публичного домена", () => { + /** + * Дефект 15.08.2026: подвал v3 вёл «Главную» на `/mera-public`, а Caddy на + * публичном домене раздавал только `/trade-in/mera-public/*` (со слэшем) — + * голый путь падал в catch-all 404. Страница, которой нет в allowlist + * Caddyfile, недоступна независимо от того, что она собралась. + */ + const caddyfile = readFileSync(path.join(REPO_ROOT, "Caddyfile"), "utf8"); + const meraBlock = caddyfile.slice( + caddyfile.indexOf("meraocenka.ru {"), + caddyfile.indexOf("merahome.ru {"), + ); + + it("блок meraocenka.ru найден (иначе проверки ниже пустые)", () => { + expect(meraBlock.length).toBeGreaterThan(500); + }); + + 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); + expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe( + true, + ); + } + }); +}); + +describe("basePath не протекает в публичные ссылки", () => { + /** + * `next/link` подставляет `basePath=/trade-in` в каждый href, поэтому + * `` уехал бы на несуществующий `/trade-in/estimate`. + * Публичное дерево ходит обычным `` (компонент `PublicLink`). + * + * Проверка на импорт, а не на строку `/trade-in` в разметке: именно импорт + * `next/link` — источник подстановки, и именно он вернётся, если кто-то + * добавит ссылку «как везде в проекте». + */ + it("ни один файл под mera-public не импортирует next/link", () => { + const offenders = collectFiles(MERA_PUBLIC, [".ts", ".tsx"]) + .filter((file) => /from\s+["']next\/link["']/.test(readFileSync(file, "utf8"))) + .map((file) => path.relative(MERA_PUBLIC, file)); + + expect( + offenders, + `next/link подставит basePath и ссылка уедет на /trade-in/...: ${offenders}`, + ).toEqual([]); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx index d1eb1e7f..55126344 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx @@ -29,10 +29,14 @@ * (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен * иметь к ним доступа даже теоретически. * - * Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED` - * в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный - * переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с - * его клавиатурной моделью и sr-live-регионом). + * КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт + * ещё не открыли» — анонимных ручек не существовало. Теперь они есть + * (`/api/public/mera/*`), а сама проверка живёт на отдельном экране + * `/estimate`: там у автокомплита есть место под список подсказок, а у + * результата — место рядом с полями. Эта форма осталась входной точкой + * лэндинга и делает ровно две полезные вещи — не пускает дальше без адреса и + * требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную + * цену по одноимённой улице в ЕКБ жителю Нижнего Тагила). */ import { useId, useRef, useState } from "react"; @@ -43,10 +47,11 @@ import { detectCityInText } from "@/lib/city-registry"; import { COVERED_CITIES, PRIMARY_CITY, - PUBLIC_ESTIMATE_ENABLED, + PUBLIC_ROUTES, REGION_NAME, SECONDARY_CITIES, } from "../content"; +import { saveDraft } from "../estimate-draft"; import styles from "../landing.module.css"; /** Значение `, не `next/link`. + * + * ПОЧЕМУ ТАК. В образ запечён `basePath=/trade-in`, и `next/link` подставляет + * его в КАЖДЫЙ href: `` уедет на `/trade-in/estimate`, + * которого не существует. Отключить подстановку для отдельной ссылки нельзя — + * это свойство сборки, а не пропс. Значит короткие адреса публичного домена + * (`/estimate`, `/oferta`, …) выразимы только обычным якорем. + * + * Что теряем: клиентскую навигацию и prefetch. На пяти статических страницах + * это незаметно, а prefetch публичному дереву прямо противопоказан — он + * подтягивал бы чанки страниц, которых анонимному посетителю видеть незачем. + * + * ЗАЧЕМ ОТДЕЛЬНЫЙ КОМПОНЕНТ, А НЕ ПРОСТО ``. Ради типа `to`: он принимает + * только ключи `PUBLIC_ROUTES`, поэтому опечатка в адресе или ссылка на + * страницу, которой нет в Caddy-allowlist публичного домена, не компилируется. + * Именно такие ссылки и ломались раньше — подвал v3 вёл на `/mera-public`, + * который на публичном домене отдавал 404, и заметил это владелец, а не тест. + */ + +type PublicRoute = keyof typeof PUBLIC_ROUTES; + +interface PublicLinkProps + extends Omit, "href"> { + to: PublicRoute; +} + +export function PublicLink({ to, children, ...rest }: PublicLinkProps) { + return ( + + {children} + + ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx index c5d9b6e2..e7f82812 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx @@ -8,24 +8,23 @@ * оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же * набор рендерят оферта и политика ПДн, и разъехаться они не должны. * - * Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не - * по коротким meraocenka.ru/oferta: короткие адреса существуют только на - * публичном хосте (rewrite в Caddy), а это же поддерево открывается и с - * gendsgn.ru/trade-in/mera-public — там короткая ссылка ушла бы в 404. + * ССЫЛКИ НА ДОКУМЕНТЫ — КОРОТКИЕ (изменено 15.08.2026 по решению владельца). + * Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы + * подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот: + * человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в + * адресной строке, и именно короткие адреса напечатаны внутри самих + * документов. Цена решения — переходы в подвале работают только на + * meraocenka.ru; разбор и что с этим делать — в шапке `PUBLIC_ROUTES` + * (content.ts). * * Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без * валидации схемы) и открывается в новой вкладке с rel="noreferrer". */ -import Link from "next/link"; - import { safeUrl } from "@/lib/safeUrl"; import { LEGAL_ENTITY, - OFFER_PATH, - PRIVACY_PATH, - REFUND_PATH, REGION_NAME, SUPPORT_EMAIL, SUPPORT_TELEGRAM_LABEL, @@ -33,6 +32,8 @@ import { } from "../content"; import styles from "../landing.module.css"; +import { PublicLink } from "./PublicLink"; + export function SiteFooter() { const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); const year = new Date().getFullYear(); @@ -79,19 +80,19 @@ export function SiteFooter() {

Документы

  • - + Публичная оферта - +
  • - + Политика возврата - +
  • - + Обработка персональных данных - +
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx new file mode 100644 index 00000000..0f7534c4 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -0,0 +1,403 @@ +"use client"; + +/** + * EstimateFlow — публичная форма проверки квартиры на `meraocenka.ru/estimate`. + * + * ПОЧЕМУ ОТДЕЛЬНАЯ СТРАНИЦА, А НЕ КАРТОЧКА В ГЕРОЕ. Карточка на первом экране + * (`v3/FreeCheckCard.tsx`) продаёт: показывает, что сервис умеет, и уводит + * читать дальше. Здесь человек уже пришёл считать — экран должен принадлежать + * задаче целиком: автокомплит с местом под список подсказок, поля рядом, + * результат на том же месте, без соседних секций, конкурирующих за внимание. + * + * ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ. Бесплатную пробу покрытия: сколько похожих + * квартир продаётся рядом и как долго висят их объявления. Ни одной рублёвой + * цифры — их отдаёт платный шаг, которого пока нет (нужен платёжный контур, + * #2896). Это не заглушка: проба ходит в живой SQL по активным объявлениям и + * отвечает по конкретному дому. + * + * НИЧЕГО НЕ СОХРАНЯЕТСЯ. Обе ручки только читают (см. + * `backend/app/api/public/mera.py`) — адрес, который человек ввёл, никуда не + * пишется. Это и позволяет форме работать ДО контура согласия 152-ФЗ (#2895). + * Появится сохранение — обязано появиться и согласие, раньше запроса, а не + * после. + */ + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +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 { + PublicApiError, + fetchAddressSuggestions, + fetchCoverage, +} from "../../public-api"; +import type { AddressSuggestion } from "../../public-api"; +import styles from "../../landing-v3.module.css"; + +/** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */ +const SUGGEST_DEBOUNCE_MS = 300; +const SUGGEST_MIN_CHARS = 3; + +const ROOM_OPTIONS = [ + { value: 0, label: "Студия" }, + { value: 1, label: "1 комната" }, + { value: 2, label: "2 комнаты" }, + { value: 3, label: "3 комнаты" }, + { value: 4, label: "4 комнаты" }, + { value: 5, label: "5 и больше" }, +] as const; + +type Phase = + | { kind: "form" } + | { kind: "loading" } + | { kind: "result"; verdict: CoverageVerdict } + | { kind: "failed"; title: string; text: string }; + +const FORM: Phase = { kind: "form" }; + +function failureCopy(error: unknown): { title: string; text: string } { + if (error instanceof PublicApiError && error.kind === "rate-limited") { + const wait = error.retryAfterS ? `${error.retryAfterS} сек.` : "минуту"; + return { + title: "Слишком много проверок подряд", + // Инструкция, которую человек может выполнить, — а не «сервис сломался». + text: `Подождите ${wait} и попробуйте снова. Ограничение стоит, чтобы форму не выгребали роботами.`, + }; + } + if (error instanceof PublicApiError && error.kind === "offline") { + return { + title: "Не получилось связаться с сервисом", + text: "Похоже, пропала связь. Проверьте интернет и попробуйте ещё раз — введённое сохранится.", + }; + } + return { + title: "Проверка не прошла", + text: "Что-то сломалось на нашей стороне. Попробуйте ещё раз через пару минут; если повторится — напишите нам, ссылка в подвале.", + }; +} + +export function EstimateFlow() { + const addressId = useId(); + const listboxId = useId(); + const cityId = useId(); + const roomsId = useId(); + const areaId = useId(); + const statusId = useId(); + + const [city, setCity] = useState(PRIMARY_CITY); + const [query, setQuery] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [highlighted, setHighlighted] = useState(-1); + const [picked, setPicked] = useState(null); + const [rooms, setRooms] = useState("2"); + const [area, setArea] = useState(""); + const [phase, setPhase] = useState(FORM); + const [fieldError, setFieldError] = useState<"address" | "area" | null>(null); + + const addressRef = useRef(null); + const areaRef = useRef(null); + const suggestAbort = useRef(null); + + // Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз + // на монтировании; координат в нём нет (и быть не может — на лэндинге нет + // автокомплита), поэтому дом всё равно придётся выбрать из подсказок. + 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); + }, []); + + // Подсказки: debounce + отмена предыдущего запроса. Без отмены ответы + // приходят в произвольном порядке, и в списке может осесть выдача по + // ПРЕДЫДУЩЕЙ строке — человек видит подсказки не к тому, что набрал. + useEffect(() => { + if (picked || query.trim().length < SUGGEST_MIN_CHARS) { + setSuggestions([]); + return; + } + + 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); + setHighlighted(-1); + }) + .catch(() => { + // Молча: подсказки — удобство. Отвалились (в т.ч. по лимиту) — + // человек всё ещё может выбрать дом, набрав адрес точнее. Ронять + // на этом весь экран было бы хуже, чем остаться без списка. + setSuggestions([]); + }); + }, SUGGEST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [query, city, picked]); + + useEffect(() => () => suggestAbort.current?.abort(), []); + + const choose = useCallback((item: AddressSuggestion) => { + setPicked(item); + setQuery(item.full_address); + setSuggestions([]); + setHighlighted(-1); + setFieldError(null); + }, []); + + function handleAddressKeyDown(event: KeyboardEvent) { + if (!suggestions.length) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlighted((i) => (i + 1) % suggestions.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlighted((i) => (i <= 0 ? suggestions.length - 1 : i - 1)); + } else if (event.key === "Enter" && highlighted >= 0) { + event.preventDefault(); + choose(suggestions[highlighted]); + } else if (event.key === "Escape") { + setSuggestions([]); + } + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + // Координаты берём ТОЛЬКО из выбранной подсказки: сама проба ничего не + // геокодирует (см. CoverageProbeInput на бэке). Набранная руками строка + // без выбора из списка — это адрес без координат, считать по нему нечего. + if (!picked) { + setFieldError("address"); + setPhase(FORM); + addressRef.current?.focus(); + return; + } + + const areaValue = Number.parseFloat(area.replace(",", ".")); + if (!Number.isFinite(areaValue) || areaValue <= 10 || areaValue >= 500) { + setFieldError("area"); + setPhase(FORM); + areaRef.current?.focus(); + return; + } + + setFieldError(null); + setPhase({ kind: "loading" }); + + fetchCoverage({ + lat: picked.lat, + lon: picked.lon, + rooms: Number.parseInt(rooms, 10), + area_m2: areaValue, + }) + .then((probe) => setPhase({ kind: "result", verdict: describeCoverage(probe) })) + .catch((error) => setPhase({ kind: "failed", ...failureCopy(error) })); + } + + const showList = suggestions.length > 0 && !picked; + + return ( +
+
+ + +
+ + + {showList && ( +
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ + + +
+ + +

+ БЕСПЛАТНО · БЕЗ РЕГИСТРАЦИИ · НИЧЕГО НЕ СОХРАНЯЕМ +

+
+ + {/* Живая область постоянно в DOM: регион, добавленный в момент ответа, + часть скринридеров не озвучивает. */} +
+ {fieldError === "address" && ( +
+

+ Выберите дом из подсказок — по ним мы знаем точные координаты. +

+
+ )} + {fieldError === "area" && ( +
+

+ Укажите площадь квартиры — от 11 до 499 м². +

+
+ )} + + {phase.kind === "failed" && ( +
+

{phase.title}

+

{phase.text}

+
+ )} + + {phase.kind === "result" && } +
+
+ ); +} + +function CoverageResult({ verdict }: { verdict: CoverageVerdict }) { + return ( +
+

+ {verdict.tone === "none" ? "ПРОВЕРИЛИ" : "ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ"} +

+

{verdict.title}

+ + {verdict.tiles.length > 0 && ( +
+ {verdict.tiles.map((tile) => ( +
+
{tile.value}
+
{tile.label}
+
+ ))} +
+ )} + + {verdict.lines.map((line) => ( +

+ {line} +

+ ))} + + {verdict.offerPaidStep && ( +
+

+ Цена и срок продажи — следующий шаг +

+

+ Полный расчёт стоит 150 ₽: диапазон цены, цена за квадратный + метр и список квартир, на которых он построен. Приём оплаты сейчас + подключается — как только заработает, кнопка появится здесь. +

+
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateHeader.tsx new file mode 100644 index 00000000..c1bc65ca --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateHeader.tsx @@ -0,0 +1,40 @@ +import { B2B_URL } from "../../content"; +import styles from "../../landing-v3.module.css"; +import { PublicLink } from "../PublicLink"; + +/** + * Шапка страницы проверки — намеренно короче лэндинговой (`v3/HeaderV3.tsx`). + * + * Из неё убраны навигация по секциям лэндинга, селектор города и CTA + * «Проверить за 30 секунд». Причина у всех трёх одна: человек уже на экране + * проверки. Якоря вроде `#accuracy` тут просто не на что навести (секций нет + * на этой странице), город выбирается прямо в форме — а второй селектор + * города в шапке означал бы два места, задающих одно значение, — а CTA вёл бы + * на страницу, где посетитель уже стоит. + * + * Логотип отсюда — ссылка на главную: это единственный обратный путь на + * лэндинг, и он же то, что человек инстинктивно нажимает. + */ +export function EstimateHeader() { + return ( +
+
+ + + + +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx index 1ff10d5d..e8087ab1 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx @@ -4,7 +4,7 @@ * * РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН * 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`, - * `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор, + * `SUPPORT_EMAIL`, короткие адреса документов (`PUBLIC_ROUTES`) — тот же набор, * что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в * другой сетке. Документы включают Политику возврата и почту поддержки: * макет их не показывал, а это обязательные элементы платного контура @@ -15,27 +15,27 @@ * оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе». * * «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3): - * раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на - * этой же странице) — отдельного маршрута отчёта не существует. Оба - * внутренних якоря — через `/mera-public#...`: подвал рендерится и на - * оферте/возврате/ПДн, где голый `#check` никуда не долистал бы. + * раздела нет в коде. + * + * ССЫЛКИ — КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` — + * отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса» + * стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг, + * то есть пункт не работал. Все переходы — обычным `` через `PublicLink`, + * потому что `next/link` подставляет basePath (см. его шапку). */ -import Link from "next/link"; - import { safeUrl } from "@/lib/safeUrl"; import { + B2B_URL, LEGAL_ENTITY, - OFFER_PATH, - PRIVACY_PATH, - REFUND_PATH, REGION_NAME, SUPPORT_EMAIL, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, } from "../../content"; import styles from "../../landing-v3.module.css"; +import { PublicLink } from "../PublicLink"; export function FooterV3() { const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); @@ -58,12 +58,12 @@ export function FooterV3() { @@ -106,15 +106,15 @@ export function FooterV3() {

Документы

- + Публичная оферта - - + + Политика возврата - - + + Обработка персональных данных - +
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx index 2adf9381..67c632f6 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -4,17 +4,13 @@ * FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3, * `МЕРА для себя v3.dc.html` строки ~129-170). * - * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена - * `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине: - * `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на - * `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в - * `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне - * границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей - * на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного - * сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом - * сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый - * адрес — не продающая формулировка, а обман конкретного человека (см. шапку - * `../../marketing-v3.ts`). + * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где + * живёт сама проверка. Макетный сценарий «спиннер 1100 мс → готовый + * результат прямо в карточке» не воспроизводится и не будет: результату нужно + * место рядом с полями, а автокомплиту — место под список; в карточке первого + * экрана нет ни того, ни другого. Раньше здесь стояло сообщение «проверка ещё + * не открыта» — анонимных ручек не существовало; теперь они есть + * (`/api/public/mera/*`). * * Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`, * `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют @@ -29,7 +25,8 @@ import { useId, useRef, useState } from "react"; import type { FormEvent } from "react"; -import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content"; +import { PRIMARY_CITY, PUBLIC_ROUTES } from "../../content"; +import { saveDraft } from "../../estimate-draft"; import styles from "../../landing-v3.module.css"; /** Три состояния квартиры — дословно из макета (строка 734 исходника). */ @@ -38,10 +35,7 @@ type Condition = (typeof CONDITIONS)[number]; type FieldName = "address" | "area" | "rooms" | "floor" | "condition"; -type Feedback = - | { kind: "none" } - | { kind: "error"; field: FieldName; text: string } - | { kind: "info"; title: string; lines: readonly string[] }; +type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string }; const NONE: Feedback = { kind: "none" }; @@ -128,22 +122,17 @@ export function FreeCheckCard() { return; } - if (PUBLIC_ESTIMATE_ENABLED) { - // TODO(backend-периметр): здесь появится реальный запрос к - // POST /trade-in/coverage. По успешному ответу — переключить карточку в - // ветку результата и отрендерить `FreeResultV3` с полученными - // analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR. - return; - } - - setFeedback({ - kind: "info", - title: "Бесплатную проверку по адресу мы пока не открыли", - lines: [ - `Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`, - "Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.", - ], + // Уходим на отдельный экран проверки — там живёт автокомплит и результат. + // Черновиком отдаём только то, что использует бесплатная проба покрытия: + // адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см. + // CoverageProbeInput на бэке) — их спросит платный шаг, когда появится, + // и спросит на том же экране, а не тащит через переход. + saveDraft({ + address: trimmedAddress, + rooms: trimmedRooms, + area: trimmedArea, }); + window.location.assign(PUBLIC_ROUTES.estimate); } const errorField = feedback.kind === "error" ? feedback.field : null; @@ -285,16 +274,6 @@ export function FreeCheckCard() {

{feedback.text}

)} - {feedback.kind === "info" && ( -
-

{feedback.title}

- {feedback.lines.map((line) => ( -

- {line} -

- ))} -
- )} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx index 3eebe677..9eb91105 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx @@ -2,28 +2,29 @@ * HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого + * навигация + селектор города + CTA. * - * Логотип НЕ ссылка на "/": next.config.ts редиректит "/" → "/v2" (закрытый - * B2B-контур за RouteGuard) — клик по вордмарку выкинул бы анонимного - * посетителя лэндинга в приложение, требующее логина (тот же комментарий, что - * в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже — НАОБОРОТ, - * намеренно ведёт на "/": это единственная ссылка в шапке, которая должна - * увести в закрытый контур. + * Логотип НЕ ссылка: на публичном домене корень — это сам лэндинг, то есть + * ссылка вела бы на текущую же страницу. * - * «Статьи» — раздела ещё нет в коде (ни маршрута, ни контента): пункт - * навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить - * ссылку в 404 на первой же публичной странице. + * НЕАКТИВНЫЕ ПУНКТЫ. «Статьи», «Проверьте себя» и «Продажа под ключ» — + * разделов нет в коде: ни маршрута, ни секции. Раньше три якоря вели на + * `/mera-public#game` и `#turnkey`, которых не существует НИГДЕ, — то есть + * клик уводил на другую страницу и там ничего не находил. Пункты приведены к + * тому же виду, что «Статьи»: видны, но не кликабельны, пока раздела нет. * - * Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка - * рендерится на каждой странице поддерева (оферта/возврат/ПДн через - * layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не - * долистал. + * ЯКОРЯ — голые (`#accuracy`, `#check`), потому что эта шапка рендерится + * только на странице, где эти секции и лежат. На юридических страницах стоит + * другая шапка (`_components/SiteHeader.tsx`), на `/estimate` — своя, + * укороченная (`_components/estimate/EstimateHeader.tsx`): туда переносить + * навигацию по секциям лэндинга незачем, там человек уже пришёл считать. + * + * «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на + * публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе. * * Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск). * Сама шапка серверная. */ -import Link from "next/link"; - +import { B2B_URL } from "../../content"; import styles from "../../landing-v3.module.css"; import { CityPicker } from "./CityPicker"; @@ -43,32 +44,29 @@ export function HeaderV3() { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx index d7b11be0..eb957cf4 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx @@ -1,12 +1,11 @@ /** * StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521). * - * Кнопка — обычная ссылка на `/mera-public#check` (форма расчёта в Hero), а - * не модалка: в этом заходе модального окна нет вовсе (макет вызывал - * `openModal`, которого в коде не существует). Путь с префиксом, а не голый - * `#check`: панель рендерится на каждой странице поддерева (оферта/возврат/ - * ПДн через layout.tsx), и там `#check` без пути к лэндингу никуда бы не - * долистал. + * Кнопка ведёт на `/estimate` — отдельный экран проверки, а не на якорь формы + * в герое и не в модалку (макет вызывал `openModal`, которого в коде нет). + * Панель липкая и видна в том числе внизу страницы, где формы уже не видно, — + * якорь заставлял бы человека прокручивать обратно наверх ради полей, которые + * на отдельном экране и так первое, что он увидит. * * `.barSpacer` — не декоративный отступ, а компенсация fixed-позиционирования: * `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг @@ -17,10 +16,10 @@ * после подвала, как Header/Footer в текущем `layout.tsx`). */ -import Link from "next/link"; - import styles from "../../landing-v3.module.css"; +import { PublicLink } from "../PublicLink"; + export function StickyCtaV3() { return ( <> @@ -29,9 +28,9 @@ export function StickyCtaV3() {

Узнайте цену и срок продажи своей квартиры

- + Проверить за 30 секунд - + ); diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index c4ab622d..77d86fa2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -34,30 +34,29 @@ import { // --------------------------------------------------------------------------- /** - * Включён ли публичный расчёт по адресу. + * Включён ли ПЛАТНЫЙ публичный расчёт по адресу. * - * Сегодня `false` и это не «недоделка фронта»: анонимный запрос к - * `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` отбивается - * `rbac_guard` (backend/app/core/rbac.py::_PUBLIC_PATHS — эти пути в белом - * списке отсутствуют), т.е. без отдельного backend-PR любая «живая» форма на - * лэндинге отдавала бы 401. Форма поэтому честно сообщает, что расчёт ещё не - * открыт, вместо имитации загрузки. - * - * Когда бэкенд откроет анонимный периметр — переключить в `true` и подключить - * реальный сабмит в `_components/AddressForm.tsx` (там помечено TODO-местом). + * ⚠️ ЗНАЧЕНИЕ ФЛАГА СУЗИЛОСЬ 15.08.2026. Раньше он означал «публичная форма + * вообще что-нибудь делает»: анонимных ручек не существовало, `rbac_guard` + * отбивал любой запрос с лэндинга, и форма честно писала «расчёт не открыт». + * Теперь бесплатная часть ОТКРЫТА — `/estimate` ходит в `/api/public/mera/*` + * (см. `backend/app/api/public/mera.py`) и показывает живую картину рынка + * вокруг дома. Флаг остался только на платном шаге, и это правильное место + * для него: бесплатная проба ничего не хранит, а платный расчёт хранит. * * ⚠️ ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные * утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть * сделаны: - * 1. `privacy/page.tsx`, раздел «Что делает эта страница» — он УЖЕ условный по - * этому флагу (ветка `true` описывает отправку и сохранение адреса); - * перечитать текст обеих веток перед включением. + * 1. `privacy/page.tsx`: п. 5.5 условен по этому флагу и утверждает, что + * платный расчёт не открыт. Перечитать раздел 5 целиком — п. 5.4 уже + * описывает бесплатную часть и переписан под неё. * 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в * `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого * согласия (`address` NOT NULL, `expires_at` применяется только на чтении). - * 3. Должен существовать реальный путь удаления данных — в бэкенде нет ни - * DELETE-джоба в `app/tasks/**`, ни ручки erasure (проверено grep'ом); - * privacy-страница поэтому и не обещает удаление. + * К бесплатной пробе это не относится — она не пишет вовсе. + * 3. Автоматический возврат средств обязан появиться ВМЕСТЕ с кнопкой + * оплаты, а не после: он уже обещан в опубликованной политике возврата + * (пп. 2.1 и 4), то есть является условием договора с момента публикации. */ export const PUBLIC_ESTIMATE_ENABLED: boolean = false; @@ -151,11 +150,12 @@ export const LEGAL_ENTITY: { * (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих * документах и уйдут в заявку эквайеру. * - * Навигация внутри сайта ходит по внутренним путям, а не по коротким: короткий - * `/oferta` существует только на хосте meraocenka.ru, а то же поддерево - * открывается и с gendsgn.ru/trade-in/mera-public — там ссылка на `/oferta` - * ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой - * странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже). + * ⚠️ НАВИГАЦИЯ ПО НИМ БОЛЬШЕ НЕ ХОДИТ (решение владельца, 15.08.2026): + * человек не должен видеть `/trade-in/mera-public/...` в адресной строке + * публичного сервиса. Ссылки внутри публичных страниц ведут на короткие + * адреса — см. `PUBLIC_ROUTES` ниже. Эти константы остались нужны для двух + * вещей: цель Caddy-rewrite'ов описывается ими же, и на них ссылается B2B + * (чекбокс согласия в v2 LeadForm.tsx), живущий на другом домене. */ // PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же // путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым @@ -164,13 +164,54 @@ export { PRIVACY_PATH } from "@/lib/legal-copy"; export const OFFER_PATH = "/mera-public/oferta"; export const REFUND_PATH = "/mera-public/refund"; +/** + * КОРОТКИЕ АДРЕСА ПУБЛИЧНОГО САЙТА — единственное, что должно попадать в + * ссылки на страницах B2C-периметра. + * + * ПОЧЕМУ НЕ `next/link` С ЭТИМИ ЗНАЧЕНИЯМИ. `basePath=/trade-in` запечён в + * образ, и `` эмитит `/trade-in/estimate` — путь, + * которого не существует. Обойти basePath через Link нельзя, поэтому + * публичные страницы ходят обычным `` (компонент `PublicLink`, см. + * `_components/PublicLink.tsx`). Полная перезагрузка на пяти статических + * страницах ничего не стоит, а router-prefetch публичному дереву даже вреден: + * он тянул бы чанки, которых анонимному посетителю видеть незачем. + * + * ЧЕМ ЗА ЭТО ПЛАТИМ. Короткие пути раздаёт ТОЛЬКО хост meraocenka.ru. То же + * поддерево открывается и с `gendsgn.ru/trade-in/mera-public` — там переходы + * по этим ссылкам уедут в чужой сайт (`/` — корень Site Finder) или в 404. + * Это принято сознательно: публичный сервис живёт на своём домене, и делать + * его адреса некрасивыми ради QA-открытия с соседнего хоста — не та цена. + * Открывать лэндинг для проверки нужно на meraocenka.ru (он публичный, логин + * не требуется). Длинные адреса продолжают работать: Caddy отдаёт с них 301 + * на короткие, так что старые ссылки и закладки не ломаются. + */ +export const PUBLIC_ROUTES = { + home: "/", + estimate: "/estimate", + oferta: "/oferta", + refund: "/refund", + privacy: "/privacy", +} as const; + /** Канонический публичный origin сервиса — база для canonical-ссылок. */ export const PUBLIC_ORIGIN = "https://meraocenka.ru"; +/** + * B2B-контур («МЕРА для бизнеса») — АБСОЛЮТНЫЙ адрес, а не относительный путь. + * + * Он живёт на другом домене. Относительная ссылка `/` (как было в подвале и + * шапке v3) на публичном домене вела на сам же лэндинг или в 404 — то есть + * пункт меню «для бизнеса» не работал вовсе. Домен здесь захардкожен + * осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её + * из текущего хоста было бы неверно. + */ +export const B2B_URL = "https://gendsgn.ru/trade-in/"; + /** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */ -export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}/privacy`; -export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}/oferta`; -export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}/refund`; +export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.privacy}`; +export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.oferta}`; +export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.refund}`; +export const PUBLIC_ESTIMATE_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.estimate}`; /** * Цена одного расчёта, ₽. Названа в оферте (п. 4.1) и в политике возврата — diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts new file mode 100644 index 00000000..aa0e09f6 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -0,0 +1,130 @@ +/** + * Проба покрытия → то, что видит человек. + * + * Вынесено из компонента отдельной чистой функцией сознательно: это не + * форматирование, а место, где решается, что именно мы утверждаем о наших + * данных. Такое проверяется тестами напрямую (`__tests__/coverage-copy.test.ts`), + * а не через рендер формы. + * + * ДВА ПРАВИЛА, КОТОРЫЕ ЗДЕСЬ ЗАЩИЩАЮТСЯ + * + * 1. «Никогда не блокировать вывод» (директива владельца, 2026-08-10). Мало + * данных — говорим прямо и предлагаем следующий шаг, а не показываем + * экран-заглушку. Пустой экран человек читает как «сервис сломан», а не + * как «по этому дому данных нет». + * + * 2. Честное имя величины. `median_listing_age_days` — возраст АКТИВНОГО + * объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё + * продаются, реальная экспозиция проданных всегда меньше. Написать под + * цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и + * тестом запинена. И при `median === null` (бэкенд занулил её, когда + * возраст известен меньше чем по пяти объявлениям) плитки просто нет — + * ноль вместо неизвестного значения был бы худшей из ошибок. + */ + +import type { CoverageProbe } from "./public-api"; + +export interface CoverageTile { + value: string; + label: string; +} + +export interface CoverageVerdict { + /** Влияет только на оформление (цвет плашки), не на смысл. */ + tone: "ok" | "thin" | "none"; + title: string; + /** Абзацы под заголовком. */ + lines: readonly string[]; + tiles: readonly CoverageTile[]; + /** Показывать ли блок платного шага. */ + offerPaidStep: boolean; +} + +function pluralize(n: number, one: string, few: string, many: string): string { + const mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return many; + const mod10 = n % 10; + if (mod10 === 1) return one; + if (mod10 >= 2 && mod10 <= 4) return few; + return many; +} + +function radiusLabel(radiusM: number): string { + return radiusM % 1000 === 0 + ? `${radiusM / 1000} км` + : `${radiusM} м`; +} + +function tilesFor(probe: CoverageProbe): CoverageTile[] { + const tiles: CoverageTile[] = [ + { + value: String(probe.n_listings), + label: `${pluralize(probe.n_listings, "похожая квартира", "похожие квартиры", "похожих квартир")} продаётся в радиусе ${radiusLabel(probe.radius_m)}`, + }, + ]; + + if (probe.median_listing_age_days !== null) { + tiles.push({ + value: `${probe.median_listing_age_days} дн.`, + // Формулировка запинена тестом — см. шапку модуля. + label: `столько в среднем висит объявление из этих ${probe.n_with_age}`, + }); + } + + return tiles; +} + +export function describeCoverage(probe: CoverageProbe): CoverageVerdict { + const where = probe.city ? `в городе ${probe.city}` : "по этому адресу"; + + if (probe.status === "not_covered") { + // Город не резолвится ни в один из тех, по которым мы собираем данные. + if (!probe.city) { + return { + tone: "none", + title: "Этот адрес вне области, по которой мы собираем данные", + lines: [ + "Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг. По другим регионам данных у нас нет — и придумывать цифру мы не станем.", + "Проверьте, что в адресе указан нужный город, или попробуйте другой дом.", + ], + tiles: [], + offerPaidStep: false, + }; + } + + // Город наш, но когорта пустая. + return { + tone: "none", + title: "Рядом сейчас не продаётся ничего похожего", + lines: [ + `Мы искали квартиры ${where} — в радиусе ${radiusLabel(probe.radius_m)} от дома, с тем же числом комнат и близкой площадью. Активных объявлений под эти условия не нашлось.`, + "Это говорит о рынке, а не о вашей квартире: в тихом районе или по редкой планировке такое обычное дело. Попробуйте изменить площадь или число комнат — посмотрим, что есть по соседству.", + ], + tiles: [], + offerPaidStep: false, + }; + } + + if (probe.status === "thin") { + return { + tone: "thin", + title: "Данные есть, но их мало", + lines: [ + `${where.charAt(0).toUpperCase()}${where.slice(1)} мы нашли ${probe.n_listings} ${pluralize(probe.n_listings, "подходящее объявление", "подходящих объявления", "подходящих объявлений")} — меньше, чем ${probe.threshold}, с которых мы считаем выборку уверенной.`, + "Расчёт по такой выборке мы сделаем, но разброс будет шире обычного, и в самом отчёте это будет написано — а не спрятано.", + ], + tiles: tilesFor(probe), + offerPaidStep: true, + }; + } + + return { + tone: "ok", + title: "По вашему дому данные есть", + lines: [ + `${where.charAt(0).toUpperCase()}${where.slice(1)} нашлось достаточно похожих квартир, чтобы считать по ним, а не по среднему за квадратный метр.`, + ], + tiles: tilesFor(probe), + offerPaidStep: true, + }; +} diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts new file mode 100644 index 00000000..105083aa --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts @@ -0,0 +1,65 @@ +/** + * Черновик заявки: то, что человек уже набрал на лэндинге, чтобы не набирать + * это заново на `/estimate`. + * + * ПОЧЕМУ sessionStorage, А НЕ QUERY-ПАРАМЕТРЫ. Очевидный способ — уйти на + * `/estimate?address=Ленина+5`. Так делать нельзя: адрес квартиры в URL + * попадёт в access-логи Caddy (`/var/log/caddy/meraocenka.ru.log`) рядом с IP + * посетителя — то есть мы начнём хранить персональные данные ровно там, где + * публично обещали ничего не хранить, и до всякого согласия (#2895). + * sessionStorage не уходит с машины вообще: ни в запрос, ни в лог, ни в + * Referer соседнего сайта. Живёт до закрытия вкладки — ровно столько, сколько + * нужно для перехода между двумя страницами. + * + * Не заводить здесь ничего сверх этих трёх полей: чем больше сюда положить, + * тем сильнее соблазн начать хранить черновики «на подольше». + */ + +const KEY = "mera:estimate-draft"; + +export interface EstimateDraft { + address: string; + /** Строкой — это ровно то, что было в поле ввода, без домыслов о числе. */ + rooms?: string; + area?: string; + city?: string; +} + +/** + * Все обращения обёрнуты: в приватном режиме Safari и при выключенном + * хранилище `sessionStorage` бросает на записи, а на чтении может вернуть + * мусор от постороннего кода. Черновик — удобство; уронить из-за него страницу + * нельзя. + */ +export function saveDraft(draft: EstimateDraft): void { + try { + window.sessionStorage.setItem(KEY, JSON.stringify(draft)); + } catch { + // Не сохранился — человек просто наберёт адрес заново. + } +} + +export function takeDraft(): EstimateDraft | null { + try { + const raw = window.sessionStorage.getItem(KEY); + if (!raw) return null; + // Забираем «на вынос»: черновик нужен ровно на один переход. Иначе + // возврат на /estimate через неделю в той же вкладке подставил бы чужой + // по смыслу адрес. + window.sessionStorage.removeItem(KEY); + + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const value = parsed as Record; + if (typeof value.address !== "string") return null; + + return { + address: value.address, + rooms: typeof value.rooms === "string" ? value.rooms : undefined, + area: typeof value.area === "string" ? value.area : undefined, + city: typeof value.city === "string" ? value.city : undefined, + }; + } catch { + return null; + } +} diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx b/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx new file mode 100644 index 00000000..a2545770 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata } from "next"; + +import { EstimateFlow } from "../_components/estimate/EstimateFlow"; +import { EstimateHeader } from "../_components/estimate/EstimateHeader"; +import { FooterV3 } from "../_components/v3/FooterV3"; +import { PUBLIC_ESTIMATE_URL } from "../content"; +import styles from "../landing-v3.module.css"; + +/** + * Страница проверки квартиры — `meraocenka.ru/estimate`. + * + * ЗАЧЕМ ОТДЕЛЬНЫМ ЭКРАНОМ. Лэндинг и расчёт решают разные задачи. Лэндинг + * объясняет и убеждает, поэтому форма там — витрина: короткая, в окружении + * секций, которые за неё же и агитируют. Пришедший считать человек в этом + * окружении мешает сам себе: подсказкам адреса нужно место под список, + * результату — место рядом с полями, а соседние секции в этот момент только + * отвлекают. Отсюда отдельный адрес, на который ведут все кнопки лэндинга. + * + * Побочный выигрыш — этот URL можно дать человеку напрямую (в переписке, в + * рекламе), минуя чтение лэндинга. + * + * NOINDEX — по той же причине, что и на остальных страницах B2C-дерева: + * маркетинговые метрики на лэндинге пока плейсхолдеры из макета (#2904), а + * приём оплаты не подключён. Снимать — вместе с ними, одним решением. + */ + +export const metadata: Metadata = { + title: "Проверить квартиру — МЕРА", + description: + "Бесплатно посмотреть, сколько похожих квартир продаётся рядом с вашим домом и как долго висят их объявления.", + alternates: { canonical: PUBLIC_ESTIMATE_URL }, + robots: { index: false, follow: false }, +}; + +export default function MeraEstimatePage() { + return ( + <> + +
+
+

БЕСПЛАТНАЯ ПРОВЕРКА

+

Что происходит с ценами рядом с вашим домом

+

+ Укажите дом, число комнат и площадь — покажем, сколько похожих + квартир сейчас продаётся вокруг и как долго они висят в продаже. Без + регистрации, без звонков; введённый адрес мы никуда не сохраняем. +

+ + + +

+ Это рыночная справка по сопоставимым объектам, а не отчёт об оценке + по 135-ФЗ: для банка, суда, опеки или нотариуса нужен отчёт + аккредитованного оценщика. +

+
+
+ + + ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index 8dd70538..a55b1c92 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -1355,3 +1355,130 @@ opacity: 0.72; margin-top: 8px; } + +/* ========================================================================== + * Страница проверки квартиры (/estimate) — префикс est. + * Секция = estimate/page.tsx + _components/estimate/EstimateFlow.tsx. + * + * Переиспользует поля и плитки результата из блока «Герой» (.hero*): это те же + * элементы того же макета, и разводить вторую копию их оформления значило бы + * завести два места, где живёт вид одного и того же поля ввода. Здесь — + * только то, чего в герое нет: шапка страницы, выпадашка автокомплита и + * блок платного шага. + * ========================================================================== */ + +.estSection { + background: var(--b2c-page-bg); + padding-block: clamp(28px, 5vw, 64px) clamp(48px, 8vw, 96px); + min-height: 60vh; +} + +.estContainer { + max-width: 760px; + margin-inline: auto; + padding-inline: clamp(20px, 5vw, 56px); +} + +.estTitle { + margin: 10px 0 0; + font-size: clamp(28px, 4.4vw, 44px); + line-height: 1.1; + letter-spacing: -0.025em; + font-weight: 800; + color: var(--b2c-ink); + text-wrap: balance; +} + +.estLead { + margin: 14px 0 0; + font-size: clamp(15px, 1.6vw, 17px); + line-height: 1.65; + color: var(--b2c-muted); + max-width: 60ch; +} + +.estCard { + margin-top: clamp(24px, 4vw, 36px); + background: var(--b2c-surface); + border: 1px solid var(--b2c-line); + border-radius: 14px; + padding: clamp(18px, 3vw, 28px); + box-shadow: 0 24px 64px color-mix(in srgb, var(--b2c-ink) 8%, transparent); +} + +.estDisclaimer { + margin-top: clamp(20px, 3vw, 28px); + font-size: 13.5px; + line-height: 1.6; + color: var(--b2c-muted); +} + +/* --- Автокомплит --------------------------------------------------------- + * Выпадашка позиционируется абсолютно: список, раздвигающий форму, смещает + * поля под курсором на каждое нажатие клавиши — попасть мышью в подсказку + * становится нельзя. */ +.estAutocomplete { + position: relative; +} + +.estSuggestList { + position: absolute; + z-index: 20; + inset-inline: 0; + top: 100%; + margin: 4px 0 0; + padding: 4px; + list-style: none; + background: var(--b2c-surface); + border: 1px solid var(--b2c-line); + border-radius: 10px; + box-shadow: 0 18px 40px color-mix(in srgb, var(--b2c-ink) 14%, transparent); + max-height: 320px; + overflow-y: auto; +} + +.estSuggestItem { + display: block; + width: 100%; + text-align: start; + padding: 9px 11px; + border: 0; + border-radius: 7px; + background: none; + cursor: pointer; + font: inherit; + color: var(--b2c-ink); +} + +.estSuggestItem:hover, +.estSuggestItemActive { + background: var(--b2c-surface-soft); +} + +.estSuggestMain { + display: block; + font-size: 14.5px; + font-weight: 600; +} + +.estSuggestSub { + display: block; + margin-top: 2px; + font-size: 12.5px; + color: var(--b2c-muted); +} + +/* --- Результат ----------------------------------------------------------- */ +.estResult { + margin-top: clamp(18px, 3vw, 26px); + padding-top: clamp(18px, 3vw, 24px); + border-top: 1px solid var(--b2c-line); +} + +.estPaidStep { + margin-top: 18px; + padding: 16px 18px; + border: 1px solid var(--b2c-line); + border-radius: 12px; + background: var(--b2c-surface-soft); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/layout.tsx b/tradein-mvp/frontend/src/app/mera-public/layout.tsx index bae99e6d..703b9217 100644 --- a/tradein-mvp/frontend/src/app/mera-public/layout.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/layout.tsx @@ -3,7 +3,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { REGION_NAME } from "./content"; import styles from "./landing.module.css"; -import { landingVars } from "./theme"; +import { b2cVars, landingVars } from "./theme"; /** * ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout @@ -76,7 +76,7 @@ export default function MeraPublicLayout({ return (
{/* Цель ссылки —
с tabIndex={-1} (см. page.tsx и privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на diff --git a/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx b/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx index 84fdf581..8c9c4b63 100644 --- a/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/oferta/page.tsx @@ -1,18 +1,17 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { LEGAL_DOCS_REVISION, LEGAL_ENTITY, - PRIVACY_PATH, PUBLIC_OFFER_URL, PUBLIC_REFUND_URL, - REFUND_PATH, SERVICE_PRICE_RUB, SUPPORT_EMAIL, } from "../content"; import styles from "../landing.module.css"; +import { PublicLink } from "../_components/PublicLink"; + import { SiteFooter } from "../_components/SiteFooter"; import { SiteHeader } from "../_components/SiteHeader"; @@ -62,12 +61,12 @@ export default function MeraPublicOfferPage() {
- ← На главную - +

Публичная оферта

@@ -315,9 +314,9 @@ export default function MeraPublicOfferPage() {

8.0. Условия настоящего раздела дублируются и подробно раскрыты в отдельном документе —{" "} - + Политике возврата денежных средств - + , размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений между настоящим разделом и указанным документом применяется настоящая Оферта. @@ -380,9 +379,9 @@ export default function MeraPublicOfferPage() {

9.1. Обработка персональных данных Заказчика осуществляется Исполнителем в соответствии с{" "} - + Политикой обработки персональных данных - + , размещённой на Сайте и являющейся неотъемлемой частью настоящей Оферты.

diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx index fbcb9222..fb409423 100644 --- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { LEGAL_ENTITY, @@ -12,6 +11,8 @@ import { SUPPORT_TELEGRAM_URL, } from "../content"; import styles from "../landing.module.css"; + +import { PublicLink } from "../_components/PublicLink"; import { safeUrl } from "@/lib/safeUrl"; import { SiteFooter } from "../_components/SiteFooter"; @@ -29,9 +30,19 @@ import { SiteHeader } from "../_components/SiteHeader"; * стала неудобной, а потому, что перестала быть правдой. * * ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ — правило честности: - * - Раздел про страницу ввода адреса УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`. - * Пока публичный расчёт выключен, адрес физически не покидает браузер, и - * писать обратное нельзя; текст меняется вместе с флагом, а не потом. + * - Раздел про страницу ввода адреса переписан 15.08.2026 ВМЕСТЕ с кодом. + * Прежний п. 5.4 утверждал, что введённый адрес не покидает браузер, — и + * это было правдой, пока форма ничего не отправляла. С открытием + * бесплатной проверки (`/estimate` → `/api/public/mera/*`) адрес уходит на + * сервер, поэтому утверждение заменено на точное: передаётся, используется + * однократно, в базах не сохраняется. Последнее проверено по коду, а не + * предположено: `suggest()` работает без кэша (докстринг + * `services/geocoder.py`), проба покрытия — один SELECT, а `RequestAudit` + * пишет строку только при наличии username, которого у анонима нет; в + * любом случае адрес едет телом запроса и в аудит не попадает. + * - Пункт про ПЛАТНЫЙ расчёт остался условным по `PUBLIC_ESTIMATE_ENABLED`: + * вот он действительно хранит результат, и его текст обязан меняться + * вместе с флагом, а не после. * - Срок хранения оплаченного отчёта рендерится из * `PAID_REPORT_RETENTION_MONTHS`, а НЕ числом, вписанным в текст, — это * гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая @@ -65,12 +76,12 @@ export default function MeraPublicPrivacyPage() {
- ← На главную - +

Политика обработки персональных данных @@ -197,13 +208,22 @@ export default function MeraPublicPrivacyPage() { сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с домена Оператора.

+

+ 5.4. Бесплатная проверка по адресу выполняется без регистрации и без + оплаты. Введённый адрес передаётся на сервер Оператора — без этого + подобрать сопоставимые объекты невозможно, — используется однократно + для формирования подсказок адреса и поиска сопоставимых объявлений и + не сохраняется: указанные операции выполняются в режиме чтения и не + создают записей об адресе в базах данных Оператора. Адрес передаётся + телом запроса и не записывается в журналы веб-сервера. Для + формирования подсказок адрес может передаваться внешним сервисам + геокодирования. +

{PUBLIC_ESTIMATE_ENABLED ? null : (

- 5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не - открыт: форма адреса на главной странице работает только в браузере - пользователя — введённый адрес не передаётся на сервер Оператора и - нигде не сохраняется. Пункты 5.1–5.2 применяются с момента открытия - публичного расчёта. + 5.5. Платный расчёт по адресу на Сайте на сегодняшний день ещё не + открыт. Пункты 5.1–5.2 в части хранения результата расчёта + применяются с момента его открытия.

)} diff --git a/tradein-mvp/frontend/src/app/mera-public/public-api.ts b/tradein-mvp/frontend/src/app/mera-public/public-api.ts new file mode 100644 index 00000000..84651541 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/public-api.ts @@ -0,0 +1,150 @@ +/** + * Клиент публичного (анонимного) API МЕРЫ — единственный модуль лэндинга, + * который вообще ходит в сеть. + * + * ПОЧЕМУ НЕ `@/lib/api`. Тот модуль — часть закрытого контура: тянет + * `sessionId`, обработку 401 и редирект на /login. Публичное дерево + * `app/mera-public/**` держится пустым по зависимостям (guard — + * `scripts/check-mera-public-isolation.mjs`), потому что root-layout + * оборачивает в `RouteGuard` в том числе и лэндинг: всё, что импортировано + * здесь статически, webpack кладёт в чанк, который скачивает анонимный + * посетитель. Поэтому — свой тонкий `fetch` без единого импорта. + * + * ПОЧЕМУ ПУТЬ С `/trade-in`, ХОТЯ ДОМЕН ПУБЛИЧНЫЙ. Один и тот же URL обязан + * работать и на `meraocenka.ru/`, и на `gendsgn.ru/trade-in/mera-public` + * (оттуда страницу открывают для QA). На gendsgn.ru корневой `/api/*` занят + * бэкендом Site Finder, поэтому короткий `/api/public/*` потребовал бы там + * второго Caddy-handle, выигрывающего по специфичности у существующего — то + * есть работоспособность формы зависела бы от порядка сортировки матчеров в + * чужом site-блоке. С префиксом `/trade-in` запрос ловит уже существующий + * `handle /trade-in/api/*`, а на публичном домене заведён ровно один новый. + * Разбор периметра — `backend/app/api/public/mera.py`. + * + * NB: это не `basePath` Next'а. `fetch` его не подставляет — префикс здесь + * настоящая часть URL, и хардкодить её приходится осознанно. + */ + +const API_BASE = "/trade-in/api/public/mera"; + +/** Кандидат автокомплита. Поля — как в `SuggestItem` бэкенда. */ +export interface AddressSuggestion { + label: string; + full_address: string; + lat: number; + lon: number; + kind: string; + fias_id: string | null; +} + +/** + * Ответ пробы покрытия. Зеркалит `CoverageProbeResponse` (backend + * app/schemas/trade_in.py) — с той же оговоркой про честность имён: + * + * `median_listing_age_days` — возраст АКТИВНОГО объявления, а не срок продажи. + * Выборка цензурирована (эти квартиры ещё продаются), поэтому называть её + * «за сколько продаются» нельзя. Бэкенд принудительно отдаёт null, если + * возраст известен меньше чем по пяти объявлениям, — UI обязан этот null + * пережить и просто не показывать плитку, а не рисовать «0 дней». + */ +export interface CoverageProbe { + status: "ok" | "thin" | "not_covered"; + n_listings: number; + median_listing_age_days: number | null; + n_with_age: number; + radius_m: number; + city: string; + threshold: number; +} + +export interface CoverageQuery { + lat: number; + lon: number; + rooms: number; + area_m2: number; +} + +/** Почему запрос не удался — от этого зависит текст, который увидит человек. */ +export type ApiFailureKind = "rate-limited" | "offline" | "server"; + +export class PublicApiError extends Error { + readonly kind: ApiFailureKind; + /** Сколько секунд ждать (только для `rate-limited`). */ + readonly retryAfterS: number | null; + + constructor(kind: ApiFailureKind, message: string, retryAfterS: number | null = null) { + super(message); + this.name = "PublicApiError"; + this.kind = kind; + this.retryAfterS = retryAfterS; + } +} + +/** + * Единая обработка ответа. 429 отделён от прочих ошибок намеренно: «слишком + * часто, подождите минуту» — это инструкция, которую человек может выполнить, + * а «что-то пошло не так» на том же месте выглядит поломкой сервиса и уводит + * его со страницы. + */ +async function parse(res: Response): Promise { + if (res.status === 429) { + const header = res.headers.get("Retry-After"); + const seconds = header ? Number.parseInt(header, 10) : Number.NaN; + throw new PublicApiError( + "rate-limited", + "Слишком много запросов подряд.", + Number.isFinite(seconds) ? seconds : null, + ); + } + if (!res.ok) { + throw new PublicApiError("server", `Сервис ответил ошибкой (${res.status}).`); + } + return (await res.json()) as T; +} + +/** `fetch`, который бросает наш тип и на сетевом сбое тоже. */ +async function request(url: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(url, init); + } catch (cause) { + // AbortError — это не сбой, а наш же отменённый предыдущий запрос: + // пробрасываем как есть, вызывающий код его молча глотает. + if (cause instanceof DOMException && cause.name === "AbortError") { + throw cause; + } + throw new PublicApiError("offline", "Не получилось связаться с сервисом."); + } + return parse(res); +} + +/** + * Подсказки адреса. POST у читающей ручки — намеренно: на публичном домене + * включён access-лог Caddy, и он пишет URI целиком, поэтому `?q=Малышева+51` + * сохранил бы адрес квартиры в файл рядом с IP посетителя. На странице мы + * обещаем обратное. Тело в лог не попадает. Тот же довод, что у черновика с + * лэндинга (`estimate-draft.ts` — sessionStorage вместо query-параметров). + */ +export async function fetchAddressSuggestions( + query: string, + options: { cityHint?: string; signal?: AbortSignal } = {}, +): Promise { + const data = await request<{ items: AddressSuggestion[] }>(`${API_BASE}/suggest`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ q: query, city_hint: options.cityHint ?? null }), + signal: options.signal, + }); + return data.items; +} + +export async function fetchCoverage( + query: CoverageQuery, + options: { signal?: AbortSignal } = {}, +): Promise { + return request(`${API_BASE}/coverage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(query), + signal: options.signal, + }); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx b/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx index 071b3174..bcefd38b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/refund/page.tsx @@ -1,10 +1,8 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { LEGAL_DOCS_REVISION, LEGAL_ENTITY, - OFFER_PATH, PUBLIC_OFFER_URL, PUBLIC_REFUND_URL, SERVICE_PRICE_RUB, @@ -12,6 +10,8 @@ import { } from "../content"; import styles from "../landing.module.css"; +import { PublicLink } from "../_components/PublicLink"; + import { SiteFooter } from "../_components/SiteFooter"; import { SiteHeader } from "../_components/SiteHeader"; @@ -53,12 +53,12 @@ export default function MeraPublicRefundPage() {
- ← На главную - +

Политика возврата денежных средств

@@ -75,9 +75,9 @@ export default function MeraPublicRefundPage() {

1.2. Настоящая Политика размещена в свободном доступе в сети Интернет по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "} - + Публичной оферты - + , размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая Политика применяются совместно; в случае противоречий между ними приоритет имеет Оферта. diff --git a/tradein-mvp/frontend/src/app/mera-public/theme.ts b/tradein-mvp/frontend/src/app/mera-public/theme.ts index acf309c3..a3678aef 100644 --- a/tradein-mvp/frontend/src/app/mera-public/theme.ts +++ b/tradein-mvp/frontend/src/app/mera-public/theme.ts @@ -21,6 +21,8 @@ import type { CSSProperties } from "react"; import { tokens } from "@/components/trade-in/v2/tokens"; +import { b2c } from "./b2c-tokens"; + /** * Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про * произвольные `--*` ключи, но React их корректно проставляет в style. @@ -63,3 +65,49 @@ export const landingVars = { "--m-font-sans": tokens.font.sans, "--m-font-mono": tokens.font.mono, } as CSSProperties; + +/** + * Переменные палитры v3 (`--b2c-*`) для `landing-v3.module.css`. + * + * ЧЕГО ЗДЕСЬ НЕ ХВАТАЛО. `b2c-tokens.ts` завёл палитру макета и запретил hex в + * CSS — но моста между ними не появилось: файл не импортировал НИКТО, ни одна + * `--b2c-*` переменная нигде не объявлялась, и `var(--b2c-page-bg)` на живой + * странице резолвился в пустоту. Каскад при этом не падает — он просто + * пропускает такое объявление, поэтому страница отдавала 200, проходила + * сборку и выглядела при этом бесцветной: карточки без фона и рамок, кнопки + * без заливки, акцент отсутствует (замер на + * `meraocenka.ru/trade-in/mera-public/v3`, 15.08.2026). + * + * Отсюда правило на будущее: TS-файл токенов, который никто не импортирует, — + * не источник правды, а мёртвый код. Единственное, что делает палитру живой, + * это вот такой мост, применённый к корневому узлу. + * + * Оба набора (`--m-*` и `--b2c-*`) вешаются на ОДИН и тот же div лэндинга: + * v1-страницы читают первый, v3 — второй, и лишние объявления ничего не + * стоят. Разделять их по разным обёрткам значило бы городить два layout'а + * ради экономии полутора десятков строк в атрибуте style. + */ +export const b2cVars = { + "--b2c-page-bg": b2c.pageBg, + "--b2c-surface": b2c.surface, + "--b2c-surface-soft": b2c.surfaceSoft, + "--b2c-surface-dark": b2c.surfaceDark, + + "--b2c-ink": b2c.ink, + "--b2c-muted": b2c.muted, + + "--b2c-line": b2c.line, + "--b2c-line-soft": b2c.lineSoft, + + // accent — только заливки/обводки; текст красится accentText (5.8:1). + // Разбор контраста — в шапке b2c-tokens.ts. + "--b2c-accent": b2c.accent, + "--b2c-accent-deep": b2c.accentDeep, + "--b2c-accent-text": b2c.accentText, + "--b2c-on-accent": b2c.onAccent, + + "--b2c-danger": b2c.danger, + + "--b2c-font-sans": b2c.font.sans, + "--b2c-font-mono": b2c.font.mono, +} as CSSProperties; From 208173d816648932d3beaf022343416e9d2a5b78 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 16 Aug 2026 09:46:46 +0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(mera/b2c):=20=D0=B4=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D1=8F=D1=82=20=E2=80=94=20`redir?= =?UTF-8?q?`=20=D0=B1=D0=B5=D0=B7=20=D0=BC=D0=B0=D1=82=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=81=D1=80=D0=B0=D0=B1=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка на живом Caddy (локальный стенд: реальный site-блок + заглушка вместо фронта) показала, что 301 не работал вовсе: длинные адреса отдавали пустой 200. Причина — разбор Caddyfile: первый аргумент директивы, начинающийся со слэша, читается как inline path-matcher. То есть `redir / permanent` означает «для пути / редиректить на permanent», а не «редиректить на /»; матчер не совпадал, директива молча не срабатывала, наружу уходило пустое тело. Пустая страница с кодом 200 хуже 404: выглядит рабочей и не ловится ни смоуком по коду ответа, ни глазом. Замер после правки (`redir * <куда>`): /trade-in/mera-public 301 → / /trade-in/mera-public/ 301 → / /trade-in/mera-public/oferta 301 → /oferta /trade-in/mera-public/v3 301 → /v3 / 200 upstream /trade-in/mera-public /estimate 200 upstream /trade-in/mera-public/estimate /trade-in/api/public/mera/coverage 200 upstream /api/public/mera/coverage /trade-in/api/v1/me 404 /trade-in/v2 404 Заодно захват сужен до `([^/].*)` — страховка от протокол-относительной цели (`//evil.example` резолвится браузером как чужой хост). Тем же замером проверено, что сегодня это недостижимо и без ограничения: Caddy нормализует путь до матчинга, и `//evil.example` / `/%2Fevil.example` приезжают одним слэшем. Ограничение оставлено как второй барьер, комментарий говорит ровно это — а не то, что чинилась живая дыра. --- Caddyfile | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Caddyfile b/Caddyfile index e02a49d7..b54f530c 100644 --- a/Caddyfile +++ b/Caddyfile @@ -304,14 +304,29 @@ meraocenka.ru { # параметров не принимает (форма проверки шлёт данные телом POST, а # черновик с лэндинга едет через sessionStorage — специально чтобы адрес # квартиры не попал в access-лог). + # `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он + # начинается со слэша, Caddy разбирает как inline path-matcher — то есть + # `redir / permanent` означает «для пути / редиректить на permanent», а не + # «редиректить на /». Проверено на живом Caddy: без `*` длинные адреса + # отдавали пустой 200 (матчер не совпадал, директива не срабатывала, тело + # пустое) — хуже, чем 404, потому что выглядит как рабочая пустая страница. @meraLongRoot path /trade-in/mera-public /trade-in/mera-public/ handle @meraLongRoot { - redir / permanent + redir * / permanent } - @meraLongSub path_regexp meralong ^/trade-in/mera-public/(.+)$ + # `([^/].*)`, а не `(.+)` — страховка от протокол-относительной цели. + # Захват, начинающийся со слэша, дал бы `redir` цель вида `//evil.example`, + # которую браузер резолвит как ЧУЖОЙ ХОСТ (открытый редирект с нашего + # домена). Проверено на живом Caddy: сегодня это недостижимо и без + # ограничения — Caddy нормализует путь ДО матчинга, схлопывая повторные + # слэши, и `//evil.example` (как и `/%2Fevil.example`) уже приезжает сюда + # одним слэшем, то есть редирект остаётся на нашем хосте. Ограничение + # оставлено намеренно: оно стоит ноль, а полагаться на нормализацию как на + # единственный барьер для дыры такого класса не хочется. + @meraLongSub path_regexp meralong ^/trade-in/mera-public/([^/].*)$ handle @meraLongSub { - redir /{re.meralong.1} permanent + redir * /{re.meralong.1} permanent } # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же From 56194c606ab33dec85e6a2c316bba49c248368c1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 16 Aug 2026 10:01:47 +0300 Subject: [PATCH 4/5] =?UTF-8?q?fix(mera/b2c):=20=D1=81=D0=B5=D0=BC=D1=8C?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=84=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B0,=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью четырьмя независимыми линзами (периметр, семантика 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 → падает тест маршрутов. --- .forgejo/workflows/ci.yml | 24 ++++ Caddyfile | 55 +++++--- tradein-mvp/backend/app/api/public/mera.py | 85 +++++++++++- .../backend/app/core/public_request.py | 97 +++++++++++++ tradein-mvp/backend/app/main.py | 8 ++ .../backend/app/observability/sentry_scrub.py | 55 ++++++++ .../backend/tests/test_public_mera_api.py | 104 ++++++++++++++ .../__tests__/estimate-draft.test.ts | 69 ++++++++++ .../__tests__/public-perimeter.test.ts | 46 ++++++- .../_components/estimate/EstimateFlow.tsx | 130 ++++++++++++++---- .../src/app/mera-public/estimate-draft.ts | 23 ++++ .../src/app/mera-public/privacy/page.tsx | 23 +++- 12 files changed, 667 insertions(+), 52 deletions(-) create mode 100644 tradein-mvp/backend/app/core/public_request.py create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index eccedb9e..0fd47b82 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 — diff --git a/Caddyfile b/Caddyfile index b54f530c..a43e8e9d 100644 --- a/Caddyfile +++ b/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-префиксом (тот же diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 21af11d0..80ed00e9 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -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) diff --git a/tradein-mvp/backend/app/core/public_request.py b/tradein-mvp/backend/app/core/public_request.py new file mode 100644 index 00000000..5a2ebbb5 --- /dev/null +++ b/tradein-mvp/backend/app/core/public_request.py @@ -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()) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index a3d16343..5a8c34cb 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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] diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 0920486e..f3c6b63a 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 06b5b031..95efc4a5 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -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 лимит сам по себе не может исчерпать суточный бюджет, " + "потолок бессмысленен — проверь, что тест сторожит реальный сценарий" + ) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts new file mode 100644 index 00000000..e75875c6 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts @@ -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(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts index c99f060c..a570b49b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -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 не протекает в публичные ссылки", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx index 0f7534c4..bcf6dcdf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -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(FORM); const [fieldError, setFieldError] = useState<"address" | "area" | null>(null); + const [suggestFailed, setSuggestFailed] = useState(false); const addressRef = useRef(null); const areaRef = useRef(null); - const suggestAbort = useRef(null); + const coverageAbort = useRef(null); + + // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе + // setState прилетает в размонтированный компонент. + useEffect(() => () => coverageAbort.current?.abort(), []); // Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз // на монтировании; координат в нём нет (и быть не может — на лэндинге нет // автокомплита), поэтому дом всё равно придётся выбрать из подсказок. + // + // Каждое поле ВАЛИДИРУЕТСЯ, а не подставляется как есть. На лэндинге + // «Комнат» — свободный текст, туда пишут «студия» или «2 комнаты»; такое + // значение не совпадает ни с одним

+ {/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и + физически перекрывает поля «Комнат» и «Площадь», в которые человек + как раз собрался попасть. onBlur на контейнере, а не на инпуте, — + чтобы клик по самой подсказке не считался уходом. */} +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setSuggestions([]); + setHighlighted(-1); + } + }} + >
)} + {suggestFailed && phase.kind !== "result" && ( +
+

+ Подсказки адреса сейчас не отвечают. Это не значит, что вашего + дома нет в базе — попробуйте набрать заново через минуту. +

+
+ )} + {phase.kind === "result" && }
diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts index 105083aa..a37bca94 100644 --- a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts +++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts @@ -63,3 +63,26 @@ export function takeDraft(): EstimateDraft | null { return null; } } + +/** + * Привести «Комнат» из черновика к значению, которое реально есть в селекте. + * + * На лэндинге это поле — свободный текст: туда пишут «студия», «2 комнаты», + * «2-к». Такая строка не совпадает ни с одним `