feat: initial foundation for collaborative canteen ordering system in vanilla PHP
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class Auth
|
||||
{
|
||||
private static ?array $cachedUser = null;
|
||||
|
||||
public static function init(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public static function check(): bool
|
||||
{
|
||||
self::init();
|
||||
return !empty($_SESSION['user_id']);
|
||||
}
|
||||
|
||||
public static function id(): ?int
|
||||
{
|
||||
self::init();
|
||||
return $_SESSION['user_id'] ?? null;
|
||||
}
|
||||
|
||||
public static function user(): ?array
|
||||
{
|
||||
self::init();
|
||||
if (!self::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::$cachedUser === null) {
|
||||
self::$cachedUser = User::find((int)$_SESSION['user_id']);
|
||||
}
|
||||
|
||||
return self::$cachedUser;
|
||||
}
|
||||
|
||||
public static function login(array $user): void
|
||||
{
|
||||
self::init();
|
||||
$_SESSION['user_id'] = (int)$user['id'];
|
||||
self::$cachedUser = $user;
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
self::init();
|
||||
unset($_SESSION['user_id']);
|
||||
self::$cachedUser = null;
|
||||
}
|
||||
|
||||
public static function attempt(string $identifier, string $password): bool
|
||||
{
|
||||
$user = User::findByIdentifier($identifier);
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (password_verify($password, $user['password_hash'])) {
|
||||
self::login($user);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Autoloader
|
||||
{
|
||||
/**
|
||||
* Map of namespace prefix to base directory
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private static array $prefixes = [];
|
||||
|
||||
/**
|
||||
* Register autoloader with SPL
|
||||
*/
|
||||
public static function register(): void
|
||||
{
|
||||
spl_autoload_register([self::class, 'loadClass']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a base directory for a namespace prefix
|
||||
*/
|
||||
public static function addNamespace(string $prefix, string $baseDir): void
|
||||
{
|
||||
$prefix = trim($prefix, '\\') . '\\';
|
||||
$baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
self::$prefixes[$prefix] = $baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the class file for a given class name.
|
||||
*/
|
||||
public static function loadClass(string $class): bool
|
||||
{
|
||||
foreach (self::$prefixes as $prefix => $baseDir) {
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Config
|
||||
{
|
||||
private static array $settings = [];
|
||||
|
||||
public static function load(string $envFile): void
|
||||
{
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($line, '=')) {
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(str_starts_with($value, '"') && str_ends_with($value, '"')) ||
|
||||
(str_starts_with($value, "'") && str_ends_with($value, "'"))
|
||||
) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
|
||||
if (!array_key_exists($key, $_SERVER) && !array_key_exists($key, $_ENV)) {
|
||||
putenv("{$key}={$value}");
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key) ?: $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
protected function render(string $view, array $data = [], ?string $layout = 'layouts/main'): string
|
||||
{
|
||||
return View::render($view, $data, $layout);
|
||||
}
|
||||
|
||||
protected function json(mixed $data, int $statusCode = 200): Response
|
||||
{
|
||||
$response = new Response();
|
||||
return $response->json($data, $statusCode);
|
||||
}
|
||||
|
||||
protected function redirect(string $url, ?string $flashSuccess = null, ?string $flashError = null): Response
|
||||
{
|
||||
if ($flashSuccess) {
|
||||
View::setFlash('success', $flashSuccess);
|
||||
}
|
||||
if ($flashError) {
|
||||
View::setFlash('error', $flashError);
|
||||
}
|
||||
|
||||
$response = new Response();
|
||||
return $response->redirect($url);
|
||||
}
|
||||
|
||||
protected function validateCsrf(Request $request): bool
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$token = $request->post('_csrf') ?: $request->getHeader('X-CSRF-Token');
|
||||
$sessionToken = $_SESSION['csrf_token'] ?? null;
|
||||
|
||||
return $token && $sessionToken && hash_equals($sessionToken, $token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Database;
|
||||
|
||||
use App\Core\Config;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
public static function getConnection(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
$connection = Config::get('DB_CONNECTION', 'sqlite');
|
||||
|
||||
try {
|
||||
if ($connection === 'sqlite') {
|
||||
$databasePath = Config::get('DB_DATABASE', __DIR__ . '/../../../storage/database.sqlite');
|
||||
$dir = dirname($databasePath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
self::$instance = new PDO("sqlite:{$databasePath}", null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
self::$instance->exec('PRAGMA foreign_keys = ON;');
|
||||
} else {
|
||||
$host = Config::get('DB_HOST', '127.0.0.1');
|
||||
$port = Config::get('DB_PORT', '3306');
|
||||
$database = Config::get('DB_DATABASE', 'order_system');
|
||||
$username = Config::get('DB_USERNAME', 'root');
|
||||
$password = Config::get('DB_PASSWORD', '');
|
||||
|
||||
$dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4";
|
||||
self::$instance = new PDO($dsn, $username, $password, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
die('Database Connection Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes database tables and seed data for the Collaborative Canteen Ordering System
|
||||
*/
|
||||
public static function initSchema(): void
|
||||
{
|
||||
$db = self::getConnection();
|
||||
$isSqlite = Config::get('DB_CONNECTION', 'sqlite') === 'sqlite';
|
||||
|
||||
// Auto-migration check: If old schema exists without user_id / creator_id, drop old tables
|
||||
if ($isSqlite) {
|
||||
$checkTable = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='employee_orders'")->fetchColumn();
|
||||
if ($checkTable) {
|
||||
$columns = $db->query("PRAGMA table_info(employee_orders)")->fetchAll();
|
||||
$hasUserId = false;
|
||||
foreach ($columns as $col) {
|
||||
if ($col['name'] === 'user_id') {
|
||||
$hasUserId = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasUserId) {
|
||||
$db->exec("DROP TABLE IF EXISTS employee_order_items;");
|
||||
$db->exec("DROP TABLE IF EXISTS employee_orders;");
|
||||
$db->exec("DROP TABLE IF EXISTS session_menus;");
|
||||
$db->exec("DROP TABLE IF EXISTS daily_sessions;");
|
||||
$db->exec("DROP TABLE IF EXISTS employees;");
|
||||
$db->exec("DROP TABLE IF EXISTS users;");
|
||||
$db->exec("DROP TABLE IF EXISTS orders;");
|
||||
$db->exec("DROP TABLE IF EXISTS order_items;");
|
||||
$db->exec("DROP TABLE IF EXISTS products;");
|
||||
$db->exec("DROP TABLE IF EXISTS customers;");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$autoIncrement = $isSqlite ? 'AUTOINCREMENT' : 'AUTO_INCREMENT';
|
||||
$dateTimeDefault = $isSqlite ? "DATETIME DEFAULT (datetime('now', 'localtime'))" : 'DATETIME DEFAULT CURRENT_TIMESTAMP';
|
||||
|
||||
// Users Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY {$autoIncrement},
|
||||
name VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at {$dateTimeDefault}
|
||||
);
|
||||
");
|
||||
|
||||
// Daily Sessions Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS daily_sessions (
|
||||
id INTEGER PRIMARY KEY {$autoIncrement},
|
||||
creator_id INTEGER NOT NULL,
|
||||
session_date DATE NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
canteen_name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'open',
|
||||
notes TEXT DEFAULT NULL,
|
||||
created_at {$dateTimeDefault},
|
||||
updated_at {$dateTimeDefault},
|
||||
FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
");
|
||||
|
||||
// Session Menus Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS session_menus (
|
||||
id INTEGER PRIMARY KEY {$autoIncrement},
|
||||
session_id INTEGER NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
final_price DECIMAL(10,2) DEFAULT 0.00,
|
||||
created_at {$dateTimeDefault},
|
||||
FOREIGN KEY (session_id) REFERENCES daily_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
");
|
||||
|
||||
// Employee Orders Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS employee_orders (
|
||||
id INTEGER PRIMARY KEY {$autoIncrement},
|
||||
session_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'unpaid',
|
||||
notes TEXT DEFAULT NULL,
|
||||
total_bill DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
created_at {$dateTimeDefault},
|
||||
updated_at {$dateTimeDefault},
|
||||
FOREIGN KEY (session_id) REFERENCES daily_sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
");
|
||||
|
||||
// Employee Order Items Table
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS employee_order_items (
|
||||
id INTEGER PRIMARY KEY {$autoIncrement},
|
||||
employee_order_id INTEGER NOT NULL,
|
||||
session_menu_id INTEGER DEFAULT NULL,
|
||||
item_name VARCHAR(255) NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
total_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
notes VARCHAR(255) DEFAULT NULL,
|
||||
created_at {$dateTimeDefault},
|
||||
FOREIGN KEY (employee_order_id) REFERENCES employee_orders(id) ON DELETE CASCADE
|
||||
);
|
||||
");
|
||||
|
||||
// Check if seed data exists
|
||||
$stmt = $db->query("SELECT COUNT(*) FROM users");
|
||||
$count = (int)$stmt->fetchColumn();
|
||||
|
||||
if ($count === 0) {
|
||||
self::seedInitialData($db);
|
||||
}
|
||||
}
|
||||
|
||||
private static function seedInitialData(PDO $db): void
|
||||
{
|
||||
$hash = password_hash('password', PASSWORD_DEFAULT);
|
||||
|
||||
// Seed Users
|
||||
$users = [
|
||||
['Dea (Coordinator)', 'dea', 'dea@company.com', $hash],
|
||||
['Alex Turner', 'alex', 'alex@company.com', $hash],
|
||||
['Sarah Jenkins', 'sarah', 'sarah@company.com', $hash],
|
||||
['Budi Santoso', 'budi', 'budi@company.com', $hash],
|
||||
['Michael Chen', 'michael', 'michael@company.com', $hash],
|
||||
];
|
||||
|
||||
$stmtUser = $db->prepare("INSERT INTO users (name, username, email, password_hash) VALUES (?, ?, ?, ?)");
|
||||
foreach ($users as $u) {
|
||||
$stmtUser->execute($u);
|
||||
}
|
||||
|
||||
// Seed Today's Session created by Dea (id = 1)
|
||||
$today = date('Y-m-d');
|
||||
$title = "Dea's Lunch Hub - Kantin Bu Siti";
|
||||
$stmtSession = $db->prepare("INSERT INTO daily_sessions (creator_id, session_date, title, canteen_name, status, notes) VALUES (1, ?, ?, 'Kantin Bu Siti', 'open', 'Please submit your lunch orders before 11:30 AM!')");
|
||||
$stmtSession->execute([$today, $title]);
|
||||
$sessionId = (int)$db->lastInsertId();
|
||||
|
||||
// Seed Menu Items
|
||||
$menuItems = [
|
||||
'Ayam Bakar Madu + Nasi',
|
||||
'Ayam Geprek Sambal Bawang + Nasi',
|
||||
'Nasi Goreng Spesial',
|
||||
'Mie Goreng Seafood',
|
||||
'Soto Ayam Lamongan + Nasi',
|
||||
'Gado-Gado Lontong',
|
||||
'Tahu & Tempe Goreng (Extra)',
|
||||
'Es Teh Manis',
|
||||
'Es Jeruk Segar',
|
||||
];
|
||||
|
||||
$stmtMenu = $db->prepare("INSERT INTO session_menus (session_id, name, final_price) VALUES (?, ?, 0.00)");
|
||||
foreach ($menuItems as $item) {
|
||||
$stmtMenu->execute([$sessionId, $item]);
|
||||
}
|
||||
|
||||
// Seed an order for Alex (user_id = 2)
|
||||
$stmtOrder = $db->prepare("INSERT INTO employee_orders (session_id, user_id, status, notes, total_bill) VALUES (?, 2, 'unpaid', 'Pedas sedang ya', 0.00)");
|
||||
$stmtOrder->execute([$sessionId]);
|
||||
$alexOrderId = (int)$db->lastInsertId();
|
||||
|
||||
$stmtItem = $db->prepare("INSERT INTO employee_order_items (employee_order_id, session_menu_id, item_name, quantity, unit_price, total_price, notes) VALUES (?, ?, ?, ?, 0.00, 0.00, ?)");
|
||||
$stmtItem->execute([$alexOrderId, 1, 'Ayam Bakar Madu + Nasi', 1, 'Pedas sedang']);
|
||||
$stmtItem->execute([$alexOrderId, 8, 'Es Teh Manis', 1, 'Kurang manis / less sugar']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Global Helper Functions
|
||||
|
||||
if (!function_exists('e')) {
|
||||
function e(mixed $value): string
|
||||
{
|
||||
return \App\Core\View::escape($value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('money')) {
|
||||
function money(float|int|string $amount): string
|
||||
{
|
||||
return '$' . number_format((float)$amount, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Http;
|
||||
|
||||
class Request
|
||||
{
|
||||
private string $method;
|
||||
private string $uri;
|
||||
private string $path;
|
||||
private array $queryParams;
|
||||
private array $bodyParams;
|
||||
private array $headers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
|
||||
// Handle method overriding via _method for forms (PUT/DELETE/PATCH)
|
||||
if ($this->method === 'POST' && isset($_POST['_method'])) {
|
||||
$this->method = strtoupper($_POST['_method']);
|
||||
}
|
||||
|
||||
$this->uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$parsedUrl = parse_url($this->uri);
|
||||
$this->path = $parsedUrl['path'] ?? '/';
|
||||
|
||||
// Trim trailing slash for consistent routing (except root)
|
||||
if ($this->path !== '/' && str_ends_with($this->path, '/')) {
|
||||
$this->path = rtrim($this->path, '/');
|
||||
}
|
||||
|
||||
$this->queryParams = $_GET;
|
||||
|
||||
// Safely extract headers in all SAPIs
|
||||
if (function_exists('getallheaders')) {
|
||||
$this->headers = getallheaders() ?: [];
|
||||
} else {
|
||||
$this->headers = [];
|
||||
foreach ($_SERVER as $name => $value) {
|
||||
if (str_starts_with($name, 'HTTP_')) {
|
||||
$headerName = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))));
|
||||
$this->headers[$headerName] = $value;
|
||||
} elseif (in_array($name, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) {
|
||||
$headerName = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $name))));
|
||||
$this->headers[$headerName] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON or form POST body
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$this->bodyParams = json_decode($rawBody, true) ?: [];
|
||||
} else {
|
||||
$this->bodyParams = $_POST;
|
||||
}
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function getUri(): string
|
||||
{
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->queryParams[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function post(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->bodyParams[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_merge($this->queryParams, $this->bodyParams);
|
||||
}
|
||||
|
||||
public function getBody(): array
|
||||
{
|
||||
return $this->bodyParams;
|
||||
}
|
||||
|
||||
public function getHeader(string $name, ?string $default = null): ?string
|
||||
{
|
||||
$name = strtolower($name);
|
||||
foreach ($this->headers as $key => $value) {
|
||||
if (strtolower($key) === $name) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function isJson(): bool
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
return str_contains($contentType, 'application/json') || str_contains($accept, 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Http;
|
||||
|
||||
class Response
|
||||
{
|
||||
private int $statusCode = 200;
|
||||
private array $headers = [];
|
||||
private string $content = '';
|
||||
|
||||
public function setStatusCode(int $code): self
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function setHeader(string $name, string $value): self
|
||||
{
|
||||
$this->headers[$name] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setContent(string $content): self
|
||||
{
|
||||
$this->content = $content;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function json(mixed $data, int $statusCode = 200): self
|
||||
{
|
||||
$this->statusCode = $statusCode;
|
||||
$this->setHeader('Content-Type', 'application/json');
|
||||
$this->content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function redirect(string $url, int $statusCode = 302): self
|
||||
{
|
||||
$this->statusCode = $statusCode;
|
||||
$this->setHeader('Location', $url);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
http_response_code($this->statusCode);
|
||||
|
||||
foreach ($this->headers as $name => $value) {
|
||||
header("{$name}: {$value}");
|
||||
}
|
||||
|
||||
echo $this->content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Routing;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
private $notFoundHandler = null;
|
||||
|
||||
public function get(string $path, array|callable $handler): self
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function post(string $path, array|callable $handler): self
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function put(string $path, array|callable $handler): self
|
||||
{
|
||||
$this->addRoute('PUT', $path, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function delete(string $path, array|callable $handler): self
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function any(string $path, array|callable $handler): self
|
||||
{
|
||||
foreach (['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as $method) {
|
||||
$this->addRoute($method, $path, $handler);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setNotFound(callable $handler): void
|
||||
{
|
||||
$this->notFoundHandler = $handler;
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, array|callable $handler): void
|
||||
{
|
||||
// Normalize path
|
||||
$path = '/' . trim($path, '/');
|
||||
if ($path === '//') {
|
||||
$path = '/';
|
||||
}
|
||||
|
||||
// Convert {param} to regex pattern (?P<param>[^/]+)
|
||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
|
||||
$regex = '#^' . $pattern . '$#';
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => strtoupper($method),
|
||||
'path' => $path,
|
||||
'regex' => $regex,
|
||||
'handler' => $handler,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(Request $request, Response $response): Response
|
||||
{
|
||||
$method = $request->getMethod();
|
||||
$path = $request->getPath();
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($route['regex'], $path, $matches)) {
|
||||
// Extract named matches for params
|
||||
$params = [];
|
||||
foreach ($matches as $key => $value) {
|
||||
if (is_string($key)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$handler = $route['handler'];
|
||||
|
||||
if (is_callable($handler)) {
|
||||
$result = call_user_func($handler, $request, $response, $params);
|
||||
} elseif (is_array($handler) && count($handler) === 2) {
|
||||
[$controllerClass, $action] = $handler;
|
||||
$controller = new $controllerClass();
|
||||
$result = call_user_func([$controller, $action], $request, $response, $params);
|
||||
} else {
|
||||
throw new \RuntimeException('Invalid route handler specified.');
|
||||
}
|
||||
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (is_string($result)) {
|
||||
return $response->setContent($result);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
if ($this->notFoundHandler) {
|
||||
$result = call_user_func($this->notFoundHandler, $request, $response);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
return $response->setStatusCode(404)->setContent((string)$result);
|
||||
}
|
||||
|
||||
return $response->setStatusCode(404)->setContent('<h1>404 Not Found</h1><p>The requested route does not exist.</p>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class View
|
||||
{
|
||||
private static string $viewsPath = __DIR__ . '/../Views/';
|
||||
private static array $sections = [];
|
||||
private static ?string $currentSection = null;
|
||||
|
||||
public static function render(string $view, array $data = [], ?string $layout = 'layouts/main'): string
|
||||
{
|
||||
$viewFile = self::$viewsPath . str_replace('.', '/', $view) . '.php';
|
||||
|
||||
if (!file_exists($viewFile)) {
|
||||
throw new \RuntimeException("View file not found: {$viewFile}");
|
||||
}
|
||||
|
||||
// Extract variables for view scope
|
||||
extract($data);
|
||||
|
||||
// Capture view content
|
||||
ob_start();
|
||||
require $viewFile;
|
||||
$content = ob_get_clean();
|
||||
|
||||
// If a layout is specified, wrap content with the layout
|
||||
if ($layout !== null) {
|
||||
$layoutFile = self::$viewsPath . str_replace('.', '/', $layout) . '.php';
|
||||
if (!file_exists($layoutFile)) {
|
||||
throw new \RuntimeException("Layout file not found: {$layoutFile}");
|
||||
}
|
||||
|
||||
ob_start();
|
||||
require $layoutFile;
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public static function setFlash(string $type, string $message): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$_SESSION['flash'][$type][] = $message;
|
||||
}
|
||||
|
||||
public static function getFlashes(): array
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$flashes = $_SESSION['flash'] ?? [];
|
||||
unset($_SESSION['flash']);
|
||||
return $flashes;
|
||||
}
|
||||
|
||||
public static function csrfToken(): string
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
public static function csrfField(): string
|
||||
{
|
||||
$token = self::csrfToken();
|
||||
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">';
|
||||
}
|
||||
|
||||
public static function escape(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user