Files
portal/app/bootstrap/app.php
T
Дмитрий 4a1eeb2ecd Merge remote-tracking branch 'gitea/main' into worktree-jivo-bot-core
# Conflicts:
#	app/routes/console.php
2026-07-14 13:27:46 +03:00

174 lines
9.9 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\EnsureSaasAdmin;
use App\Http\Middleware\ImpersonationContext;
use App\Http\Middleware\SetTenantContext;
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,
'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',
]);
})
->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();