config = $config; $this->repository = $repository; $this->comments = $comments; $this->stats = $stats; View::setBasePath($this->config->basePath()); } public function handle(): void { try { $path = $this->path(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && preg_match('#^comment/([a-z0-9-]+)$#', $path, $matches)) { $this->handleComment($matches[1]); return; } if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') { $this->render('error', ['title' => 'Method not allowed', 'message' => 'This route only accepts GET requests.'], 405, 'error'); return; } $this->route($path); } catch (Throwable $error) { $this->render('error', [ 'title' => 'Server error', 'message' => 'Something went wrong while rendering this page.', 'debug' => $error->getMessage(), ], 500, 'error'); } } public function repository(): ContentRepository { return $this->repository; } public function comments(): CommentService { return $this->comments; } public function stats(): Stats { return $this->stats; } public function config(): Config { return $this->config; } private function route(string $path): void { if ($this->servePublicFile($path)) { return; } if ($path === '' || $path === 'home') { $this->home(1); return; } if (preg_match('#^page/(\d+)$#', $path, $matches)) { $this->home((int) $matches[1]); return; } if ($path === 'search') { $this->search(); return; } if ($path === 'feed.xml') { $this->feed(); return; } if ($path === 'sitemap.xml') { $this->sitemap(); return; } if (preg_match('#^post/([a-z0-9-]+)$#', $path, $matches)) { $this->single($matches[1], 'post'); return; } if (preg_match('#^category/([a-z0-9-]+)(?:/page/(\d+))?$#', $path, $matches)) { $this->taxonomy('category', $matches[1], isset($matches[2]) ? (int) $matches[2] : 1); return; } if (preg_match('#^tag/([a-z0-9-]+)(?:/page/(\d+))?$#', $path, $matches)) { $this->taxonomy('tag', $matches[1], isset($matches[2]) ? (int) $matches[2] : 1); return; } if (preg_match('#^archive/(\d{4})(?:/(\d{2}))?(?:/page/(\d+))?$#', $path, $matches)) { $this->archive((int) $matches[1], isset($matches[2]) ? (int) $matches[2] : null, isset($matches[3]) ? (int) $matches[3] : 1); return; } $item = $this->repository->find($path); if ($item !== null) { $this->single($path, (string) $item['type']); return; } $this->render('error', ['title' => 'Not found', 'message' => 'That page does not exist.'], 404, 'not_found'); } private function home(int $page): void { $perPage = (int) $this->config->get('posts_per_page', 6); $result = $this->repository->paginate($this->repository->posts(false), $page, $perPage); $this->render('listing', [ 'title' => (string) $this->config->get('site.title'), 'heading' => (string) $this->config->get('site.tagline'), 'items' => $result['items'], 'pagination' => $result['pagination'], 'pagination_url' => static fn (int $target): string => $target === 1 ? View::url('') : View::url('page/' . $target), ], 200, 'home'); } private function search(): void { $query = trim((string) ($_GET['q'] ?? '')); $page = max(1, (int) ($_GET['page'] ?? 1)); $perPage = (int) $this->config->get('posts_per_page', 6); $result = $this->repository->search($query, $page, $perPage); $this->render('listing', [ 'title' => $query === '' ? 'Search' : 'Search: ' . $query, 'heading' => $query === '' ? 'Search the archive' : 'Search results for "' . $query . '"', 'items' => $result['items'], 'pagination' => $result['pagination'], 'search_query' => $query, 'empty_message' => $query === '' ? 'Enter a word or phrase to search posts and pages.' : 'No matching posts were found.', 'pagination_url' => static fn (int $target): string => View::url('search', ['q' => $query, 'page' => $target]), ], 200, 'search'); } private function taxonomy(string $type, string $slug, int $page): void { $items = $type === 'category' ? $this->repository->byCategory($slug) : $this->repository->byTag($slug); $label = $this->termLabel($items, $type, $slug); $perPage = (int) $this->config->get('posts_per_page', 6); $result = $this->repository->paginate($items, $page, $perPage); $this->render('listing', [ 'title' => ucfirst($type) . ': ' . $label, 'heading' => ucfirst($type) . ': ' . $label, 'items' => $result['items'], 'pagination' => $result['pagination'], 'empty_message' => 'No posts live here yet.', 'pagination_url' => static fn (int $target): string => $target === 1 ? View::url($type . '/' . $slug) : View::url($type . '/' . $slug . '/page/' . $target), ], 200, $type); } private function archive(int $year, ?int $month, int $page): void { $items = $this->repository->byArchive($year, $month); $perPage = (int) $this->config->get('posts_per_page', 6); $result = $this->repository->paginate($items, $page, $perPage); $label = $month === null ? (string) $year : date('F Y', strtotime(sprintf('%04d-%02d-01', $year, $month))); $base = $month === null ? 'archive/' . $year : sprintf('archive/%04d/%02d', $year, $month); $this->render('listing', [ 'title' => 'Archive: ' . $label, 'heading' => 'Archive: ' . $label, 'items' => $result['items'], 'pagination' => $result['pagination'], 'empty_message' => 'No posts were published in this window.', 'pagination_url' => static fn (int $target): string => $target === 1 ? View::url($base) : View::url($base . '/page/' . $target), ], 200, 'archive'); } private function single(string $slug, string $type): void { $item = $this->repository->find($slug); if ($item === null || $item['type'] !== $type) { $this->render('error', ['title' => 'Not found', 'message' => 'That content is not published.'], 404, 'not_found'); return; } $approved = $this->comments->approvedFor((string) $item['slug']); $captcha = $this->config->get('comments.captcha', true) ? Captcha::challenge() : ['token' => '', 'question' => '']; $this->render('single', [ 'title' => $item['title'], 'item' => $item, 'approved_comments' => $approved, 'captcha' => $captcha, ], 200, (string) $item['type'], (string) $item['slug']); } private function feed(): void { $base = $this->config->baseUrl(); $items = array_slice($this->repository->posts(false), 0, 20); $xml = [ '', '', '', '' . $this->xml((string) $this->config->get('site.title')) . '', '' . $this->xml($base . View::url('')) . '', '' . $this->xml((string) $this->config->get('site.description')) . '', ]; foreach ($items as $item) { $xml[] = ''; $xml[] = '' . $this->xml((string) $item['title']) . ''; $xml[] = '' . $this->xml($base . View::itemUrl($item)) . ''; $xml[] = '' . $this->xml($base . View::itemUrl($item)) . ''; $xml[] = '' . gmdate(DATE_RSS, strtotime((string) $item['date']) ?: time()) . ''; $xml[] = '' . $this->xml((string) $item['summary']) . ''; $xml[] = ''; } $xml[] = ''; $xml[] = ''; $this->respond(implode("\n", $xml), 'application/rss+xml; charset=utf-8', 200, 'feed'); } private function sitemap(): void { $base = $this->config->baseUrl(); $xml = [ '', '', '' . $this->xml($base . View::url('')) . '', ]; foreach ($this->repository->all(false) as $item) { $xml[] = ''; $xml[] = '' . $this->xml($base . View::itemUrl($item)) . ''; $xml[] = '' . $this->xml(date('Y-m-d', strtotime((string) $item['modified']) ?: time())) . ''; $xml[] = ''; } $xml[] = ''; $this->respond(implode("\n", $xml), 'application/xml; charset=utf-8', 200, 'sitemap'); } private function handleComment(string $slug): void { $item = $this->repository->find($slug); if ($item === null || !$item['allow_comments'] || !$this->config->get('comments.enabled', true)) { $this->flash('error', 'Comments are closed for that post.'); $this->redirect(View::url('')); return; } if ($this->config->get('comments.captcha', true)) { $valid = Captcha::verify((string) ($_POST['captcha_token'] ?? ''), (string) ($_POST['captcha_answer'] ?? '')); if (!$valid) { $this->flash('error', 'The captcha answer was not correct.'); $this->redirect(View::itemUrl($item) . '#comments'); return; } } $result = $this->comments->add((string) $item['slug'], $_POST); $this->flash($result['ok'] ? 'success' : 'error', $result['message']); $this->redirect(View::itemUrl($item) . '#comments'); } /** @param array $data */ private function render(string $template, array $data, int $statusCode, string $routeType, string $slug = ''): void { http_response_code($statusCode); header('Content-Type: text/html; charset=utf-8'); $theme = preg_replace('/[^a-z0-9_-]/', '', (string) $this->config->get('theme', 'neon')) ?: 'neon'; $layout = BLOG_ROOT . '/themes/' . $theme . '/layout.php'; $config = $this->config; $repository = $this->repository; $facets = $this->repository->facets(); $navPages = $this->repository->pages(false); usort($navPages, static fn (array $a, array $b): int => ((int) $a['menu_order']) <=> ((int) $b['menu_order'])); $flash = $this->consumeFlash(); extract($data, EXTR_SKIP); ob_start(); require $layout; $body = (string) ob_get_clean(); $this->stats->record($routeType, $slug, $statusCode); echo $body; } private function respond(string $body, string $contentType, int $statusCode, string $routeType): void { http_response_code($statusCode); header('Content-Type: ' . $contentType); $this->stats->record($routeType, '', $statusCode); echo $body; } private function servePublicFile(string $path): bool { if (!preg_match('#^(themes/[a-z0-9_-]+/assets/.+|bl-content/uploads/.+)$#', $path)) { return false; } $file = realpath(BLOG_ROOT . '/' . $path); $allowedRoots = [ realpath(BLOG_ROOT . '/themes'), realpath(BLOG_ROOT . '/bl-content/uploads'), ]; $insideAllowedRoot = false; foreach ($allowedRoots as $root) { if (is_string($root) && is_string($file) && str_starts_with($file, $root . DIRECTORY_SEPARATOR)) { $insideAllowedRoot = true; break; } } if (!$insideAllowedRoot || !is_file((string) $file)) { return false; } $extension = strtolower(pathinfo((string) $file, PATHINFO_EXTENSION)); $types = [ 'css' => 'text/css; charset=utf-8', 'js' => 'application/javascript; charset=utf-8', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'ico' => 'image/x-icon', 'woff' => 'font/woff', 'woff2' => 'font/woff2', ]; http_response_code(200); header('Content-Type: ' . ($types[$extension] ?? 'application/octet-stream')); header('Content-Length: ' . filesize((string) $file)); header('Cache-Control: public, max-age=3600'); $this->stats->record('asset', $path, 200); readfile((string) $file); return true; } private function path(): string { $route = (string) ($_GET['route'] ?? ''); if ($route === '') { $route = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH); } $scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? ''))); $route = '/' . ltrim($route, '/'); $basePath = $this->config->basePath(); if ($basePath !== '' && ($route === $basePath || str_starts_with($route, $basePath . '/'))) { $route = substr($route, strlen($basePath)); } elseif ($scriptDir !== '/' && str_starts_with($route, $scriptDir . '/')) { $route = substr($route, strlen($scriptDir)); } return trim($route, '/'); } private function termLabel(array $items, string $type, string $slug): string { foreach ($items as $item) { if ($type === 'category') { return (string) $item['category']; } foreach ($item['tags'] as $tag) { if (ContentRepository::slugify((string) $tag) === $slug) { return (string) $tag; } } } return ucwords(str_replace('-', ' ', $slug)); } private function flash(string $type, string $message): void { if (session_status() === PHP_SESSION_ACTIVE) { $_SESSION['flash'] = ['type' => $type, 'message' => $message]; } } /** @return array{type: string, message: string}|null */ private function consumeFlash(): ?array { if (session_status() !== PHP_SESSION_ACTIVE || !isset($_SESSION['flash'])) { return null; } $flash = $_SESSION['flash']; unset($_SESSION['flash']); return is_array($flash) ? $flash : null; } private function redirect(string $url): void { header('Location: ' . $url, true, 303); } private function xml(string $value): string { return htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8'); } }