routes['GET'][$path] = $handler; } public function post(string $path, callable $handler): void { $this->routes['POST'][$path] = $handler; } public function registerModules(string $modulesPath): void { foreach (glob($modulesPath . '/*/module.php') as $moduleFile) { $module = require $moduleFile; if (is_callable($module)) { $module($this); } } } public function dispatch(string $uri, string $method): Response { $path = parse_url($uri, PHP_URL_PATH) ?: '/'; if ($path !== '/' && str_ends_with($path, '/')) { $path = rtrim($path, '/'); } $method = strtoupper($method); if ($method === 'POST' && $this->requiresCsrf($path) && !Csrf::verifyRequest()) { return new Response('Invalid request token. Please refresh and try again.', 419); } if ($method === 'GET') { $redirect = $this->matchRedirect($path); if ($redirect !== null) { return $redirect; } } if (str_starts_with($path, '/admin')) { $permission = Permissions::routePermission($path); if ($permission !== null && Auth::check() && !Auth::can($permission)) { return new Response('', 302, ['Location' => '/admin?denied=1']); } } if (isset($this->routes[$method][$path])) { $handler = $this->routes[$method][$path]; return $handler(); } if ($method === 'GET') { if (str_starts_with($path, '/news/')) { $postSlug = trim(substr($path, strlen('/news/'))); if ($postSlug !== '' && strpos($postSlug, '/') === false) { $db = Database::get(); if ($db instanceof PDO) { try { $stmt = $db->prepare(" SELECT title, content_html, published_at, featured_image_url, author_name, category, tags FROM ac_posts WHERE slug = :slug AND is_published = 1 LIMIT 1 "); $stmt->execute([':slug' => $postSlug]); $post = $stmt->fetch(PDO::FETCH_ASSOC); if ($post) { $view = new View(__DIR__ . '/../../modules/blog/views'); return new Response($view->render('site/show.php', [ 'title' => (string)$post['title'], 'content_html' => (string)$post['content_html'], 'published_at' => (string)($post['published_at'] ?? ''), 'featured_image_url' => (string)($post['featured_image_url'] ?? ''), 'author_name' => (string)($post['author_name'] ?? ''), 'category' => (string)($post['category'] ?? ''), 'tags' => (string)($post['tags'] ?? ''), ])); } } catch (Throwable $e) { } } } } $slug = ltrim($path, '/'); if ($slug !== '' && strpos($slug, '/') === false) { $db = Database::get(); if ($db instanceof PDO) { try { $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1"); $stmt->execute([':slug' => $slug]); $page = $stmt->fetch(PDO::FETCH_ASSOC); if ($page) { $view = new View(__DIR__ . '/../../modules/pages/views'); return new Response($view->render('site/show.php', [ 'title' => (string)$page['title'], 'content_html' => Shortcodes::render((string)$page['content_html'], [ 'page_slug' => (string)$slug, ]), ])); } } catch (Throwable $e) { } } } } $view = new View(); return new Response($view->render('site/404.php', [ 'title' => 'Not Found', 'message' => 'Page not found.', ]), 404); } private function requiresCsrf(string $path): bool { // All browser-initiated POST routes require CSRF protection. return true; } private function matchRedirect(string $path): ?Response { $db = Database::get(); if (!($db instanceof PDO)) { return null; } try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_redirects ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, source_path VARCHAR(255) NOT NULL UNIQUE, target_url VARCHAR(1000) NOT NULL, status_code SMALLINT NOT NULL DEFAULT 301, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $stmt = $db->prepare(" SELECT target_url, status_code FROM ac_redirects WHERE source_path = :path AND is_active = 1 LIMIT 1 "); $stmt->execute([':path' => $path]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $code = (int)($row['status_code'] ?? 301); if (!in_array($code, [301, 302, 307, 308], true)) { $code = 301; } $target = trim((string)($row['target_url'] ?? '')); if ($target === '' || $target === $path) { return null; } return new Response('', $code, ['Location' => $target]); } catch (Throwable $e) { return null; } } }