Files
AudioCore/core/http/Router.php
AudioCore Bot 9deabe1ec9 Release v1.5.1
2026-04-01 14:12:17 +00:00

198 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Http;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Csrf;
use Core\Services\Permissions;
use Core\Services\Shortcodes;
use Core\Views\View;
use PDO;
use Throwable;
class Router
{
private array $routes = [];
public function get(string $path, callable $handler): void
{
$this->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 === '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 (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true) && !$this->isCsrfExempt($path) && !Csrf::verifyRequest()) {
return $this->csrfFailureResponse($path);
}
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 isCsrfExempt(string $path): bool
{
return str_starts_with($path, '/api/');
}
private function csrfFailureResponse(string $path): Response
{
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if (stripos($accept, 'application/json') !== false || stripos($contentType, 'application/json') !== false) {
return new Response(json_encode(['ok' => false, 'error' => 'Invalid CSRF token.']), 419, ['Content-Type' => 'application/json']);
}
$target = (string)($_SERVER['HTTP_REFERER'] ?? $path);
if ($target === '') {
$target = '/';
}
$separator = str_contains($target, '?') ? '&' : '?';
return new Response('', 302, ['Location' => $target . $separator . 'error=csrf']);
}
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;
}
}
}