Bicycle/System/Classes/Controller.php
2026-08-10 23:24:03 +03:00

167 lines
7.5 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
/**
* @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) {
// getResponse() 302 сам делает Location + exit — throw здесь не подходит:
// неперехваченные исключения уходят в MyException::handler(), который
// getResponse() не вызывает и настоящий редирект не отправит.
HTTPException::factory(302, '/login')->getResponse();
}
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);
}
}