Bicycle/System/Classes/Controller.php
Egor Isaev c9b81cdcd1 dev
2026-08-14 13:09:54 +03:00

181 lines
8.8 KiB
PHP
Raw Permalink 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
/**
* @package Bicycle
* @author Egor Isaev
* @description Controller.php
* @copyright (c) 03/06/2026
*/
namespace System\Classes;
use Services\Auth;
use Services\DataBase\Classes\Profiler;
use System\Classes\HTTP\HTTPException;
use System\Classes\HTTP\Request as HTTPRequest;
/**
* Веб-контроллер: рендеринг layout + content, авто-проверка CSRF на небезопасных
* методах и JSON-ответы. Контроллеры приложения наследуют его.
*/
class Controller extends BaseController
{
/** @var string Имя layout-шаблона в App/view (см. Core::findFile) */
protected string $_layout = 'layout';
/** @var bool Проверять ли CSRF-токен на небезопасных методах */
protected bool $_csrf_protection = true;
/** @var bool Требовать ли авторизацию (см. {@see Auth}) для всех экшенов контроллера */
protected bool $_auth_protection = false;
/** @var string|null Имя драйвера авторизации; null — значение по умолчанию из конфига */
protected ?string $_auth_driver = null;
/** @var array<int,string> Роли, которым разрешён доступ; пустой массив — любой авторизованный */
protected array $_auth_roles = [];
/**
* Проверяет CSRF-токен на POST/PUT/PATCH/DELETE и, если включено,
* авторизацию + роль (редирект на /login при отсутствии авторизации,
* 403 — при недостаточной роли).
*
* @return void
* @throws HTTPException|MyException 403, если CSRF-токен не прошёл или роль не подходит
*/
protected function before(): void
{
if ($this->_csrf_protection) {
$request = Request::$current;
$method = $request?->method() ?? HTTPRequest::GET;
$unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE];
if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) {
throw HTTPException::factory(403);
}
}
if ($this->_auth_protection) {
$user = Auth::instance($this->_auth_driver)->getUser();
if ($user === null) {
// Не исключительная ситуация — штатное поведение (гостя отправляем логиниться),
// поэтому обычный редирект, а не throw HTTPException: throw ушёл бы в
// MyException::handler() и отрендерил бы страницу ошибки вместо перехода на /login.
HTTP::redirect('/login');
}
if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) {
throw HTTPException::factory(403);
}
}
}
/**
* Для admin — заголовок X-Profiler с полной картиной запроса (время, память, список
* SQL-запросов с временем каждого). Виден в devtools (Network → заголовки ответа) для
* любого ответа, включая ajax — там HTML-панель ProfilerToolbar не рендерится вовсе
* (renderContent()/json() не проходят через layout.html, где она подключена). Тело
* ответа не трогаем осознанно — ajax-эндпоинты отдают JSON/HTML, менять их форму ради
* дебага не нужно.
*
* @return void
* @throws \JsonException
*/
/** @var int Безопасный предел размера X-Profiler в байтах (см. after()) */
protected const PROFILER_HEADER_LIMIT = 3000;
protected function after(): void
{
$user = Auth::instance()->getUser();
if (($user['role'] ?? null) !== 'admin' || headers_sent()) {
return;
}
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
$data = [
'time_ms' => round($time_ms, 1),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
'sql_count' => Profiler::count(),
'sql_time_ms' => round(Profiler::totalTime(), 1),
'sql' => array_map(
static fn (array $entry) => ['sql' => $entry['sql'], 'time_ms' => round($entry['time_ms'], 2)],
Profiler::entries()
),
];
// Заголовок ограничен буфером прокси перед приложением (nginx proxy_buffer_size —
// обычно 4-8Кб). При запросе с большим числом SQL за раз (пример — сохранение годового
// плана бюджета: десятки upsert) полный список легко превышает лимит, и nginx рвёт весь
// ответ 502 ("upstream sent too big header") ещё до тела — уже закоммиченный к этому
// моменту результат запроса клиент не увидит. sql_count/sql_time_ms — агрегаты из
// Profiler, не из этого массива, поэтому обрезка списка их не искажает.
while ($data['sql'] !== [] && strlen(json_encode($data, JSON_UNESCAPED_UNICODE)) > self::PROFILER_HEADER_LIMIT) {
array_pop($data['sql']);
$data['sql_truncated'] = true;
}
header('X-Profiler: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
/**
* Рендерит шаблон контента внутри layout. Меню — не забота движка: разметка/ссылки
* навигации живут прямо в App/view/layout.html (см. этот файл в проекте), а не строятся
* тут из PHP-массива.
*
* @param string $template Имя шаблона контента без расширения
* @param array $data Данные, передаваемые в шаблон
* @param string $dir Каталог шаблона относительно view/ (опционально)
* @return string Готовый HTML
* @throws MyException
*/
protected function render(string $template, array $data = [], string $dir = ''): string
{
return (new View($this->_layout, 'view', [
'content' => $this->renderContent($template, $data, $dir),
]))->render();
}
/**
* Рендерит шаблон контента без layout — просто view, без меню и обвязки.
* Если $dir пуст — определяется автоматически из имени класса
* (App\Controller\FooController → view/Foo). Пригодится для ajax-фрагментов:
* экшен сам решает, вызывать render() или renderContent(), без проверки isAjax().
*
* @param string $template Имя шаблона контента без расширения
* @param array $data Данные, передаваемые в шаблон
* @param string $dir Каталог шаблона относительно view/ (опционально)
* @return string Готовый HTML фрагмента
* @throws MyException
*/
protected function renderContent(string $template, array $data = [], string $dir = ''): string
{
if ($dir === '') {
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
}
return (new View($template, $dir, $data))->render();
}
/**
* Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные.
*
* @param mixed $data Данные ответа
* @param int $status HTTP-статус
* @return string JSON
* @throws \JsonException
*/
protected function json(mixed $data, int $status = 200): string
{
http_response_code($status);
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
}