Роли, которым разрешён доступ; пустой массив — любой авторизованный */ 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); } }