Bicycle/System/Classes/Controller.php
Egor Isaev bbeb36b2e8 dev
2026-08-06 16:52:19 +03:00

153 lines
6.1 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 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.
* Если $dir пуст — определяется автоматически из имени класса
* (App\Controller\FooController → view/Foo).
*
* @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
{
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($this->_layout, 'view', [
'content' => (new View($template, $dir, $data))->render(),
] + $this->layoutData()))->render();
}
/**
* Дополнительные данные, передаваемые в layout помимо 'content'.
* По умолчанию — пункты главного меню; наследники могут переопределить
* полностью (как AdminController — своим боковым меню).
*
* @return array
*/
protected function layoutData(): array
{
return ['menu' => $this->menu()];
}
/**
* Пункты главного меню сайта. Активный пункт — по текущему URI.
*
* @return array
*/
protected function menu(): 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;
}
/**
* Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные.
*
* @param mixed $data Данные ответа
* @param int $status HTTP-статус
* @return string JSON
*/
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);
}
}