Files
portal/app/bootstrap/app.php
T

192 lines
11 KiB
PHP
Raw Normal View History

<?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;
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: поведение по умолчанию
});
// 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();