Files
portal/app/bootstrap/app.php
T
Дмитрий 7c8618becc fix(портал): 404 называл посторонним внутренние имена, красную подпись не охранял никто
Живой замер 05.08.2026: любой вошедший клиент, запросив несуществующий или чужой
номер, получал в ответ «No query results for model [App\Models\AdCampaign].»
Три беды в одной строке. Английский язык — клиенту он ничего не говорит. Названы
внутренние имена: и класс, и раскладка папок портала. И сообщение подтверждало,
что за номером стоит именно кампания, — а на чужой номер портал обязан отвечать
ровно так же, как на несуществующий, ничего не подтверждая.

Само имя класса не отмычка, но экономит время тому, кто ищет вход: видно, на чём
написан портал, как называются сущности и где их искать. Теперь на любой
ненайденный путь, просящий JSON, уходит «Не найдено.» — и на чужой номер ответ
байт в байт тот же, что на несуществующий. В журнал прежний текст пишется без
изменений: разработчику он нужен.

Место общее на весь портал: так отвечал КАЖДЫЙ firstOrFail() во всех разделах.

Ловушка: обработчик на ModelNotFoundException не срабатывает никогда — Laravel
успевает завернуть её в NotFoundHttpException раньше. Поймано тем, что сторож
остался красным с тем же самым английским текстом.

Второе: под слотом баннера есть красная подпись — единственное место, где клиент
узнаёт, почему картинка не загрузилась. Сторожа на неё не было ни одного. Пропади
она — заметить было бы некому: клиент жмёт «загрузить», ничего не появляется, и
никакого объяснения. Написаны три: подпись показывает слова сервера, а не общую
отговорку; пока отказа нет, подписи нет вовсе; сервер промолчал — портал всё равно
объясняет.

Ловушка: extractErrorMessage достаёт слова сервера только у настоящей ошибки axios.
Подделка без isAxiosError проваливается в общую отговорку, и сторож зеленеет, не
проверив того, ради чего написан.

Прогоны: 3909 тестов Feature зелёные, vue-tsc чисто.

Заодно промт смене 21 дописан утренними итогами: выкат сделан, восемь карточек
пройдены, снята ложная тревога про главную ветку, добавлен разбор чужого слияния.
2026-08-05 08:32:03 +03:00

217 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Http\Middleware\ApiKeyAuth;
use App\Http\Middleware\CreativeRobotToken;
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\EnsureSalesUser;
use App\Http\Middleware\ImpersonationContext;
use App\Http\Middleware\SalesIntegrationToken;
use App\Http\Middleware\SetTenantContext;
use App\Http\Middleware\TgRobotToken;
use App\Http\Middleware\UseAdminConnection;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// /api/auth/* размещены в web.php (см. routes/web.php) и используют
// session-based Sanctum auth. Token-based mode (через api.php) пока
// не нужен — добавим если понадобится для интеграций.
$middleware->alias([
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
'admin-db' => UseAdminConnection::class,
'sales-portal' => EnsureSalesUser::class,
'sales-integration' => SalesIntegrationToken::class,
'creative-robot' => CreativeRobotToken::class,
'tg-robot' => TgRobotToken::class,
'apikey' => ApiKeyAuth::class,
]);
$middleware->web(append: [
ImpersonationContext::class,
]);
// FN-LOGIN-ROUTE (приёмка 22.06.2026): по умолчанию Laravel при неаутент.
// не-JSON запросе зовёт route('login') для guest-редиректа. У SPA нет
// backend-роута с именем 'login' (логин — клиентский Vue-роут), поэтому
// дефолт бросал «Route [login] not defined» (500) на прямых заходах
// браузера/бота к /api/*. Возвращаем строковый путь SPA-логина — никакого
// route() вызова. Для /api/* финальный ответ — 401 JSON (render ниже во
// withExceptions), сюда не доходит.
$middleware->redirectGuestsTo(fn (): string => '/login');
// Защитные HTTP-заголовки (CSP, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy, HSTS, Permissions-Policy, COOP/CORP) ставит nginx —
// единый источник: /etc/nginx/sites-available/liderra (add_header ... always).
// App-уровневый middleware SecurityHeaders удалён 18.06.2026: он дублировал
// те же заголовки, и на проде add_header always + PHP-заголовок давали дубль
// в ответе. CSP в nginx — enforcing (был Report-Only в middleware).
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
// CSRF — запросы приходят от внешних CRM-систем без сессии браузера.
// Авторизация — через webhook_token в URL + (на prod) HMAC.
// Маячок учёта посетителей (spec 2026-07-13) — публичный POST без сессии/CSRF-
// токена (шлёт JS лендинга/портала, иногда до инициализации SPA-сессии).
$middleware->validateCsrfTokens(except: [
'api/webhook/*',
// Чат открыт гостям с лендинга: сессии и CSRF-токена у них нет.
// Защита — счётчики ChatRateLimiter (спека §6), не CSRF.
'api/chat/*',
'api/track',
// Сервис-канал «Поиск → Портал» (Этап 2): защита — сервис-токен
// (SalesIntegrationToken), не CSRF. У Python-поиска нет сессии/куки.
'api/sales/integration/*',
// Служебный канал робота-грузчика креативов: у робота нет браузерной сессии
// и CSRF-токена, защита канала — сервис-токен (CreativeRobotToken).
'api/creative-robot/*',
// Служебный канал телеграм-робота кабинета МТС: робот живёт на другой машине,
// браузерной сессии и CSRF-токена у него нет — защита канала сервис-токеном
// (TgRobotToken).
'api/tg-robot/*',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// FN-LOGIN-ROUTE (приёмка 22.06.2026): неаутентифицированный запрос к
// auth:sanctum-роуту, который НЕ просит JSON (прямой заход браузером/ботом
// без Accept: application/json), уводил Laravel в Authenticate::redirectTo()
// → route('login'), которого в SPA нет (логин — клиентский Vue-роут) →
// «Route [login] not defined» (500). Все защищённые маршруты тут — /api/*,
// поэтому для них всегда отдаём 401 JSON вместо редиректа на несуществующий
// именованный роут. Наблюдалось в проде 08.06 и 21.06.
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Требуется авторизация.',
], 401);
}
return null; // не-API: поведение по умолчанию
});
// 🔴 Живой замер 05.08.2026: на несуществующий (или чужой) номер портал отвечал
// «No query results for model [App\Models\AdCampaign].» — по-английски, с именем
// класса и раскладкой папок. Так отвечал КАЖДЫЙ firstOrFail() во всех разделах.
//
// Само имя класса не отмычка, но экономит время тому, кто ищет вход: видно, на чём
// написан портал, как называются сущности и где их искать. И — главное — текст
// подтверждал, что за номером стоит именно кампания. На чужой номер портал обязан
// отвечать ровно так же, как на несуществующий: одинаково и ничего не выдавая.
//
// Внутрь ошибки (в журнал) прежний текст уходит без изменений — разработчику он
// нужен; наружу клиенту уходит только «Не найдено.».
//
// 🪤 Ловим NotFoundHttpException, а НЕ ModelNotFoundException: Laravel успевает
// завернуть вторую в первую раньше, чем дело доходит до наших обработчиков —
// обработчик на ModelNotFoundException просто никогда не срабатывает. Проверено:
// с ним сторож остался красным с тем же самым английским текстом.
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Не найдено.'], 404);
}
return null; // не-JSON: SPA сама покажет свою страницу
});
// Reduce verbosity of constraint-violation logging (SQLSTATE 23xxx):
// data-validity errors do not need a full stack trace в laravel.log.
// Incident 2026-05-29: 420k повторов B1+SMS check_violation накопили
// 8.7 GB stack traces → disk full → 4h prod downtime.
// Solution: log a warning summary с sqlstate, return false to stop
// default reporting (which would write full stack trace).
// Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
$exceptions->reportable(function (QueryException $e) {
$sqlState = $e->errorInfo[0] ?? '';
if (is_string($sqlState) && str_starts_with($sqlState, '23')) {
Log::warning('db.constraint_violation', [
'sqlstate' => $sqlState,
'message' => mb_substr($e->getMessage(), 0, 200),
]);
return false; // skip default reporting (no stack trace в laravel.log)
}
return null; // continue default reporting для non-constraint QueryExceptions
});
$exceptions->render(function (QueryException $e, Request $request) {
$sqlState = $e->errorInfo[0] ?? '';
$isConstraintViolation = is_string($sqlState) && str_starts_with($sqlState, '23');
if (! $isConstraintViolation) {
// Default verbose log для non-constraint QueryExceptions (table missing,
// syntax error, etc. — these are bugs needing investigation).
Log::error('db.query_exception', [
'message' => $e->getMessage(),
'sql' => $e->getSql(),
'path' => $request->path(),
]);
}
// Constraint violations уже залогированы в reportable() выше как warning,
// дублировать не нужно.
if ($request->expectsJson()) {
return response()->json([
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
], 422);
}
return null; // default render for non-JSON
});
// Supplier webhook always returns JSON, even when client omits Accept header.
// Without this render, Laravel's default ValidationException handler returns
// 302 redirect to /, which strips POST body — losing supplier leads.
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
$exceptions->render(function (ValidationException $e, Request $request) {
// Журнал отказов по проверке полей (14.07.2026). Живой клиент получил 14 отказов
// подряд в трёх формах — и в логах не осталось НИ СЛОВА о причинах: их пришлось
// час восстанавливать по размеру HTTP-ответа в nginx. Пишем маршрут, клиента и
// ИМЕНА полей, на которые ругались.
//
// ЗНАЧЕНИЯ полей НЕ пишем никогда: там ПДн (телефоны, имена, ИНН) — 152-ФЗ.
// api/track — публичный маячок посетителей: боты дают отказы пачками, в журнале не нужны.
if ($request->is('api/*') && ! $request->is('api/webhook/*') && ! $request->is('api/track')) {
$user = $request->user();
// Уровень warning (не info) намеренно: на проде LOG_LEVEL=warning, и info
// в журнал просто не попадает — отказы остались бы невидимыми, ради чего
// всё и затевалось.
Log::warning('validation.rejected', [
'method' => $request->method(),
'path' => $request->path(),
'tenant_id' => $user?->tenant_id,
'user_id' => $user?->id,
'fields' => array_keys($e->errors()), // только ИМЕНА полей
'messages' => array_values(array_map(
static fn (array $messages): string => (string) ($messages[0] ?? ''),
$e->errors(),
)), // тексты НАШИХ сообщений, без данных клиента
]);
}
if ($request->is('api/webhook/supplier/*')) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
}
return null; // default render for other routes
});
})->create();