Bicycle/System/Classes/Controller.php
Egor Isaev 9d4edf147a dev
2026-08-07 11:40:03 +03:00

208 lines
8.4 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 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);
}
}
}
/**
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
*
* @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', [
'menu_top' => $this->renderMenuTop(),
'menu_side' => $this->renderMenuSide(),
'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();
}
/**
* HTML верхнего меню сайта — рендерит {@see menuTop()} через шаблон view/menu_top.
*
* @return string
* @throws MyException
*/
protected function renderMenuTop(): string
{
return (new View('menu_top', 'view', ['menu' => $this->menuTop()]))->render();
}
/**
* HTML бокового меню раздела — рендерит {@see menuSide()} через шаблон view/menu_side.
* Пустая строка, если пунктов нет (по умолчанию бокового меню нет вовсе;
* layout.html в этом случае не рисует колонку сайдбара).
*
* @return string
* @throws MyException
*/
protected function renderMenuSide(): string
{
$items = $this->menuSide();
return $items ? (new View('menu_side', 'view', ['menu' => $items]))->render() : '';
}
/**
* Пункты верхнего меню сайта. Активный пункт — по текущему URI.
*
* @return array
*/
protected function menuTop(): array
{
$uri = Request::$current?->uri() ?? '';
$user = Auth::instance()->getUser();
$items = [
['title' => 'Главная', 'url' => '/'],
['title' => 'Обратная связь', 'url' => '/feedback'],
];
if (($user['role'] ?? null) === 'admin') {
$items[] = ['title' => 'Админка', 'url' => '/admin/logs'];
}
$items[] = $user
? ['title' => 'Выйти (' . $user['login'] . ')', 'url' => '/login/logout']
: ['title' => 'Войти', 'url' => '/login'];
foreach ($items as &$item) {
$item['active'] = trim($item['url'], '/') === $uri;
}
return $items;
}
/**
* Пункты бокового меню — общие для всего сайта (как и menuTop()), видны
* на любой странице любому пользователю, независимо от роли и авторизации.
* Активный пункт — по текущему URI.
*
* @return array
*/
protected function menuSide(): array
{
$uri = Request::$current?->uri() ?? '';
$items = [
['title' => 'Главная', 'url' => '/'],
['title' => 'О нас', 'url' => '/about'],
['title' => 'Новости', 'url' => '/news'],
];
foreach ($items as &$item) {
$item['active'] = trim($item['url'], '/') === $uri;
}
return $items;
}
/**
* Формирует 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);
}
}