167 lines
7.5 KiB
PHP
167 lines
7.5 KiB
PHP
<?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
|
||
*/
|
||
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()
|
||
),
|
||
];
|
||
|
||
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);
|
||
}
|
||
}
|