Initial dev export (exclude uploads/runtime)
This commit is contained in:
27
core/http/Response.php
Normal file
27
core/http/Response.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Http;
|
||||
|
||||
class Response
|
||||
{
|
||||
private string $body;
|
||||
private int $status;
|
||||
private array $headers;
|
||||
|
||||
public function __construct(string $body = '', int $status = 200, array $headers = [])
|
||||
{
|
||||
$this->body = $body;
|
||||
$this->status = $status;
|
||||
$this->headers = $headers;
|
||||
}
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
http_response_code($this->status);
|
||||
foreach ($this->headers as $name => $value) {
|
||||
header($name . ': ' . $value);
|
||||
}
|
||||
echo $this->body;
|
||||
}
|
||||
}
|
||||
185
core/http/Router.php
Normal file
185
core/http/Router.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Http;
|
||||
|
||||
use Core\Services\Auth;
|
||||
use Core\Services\Csrf;
|
||||
use Core\Services\Database;
|
||||
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 === '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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user