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