153 lines
6.1 KiB
PHP
153 lines
6.1 KiB
PHP
<?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);
|
||
}
|
||
}
|