2
0

tab indentation

This commit is contained in:
Sky Johnson 2025-09-11 10:25:02 -05:00
parent 5cc24441d0
commit 65bfec8078
13 changed files with 2286 additions and 2294 deletions

View File

@ -8,326 +8,326 @@ require_once __DIR__ . '/Validator.php';
*/ */
class Context class Context
{ {
public Request $request; public Request $request;
public Response $response; public Response $response;
public Session $session; public Session $session;
public array $state = []; public array $state = [];
/** /**
* __construct creates a new Context with request and response * __construct creates a new Context with request and response
*/ */
public function __construct() public function __construct()
{ {
$this->request = new Request(); $this->request = new Request();
$this->response = new Response(); $this->response = new Response();
$this->session = new Session(); $this->session = new Session();
$this->session->start(); $this->session->start();
} }
/** /**
* set stores a value in the context state * set stores a value in the context state
*/ */
public function set(string $key, mixed $value): void public function set(string $key, mixed $value): void
{ {
$this->state[$key] = $value; $this->state[$key] = $value;
} }
/** /**
* get retrieves a value from the context state * get retrieves a value from the context state
*/ */
public function get(string $key): mixed public function get(string $key): mixed
{ {
return $this->state[$key] ?? null; return $this->state[$key] ?? null;
} }
/** /**
* json sends a JSON response * json sends a JSON response
*/ */
public function json(mixed $data, int $status = 200): void public function json(mixed $data, int $status = 200): void
{ {
$this->response->json($data, $status)->send(); $this->response->json($data, $status)->send();
} }
/** /**
* text sends a plain text response * text sends a plain text response
*/ */
public function text(string $text, int $status = 200): void public function text(string $text, int $status = 200): void
{ {
$this->response->text($text, $status)->send(); $this->response->text($text, $status)->send();
} }
/** /**
* html sends an HTML response * html sends an HTML response
*/ */
public function html(string $html, int $status = 200): void public function html(string $html, int $status = 200): void
{ {
$this->response->html($html, $status)->send(); $this->response->html($html, $status)->send();
} }
/** /**
* redirect sends a redirect response * redirect sends a redirect response
*/ */
public function redirect(string $url, int $status = 302): void public function redirect(string $url, int $status = 302): void
{ {
$this->response->redirect($url, $status)->send(); $this->response->redirect($url, $status)->send();
} }
/** /**
* error sends an error response with appropriate content type * error sends an error response with appropriate content type
*/ */
public function error(int $status, string $message = ''): void public function error(int $status, string $message = ''): void
{ {
$messages = [ $messages = [
400 => 'Bad Request', 400 => 'Bad Request',
401 => 'Unauthorized', 401 => 'Unauthorized',
403 => 'Forbidden', 403 => 'Forbidden',
404 => 'Not Found', 404 => 'Not Found',
405 => 'Method Not Allowed', 405 => 'Method Not Allowed',
500 => 'Internal Server Error', 500 => 'Internal Server Error',
502 => 'Bad Gateway', 502 => 'Bad Gateway',
503 => 'Service Unavailable' 503 => 'Service Unavailable'
]; ];
$message = $message ?: ($messages[$status] ?? 'Error'); $message = $message ?: ($messages[$status] ?? 'Error');
$this->response->status($status); $this->response->status($status);
if ($this->request->header('accept') && str_contains($this->request->header('accept'), 'application/json')) { if ($this->request->header('accept') && str_contains($this->request->header('accept'), 'application/json')) {
$this->json(['error' => $message], $status); $this->json(['error' => $message], $status);
} else { } else {
$this->text($message, $status); $this->text($message, $status);
} }
} }
/** /**
* validate validates input data against rules * validate validates input data against rules
*/ */
public function validate(array $data, array $rules, array $messages = []): Validator public function validate(array $data, array $rules, array $messages = []): Validator
{ {
$validator = new Validator(); $validator = new Validator();
$validator->validate($data, $rules, $messages); $validator->validate($data, $rules, $messages);
if ($validator->failed()) { if ($validator->failed()) {
throw new ValidationException($validator->errors()); throw new ValidationException($validator->errors());
} }
return $validator; return $validator;
} }
/** /**
* validateRequest validates request data against rules * validateRequest validates request data against rules
*/ */
public function validateRequest(array $rules, array $messages = []): Validator public function validateRequest(array $rules, array $messages = []): Validator
{ {
$data = $this->request->all(); $data = $this->request->all();
return $this->validate($data, $rules, $messages); return $this->validate($data, $rules, $messages);
} }
/** /**
* header returns the value of a request header * header returns the value of a request header
*/ */
public function header(string $name): ?string public function header(string $name): ?string
{ {
return $this->request->header($name); return $this->request->header($name);
} }
/** /**
* input returns a form input value (POST data) * input returns a form input value (POST data)
*/ */
public function input(string $name, mixed $default = null): mixed public function input(string $name, mixed $default = null): mixed
{ {
return $this->request->input($name, $default); return $this->request->input($name, $default);
} }
/** /**
* query returns a query parameter value * query returns a query parameter value
*/ */
public function query(string $name, mixed $default = null): mixed public function query(string $name, mixed $default = null): mixed
{ {
return $this->request->query($name, $default); return $this->request->query($name, $default);
} }
/** /**
* jsonValue returns a value from JSON body * jsonValue returns a value from JSON body
*/ */
public function jsonValue(string $name, mixed $default = null): mixed public function jsonValue(string $name, mixed $default = null): mixed
{ {
return $this->request->jsonValue($name, $default); return $this->request->jsonValue($name, $default);
} }
/** /**
* param returns a route parameter by integer index * param returns a route parameter by integer index
*/ */
public function param(int $index, mixed $default = null): mixed public function param(int $index, mixed $default = null): mixed
{ {
return $this->request->param($index, $default); return $this->request->param($index, $default);
} }
/** /**
* all returns all input data merged from all sources * all returns all input data merged from all sources
*/ */
public function all(): array public function all(): array
{ {
return $this->request->all(); return $this->request->all();
} }
/** /**
* only returns only specified keys from input * only returns only specified keys from input
*/ */
public function only(array $keys): array public function only(array $keys): array
{ {
return $this->request->only($keys); return $this->request->only($keys);
} }
/** /**
* except returns all input except specified keys * except returns all input except specified keys
*/ */
public function except(array $keys): array public function except(array $keys): array
{ {
return $this->request->except($keys); return $this->request->except($keys);
} }
/** /**
* has checks if input key exists * has checks if input key exists
*/ */
public function has(string $key): bool public function has(string $key): bool
{ {
return $this->request->has($key); return $this->request->has($key);
} }
/** /**
* cookie returns the value of a request cookie * cookie returns the value of a request cookie
*/ */
public function cookie(string $name): ?string public function cookie(string $name): ?string
{ {
return $this->request->cookie($name); return $this->request->cookie($name);
} }
/** /**
* expectsJson checks if request expects JSON response * expectsJson checks if request expects JSON response
*/ */
public function expectsJson(): bool public function expectsJson(): bool
{ {
return $this->request->expectsJson(); return $this->request->expectsJson();
} }
/** /**
* isAjax checks if request is AJAX * isAjax checks if request is AJAX
*/ */
public function isAjax(): bool public function isAjax(): bool
{ {
return $this->request->isAjax(); return $this->request->isAjax();
} }
/** /**
* ip returns the client IP address * ip returns the client IP address
*/ */
public function ip(): string public function ip(): string
{ {
return $this->request->ip(); return $this->request->ip();
} }
/** /**
* userAgent returns the user agent string * userAgent returns the user agent string
*/ */
public function userAgent(): string public function userAgent(): string
{ {
return $this->request->userAgent(); return $this->request->userAgent();
} }
/** /**
* referer returns the referer URL * referer returns the referer URL
*/ */
public function referer(): ?string public function referer(): ?string
{ {
return $this->request->referer(); return $this->request->referer();
} }
/** /**
* isSecure checks if request is over HTTPS * isSecure checks if request is over HTTPS
*/ */
public function isSecure(): bool public function isSecure(): bool
{ {
return $this->request->isSecure(); return $this->request->isSecure();
} }
/** /**
* url returns the full URL of the request * url returns the full URL of the request
*/ */
public function url(): string public function url(): string
{ {
return $this->request->url(); return $this->request->url();
} }
/** /**
* fullUrl returns the URL with query string * fullUrl returns the URL with query string
*/ */
public function fullUrl(): string public function fullUrl(): string
{ {
return $this->request->fullUrl(); return $this->request->fullUrl();
} }
/** /**
* is checks if the request path matches a pattern * is checks if the request path matches a pattern
*/ */
public function is(string $pattern): bool public function is(string $pattern): bool
{ {
return $this->request->is($pattern); return $this->request->is($pattern);
} }
/** /**
* contentType returns the request content type without charset * contentType returns the request content type without charset
*/ */
public function contentType(): string public function contentType(): string
{ {
return $this->request->contentType(); return $this->request->contentType();
} }
/** /**
* status sets the HTTP status code for the response * status sets the HTTP status code for the response
*/ */
public function status(int $code): Response public function status(int $code): Response
{ {
return $this->response->status($code); return $this->response->status($code);
} }
/** /**
* setHeader sets a header for the response * setHeader sets a header for the response
*/ */
public function setHeader(string $name, string $value): Response public function setHeader(string $name, string $value): Response
{ {
return $this->response->header($name, $value); return $this->response->header($name, $value);
} }
/** /**
* setCookie sets a cookie with the given name, value, and options * setCookie sets a cookie with the given name, value, and options
*/ */
public function setCookie(string $name, string $value, array $options = []): Response public function setCookie(string $name, string $value, array $options = []): Response
{ {
return $this->response->cookie($name, $value, $options); return $this->response->cookie($name, $value, $options);
} }
/** /**
* write adds content to the response body * write adds content to the response body
*/ */
public function write(string $content): Response public function write(string $content): Response
{ {
return $this->response->write($content); return $this->response->write($content);
} }
/** /**
* end ends the response with optional content * end ends the response with optional content
*/ */
public function end(string $content = ''): void public function end(string $content = ''): void
{ {
$this->response->end($content); $this->response->end($content);
} }
/** /**
* send sends the response to the client * send sends the response to the client
*/ */
public function send(): void public function send(): void
{ {
$this->response->send(); $this->response->send();
} }
} }

View File

@ -11,11 +11,11 @@ require_once 'Web.php';
$app = new Web(debug: true); $app = new Web(debug: true);
$app->get('/', function(Context $context) { $app->get('/', function(Context $context) {
return 'Hello World!'; return 'Hello World!';
}); });
$app->get('/users/:id', function(Context $context, $id) { $app->get('/users/:id', function(Context $context, $id) {
return ['user_id' => $id, 'name' => 'John Doe']; return ['user_id' => $id, 'name' => 'John Doe'];
}); });
$app->run(); $app->run();
@ -32,9 +32,9 @@ $app = new Web(debug: true);
// Add global middleware // Add global middleware
$app->use(function(Context $context, callable $next) { $app->use(function(Context $context, callable $next) {
// Pre-processing // Pre-processing
$next(); $next();
// Post-processing // Post-processing
}); });
// Define routes // Define routes
@ -47,17 +47,17 @@ $app->head('/path', $handler);
// Route groups // Route groups
$app->group('/api', function(Web $app) { $app->group('/api', function(Web $app) {
$app->get('/users', $usersHandler); $app->get('/users', $usersHandler);
$app->post('/users', $createUserHandler); $app->post('/users', $createUserHandler);
}); });
// Custom error handlers // Custom error handlers
$app->setErrorHandler(404, function(Context $context) { $app->setErrorHandler(404, function(Context $context) {
$context->json(['error' => 'Custom 404'], 404); $context->json(['error' => 'Custom 404'], 404);
}); });
$app->setDefaultErrorHandler(function(Context $context, int $status, string $message, ?Exception $e) { $app->setDefaultErrorHandler(function(Context $context, int $status, string $message, ?Exception $e) {
$context->json(['error' => $message, 'status' => $status], $status); $context->json(['error' => $message, 'status' => $status], $status);
}); });
// Start the application // Start the application
@ -78,9 +78,9 @@ $result = $router->lookup('GET', '/posts/123');
// Returns: ['code' => 200, 'handler' => $handler, 'params' => ['123']] // Returns: ['code' => 200, 'handler' => $handler, 'params' => ['123']]
// Route patterns // Route patterns
'/users/:id' // matches /users/123, /users/abc '/users/:id' // matches /users/123, /users/abc
'/posts/:slug/edit' // matches /posts/my-post/edit '/posts/:slug/edit' // matches /posts/my-post/edit
'/' // matches root path '/' // matches root path
``` ```
## Context Examples ## Context Examples
@ -89,27 +89,27 @@ $result = $router->lookup('GET', '/posts/123');
```php ```php
// Get input from specific sources - explicit and predictable // Get input from specific sources - explicit and predictable
$name = $context->input('name', 'default'); // POST data only $name = $context->input('name', 'default'); // POST data only
$search = $context->query('search', ''); // Query params only $search = $context->query('search', ''); // Query params only
$data = $context->jsonValue('data', []); // JSON body only $data = $context->jsonValue('data', []); // JSON body only
$id = $context->param(0, 0); // First route parameter $id = $context->param(0, 0); // First route parameter
// Examples with route parameters by index // Examples with route parameters by index
// Route: /users/:userId/posts/:postId // Route: /users/:userId/posts/:postId
// URL: /users/123/posts/456 // URL: /users/123/posts/456
$userId = $context->param(0); // "123" (first parameter) $userId = $context->param(0); // "123" (first parameter)
$postId = $context->param(1); // "456" (second parameter) $postId = $context->param(1); // "456" (second parameter)
// Examples with conflicting parameter names in different sources // Examples with conflicting parameter names in different sources
// URL: /users/123?name=query_name // URL: /users/123?name=query_name
// POST: name=post_name // POST: name=post_name
// JSON: {"name": "json_name"} // JSON: {"name": "json_name"}
$routeId = $context->param(0); // "123" from route (first param) $routeId = $context->param(0); // "123" from route (first param)
$queryName = $context->query('name'); // "query_name" from URL $queryName = $context->query('name'); // "query_name" from URL
$postName = $context->input('name'); // "post_name" from form $postName = $context->input('name'); // "post_name" from form
$jsonName = $context->jsonValue('name'); // "json_name" from JSON $jsonName = $context->jsonValue('name'); // "json_name" from JSON
// Get all input data merged from all sources (route params override all) // Get all input data merged from all sources (route params override all)
$all = $context->all(); $all = $context->all();
@ -118,7 +118,7 @@ $data = $context->except(['password']); // all except specified keys
// Check if input exists (checks all sources) // Check if input exists (checks all sources)
if ($context->has('email')) { if ($context->has('email')) {
// handle email // handle email
} }
// Request headers and cookies // Request headers and cookies
@ -133,15 +133,15 @@ $contentType = $context->contentType();
// Request checks // Request checks
if ($context->expectsJson()) { if ($context->expectsJson()) {
// return JSON response // return JSON response
} }
if ($context->isAjax()) { if ($context->isAjax()) {
// handle AJAX request // handle AJAX request
} }
if ($context->isSecure()) { if ($context->isSecure()) {
// HTTPS request // HTTPS request
} }
// URL helpers // URL helpers
@ -150,7 +150,7 @@ $fullUrl = $context->fullUrl(); // URL with query string
// Path matching // Path matching
if ($context->is('api/*')) { if ($context->is('api/*')) {
// matches API routes // matches API routes
} }
``` ```
@ -191,13 +191,13 @@ $user = $context->get('user');
```php ```php
// Validate request data // Validate request data
$validator = $context->validateRequest([ $validator = $context->validateRequest([
'email' => 'required|email', 'email' => 'required|email',
'name' => 'required|string|min:2' 'name' => 'required|string|min:2'
]); ]);
// Validate any data // Validate any data
$validator = $context->validate($data, [ $validator = $context->validate($data, [
'field' => 'required|string' 'field' => 'required|string'
], ['field.required' => 'Custom error message']); ], ['field.required' => 'Custom error message']);
``` ```
@ -207,23 +207,23 @@ $validator = $context->validate($data, [
$request = new Request(); $request = new Request();
// Basic properties // Basic properties
$request->method; // GET, POST, etc. $request->method; // GET, POST, etc.
$request->uri; // /path?query=value $request->uri; // /path?query=value
$request->path; // /path $request->path; // /path
$request->query; // query=value $request->query; // query=value
$request->body; // raw request body $request->body; // raw request body
// Parsed data // Parsed data
$request->queryParams; // parsed query parameters $request->queryParams; // parsed query parameters
$request->postData; // parsed POST data $request->postData; // parsed POST data
$request->params; // route parameters (set by router) $request->params; // route parameters (set by router)
// Input methods // Input methods
$value = $request->input('key', 'default'); // POST data only $value = $request->input('key', 'default'); // POST data only
$value = $request->query('key', 'default'); // Query params only $value = $request->query('key', 'default'); // Query params only
$value = $request->jsonValue('key', 'default'); // JSON body only $value = $request->jsonValue('key', 'default'); // JSON body only
$value = $request->param(0, 'default'); // Route params by index $value = $request->param(0, 'default'); // Route params by index
$all = $request->all(); // all input merged $all = $request->all(); // all input merged
$subset = $request->only(['key1', 'key2']); $subset = $request->only(['key1', 'key2']);
$subset = $request->except(['password']); $subset = $request->except(['password']);
$exists = $request->has('key'); $exists = $request->has('key');
@ -257,22 +257,22 @@ $response = new Response();
// Set status and headers // Set status and headers
$response->status(201) $response->status(201)
->header('Content-Type', 'application/json') ->header('Content-Type', 'application/json')
->header('X-Custom', 'value'); ->header('X-Custom', 'value');
// Content methods // Content methods
$response->json($data, 201); // JSON with status $response->json($data, 201); // JSON with status
$response->text('Hello', 200); // Plain text $response->text('Hello', 200); // Plain text
$response->html('<h1>Hi</h1>', 200); // HTML $response->html('<h1>Hi</h1>', 200); // HTML
$response->redirect('/login', 302); // Redirect $response->redirect('/login', 302); // Redirect
// Cookies // Cookies
$response->cookie('name', 'value', [ $response->cookie('name', 'value', [
'expires' => time() + 3600, 'expires' => time() + 3600,
'path' => '/', 'path' => '/',
'secure' => true, 'secure' => true,
'httponly' => true, 'httponly' => true,
'samesite' => 'Strict' 'samesite' => 'Strict'
]); ]);
// Manual building // Manual building
@ -307,8 +307,8 @@ $valid = $session->validateCsrf($userToken);
// Session management // Session management
$session->regenerate(); // Regenerate session ID $session->regenerate(); // Regenerate session ID
$session->destroy(); // Destroy session completely $session->destroy(); // Destroy session completely
$session->clear(); // Clear data but keep session active $session->clear(); // Clear data but keep session active
$all = $session->all(); // Get all session data $all = $session->all(); // Get all session data
``` ```
@ -319,27 +319,27 @@ $validator = new Validator();
// Validate data // Validate data
$isValid = $validator->validate($data, [ $isValid = $validator->validate($data, [
'email' => 'required|email', 'email' => 'required|email',
'password' => 'required|min:8', 'password' => 'required|min:8',
'age' => 'integer|between:18,120', 'age' => 'integer|between:18,120',
'tags' => 'array', 'tags' => 'array',
'website' => 'url', 'website' => 'url',
'role' => 'in:admin,user,moderator' 'role' => 'in:admin,user,moderator'
], [ ], [
'email.required' => 'Email is mandatory', 'email.required' => 'Email is mandatory',
'password.min' => 'Password must be at least 8 characters' 'password.min' => 'Password must be at least 8 characters'
]); ]);
// Check results // Check results
if ($validator->failed()) { if ($validator->failed()) {
$errors = $validator->errors(); $errors = $validator->errors();
$firstError = $validator->firstError(); $firstError = $validator->firstError();
$fieldError = $validator->firstError('email'); $fieldError = $validator->firstError('email');
} }
// Custom validation rules // Custom validation rules
Validator::extend('custom', function($value, $parameters, $data) { Validator::extend('custom', function($value, $parameters, $data) {
return $value === 'custom_value'; return $value === 'custom_value';
}); });
``` ```
@ -347,9 +347,9 @@ Validator::extend('custom', function($value, $parameters, $data) {
```php ```php
$auth = new Auth($session, [ $auth = new Auth($session, [
'cookie_name' => 'remember_token', 'cookie_name' => 'remember_token',
'cookie_lifetime' => 2592000, // 30 days 'cookie_lifetime' => 2592000, // 30 days
'cookie_secure' => true 'cookie_secure' => true
]); ]);
// Authentication // Authentication
@ -376,10 +376,10 @@ $auth->clear(); // Clear all auth data
```php ```php
$user = new User([ $user = new User([
'id' => 1, 'id' => 1,
'username' => 'john', 'username' => 'john',
'email' => 'john@example.com', 'email' => 'john@example.com',
'role' => 'admin' 'role' => 'admin'
]); ]);
// Properties // Properties
@ -391,8 +391,8 @@ $user->role;
// Methods // Methods
$identifier = $user->getIdentifier(); // email or username $identifier = $user->getIdentifier(); // email or username
$id = $user->getId(); $id = $user->getId();
$array = $user->toArray(); // with all data $array = $user->toArray(); // with all data
$safe = $user->toSafeArray(); // without sensitive data $safe = $user->toSafeArray(); // without sensitive data
``` ```
## Auth Middleware Examples ## Auth Middleware Examples
@ -430,11 +430,11 @@ $errorHandler = new ErrorHandler(debug: true);
// Register custom error handlers // Register custom error handlers
$errorHandler->register(404, function(Context $context, int $status, string $message) { $errorHandler->register(404, function(Context $context, int $status, string $message) {
$context->json(['error' => 'Custom not found'], 404); $context->json(['error' => 'Custom not found'], 404);
}); });
$errorHandler->setDefaultHandler(function(Context $context, int $status, string $message, ?Exception $e) { $errorHandler->setDefaultHandler(function(Context $context, int $status, string $message, ?Exception $e) {
$context->json(['error' => $message, 'code' => $status], $status); $context->json(['error' => $message, 'code' => $status], $status);
}); });
// Handle errors manually // Handle errors manually
@ -451,46 +451,46 @@ throw new ValidationException($validator->errors(), 'Validation failed');
```php ```php
// Basic middleware // Basic middleware
$app->use(function(Context $context, callable $next) { $app->use(function(Context $context, callable $next) {
// Pre-processing // Pre-processing
$start = microtime(true); $start = microtime(true);
$next(); // Call next middleware/handler $next(); // Call next middleware/handler
// Post-processing // Post-processing
$duration = microtime(true) - $start; $duration = microtime(true) - $start;
$context->setHeader('X-Response-Time', $duration . 'ms'); $context->setHeader('X-Response-Time', $duration . 'ms');
}); });
// Conditional middleware // Conditional middleware
$app->use(function(Context $context, callable $next) { $app->use(function(Context $context, callable $next) {
if ($context->is('api/*')) { if ($context->is('api/*')) {
$context->setHeader('Content-Type', 'application/json'); $context->setHeader('Content-Type', 'application/json');
} }
$next(); $next();
}); });
// Logging middleware // Logging middleware
$app->use(function(Context $context, callable $next) { $app->use(function(Context $context, callable $next) {
$method = $context->request->method; $method = $context->request->method;
$path = $context->request->path; $path = $context->request->path;
$ip = $context->ip(); $ip = $context->ip();
error_log("[$method] $path from $ip"); error_log("[$method] $path from $ip");
$next(); $next();
}); });
// CORS middleware // CORS middleware
$app->use(function(Context $context, callable $next) { $app->use(function(Context $context, callable $next) {
$context->setHeader('Access-Control-Allow-Origin', '*'); $context->setHeader('Access-Control-Allow-Origin', '*');
$context->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); $context->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$context->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); $context->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if ($context->request->method === 'OPTIONS') { if ($context->request->method === 'OPTIONS') {
$context->status(200)->send(); $context->status(200)->send();
return; return;
} }
$next(); $next();
}); });
``` ```
@ -499,62 +499,62 @@ $app->use(function(Context $context, callable $next) {
```php ```php
// Simple handler // Simple handler
$app->get('/hello', function(Context $context) { $app->get('/hello', function(Context $context) {
return 'Hello World!'; return 'Hello World!';
}); });
// Handler with parameters // Handler with parameters
$app->get('/users/:id/posts/:slug', function(Context $context, $userId, $slug) { $app->get('/users/:id/posts/:slug', function(Context $context, $userId, $slug) {
// Parameters are passed as function arguments in order // Parameters are passed as function arguments in order
// Or access via index: $userId = $context->param(0), $slug = $context->param(1) // Or access via index: $userId = $context->param(0), $slug = $context->param(1)
return [ return [
'user_id' => $userId, 'user_id' => $userId,
'post_slug' => $slug, 'post_slug' => $slug,
'data' => $context->all() 'data' => $context->all()
]; ];
}); });
// Handler return types // Handler return types
$app->get('/json', fn($context) => ['key' => 'value']); // Auto JSON $app->get('/json', fn($context) => ['key' => 'value']); // Auto JSON
$app->get('/text', fn($context) => 'Plain text'); // Auto text $app->get('/text', fn($context) => 'Plain text'); // Auto text
$app->get('/response', fn($context) => $context->json([])); // Manual response $app->get('/response', fn($context) => $context->json([])); // Manual response
// Complex handler with validation // Complex handler with validation
$app->post('/users', function(Context $context) { $app->post('/users', function(Context $context) {
$context->validateRequest([ $context->validateRequest([
'name' => 'required|string|min:2', 'name' => 'required|string|min:2',
'email' => 'required|email', 'email' => 'required|email',
'age' => 'integer|min:18' 'age' => 'integer|min:18'
]); ]);
$userData = $context->only(['name', 'email', 'age']); $userData = $context->only(['name', 'email', 'age']);
// Save user logic here // Save user logic here
$userId = saveUser($userData); $userId = saveUser($userData);
return [ return [
'message' => 'User created successfully', 'message' => 'User created successfully',
'user_id' => $userId 'user_id' => $userId
]; ];
}); });
// File upload handler // File upload handler
$app->post('/upload', function(Context $context) { $app->post('/upload', function(Context $context) {
if (!isset($_FILES['file'])) { if (!isset($_FILES['file'])) {
$context->error(400, 'No file uploaded'); $context->error(400, 'No file uploaded');
return; return;
} }
$file = $_FILES['file']; $file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) { if ($file['error'] !== UPLOAD_ERR_OK) {
$context->error(400, 'Upload failed'); $context->error(400, 'Upload failed');
return; return;
} }
// Handle file upload logic // Handle file upload logic
$filename = handleFileUpload($file); $filename = handleFileUpload($file);
return ['filename' => $filename]; return ['filename' => $filename];
}); });
``` ```
@ -574,142 +574,142 @@ $authMiddleware = new AuthMiddleware($auth);
// Global middleware // Global middleware
$app->use(function(Context $context, callable $next) { $app->use(function(Context $context, callable $next) {
$context->setHeader('X-Framework', 'Web'); $context->setHeader('X-Framework', 'Web');
$next(); $next();
}); });
// Auth routes // Auth routes
$app->post('/login', function(Context $context) use ($auth) { $app->post('/login', function(Context $context) use ($auth) {
$context->validateRequest([ $context->validateRequest([
'email' => 'required|email', 'email' => 'required|email',
'password' => 'required|min:6' 'password' => 'required|min:6'
]); ]);
$email = $context->input('email'); $email = $context->input('email');
$password = $context->input('password'); $password = $context->input('password');
// Verify credentials externally, then login // Verify credentials externally, then login
if (verifyCredentials($email, $password)) { if (verifyCredentials($email, $password)) {
$userData = getUserData($email); $userData = getUserData($email);
$auth->login($userData, $context->input('remember')); $auth->login($userData, $context->input('remember'));
return ['message' => 'Logged in successfully']; return ['message' => 'Logged in successfully'];
} }
$context->error(401, 'Invalid credentials'); $context->error(401, 'Invalid credentials');
}); });
$app->post('/logout', function(Context $context) use ($auth) { $app->post('/logout', function(Context $context) use ($auth) {
$auth->logout(); $auth->logout();
return ['message' => 'Logged out']; return ['message' => 'Logged out'];
}); });
// Protected routes // Protected routes
$app->group('/api', function(Web $app) use ($authMiddleware) { $app->group('/api', function(Web $app) use ($authMiddleware) {
$app->use($authMiddleware->requireAuth()); $app->use($authMiddleware->requireAuth());
$app->use($authMiddleware->rateLimit(100, 1)); $app->use($authMiddleware->rateLimit(100, 1));
$app->get('/profile', function(Context $context) { $app->get('/profile', function(Context $context) {
return $context->get('user')->toSafeArray(); return $context->get('user')->toSafeArray();
}); });
$app->put('/profile', function(Context $context) { $app->put('/profile', function(Context $context) {
$context->validateRequest([ $context->validateRequest([
'name' => 'required|string|min:2', 'name' => 'required|string|min:2',
'email' => 'required|email' 'email' => 'required|email'
]); ]);
// Update profile logic here // Update profile logic here
return ['message' => 'Profile updated']; return ['message' => 'Profile updated'];
}); });
$app->get('/posts', function(Context $context) { $app->get('/posts', function(Context $context) {
$page = $context->input('page', 1); $page = $context->input('page', 1);
$limit = $context->input('limit', 10); $limit = $context->input('limit', 10);
// Get posts with pagination // Get posts with pagination
$posts = getPosts($page, $limit); $posts = getPosts($page, $limit);
return [ return [
'posts' => $posts, 'posts' => $posts,
'pagination' => [ 'pagination' => [
'page' => $page, 'page' => $page,
'limit' => $limit 'limit' => $limit
] ]
]; ];
}); });
$app->post('/posts', function(Context $context) { $app->post('/posts', function(Context $context) {
$context->validateRequest([ $context->validateRequest([
'title' => 'required|string|min:5', 'title' => 'required|string|min:5',
'content' => 'required|string|min:10', 'content' => 'required|string|min:10',
'tags' => 'array' 'tags' => 'array'
]); ]);
$postData = $context->only(['title', 'content', 'tags']); $postData = $context->only(['title', 'content', 'tags']);
$postData['user_id'] = $context->get('user')->id; $postData['user_id'] = $context->get('user')->id;
$postId = createPost($postData); $postId = createPost($postData);
return [ return [
'message' => 'Post created successfully', 'message' => 'Post created successfully',
'post_id' => $postId 'post_id' => $postId
]; ];
}); });
}); });
// Admin routes // Admin routes
$app->group('/admin', function(Web $app) use ($authMiddleware) { $app->group('/admin', function(Web $app) use ($authMiddleware) {
$app->use($authMiddleware->requireRole('admin')); $app->use($authMiddleware->requireRole('admin'));
$app->get('/users', function(Context $context) { $app->get('/users', function(Context $context) {
return ['users' => getAllUsers()]; return ['users' => getAllUsers()];
}); });
$app->delete('/users/:id', function(Context $context, $userId) { $app->delete('/users/:id', function(Context $context, $userId) {
deleteUser($userId); deleteUser($userId);
return ['message' => 'User deleted successfully']; return ['message' => 'User deleted successfully'];
}); });
$app->get('/stats', function(Context $context) { $app->get('/stats', function(Context $context) {
return [ return [
'total_users' => getUserCount(), 'total_users' => getUserCount(),
'total_posts' => getPostCount(), 'total_posts' => getPostCount(),
'active_sessions' => getActiveSessionCount() 'active_sessions' => getActiveSessionCount()
]; ];
}); });
}); });
// Public API routes // Public API routes
$app->group('/public', function(Web $app) { $app->group('/public', function(Web $app) {
$app->get('/posts', function(Context $context) { $app->get('/posts', function(Context $context) {
$posts = getPublicPosts(); $posts = getPublicPosts();
return ['posts' => $posts]; return ['posts' => $posts];
}); });
$app->get('/posts/:id', function(Context $context, $postId) { $app->get('/posts/:id', function(Context $context, $postId) {
$post = getPublicPost($postId); $post = getPublicPost($postId);
if (!$post) { if (!$post) {
$context->error(404, 'Post not found'); $context->error(404, 'Post not found');
return; return;
} }
return ['post' => $post]; return ['post' => $post];
}); });
}); });
// Error handlers // Error handlers
$app->setErrorHandler(404, function(Context $context) { $app->setErrorHandler(404, function(Context $context) {
if ($context->expectsJson()) { if ($context->expectsJson()) {
$context->json(['error' => 'Endpoint not found'], 404); $context->json(['error' => 'Endpoint not found'], 404);
} else { } else {
$context->html('<h1>Page Not Found</h1>', 404); $context->html('<h1>Page Not Found</h1>', 404);
} }
}); });
$app->setErrorHandler(429, function(Context $context) { $app->setErrorHandler(429, function(Context $context) {
$context->json([ $context->json([
'error' => 'Rate limit exceeded', 'error' => 'Rate limit exceeded',
'message' => 'Please slow down your requests' 'message' => 'Please slow down your requests'
], 429); ], 429);
}); });
$app->run(); $app->run();
@ -721,21 +721,21 @@ $app->run();
```php ```php
// Simple test setup // Simple test setup
function testRoute($method, $path, $data = []) { function testRoute($method, $path, $data = []) {
$_SERVER['REQUEST_METHOD'] = $method; $_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['REQUEST_URI'] = $path; $_SERVER['REQUEST_URI'] = $path;
if ($method === 'POST' && $data) { if ($method === 'POST' && $data) {
$_POST = $data; $_POST = $data;
} }
$app = new Web(); $app = new Web();
// Setup your routes... // Setup your routes...
ob_start(); ob_start();
$app->run(); $app->run();
$output = ob_get_clean(); $output = ob_get_clean();
return $output; return $output;
} }
// Test examples // Test examples

View File

@ -5,404 +5,404 @@
*/ */
class ErrorHandler class ErrorHandler
{ {
private array $handlers = []; private array $handlers = [];
private $defaultHandler = null; private $defaultHandler = null;
private bool $debug = false; private bool $debug = false;
/** /**
* __construct creates a new ErrorHandler * __construct creates a new ErrorHandler
*/ */
public function __construct(bool $debug = false) public function __construct(bool $debug = false)
{ {
$this->debug = $debug; $this->debug = $debug;
$this->registerDefaultHandlers(); $this->registerDefaultHandlers();
} }
/** /**
* register registers a custom error handler for a specific status code * register registers a custom error handler for a specific status code
*/ */
public function register(int $status, callable $handler): void public function register(int $status, callable $handler): void
{ {
$this->handlers[$status] = $handler; $this->handlers[$status] = $handler;
} }
/** /**
* setDefaultHandler sets the default error handler for unregistered status codes * setDefaultHandler sets the default error handler for unregistered status codes
*/ */
public function setDefaultHandler(callable $handler): void public function setDefaultHandler(callable $handler): void
{ {
$this->defaultHandler = $handler; $this->defaultHandler = $handler;
} }
/** /**
* handle handles an error with the appropriate handler * handle handles an error with the appropriate handler
*/ */
public function handle(Context $context, int $status, string $message = '', ?Exception $exception = null): void public function handle(Context $context, int $status, string $message = '', ?Exception $exception = null): void
{ {
if (isset($this->handlers[$status])) { if (isset($this->handlers[$status])) {
$handler = $this->handlers[$status]; $handler = $this->handlers[$status];
$handler($context, $status, $message, $exception); $handler($context, $status, $message, $exception);
return; return;
} }
if ($this->defaultHandler) { if ($this->defaultHandler) {
($this->defaultHandler)($context, $status, $message, $exception); ($this->defaultHandler)($context, $status, $message, $exception);
return; return;
} }
$this->renderDefaultError($context, $status, $message, $exception); $this->renderDefaultError($context, $status, $message, $exception);
} }
/** /**
* handleException handles uncaught exceptions * handleException handles uncaught exceptions
*/ */
public function handleException(Context $context, Exception $exception): void public function handleException(Context $context, Exception $exception): void
{ {
$status = $this->getStatusFromException($exception); $status = $this->getStatusFromException($exception);
$message = $this->debug ? $exception->getMessage() : $this->getDefaultMessage($status); $message = $this->debug ? $exception->getMessage() : $this->getDefaultMessage($status);
if ($this->debug) { if ($this->debug) {
error_log($exception->getMessage() . "\n" . $exception->getTraceAsString()); error_log($exception->getMessage() . "\n" . $exception->getTraceAsString());
} }
$this->handle($context, $status, $message, $exception); $this->handle($context, $status, $message, $exception);
} }
/** /**
* getStatusFromException determines HTTP status from exception type * getStatusFromException determines HTTP status from exception type
*/ */
private function getStatusFromException(Exception $exception): int private function getStatusFromException(Exception $exception): int
{ {
if ($exception instanceof HttpException) { if ($exception instanceof HttpException) {
return $exception->getStatusCode(); return $exception->getStatusCode();
} }
return match(get_class($exception)) { return match(get_class($exception)) {
'InvalidArgumentException' => 400, 'InvalidArgumentException' => 400,
'UnauthorizedException' => 401, 'UnauthorizedException' => 401,
'ForbiddenException' => 403, 'ForbiddenException' => 403,
'NotFoundException' => 404, 'NotFoundException' => 404,
'MethodNotAllowedException' => 405, 'MethodNotAllowedException' => 405,
'ValidationException' => 422, 'ValidationException' => 422,
default => 500 default => 500
}; };
} }
/** /**
* registerDefaultHandlers registers built-in error handlers * registerDefaultHandlers registers built-in error handlers
*/ */
private function registerDefaultHandlers(): void private function registerDefaultHandlers(): void
{ {
// 404 Not Found // 404 Not Found
$this->register(404, function(Context $context, int $status, string $message) { $this->register(404, function(Context $context, int $status, string $message) {
$accept = $context->request->header('accept') ?? ''; $accept = $context->request->header('accept') ?? '';
if (str_contains($accept, 'application/json')) { if (str_contains($accept, 'application/json')) {
$context->json(['error' => $message ?: 'Not Found'], 404); $context->json(['error' => $message ?: 'Not Found'], 404);
} else { } else {
$html = $this->render404Page($message); $html = $this->render404Page($message);
$context->html($html, 404); $context->html($html, 404);
} }
}); });
// 500 Internal Server Error // 500 Internal Server Error
$this->register(500, function(Context $context, int $status, string $message, ?Exception $exception) { $this->register(500, function(Context $context, int $status, string $message, ?Exception $exception) {
$accept = $context->request->header('accept') ?? ''; $accept = $context->request->header('accept') ?? '';
if (str_contains($accept, 'application/json')) { if (str_contains($accept, 'application/json')) {
$response = ['error' => $message ?: 'Internal Server Error']; $response = ['error' => $message ?: 'Internal Server Error'];
if ($this->debug && $exception) { if ($this->debug && $exception) {
$response['trace'] = $exception->getTrace(); $response['trace'] = $exception->getTrace();
} }
$context->json($response, 500); $context->json($response, 500);
} else { } else {
$html = $this->render500Page($message, $exception); $html = $this->render500Page($message, $exception);
$context->html($html, 500); $context->html($html, 500);
} }
}); });
} }
/** /**
* renderDefaultError renders a generic error response * renderDefaultError renders a generic error response
*/ */
private function renderDefaultError(Context $context, int $status, string $message, ?Exception $exception): void private function renderDefaultError(Context $context, int $status, string $message, ?Exception $exception): void
{ {
$message = $message ?: $this->getDefaultMessage($status); $message = $message ?: $this->getDefaultMessage($status);
$accept = $context->request->header('accept') ?? ''; $accept = $context->request->header('accept') ?? '';
if (str_contains($accept, 'application/json')) { if (str_contains($accept, 'application/json')) {
$response = ['error' => $message]; $response = ['error' => $message];
if ($this->debug && $exception) { if ($this->debug && $exception) {
$response['trace'] = $exception->getTrace(); $response['trace'] = $exception->getTrace();
} }
$context->json($response, $status); $context->json($response, $status);
} else { } else {
$html = $this->renderErrorPage($status, $message, $exception); $html = $this->renderErrorPage($status, $message, $exception);
$context->html($html, $status); $context->html($html, $status);
} }
} }
/** /**
* getDefaultMessage gets default message for status code * getDefaultMessage gets default message for status code
*/ */
private function getDefaultMessage(int $status): string private function getDefaultMessage(int $status): string
{ {
return match($status) { return match($status) {
400 => 'Bad Request', 400 => 'Bad Request',
401 => 'Unauthorized', 401 => 'Unauthorized',
403 => 'Forbidden', 403 => 'Forbidden',
404 => 'Not Found', 404 => 'Not Found',
405 => 'Method Not Allowed', 405 => 'Method Not Allowed',
422 => 'Unprocessable Entity', 422 => 'Unprocessable Entity',
429 => 'Too Many Requests', 429 => 'Too Many Requests',
500 => 'Internal Server Error', 500 => 'Internal Server Error',
502 => 'Bad Gateway', 502 => 'Bad Gateway',
503 => 'Service Unavailable', 503 => 'Service Unavailable',
default => 'Error' default => 'Error'
}; };
} }
/** /**
* render404Page renders a 404 error page * render404Page renders a 404 error page
*/ */
private function render404Page(string $message): string private function render404Page(string $message): string
{ {
$message = htmlspecialchars($message ?: 'Not Found'); $message = htmlspecialchars($message ?: 'Not Found');
return <<<HTML return <<<HTML
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>404 - Not Found</title> <title>404 - Not Found</title>
<style> <style>
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; background: #f5f5f5;
color: #333; color: #333;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
} }
.error-container { .error-container {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
} }
h1 { h1 {
font-size: 6rem; font-size: 6rem;
margin: 0; margin: 0;
color: #e74c3c; color: #e74c3c;
} }
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
margin: 1rem 0; margin: 1rem 0;
font-weight: normal; font-weight: normal;
} }
p { p {
color: #666; color: #666;
margin: 1rem 0; margin: 1rem 0;
} }
a { a {
color: #3498db; color: #3498db;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="error-container"> <div class="error-container">
<h1>404</h1> <h1>404</h1>
<h2>{$message}</h2> <h2>{$message}</h2>
<p>The page you are looking for could not be found.</p> <p>The page you are looking for could not be found.</p>
<a href="/">Go to Homepage</a> <a href="/">Go to Homepage</a>
</div> </div>
</body> </body>
</html> </html>
HTML; HTML;
} }
/** /**
* render500Page renders a 500 error page * render500Page renders a 500 error page
*/ */
private function render500Page(string $message, ?Exception $exception): string private function render500Page(string $message, ?Exception $exception): string
{ {
$message = htmlspecialchars($message ?: 'Internal Server Error'); $message = htmlspecialchars($message ?: 'Internal Server Error');
$debugInfo = ''; $debugInfo = '';
if ($this->debug && $exception) { if ($this->debug && $exception) {
$exceptionClass = get_class($exception); $exceptionClass = get_class($exception);
$file = htmlspecialchars($exception->getFile()); $file = htmlspecialchars($exception->getFile());
$line = $exception->getLine(); $line = $exception->getLine();
$trace = htmlspecialchars($exception->getTraceAsString()); $trace = htmlspecialchars($exception->getTraceAsString());
$debugInfo = <<<HTML $debugInfo = <<<HTML
<div class="debug-info"> <div class="debug-info">
<h3>Debug Information</h3> <h3>Debug Information</h3>
<p><strong>Exception:</strong> {$exceptionClass}</p> <p><strong>Exception:</strong> {$exceptionClass}</p>
<p><strong>File:</strong> {$file}:{$line}</p> <p><strong>File:</strong> {$file}:{$line}</p>
<pre>{$trace}</pre> <pre>{$trace}</pre>
</div> </div>
HTML; HTML;
} }
return <<<HTML return <<<HTML
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>500 - Internal Server Error</title> <title>500 - Internal Server Error</title>
<style> <style>
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; background: #f5f5f5;
color: #333; color: #333;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
} }
.error-container { .error-container {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
max-width: 800px; max-width: 800px;
} }
h1 { h1 {
font-size: 6rem; font-size: 6rem;
margin: 0; margin: 0;
color: #e74c3c; color: #e74c3c;
} }
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
margin: 1rem 0; margin: 1rem 0;
font-weight: normal; font-weight: normal;
} }
p { p {
color: #666; color: #666;
margin: 1rem 0; margin: 1rem 0;
} }
.debug-info { .debug-info {
margin-top: 2rem; margin-top: 2rem;
padding: 1rem; padding: 1rem;
background: #fff; background: #fff;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
text-align: left; text-align: left;
} }
.debug-info h3 { .debug-info h3 {
margin-top: 0; margin-top: 0;
color: #e74c3c; color: #e74c3c;
} }
.debug-info pre { .debug-info pre {
background: #f8f8f8; background: #f8f8f8;
padding: 1rem; padding: 1rem;
overflow-x: auto; overflow-x: auto;
font-size: 0.875rem; font-size: 0.875rem;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="error-container"> <div class="error-container">
<h1>500</h1> <h1>500</h1>
<h2>{$message}</h2> <h2>{$message}</h2>
<p>Something went wrong on our end. Please try again later.</p> <p>Something went wrong on our end. Please try again later.</p>
{$debugInfo} {$debugInfo}
</div> </div>
</body> </body>
</html> </html>
HTML; HTML;
} }
/** /**
* renderErrorPage renders a generic error page * renderErrorPage renders a generic error page
*/ */
private function renderErrorPage(int $status, string $message, ?Exception $exception): string private function renderErrorPage(int $status, string $message, ?Exception $exception): string
{ {
$message = htmlspecialchars($message); $message = htmlspecialchars($message);
$debugInfo = ''; $debugInfo = '';
if ($this->debug && $exception) { if ($this->debug && $exception) {
$exceptionClass = get_class($exception); $exceptionClass = get_class($exception);
$file = htmlspecialchars($exception->getFile()); $file = htmlspecialchars($exception->getFile());
$line = $exception->getLine(); $line = $exception->getLine();
$trace = htmlspecialchars($exception->getTraceAsString()); $trace = htmlspecialchars($exception->getTraceAsString());
$debugInfo = <<<HTML $debugInfo = <<<HTML
<div class="debug-info"> <div class="debug-info">
<h3>Debug Information</h3> <h3>Debug Information</h3>
<p><strong>Exception:</strong> {$exceptionClass}</p> <p><strong>Exception:</strong> {$exceptionClass}</p>
<p><strong>File:</strong> {$file}:{$line}</p> <p><strong>File:</strong> {$file}:{$line}</p>
<pre>{$trace}</pre> <pre>{$trace}</pre>
</div> </div>
HTML; HTML;
} }
return <<<HTML return <<<HTML
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>{$status} - {$message}</title> <title>{$status} - {$message}</title>
<style> <style>
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; background: #f5f5f5;
color: #333; color: #333;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
} }
.error-container { .error-container {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
max-width: 800px; max-width: 800px;
} }
h1 { h1 {
font-size: 6rem; font-size: 6rem;
margin: 0; margin: 0;
color: #e74c3c; color: #e74c3c;
} }
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
margin: 1rem 0; margin: 1rem 0;
font-weight: normal; font-weight: normal;
} }
.debug-info { .debug-info {
margin-top: 2rem; margin-top: 2rem;
padding: 1rem; padding: 1rem;
background: #fff; background: #fff;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
text-align: left; text-align: left;
} }
.debug-info h3 { .debug-info h3 {
margin-top: 0; margin-top: 0;
color: #e74c3c; color: #e74c3c;
} }
.debug-info pre { .debug-info pre {
background: #f8f8f8; background: #f8f8f8;
padding: 1rem; padding: 1rem;
overflow-x: auto; overflow-x: auto;
font-size: 0.875rem; font-size: 0.875rem;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="error-container"> <div class="error-container">
<h1>{$status}</h1> <h1>{$status}</h1>
<h2>{$message}</h2> <h2>{$message}</h2>
{$debugInfo} {$debugInfo}
</div> </div>
</body> </body>
</html> </html>
HTML; HTML;
} }
} }
/** /**
@ -410,18 +410,18 @@ HTML;
*/ */
class HttpException extends Exception class HttpException extends Exception
{ {
protected int $statusCode; protected int $statusCode;
public function __construct(int $statusCode, string $message = '', int $code = 0, Exception $previous = null) public function __construct(int $statusCode, string $message = '', int $code = 0, Exception $previous = null)
{ {
$this->statusCode = $statusCode; $this->statusCode = $statusCode;
parent::__construct($message, $code, $previous); parent::__construct($message, $code, $previous);
} }
public function getStatusCode(): int public function getStatusCode(): int
{ {
return $this->statusCode; return $this->statusCode;
} }
} }
/** /**
@ -429,16 +429,16 @@ class HttpException extends Exception
*/ */
class ValidationException extends HttpException class ValidationException extends HttpException
{ {
private array $errors; private array $errors;
public function __construct(array $errors, string $message = 'Validation failed') public function __construct(array $errors, string $message = 'Validation failed')
{ {
$this->errors = $errors; $this->errors = $errors;
parent::__construct(422, $message); parent::__construct(422, $message);
} }
public function getErrors(): array public function getErrors(): array
{ {
return $this->errors; return $this->errors;
} }
} }

View File

@ -4,36 +4,36 @@
* HTTPMethod represents an HTTP method * HTTPMethod represents an HTTP method
*/ */
enum HTTPMethod: string { enum HTTPMethod: string {
case GET = 'GET'; case GET = 'GET';
case POST = 'POST'; case POST = 'POST';
case PUT = 'PUT'; case PUT = 'PUT';
case DELETE = 'DELETE'; case DELETE = 'DELETE';
case PATCH = 'PATCH'; case PATCH = 'PATCH';
case OPTIONS = 'OPTIONS'; case OPTIONS = 'OPTIONS';
case HEAD = 'HEAD'; case HEAD = 'HEAD';
/** /**
* fromString converts a string to an HTTPMethod * fromString converts a string to an HTTPMethod
*/ */
public static function fromString(string $method): self public static function fromString(string $method): self
{ {
return match(strtoupper($method)) { return match(strtoupper($method)) {
'GET' => self::GET, 'GET' => self::GET,
'POST' => self::POST, 'POST' => self::POST,
'PUT' => self::PUT, 'PUT' => self::PUT,
'DELETE' => self::DELETE, 'DELETE' => self::DELETE,
'PATCH' => self::PATCH, 'PATCH' => self::PATCH,
'OPTIONS' => self::OPTIONS, 'OPTIONS' => self::OPTIONS,
'HEAD' => self::HEAD, 'HEAD' => self::HEAD,
default => self::GET default => self::GET
}; };
} }
/** /**
* toString returns the string representation of the method * toString returns the string representation of the method
*/ */
public function toString(): string public function toString(): string
{ {
return $this->value; return $this->value;
} }
} }

View File

@ -11,11 +11,11 @@ require_once 'Web.php';
$app = new Web(debug: true); $app = new Web(debug: true);
$app->get('/', function(Context $context) { $app->get('/', function(Context $context) {
return 'Hello World!'; return 'Hello World!';
}); });
$app->get('/users/:id', function(Context $context, $id) { $app->get('/users/:id', function(Context $context, $id) {
return ['user_id' => $id, 'name' => 'John Doe']; return ['user_id' => $id, 'name' => 'John Doe'];
}); });
$app->run(); $app->run();
@ -112,10 +112,10 @@ run(): void
### Context Class ### Context Class
```php ```php
// Request helpers // Request helpers
input(string $name, mixed $default = null): mixed // POST data input(string $name, mixed $default = null): mixed // POST data
query(string $name, mixed $default = null): mixed // Query params query(string $name, mixed $default = null): mixed // Query params
jsonValue(string $name, mixed $default = null): mixed // JSON body jsonValue(string $name, mixed $default = null): mixed // JSON body
param(int $index, mixed $default = null): mixed // Route params by index param(int $index, mixed $default = null): mixed // Route params by index
all(): array all(): array
only(array $keys): array only(array $keys): array
except(array $keys): array except(array $keys): array

View File

@ -5,265 +5,265 @@
*/ */
class Request class Request
{ {
public string $method; public string $method;
public string $uri; public string $uri;
public string $path; public string $path;
public string $query; public string $query;
public array $headers; public array $headers;
public string $body; public string $body;
public array $params = []; public array $params = [];
public array $queryParams = []; public array $queryParams = [];
public array $postData = []; public array $postData = [];
public array $cookies; public array $cookies;
/** /**
* __construct creates a new Request from PHP globals * __construct creates a new Request from PHP globals
*/ */
public function __construct() public function __construct()
{ {
$this->method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $this->method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->uri = $_SERVER['REQUEST_URI'] ?? '/'; $this->uri = $_SERVER['REQUEST_URI'] ?? '/';
$urlParts = parse_url($this->uri); $urlParts = parse_url($this->uri);
$this->path = $urlParts['path'] ?? '/'; $this->path = $urlParts['path'] ?? '/';
$this->query = $urlParts['query'] ?? ''; $this->query = $urlParts['query'] ?? '';
parse_str($this->query, $this->queryParams); parse_str($this->query, $this->queryParams);
$this->headers = $this->parseHeaders(); $this->headers = $this->parseHeaders();
$this->body = file_get_contents('php://input') ?: ''; $this->body = file_get_contents('php://input') ?: '';
$this->cookies = $_COOKIE ?? []; $this->cookies = $_COOKIE ?? [];
if ($this->method === 'POST' && $this->contentType() === 'application/x-www-form-urlencoded') { if ($this->method === 'POST' && $this->contentType() === 'application/x-www-form-urlencoded') {
parse_str($this->body, $this->postData); parse_str($this->body, $this->postData);
} elseif ($this->method === 'POST' && str_contains($this->contentType(), 'multipart/form-data')) { } elseif ($this->method === 'POST' && str_contains($this->contentType(), 'multipart/form-data')) {
$this->postData = $_POST ?? []; $this->postData = $_POST ?? [];
} }
} }
/** /**
* parseHeaders extracts HTTP headers from $_SERVER * parseHeaders extracts HTTP headers from $_SERVER
*/ */
private function parseHeaders(): array private function parseHeaders(): array
{ {
$headers = []; $headers = [];
foreach ($_SERVER as $key => $value) { foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) { if (str_starts_with($key, 'HTTP_')) {
$header = str_replace('_', '-', substr($key, 5)); $header = str_replace('_', '-', substr($key, 5));
$headers[strtolower($header)] = $value; $headers[strtolower($header)] = $value;
} }
} }
if (isset($_SERVER['CONTENT_TYPE'])) $headers['content-type'] = $_SERVER['CONTENT_TYPE']; if (isset($_SERVER['CONTENT_TYPE'])) $headers['content-type'] = $_SERVER['CONTENT_TYPE'];
if (isset($_SERVER['CONTENT_LENGTH'])) $headers['content-length'] = $_SERVER['CONTENT_LENGTH']; if (isset($_SERVER['CONTENT_LENGTH'])) $headers['content-length'] = $_SERVER['CONTENT_LENGTH'];
return $headers; return $headers;
} }
/** /**
* header returns the value of the specified header * header returns the value of the specified header
*/ */
public function header(string $name): ?string public function header(string $name): ?string
{ {
return $this->headers[strtolower($name)] ?? null; return $this->headers[strtolower($name)] ?? null;
} }
/** /**
* contentType returns the content type without charset * contentType returns the content type without charset
*/ */
public function contentType(): string public function contentType(): string
{ {
$contentType = $this->header('content-type') ?? ''; $contentType = $this->header('content-type') ?? '';
return explode(';', $contentType)[0]; return explode(';', $contentType)[0];
} }
/** /**
* json decodes the request body as JSON * json decodes the request body as JSON
*/ */
public function json(): mixed public function json(): mixed
{ {
return json_decode($this->body, true); return json_decode($this->body, true);
} }
/** /**
* cookie returns the value of the specified cookie * cookie returns the value of the specified cookie
*/ */
public function cookie(string $name): ?string public function cookie(string $name): ?string
{ {
return $this->cookies[$name] ?? null; return $this->cookies[$name] ?? null;
} }
/** /**
* param returns a route parameter by integer index * param returns a route parameter by integer index
*/ */
public function param(int $index, mixed $default = null): mixed public function param(int $index, mixed $default = null): mixed
{ {
return $this->params[$index] ?? $default; return $this->params[$index] ?? $default;
} }
/** /**
* input returns a form input value (POST data) * input returns a form input value (POST data)
*/ */
public function input(string $name, mixed $default = null): mixed public function input(string $name, mixed $default = null): mixed
{ {
return $this->postData[$name] ?? $default; return $this->postData[$name] ?? $default;
} }
/** /**
* query returns a query parameter value * query returns a query parameter value
*/ */
public function query(string $name, mixed $default = null): mixed public function query(string $name, mixed $default = null): mixed
{ {
return $this->queryParams[$name] ?? $default; return $this->queryParams[$name] ?? $default;
} }
/** /**
* jsonValue returns a value from JSON body * jsonValue returns a value from JSON body
*/ */
public function jsonValue(string $name, mixed $default = null): mixed public function jsonValue(string $name, mixed $default = null): mixed
{ {
if ($this->contentType() !== 'application/json') { if ($this->contentType() !== 'application/json') {
return $default; return $default;
} }
$json = $this->json(); $json = $this->json();
if (!is_array($json)) { if (!is_array($json)) {
return $default; return $default;
} }
return $json[$name] ?? $default; return $json[$name] ?? $default;
} }
/** /**
* all returns all input data merged from all sources * all returns all input data merged from all sources
*/ */
public function all(): array public function all(): array
{ {
$data = array_merge($this->queryParams, $this->postData, $this->params); $data = array_merge($this->queryParams, $this->postData, $this->params);
if ($this->contentType() === 'application/json') { if ($this->contentType() === 'application/json') {
$json = $this->json(); $json = $this->json();
if (is_array($json)) { if (is_array($json)) {
$data = array_merge($data, $json); $data = array_merge($data, $json);
} }
} }
return $data; return $data;
} }
/** /**
* only returns only specified keys from input * only returns only specified keys from input
*/ */
public function only(array $keys): array public function only(array $keys): array
{ {
$all = $this->all(); $all = $this->all();
return array_intersect_key($all, array_flip($keys)); return array_intersect_key($all, array_flip($keys));
} }
/** /**
* except returns all input except specified keys * except returns all input except specified keys
*/ */
public function except(array $keys): array public function except(array $keys): array
{ {
$all = $this->all(); $all = $this->all();
return array_diff_key($all, array_flip($keys)); return array_diff_key($all, array_flip($keys));
} }
/** /**
* has checks if input key exists * has checks if input key exists
*/ */
public function has(string $key): bool public function has(string $key): bool
{ {
return $this->input($key) !== null; return $this->input($key) !== null;
} }
/** /**
* expectsJson checks if request expects JSON response * expectsJson checks if request expects JSON response
*/ */
public function expectsJson(): bool public function expectsJson(): bool
{ {
$accept = $this->header('accept') ?? ''; $accept = $this->header('accept') ?? '';
return str_contains($accept, 'application/json') || return str_contains($accept, 'application/json') ||
str_contains($accept, 'text/json') || str_contains($accept, 'text/json') ||
$this->header('x-requested-with') === 'XMLHttpRequest'; $this->header('x-requested-with') === 'XMLHttpRequest';
} }
/** /**
* isAjax checks if request is AJAX * isAjax checks if request is AJAX
*/ */
public function isAjax(): bool public function isAjax(): bool
{ {
return $this->header('x-requested-with') === 'XMLHttpRequest'; return $this->header('x-requested-with') === 'XMLHttpRequest';
} }
/** /**
* ip returns the client IP address * ip returns the client IP address
*/ */
public function ip(): string public function ip(): string
{ {
// Check for proxied IPs // Check for proxied IPs
if ($ip = $this->header('x-forwarded-for')) { if ($ip = $this->header('x-forwarded-for')) {
return explode(',', $ip)[0]; return explode(',', $ip)[0];
} }
if ($ip = $this->header('x-real-ip')) { if ($ip = $this->header('x-real-ip')) {
return $ip; return $ip;
} }
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
} }
/** /**
* userAgent returns the user agent string * userAgent returns the user agent string
*/ */
public function userAgent(): string public function userAgent(): string
{ {
return $this->header('user-agent') ?? ''; return $this->header('user-agent') ?? '';
} }
/** /**
* referer returns the referer URL * referer returns the referer URL
*/ */
public function referer(): ?string public function referer(): ?string
{ {
return $this->header('referer'); return $this->header('referer');
} }
/** /**
* isSecure checks if request is over HTTPS * isSecure checks if request is over HTTPS
*/ */
public function isSecure(): bool public function isSecure(): bool
{ {
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
($_SERVER['SERVER_PORT'] ?? 80) == 443 || ($_SERVER['SERVER_PORT'] ?? 80) == 443 ||
($this->header('x-forwarded-proto') === 'https'); ($this->header('x-forwarded-proto') === 'https');
} }
/** /**
* url returns the full URL of the request * url returns the full URL of the request
*/ */
public function url(): string public function url(): string
{ {
$protocol = $this->isSecure() ? 'https' : 'http'; $protocol = $this->isSecure() ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $protocol . '://' . $host . $this->uri; return $protocol . '://' . $host . $this->uri;
} }
/** /**
* fullUrl returns the URL with query string * fullUrl returns the URL with query string
*/ */
public function fullUrl(): string public function fullUrl(): string
{ {
return $this->url(); return $this->url();
} }
/** /**
* is checks if the request path matches a pattern * is checks if the request path matches a pattern
*/ */
public function is(string $pattern): bool public function is(string $pattern): bool
{ {
$pattern = preg_quote($pattern, '#'); $pattern = preg_quote($pattern, '#');
$pattern = str_replace('\*', '.*', $pattern); $pattern = str_replace('\*', '.*', $pattern);
return preg_match('#^' . $pattern . '$#', $this->path) === 1; return preg_match('#^' . $pattern . '$#', $this->path) === 1;
} }
} }

View File

@ -5,128 +5,128 @@
*/ */
class Response class Response
{ {
private int $statusCode = 200; private int $statusCode = 200;
private array $headers = []; private array $headers = [];
private string $body = ''; private string $body = '';
private bool $sent = false; private bool $sent = false;
/** /**
* Set the HTTP status code for the response * Set the HTTP status code for the response
*/ */
public function status(int $code): Response public function status(int $code): Response
{ {
$this->statusCode = $code; $this->statusCode = $code;
return $this; return $this;
} }
/** /**
* Set a header for the response * Set a header for the response
*/ */
public function header(string $name, string $value): Response public function header(string $name, string $value): Response
{ {
$this->headers[$name] = $value; $this->headers[$name] = $value;
return $this; return $this;
} }
/** /**
* Set a JSON response with the given data and status code * Set a JSON response with the given data and status code
*/ */
public function json(mixed $data, int $status = 200): Response public function json(mixed $data, int $status = 200): Response
{ {
$this->statusCode = $status; $this->statusCode = $status;
$this->headers['Content-Type'] = 'application/json'; $this->headers['Content-Type'] = 'application/json';
$this->body = json_encode($data); $this->body = json_encode($data);
return $this; return $this;
} }
/** /**
* Set a text response with the given text and status code * Set a text response with the given text and status code
*/ */
public function text(string $text, int $status = 200): Response public function text(string $text, int $status = 200): Response
{ {
$this->statusCode = $status; $this->statusCode = $status;
$this->headers['Content-Type'] = 'text/plain'; $this->headers['Content-Type'] = 'text/plain';
$this->body = $text; $this->body = $text;
return $this; return $this;
} }
/** /**
* Set an HTML response with the given HTML and status code * Set an HTML response with the given HTML and status code
*/ */
public function html(string $html, int $status = 200): Response public function html(string $html, int $status = 200): Response
{ {
$this->statusCode = $status; $this->statusCode = $status;
$this->headers['Content-Type'] = 'text/html; charset=utf-8'; $this->headers['Content-Type'] = 'text/html; charset=utf-8';
$this->body = $html; $this->body = $html;
return $this; return $this;
} }
/** /**
* Redirect to the given URL with the given status code * Redirect to the given URL with the given status code
*/ */
public function redirect(string $url, int $status = 302): Response public function redirect(string $url, int $status = 302): Response
{ {
$this->statusCode = $status; $this->statusCode = $status;
$this->headers['Location'] = $url; $this->headers['Location'] = $url;
return $this; return $this;
} }
/** /**
* Set a cookie with the given name, value, and options * Set a cookie with the given name, value, and options
*/ */
public function cookie(string $name, string $value, array $options = []): Response public function cookie(string $name, string $value, array $options = []): Response
{ {
$options = array_merge([ $options = array_merge([
'expires' => 0, 'expires' => 0,
'path' => '/', 'path' => '/',
'domain' => '', 'domain' => '',
'secure' => false, 'secure' => false,
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax' 'samesite' => 'Lax'
], $options); ], $options);
setcookie($name, $value, [ setcookie($name, $value, [
'expires' => $options['expires'], 'expires' => $options['expires'],
'path' => $options['path'], 'path' => $options['path'],
'domain' => $options['domain'], 'domain' => $options['domain'],
'secure' => $options['secure'], 'secure' => $options['secure'],
'httponly' => $options['httponly'], 'httponly' => $options['httponly'],
'samesite' => $options['samesite'] 'samesite' => $options['samesite']
]); ]);
return $this; return $this;
} }
/** /**
* Send the response to the client * Send the response to the client
*/ */
public function send(): void public function send(): void
{ {
if ($this->sent) return; if ($this->sent) return;
http_response_code($this->statusCode); http_response_code($this->statusCode);
foreach ($this->headers as $name => $value) header("$name: $value"); foreach ($this->headers as $name => $value) header("$name: $value");
echo $this->body; echo $this->body;
$this->sent = true; $this->sent = true;
} }
/** /**
* Write the given content to the response body * Write the given content to the response body
*/ */
public function write(string $content): Response public function write(string $content): Response
{ {
$this->body .= $content; $this->body .= $content;
return $this; return $this;
} }
/** /**
* End the response with the given content * End the response with the given content
*/ */
public function end(string $content = ''): void public function end(string $content = ''): void
{ {
if ($content) $this->body .= $content; if ($content) $this->body .= $content;
$this->send(); $this->send();
} }
} }

View File

@ -5,174 +5,174 @@
*/ */
class Session class Session
{ {
private bool $started = false; private bool $started = false;
private array $flashData = []; private array $flashData = [];
/** /**
* start initializes the session if not already started * start initializes the session if not already started
*/ */
public function start(): void public function start(): void
{ {
if ($this->started || session_status() === PHP_SESSION_ACTIVE) { if ($this->started || session_status() === PHP_SESSION_ACTIVE) {
$this->started = true; $this->started = true;
$this->loadFlashData(); $this->loadFlashData();
return; return;
} }
session_start(); session_start();
$this->started = true; $this->started = true;
$this->loadFlashData(); $this->loadFlashData();
} }
/** /**
* get retrieves a value from the session * get retrieves a value from the session
*/ */
public function get(string $key, mixed $default = null): mixed public function get(string $key, mixed $default = null): mixed
{ {
$this->ensureStarted(); $this->ensureStarted();
return $_SESSION[$key] ?? $default; return $_SESSION[$key] ?? $default;
} }
/** /**
* set stores a value in the session * set stores a value in the session
*/ */
public function set(string $key, mixed $value): void public function set(string $key, mixed $value): void
{ {
$this->ensureStarted(); $this->ensureStarted();
$_SESSION[$key] = $value; $_SESSION[$key] = $value;
} }
/** /**
* has checks if a key exists in the session * has checks if a key exists in the session
*/ */
public function has(string $key): bool public function has(string $key): bool
{ {
$this->ensureStarted(); $this->ensureStarted();
return isset($_SESSION[$key]); return isset($_SESSION[$key]);
} }
/** /**
* remove deletes a value from the session * remove deletes a value from the session
*/ */
public function remove(string $key): void public function remove(string $key): void
{ {
$this->ensureStarted(); $this->ensureStarted();
unset($_SESSION[$key]); unset($_SESSION[$key]);
} }
/** /**
* flash sets a flash message that will be available only for the next request * flash sets a flash message that will be available only for the next request
*/ */
public function flash(string $key, mixed $value): void public function flash(string $key, mixed $value): void
{ {
$this->ensureStarted(); $this->ensureStarted();
$_SESSION['_flash_new'][$key] = $value; $_SESSION['_flash_new'][$key] = $value;
} }
/** /**
* getFlash retrieves a flash message (available only for current request) * getFlash retrieves a flash message (available only for current request)
*/ */
public function getFlash(string $key, mixed $default = null): mixed public function getFlash(string $key, mixed $default = null): mixed
{ {
return $this->flashData[$key] ?? $default; return $this->flashData[$key] ?? $default;
} }
/** /**
* hasFlash checks if a flash message exists * hasFlash checks if a flash message exists
*/ */
public function hasFlash(string $key): bool public function hasFlash(string $key): bool
{ {
return isset($this->flashData[$key]); return isset($this->flashData[$key]);
} }
/** /**
* csrfToken generates or retrieves the CSRF token for the session * csrfToken generates or retrieves the CSRF token for the session
*/ */
public function csrfToken(): string public function csrfToken(): string
{ {
$this->ensureStarted(); $this->ensureStarted();
if (!isset($_SESSION['_csrf_token'])) $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); if (!isset($_SESSION['_csrf_token'])) $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
return $_SESSION['_csrf_token']; return $_SESSION['_csrf_token'];
} }
/** /**
* validateCsrf validates a CSRF token * validateCsrf validates a CSRF token
*/ */
public function validateCsrf(string $token): bool public function validateCsrf(string $token): bool
{ {
$this->ensureStarted(); $this->ensureStarted();
if (!isset($_SESSION['_csrf_token'])) return false; if (!isset($_SESSION['_csrf_token'])) return false;
return hash_equals($_SESSION['_csrf_token'], $token); return hash_equals($_SESSION['_csrf_token'], $token);
} }
/** /**
* regenerate regenerates the session ID for security * regenerate regenerates the session ID for security
*/ */
public function regenerate(bool $deleteOldSession = true): bool public function regenerate(bool $deleteOldSession = true): bool
{ {
$this->ensureStarted(); $this->ensureStarted();
return session_regenerate_id($deleteOldSession); return session_regenerate_id($deleteOldSession);
} }
/** /**
* destroy destroys the session * destroy destroys the session
*/ */
public function destroy(): void public function destroy(): void
{ {
$this->ensureStarted(); $this->ensureStarted();
$_SESSION = []; $_SESSION = [];
if (ini_get("session.use_cookies")) { if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params(); $params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"], $params["path"], $params["domain"],
$params["secure"], $params["httponly"] $params["secure"], $params["httponly"]
); );
} }
session_destroy(); session_destroy();
$this->started = false; $this->started = false;
$this->flashData = []; $this->flashData = [];
} }
/** /**
* clear removes all session data but keeps the session active * clear removes all session data but keeps the session active
*/ */
public function clear(): void public function clear(): void
{ {
$this->ensureStarted(); $this->ensureStarted();
$_SESSION = []; $_SESSION = [];
$this->flashData = []; $this->flashData = [];
} }
/** /**
* all returns all session data * all returns all session data
*/ */
public function all(): array public function all(): array
{ {
$this->ensureStarted(); $this->ensureStarted();
$data = $_SESSION; $data = $_SESSION;
unset($data['_flash_old'], $data['_flash_new'], $data['_csrf_token']); unset($data['_flash_old'], $data['_flash_new'], $data['_csrf_token']);
return $data; return $data;
} }
/** /**
* ensureStarted ensures the session is started * ensureStarted ensures the session is started
*/ */
private function ensureStarted(): void private function ensureStarted(): void
{ {
if (!$this->started) $this->start(); if (!$this->started) $this->start();
} }
/** /**
* loadFlashData loads flash messages and rotates them * loadFlashData loads flash messages and rotates them
*/ */
private function loadFlashData(): void private function loadFlashData(): void
{ {
$this->flashData = $_SESSION['_flash_old'] ?? []; $this->flashData = $_SESSION['_flash_old'] ?? [];
$_SESSION['_flash_old'] = $_SESSION['_flash_new'] ?? []; $_SESSION['_flash_old'] = $_SESSION['_flash_new'] ?? [];
unset($_SESSION['_flash_new']); unset($_SESSION['_flash_new']);
} }
} }

View File

@ -5,351 +5,351 @@
*/ */
class Validator class Validator
{ {
private array $errors = []; private array $errors = [];
private array $data = []; private array $data = [];
private array $rules = []; private array $rules = [];
private array $messages = []; private array $messages = [];
/** /**
* Custom validation rules registry * Custom validation rules registry
*/ */
private static array $customRules = []; private static array $customRules = [];
/** /**
* validate performs validation on data with given rules * validate performs validation on data with given rules
*/ */
public function validate(array $data, array $rules, array $messages = []): bool public function validate(array $data, array $rules, array $messages = []): bool
{ {
$this->data = $data; $this->data = $data;
$this->rules = $rules; $this->rules = $rules;
$this->messages = $messages; $this->messages = $messages;
$this->errors = []; $this->errors = [];
foreach ($rules as $field => $fieldRules) { foreach ($rules as $field => $fieldRules) {
$this->validateField($field, $fieldRules); $this->validateField($field, $fieldRules);
} }
return empty($this->errors); return empty($this->errors);
} }
/** /**
* validateField validates a single field against its rules * validateField validates a single field against its rules
*/ */
private function validateField(string $field, string|array $rules): void private function validateField(string $field, string|array $rules): void
{ {
$rules = is_string($rules) ? explode('|', $rules) : $rules; $rules = is_string($rules) ? explode('|', $rules) : $rules;
$value = $this->getValue($field); $value = $this->getValue($field);
foreach ($rules as $rule) { foreach ($rules as $rule) {
$this->applyRule($field, $value, $rule); $this->applyRule($field, $value, $rule);
} }
} }
/** /**
* getValue gets a value from data using dot notation * getValue gets a value from data using dot notation
*/ */
private function getValue(string $field): mixed private function getValue(string $field): mixed
{ {
$keys = explode('.', $field); $keys = explode('.', $field);
$value = $this->data; $value = $this->data;
foreach ($keys as $key) { foreach ($keys as $key) {
if (!is_array($value) || !array_key_exists($key, $value)) { if (!is_array($value) || !array_key_exists($key, $value)) {
return null; return null;
} }
$value = $value[$key]; $value = $value[$key];
} }
return $value; return $value;
} }
/** /**
* applyRule applies a single validation rule * applyRule applies a single validation rule
*/ */
private function applyRule(string $field, mixed $value, string $rule): void private function applyRule(string $field, mixed $value, string $rule): void
{ {
$parts = explode(':', $rule, 2); $parts = explode(':', $rule, 2);
$ruleName = $parts[0]; $ruleName = $parts[0];
$parameters = isset($parts[1]) ? explode(',', $parts[1]) : []; $parameters = isset($parts[1]) ? explode(',', $parts[1]) : [];
$passes = match($ruleName) { $passes = match($ruleName) {
'required' => $this->validateRequired($value), 'required' => $this->validateRequired($value),
'email' => $this->validateEmail($value), 'email' => $this->validateEmail($value),
'url' => $this->validateUrl($value), 'url' => $this->validateUrl($value),
'alpha' => $this->validateAlpha($value), 'alpha' => $this->validateAlpha($value),
'alphaNum' => $this->validateAlphaNum($value), 'alphaNum' => $this->validateAlphaNum($value),
'numeric' => $this->validateNumeric($value), 'numeric' => $this->validateNumeric($value),
'integer' => $this->validateInteger($value), 'integer' => $this->validateInteger($value),
'float' => $this->validateFloat($value), 'float' => $this->validateFloat($value),
'boolean' => $this->validateBoolean($value), 'boolean' => $this->validateBoolean($value),
'array' => $this->validateArray($value), 'array' => $this->validateArray($value),
'json' => $this->validateJson($value), 'json' => $this->validateJson($value),
'date' => $this->validateDate($value), 'date' => $this->validateDate($value),
'min' => $this->validateMin($value, $parameters[0] ?? 0), 'min' => $this->validateMin($value, $parameters[0] ?? 0),
'max' => $this->validateMax($value, $parameters[0] ?? 0), 'max' => $this->validateMax($value, $parameters[0] ?? 0),
'between' => $this->validateBetween($value, $parameters[0] ?? 0, $parameters[1] ?? 0), 'between' => $this->validateBetween($value, $parameters[0] ?? 0, $parameters[1] ?? 0),
'length' => $this->validateLength($value, $parameters[0] ?? 0), 'length' => $this->validateLength($value, $parameters[0] ?? 0),
'in' => $this->validateIn($value, $parameters), 'in' => $this->validateIn($value, $parameters),
'notIn' => $this->validateNotIn($value, $parameters), 'notIn' => $this->validateNotIn($value, $parameters),
'regex' => $this->validateRegex($value, $parameters[0] ?? ''), 'regex' => $this->validateRegex($value, $parameters[0] ?? ''),
'confirmed' => $this->validateConfirmed($field, $value), 'confirmed' => $this->validateConfirmed($field, $value),
'unique' => $this->validateUnique($value, $parameters), 'unique' => $this->validateUnique($value, $parameters),
'exists' => $this->validateExists($value, $parameters), 'exists' => $this->validateExists($value, $parameters),
default => $this->applyCustomRule($ruleName, $value, $parameters) default => $this->applyCustomRule($ruleName, $value, $parameters)
}; };
if (!$passes) { if (!$passes) {
$this->addError($field, $ruleName, $parameters); $this->addError($field, $ruleName, $parameters);
} }
} }
/** /**
* Validation rule methods * Validation rule methods
*/ */
private function validateRequired(mixed $value): bool private function validateRequired(mixed $value): bool
{ {
if ($value === null) return false; if ($value === null) return false;
if (is_string($value) && trim($value) === '') return false; if (is_string($value) && trim($value) === '') return false;
if (is_array($value) && empty($value)) return false; if (is_array($value) && empty($value)) return false;
return true; return true;
} }
private function validateEmail(mixed $value): bool private function validateEmail(mixed $value): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; return filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
} }
private function validateUrl(mixed $value): bool private function validateUrl(mixed $value): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
return filter_var($value, FILTER_VALIDATE_URL) !== false; return filter_var($value, FILTER_VALIDATE_URL) !== false;
} }
private function validateAlpha(mixed $value): bool private function validateAlpha(mixed $value): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
return ctype_alpha($value); return ctype_alpha($value);
} }
private function validateAlphaNum(mixed $value): bool private function validateAlphaNum(mixed $value): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
return ctype_alnum($value); return ctype_alnum($value);
} }
private function validateNumeric(mixed $value): bool private function validateNumeric(mixed $value): bool
{ {
return is_numeric($value); return is_numeric($value);
} }
private function validateInteger(mixed $value): bool private function validateInteger(mixed $value): bool
{ {
return filter_var($value, FILTER_VALIDATE_INT) !== false; return filter_var($value, FILTER_VALIDATE_INT) !== false;
} }
private function validateFloat(mixed $value): bool private function validateFloat(mixed $value): bool
{ {
return filter_var($value, FILTER_VALIDATE_FLOAT) !== false; return filter_var($value, FILTER_VALIDATE_FLOAT) !== false;
} }
private function validateBoolean(mixed $value): bool private function validateBoolean(mixed $value): bool
{ {
return in_array($value, [true, false, 0, 1, '0', '1', 'true', 'false'], true); return in_array($value, [true, false, 0, 1, '0', '1', 'true', 'false'], true);
} }
private function validateArray(mixed $value): bool private function validateArray(mixed $value): bool
{ {
return is_array($value); return is_array($value);
} }
private function validateJson(mixed $value): bool private function validateJson(mixed $value): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
json_decode($value); json_decode($value);
return json_last_error() === JSON_ERROR_NONE; return json_last_error() === JSON_ERROR_NONE;
} }
private function validateDate(mixed $value): bool private function validateDate(mixed $value): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
$date = date_parse($value); $date = date_parse($value);
return $date['error_count'] === 0 && $date['warning_count'] === 0; return $date['error_count'] === 0 && $date['warning_count'] === 0;
} }
private function validateMin(mixed $value, mixed $min): bool private function validateMin(mixed $value, mixed $min): bool
{ {
if (is_numeric($value)) return $value >= $min; if (is_numeric($value)) return $value >= $min;
if (is_string($value)) return strlen($value) >= $min; if (is_string($value)) return strlen($value) >= $min;
if (is_array($value)) return count($value) >= $min; if (is_array($value)) return count($value) >= $min;
return false; return false;
} }
private function validateMax(mixed $value, mixed $max): bool private function validateMax(mixed $value, mixed $max): bool
{ {
if (is_numeric($value)) return $value <= $max; if (is_numeric($value)) return $value <= $max;
if (is_string($value)) return strlen($value) <= $max; if (is_string($value)) return strlen($value) <= $max;
if (is_array($value)) return count($value) <= $max; if (is_array($value)) return count($value) <= $max;
return false; return false;
} }
private function validateBetween(mixed $value, mixed $min, mixed $max): bool private function validateBetween(mixed $value, mixed $min, mixed $max): bool
{ {
return $this->validateMin($value, $min) && $this->validateMax($value, $max); return $this->validateMin($value, $min) && $this->validateMax($value, $max);
} }
private function validateLength(mixed $value, mixed $length): bool private function validateLength(mixed $value, mixed $length): bool
{ {
if (is_string($value)) return strlen($value) == $length; if (is_string($value)) return strlen($value) == $length;
if (is_array($value)) return count($value) == $length; if (is_array($value)) return count($value) == $length;
return false; return false;
} }
private function validateIn(mixed $value, array $values): bool private function validateIn(mixed $value, array $values): bool
{ {
return in_array($value, $values, true); return in_array($value, $values, true);
} }
private function validateNotIn(mixed $value, array $values): bool private function validateNotIn(mixed $value, array $values): bool
{ {
return !in_array($value, $values, true); return !in_array($value, $values, true);
} }
private function validateRegex(mixed $value, string $pattern): bool private function validateRegex(mixed $value, string $pattern): bool
{ {
if (!is_string($value)) return false; if (!is_string($value)) return false;
return preg_match($pattern, $value) === 1; return preg_match($pattern, $value) === 1;
} }
private function validateConfirmed(string $field, mixed $value): bool private function validateConfirmed(string $field, mixed $value): bool
{ {
$confirmField = $field . '_confirmation'; $confirmField = $field . '_confirmation';
return $value === $this->getValue($confirmField); return $value === $this->getValue($confirmField);
} }
private function validateUnique(mixed $value, array $parameters): bool private function validateUnique(mixed $value, array $parameters): bool
{ {
// Placeholder for database uniqueness check // Placeholder for database uniqueness check
// Would require database connection in real implementation // Would require database connection in real implementation
return true; return true;
} }
private function validateExists(mixed $value, array $parameters): bool private function validateExists(mixed $value, array $parameters): bool
{ {
// Placeholder for database existence check // Placeholder for database existence check
// Would require database connection in real implementation // Would require database connection in real implementation
return true; return true;
} }
/** /**
* applyCustomRule applies a custom validation rule * applyCustomRule applies a custom validation rule
*/ */
private function applyCustomRule(string $ruleName, mixed $value, array $parameters): bool private function applyCustomRule(string $ruleName, mixed $value, array $parameters): bool
{ {
if (!isset(self::$customRules[$ruleName])) { if (!isset(self::$customRules[$ruleName])) {
return true; // Unknown rules pass by default return true; // Unknown rules pass by default
} }
return call_user_func(self::$customRules[$ruleName], $value, $parameters, $this->data); return call_user_func(self::$customRules[$ruleName], $value, $parameters, $this->data);
} }
/** /**
* addError adds a validation error * addError adds a validation error
*/ */
private function addError(string $field, string $rule, array $parameters = []): void private function addError(string $field, string $rule, array $parameters = []): void
{ {
$message = $this->messages["$field.$rule"] $message = $this->messages["$field.$rule"]
?? $this->messages[$rule] ?? $this->messages[$rule]
?? $this->getDefaultMessage($field, $rule, $parameters); ?? $this->getDefaultMessage($field, $rule, $parameters);
if (!isset($this->errors[$field])) { if (!isset($this->errors[$field])) {
$this->errors[$field] = []; $this->errors[$field] = [];
} }
$this->errors[$field][] = $message; $this->errors[$field][] = $message;
} }
/** /**
* getDefaultMessage gets a default error message * getDefaultMessage gets a default error message
*/ */
private function getDefaultMessage(string $field, string $rule, array $parameters): string private function getDefaultMessage(string $field, string $rule, array $parameters): string
{ {
$fieldName = str_replace('_', ' ', $field); $fieldName = str_replace('_', ' ', $field);
return match($rule) { return match($rule) {
'required' => "The {$fieldName} field is required.", 'required' => "The {$fieldName} field is required.",
'email' => "The {$fieldName} must be a valid email address.", 'email' => "The {$fieldName} must be a valid email address.",
'url' => "The {$fieldName} must be a valid URL.", 'url' => "The {$fieldName} must be a valid URL.",
'alpha' => "The {$fieldName} may only contain letters.", 'alpha' => "The {$fieldName} may only contain letters.",
'alphaNum' => "The {$fieldName} may only contain letters and numbers.", 'alphaNum' => "The {$fieldName} may only contain letters and numbers.",
'numeric' => "The {$fieldName} must be a number.", 'numeric' => "The {$fieldName} must be a number.",
'integer' => "The {$fieldName} must be an integer.", 'integer' => "The {$fieldName} must be an integer.",
'float' => "The {$fieldName} must be a float.", 'float' => "The {$fieldName} must be a float.",
'boolean' => "The {$fieldName} must be a boolean.", 'boolean' => "The {$fieldName} must be a boolean.",
'array' => "The {$fieldName} must be an array.", 'array' => "The {$fieldName} must be an array.",
'json' => "The {$fieldName} must be valid JSON.", 'json' => "The {$fieldName} must be valid JSON.",
'date' => "The {$fieldName} must be a valid date.", 'date' => "The {$fieldName} must be a valid date.",
'min' => "The {$fieldName} must be at least {$parameters[0]}.", 'min' => "The {$fieldName} must be at least {$parameters[0]}.",
'max' => "The {$fieldName} must not be greater than {$parameters[0]}.", 'max' => "The {$fieldName} must not be greater than {$parameters[0]}.",
'between' => "The {$fieldName} must be between {$parameters[0]} and {$parameters[1]}.", 'between' => "The {$fieldName} must be between {$parameters[0]} and {$parameters[1]}.",
'length' => "The {$fieldName} must be exactly {$parameters[0]} characters.", 'length' => "The {$fieldName} must be exactly {$parameters[0]} characters.",
'in' => "The selected {$fieldName} is invalid.", 'in' => "The selected {$fieldName} is invalid.",
'notIn' => "The selected {$fieldName} is invalid.", 'notIn' => "The selected {$fieldName} is invalid.",
'regex' => "The {$fieldName} format is invalid.", 'regex' => "The {$fieldName} format is invalid.",
'confirmed' => "The {$fieldName} confirmation does not match.", 'confirmed' => "The {$fieldName} confirmation does not match.",
'unique' => "The {$fieldName} has already been taken.", 'unique' => "The {$fieldName} has already been taken.",
'exists' => "The selected {$fieldName} is invalid.", 'exists' => "The selected {$fieldName} is invalid.",
default => "The {$fieldName} is invalid." default => "The {$fieldName} is invalid."
}; };
} }
/** /**
* errors returns all validation errors * errors returns all validation errors
*/ */
public function errors(): array public function errors(): array
{ {
return $this->errors; return $this->errors;
} }
/** /**
* failed checks if validation failed * failed checks if validation failed
*/ */
public function failed(): bool public function failed(): bool
{ {
return !empty($this->errors); return !empty($this->errors);
} }
/** /**
* passed checks if validation passed * passed checks if validation passed
*/ */
public function passed(): bool public function passed(): bool
{ {
return empty($this->errors); return empty($this->errors);
} }
/** /**
* firstError gets the first error message * firstError gets the first error message
*/ */
public function firstError(string $field = null): ?string public function firstError(string $field = null): ?string
{ {
if ($field) { if ($field) {
return $this->errors[$field][0] ?? null; return $this->errors[$field][0] ?? null;
} }
foreach ($this->errors as $errors) { foreach ($this->errors as $errors) {
if (!empty($errors)) { if (!empty($errors)) {
return $errors[0]; return $errors[0];
} }
} }
return null; return null;
} }
/** /**
* extend adds a custom validation rule * extend adds a custom validation rule
*/ */
public static function extend(string $name, callable $callback): void public static function extend(string $name, callable $callback): void
{ {
self::$customRules[$name] = $callback; self::$customRules[$name] = $callback;
} }
} }

254
Web.php
View File

@ -12,158 +12,158 @@ require_once __DIR__ . '/ErrorHandler.php';
*/ */
class Web class Web
{ {
private Router $router; private Router $router;
private array $middleware = []; private array $middleware = [];
private Context $context; private Context $context;
private ErrorHandler $errorHandler; private ErrorHandler $errorHandler;
public function __construct(bool $debug = false) public function __construct(bool $debug = false)
{ {
$this->router = new Router(); $this->router = new Router();
$this->errorHandler = new ErrorHandler($debug); $this->errorHandler = new ErrorHandler($debug);
} }
public function use(callable $middleware): self public function use(callable $middleware): self
{ {
$this->middleware[] = $middleware; $this->middleware[] = $middleware;
return $this; return $this;
} }
public function get(string $route, callable $handler): self public function get(string $route, callable $handler): self
{ {
$this->router->get($route, $handler); $this->router->get($route, $handler);
return $this; return $this;
} }
public function post(string $route, callable $handler): self public function post(string $route, callable $handler): self
{ {
$this->router->post($route, $handler); $this->router->post($route, $handler);
return $this; return $this;
} }
public function put(string $route, callable $handler): self public function put(string $route, callable $handler): self
{ {
$this->router->put($route, $handler); $this->router->put($route, $handler);
return $this; return $this;
} }
public function patch(string $route, callable $handler): self public function patch(string $route, callable $handler): self
{ {
$this->router->patch($route, $handler); $this->router->patch($route, $handler);
return $this; return $this;
} }
public function delete(string $route, callable $handler): self public function delete(string $route, callable $handler): self
{ {
$this->router->delete($route, $handler); $this->router->delete($route, $handler);
return $this; return $this;
} }
public function head(string $route, callable $handler): self public function head(string $route, callable $handler): self
{ {
$this->router->head($route, $handler); $this->router->head($route, $handler);
return $this; return $this;
} }
public function route(string $method, string $route, callable $handler): self public function route(string $method, string $route, callable $handler): self
{ {
$this->router->add($method, $route, $handler); $this->router->add($method, $route, $handler);
return $this; return $this;
} }
public function group(string $prefix, callable $callback): self public function group(string $prefix, callable $callback): self
{ {
$originalRouter = $this->router; $originalRouter = $this->router;
$groupRouter = new Router(); $groupRouter = new Router();
$this->router = $groupRouter; $this->router = $groupRouter;
$callback($this); $callback($this);
foreach ($groupRouter->dump() as $path => $methods) { foreach ($groupRouter->dump() as $path => $methods) {
$this->addGroupRoutes($originalRouter, $prefix, $path, $methods); $this->addGroupRoutes($originalRouter, $prefix, $path, $methods);
} }
$this->router = $originalRouter; $this->router = $originalRouter;
return $this; return $this;
} }
public function setErrorHandler(int $status, callable $handler): self public function setErrorHandler(int $status, callable $handler): self
{ {
$this->errorHandler->register($status, $handler); $this->errorHandler->register($status, $handler);
return $this; return $this;
} }
public function setDefaultErrorHandler(callable $handler): self public function setDefaultErrorHandler(callable $handler): self
{ {
$this->errorHandler->setDefaultHandler($handler); $this->errorHandler->setDefaultHandler($handler);
return $this; return $this;
} }
private function addGroupRoutes(Router $router, string $prefix, string $path, mixed $node, string $currentPath = ''): void private function addGroupRoutes(Router $router, string $prefix, string $path, mixed $node, string $currentPath = ''): void
{ {
if ($path !== '') $currentPath = $currentPath ? "$currentPath/$path" : $path; if ($path !== '') $currentPath = $currentPath ? "$currentPath/$path" : $path;
if (!is_array($node)) return; if (!is_array($node)) return;
foreach ($node as $key => $value) { foreach ($node as $key => $value) {
if (in_array($key, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])) { if (in_array($key, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])) {
$fullPath = rtrim($prefix, '/') . '/' . ltrim($currentPath, '/'); $fullPath = rtrim($prefix, '/') . '/' . ltrim($currentPath, '/');
$fullPath = str_replace(':x', ':param', $fullPath); $fullPath = str_replace(':x', ':param', $fullPath);
$router->add($key, $fullPath, $value); $router->add($key, $fullPath, $value);
} elseif (is_array($value)) { } elseif (is_array($value)) {
$this->addGroupRoutes($router, $prefix, $key, $value, $currentPath); $this->addGroupRoutes($router, $prefix, $key, $value, $currentPath);
} }
} }
} }
public function run(): void public function run(): void
{ {
$this->context = new Context(); $this->context = new Context();
try { try {
$next = function() { $next = function() {
$result = $this->router->lookup( $result = $this->router->lookup(
$this->context->request->method, $this->context->request->method,
$this->context->request->path $this->context->request->path
); );
if ($result['code'] === 404) { if ($result['code'] === 404) {
$this->errorHandler->handle($this->context, 404); $this->errorHandler->handle($this->context, 404);
return; return;
} }
if ($result['code'] === 405) { if ($result['code'] === 405) {
$this->errorHandler->handle($this->context, 405); $this->errorHandler->handle($this->context, 405);
return; return;
} }
$this->context->request->params = $result['params']; $this->context->request->params = $result['params'];
$handler = $result['handler']; $handler = $result['handler'];
$response = $handler($this->context, ...$result['params']); $response = $handler($this->context, ...$result['params']);
if ($response instanceof Response) { if ($response instanceof Response) {
$response->send(); $response->send();
} elseif (is_array($response) || is_object($response)) { } elseif (is_array($response) || is_object($response)) {
$this->context->json($response); $this->context->json($response);
} elseif (is_string($response)) { } elseif (is_string($response)) {
$this->context->text($response); $this->context->text($response);
} }
}; };
$chain = array_reduce( $chain = array_reduce(
array_reverse($this->middleware), array_reverse($this->middleware),
function($next, $middleware) { function($next, $middleware) {
return function() use ($middleware, $next) { return function() use ($middleware, $next) {
$middleware($this->context, $next); $middleware($this->context, $next);
}; };
}, },
$next $next
); );
$chain(); $chain();
} catch (Exception $e) { } catch (Exception $e) {
$this->errorHandler->handleException($this->context, $e); $this->errorHandler->handleException($this->context, $e);
} }
} }
} }

View File

@ -8,245 +8,237 @@ require_once __DIR__ . '/../Session.php';
*/ */
class Auth class Auth
{ {
private Session $session; private Session $session;
private ?User $user = null; private ?User $user = null;
private array $config; private array $config;
const SESSION_KEY = 'auth_user_data'; const SESSION_KEY = 'auth_user_data';
const REMEMBER_COOKIE = 'remember_token'; const REMEMBER_COOKIE = 'remember_token';
const REMEMBER_DURATION = 2592000; // 30 days in seconds const REMEMBER_DURATION = 2592000; // 30 days in seconds
public function __construct(Session $session, array $config = []) public function __construct(Session $session, array $config = [])
{ {
$this->session = $session; $this->session = $session;
$this->config = array_merge([ $this->config = array_merge([
'cookie_name' => self::REMEMBER_COOKIE, 'cookie_name' => self::REMEMBER_COOKIE,
'cookie_lifetime' => self::REMEMBER_DURATION, 'cookie_lifetime' => self::REMEMBER_DURATION,
'cookie_path' => '/', 'cookie_path' => '/',
'cookie_domain' => '', 'cookie_domain' => '',
'cookie_secure' => false, 'cookie_secure' => false,
'cookie_httponly' => true, 'cookie_httponly' => true,
'cookie_samesite' => 'Lax' 'cookie_samesite' => 'Lax'
], $config); ], $config);
$this->initializeFromSession(); $this->initializeFromSession();
} }
/** /**
* Login with externally verified user data * Login with externally verified user data
*/ */
public function login(array $userData, bool $remember = false): void public function login(array $userData, bool $remember = false): void
{ {
$this->user = new User($userData); $this->user = new User($userData);
$this->session->set(self::SESSION_KEY, $userData); $this->session->set(self::SESSION_KEY, $userData);
$this->session->regenerate(); $this->session->regenerate();
if ($remember) { if ($remember) $this->createRememberToken($userData);
$this->createRememberToken($userData); }
}
}
/** /**
* Login using user data directly * Login using user data directly
*/ */
public function loginUser(User $user, bool $remember = false): void public function loginUser(User $user, bool $remember = false): void
{ {
$this->user = $user; $this->user = $user;
$this->session->set(self::SESSION_KEY, $user->toArray()); $this->session->set(self::SESSION_KEY, $user->toArray());
$this->session->regenerate(); $this->session->regenerate();
if ($remember) { if ($remember) $this->createRememberToken($user->toArray());
$this->createRememberToken($user->toArray()); }
}
}
/** /**
* Logout the current user * Logout the current user
*/ */
public function logout(): void public function logout(): void
{ {
$this->user = null; $this->user = null;
$this->session->remove(self::SESSION_KEY); $this->session->remove(self::SESSION_KEY);
$this->session->regenerate(); $this->session->regenerate();
$this->clearRememberCookie(); $this->clearRememberCookie();
} }
/** /**
* Check if user is authenticated * Check if user is authenticated
*/ */
public function check(): bool public function check(): bool
{ {
return $this->user() !== null; return $this->user() !== null;
} }
/** /**
* Check if user is guest (not authenticated) * Check if user is guest (not authenticated)
*/ */
public function guest(): bool public function guest(): bool
{ {
return !$this->check(); return !$this->check();
} }
/** /**
* Get the currently authenticated user * Get the currently authenticated user
*/ */
public function user(): ?User public function user(): ?User
{ {
if ($this->user) { if ($this->user) return $this->user;
return $this->user;
}
// Try to load from session // Try to load from session
$userData = $this->session->get(self::SESSION_KEY); $userData = $this->session->get(self::SESSION_KEY);
if ($userData) { if ($userData) {
$this->user = new User($userData); $this->user = new User($userData);
return $this->user; return $this->user;
} }
// Try to load from remember cookie // Try to load from remember cookie
$userData = $this->getUserDataFromRememberCookie(); $userData = $this->getUserDataFromRememberCookie();
if ($userData) { if ($userData) {
$this->user = new User($userData); $this->user = new User($userData);
$this->session->set(self::SESSION_KEY, $userData); $this->session->set(self::SESSION_KEY, $userData);
} }
return $this->user; return $this->user;
} }
/** /**
* Get user ID * Get user ID
*/ */
public function id(): int|string|null public function id(): int|string|null
{ {
return $this->user()?->getId(); return $this->user()?->getId();
} }
/** /**
* Set user data after external verification * Set user data after external verification
*/ */
public function setUserData(array $userData): void public function setUserData(array $userData): void
{ {
$this->user = new User($userData); $this->user = new User($userData);
$this->session->set(self::SESSION_KEY, $userData); $this->session->set(self::SESSION_KEY, $userData);
} }
/** /**
* Hash a password * Hash a password
*/ */
public function hashPassword(string $password): string public function hashPassword(string $password): string
{ {
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]); return password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
} }
/** /**
* Verify a password against hash * Verify a password against hash
*/ */
public function verifyPassword(string $password, string $hash): bool public function verifyPassword(string $password, string $hash): bool
{ {
return password_verify($password, $hash); return password_verify($password, $hash);
} }
/** /**
* Initialize user from session * Initialize user from session
*/ */
private function initializeFromSession(): void private function initializeFromSession(): void
{ {
$this->session->start(); $this->session->start();
$this->user(); $this->user();
} }
/** /**
* Create remember token for user * Create remember token for user
*/ */
private function createRememberToken(array $userData): void private function createRememberToken(array $userData): void
{ {
$token = $this->generateRememberToken(); $token = $this->generateRememberToken();
$hashedToken = hash('sha256', $token); $hashedToken = hash('sha256', $token);
$userData['remember_token'] = $hashedToken; $userData['remember_token'] = $hashedToken;
$this->session->set('remember_user_data', $userData); $this->session->set('remember_user_data', $userData);
$this->setRememberCookie($userData['id'] . '|' . $token); $this->setRememberCookie($userData['id'] . '|' . $token);
} }
/** /**
* Generate a random remember token * Generate a random remember token
*/ */
private function generateRememberToken(): string private function generateRememberToken(): string
{ {
return bin2hex(random_bytes(32)); return bin2hex(random_bytes(32));
} }
/** /**
* Set remember cookie * Set remember cookie
*/ */
private function setRememberCookie(string $value): void private function setRememberCookie(string $value): void
{ {
setcookie( setcookie(
$this->config['cookie_name'], $this->config['cookie_name'],
$value, $value,
[ [
'expires' => time() + $this->config['cookie_lifetime'], 'expires' => time() + $this->config['cookie_lifetime'],
'path' => $this->config['cookie_path'], 'path' => $this->config['cookie_path'],
'domain' => $this->config['cookie_domain'], 'domain' => $this->config['cookie_domain'],
'secure' => $this->config['cookie_secure'], 'secure' => $this->config['cookie_secure'],
'httponly' => $this->config['cookie_httponly'], 'httponly' => $this->config['cookie_httponly'],
'samesite' => $this->config['cookie_samesite'] 'samesite' => $this->config['cookie_samesite']
] ]
); );
} }
/** /**
* Clear remember cookie * Clear remember cookie
*/ */
private function clearRememberCookie(): void private function clearRememberCookie(): void
{ {
setcookie( setcookie(
$this->config['cookie_name'], $this->config['cookie_name'],
'', '',
[ [
'expires' => time() - 3600, 'expires' => time() - 3600,
'path' => $this->config['cookie_path'], 'path' => $this->config['cookie_path'],
'domain' => $this->config['cookie_domain'], 'domain' => $this->config['cookie_domain'],
'secure' => $this->config['cookie_secure'], 'secure' => $this->config['cookie_secure'],
'httponly' => $this->config['cookie_httponly'], 'httponly' => $this->config['cookie_httponly'],
'samesite' => $this->config['cookie_samesite'] 'samesite' => $this->config['cookie_samesite']
] ]
); );
} }
/** /**
* Get user data from remember cookie * Get user data from remember cookie
*/ */
private function getUserDataFromRememberCookie(): ?array private function getUserDataFromRememberCookie(): ?array
{ {
$cookie = $_COOKIE[$this->config['cookie_name']] ?? null; $cookie = $_COOKIE[$this->config['cookie_name']] ?? null;
if (!$cookie || !str_contains($cookie, '|')) { if (!$cookie || !str_contains($cookie, '|')) return null;
return null;
}
[$id, $token] = explode('|', $cookie, 2); [$id, $token] = explode('|', $cookie, 2);
$hashedToken = hash('sha256', $token); $hashedToken = hash('sha256', $token);
$userData = $this->session->get('remember_user_data'); $userData = $this->session->get('remember_user_data');
if (!$userData || $userData['id'] != $id || ($userData['remember_token'] ?? null) !== $hashedToken) { if (!$userData || $userData['id'] != $id || ($userData['remember_token'] ?? null) !== $hashedToken) {
$this->clearRememberCookie(); $this->clearRememberCookie();
return null; return null;
} }
return $userData; return $userData;
} }
/** /**
* Clear user data and session * Clear user data and session
*/ */
public function clear(): void public function clear(): void
{ {
$this->user = null; $this->user = null;
$this->session->remove(self::SESSION_KEY); $this->session->remove(self::SESSION_KEY);
$this->session->remove('remember_user_data'); $this->session->remove('remember_user_data');
$this->clearRememberCookie(); $this->clearRememberCookie();
} }
} }

View File

@ -7,198 +7,198 @@ require_once __DIR__ . '/Auth.php';
*/ */
class AuthMiddleware class AuthMiddleware
{ {
private Auth $auth; private Auth $auth;
public function __construct(Auth $auth) public function __construct(Auth $auth)
{ {
$this->auth = $auth; $this->auth = $auth;
} }
/** /**
* Require authentication * Require authentication
*/ */
public function requireAuth(): callable public function requireAuth(): callable
{ {
return function(Context $context, callable $next) { return function(Context $context, callable $next) {
if ($this->auth->guest()) { if ($this->auth->guest()) {
// Check if request expects JSON // Check if request expects JSON
if ($context->request->expectsJson()) { if ($context->request->expectsJson()) {
$context->json(['error' => 'Unauthenticated'], 401); $context->json(['error' => 'Unauthenticated'], 401);
return; return;
} }
// Redirect to login page // Redirect to login page
$context->redirect('/login'); $context->redirect('/login');
return; return;
} }
// Add user to context // Add user to context
$context->set('user', $this->auth->user()); $context->set('user', $this->auth->user());
$next(); $next();
}; };
} }
/** /**
* Require guest (not authenticated) * Require guest (not authenticated)
*/ */
public function requireGuest(): callable public function requireGuest(): callable
{ {
return function(Context $context, callable $next) { return function(Context $context, callable $next) {
if ($this->auth->check()) { if ($this->auth->check()) {
// Check if request expects JSON // Check if request expects JSON
if ($context->request->expectsJson()) { if ($context->request->expectsJson()) {
$context->json(['error' => 'Already authenticated'], 403); $context->json(['error' => 'Already authenticated'], 403);
return; return;
} }
// Redirect to home or dashboard // Redirect to home or dashboard
$context->redirect('/'); $context->redirect('/');
return; return;
} }
$next(); $next();
}; };
} }
/** /**
* Optional authentication (sets user if authenticated) * Optional authentication (sets user if authenticated)
*/ */
public function optional(): callable public function optional(): callable
{ {
return function(Context $context, callable $next) { return function(Context $context, callable $next) {
if ($this->auth->check()) $context->set('user', $this->auth->user()); if ($this->auth->check()) $context->set('user', $this->auth->user());
$next(); $next();
}; };
} }
/** /**
* Check if user has specific role * Check if user has specific role
*/ */
public function requireRole(string|array $roles): callable public function requireRole(string|array $roles): callable
{ {
$roles = is_array($roles) ? $roles : [$roles]; $roles = is_array($roles) ? $roles : [$roles];
return function(Context $context, callable $next) use ($roles) { return function(Context $context, callable $next) use ($roles) {
if ($this->auth->guest()) { if ($this->auth->guest()) {
if ($context->request->expectsJson()) { if ($context->request->expectsJson()) {
$context->json(['error' => 'Unauthenticated'], 401); $context->json(['error' => 'Unauthenticated'], 401);
return; return;
} }
$context->redirect('/login'); $context->redirect('/login');
return; return;
} }
$user = $this->auth->user(); $user = $this->auth->user();
$userRole = $user->role; $userRole = $user->role;
if (!in_array($userRole, $roles)) { if (!in_array($userRole, $roles)) {
if ($context->request->expectsJson()) { if ($context->request->expectsJson()) {
$context->json(['error' => 'Insufficient permissions'], 403); $context->json(['error' => 'Insufficient permissions'], 403);
return; return;
} }
$context->error(403, 'Forbidden'); $context->error(403, 'Forbidden');
return; return;
} }
$context->set('user', $user); $context->set('user', $user);
$next(); $next();
}; };
} }
/** /**
* Rate limiting per user * Rate limiting per user
*/ */
public function rateLimit(int $maxAttempts = 60, int $decayMinutes = 1): callable public function rateLimit(int $maxAttempts = 60, int $decayMinutes = 1): callable
{ {
return function(Context $context, callable $next) use ($maxAttempts, $decayMinutes) { return function(Context $context, callable $next) use ($maxAttempts, $decayMinutes) {
if ($this->auth->guest()) { if ($this->auth->guest()) {
$identifier = $context->request->ip(); $identifier = $context->request->ip();
} else { } else {
$identifier = 'user:' . $this->auth->id(); $identifier = 'user:' . $this->auth->id();
} }
$key = 'rate_limit:' . $identifier . ':' . $context->request->path; $key = 'rate_limit:' . $identifier . ':' . $context->request->path;
$attempts = $context->session->get($key, 0); $attempts = $context->session->get($key, 0);
$resetTime = $context->session->get($key . ':reset', 0); $resetTime = $context->session->get($key . ':reset', 0);
// Reset counter if decay time has passed // Reset counter if decay time has passed
if (time() > $resetTime) { if (time() > $resetTime) {
$attempts = 0; $attempts = 0;
$context->session->set($key . ':reset', time() + ($decayMinutes * 60)); $context->session->set($key . ':reset', time() + ($decayMinutes * 60));
} }
if ($attempts >= $maxAttempts) { if ($attempts >= $maxAttempts) {
$retryAfter = $resetTime - time(); $retryAfter = $resetTime - time();
$context->response->header('X-RateLimit-Limit', (string)$maxAttempts); $context->response->header('X-RateLimit-Limit', (string)$maxAttempts);
$context->response->header('X-RateLimit-Remaining', '0'); $context->response->header('X-RateLimit-Remaining', '0');
$context->response->header('Retry-After', (string)$retryAfter); $context->response->header('Retry-After', (string)$retryAfter);
if ($context->request->expectsJson()) { if ($context->request->expectsJson()) {
$context->json([ $context->json([
'error' => 'Too many requests', 'error' => 'Too many requests',
'retry_after' => $retryAfter 'retry_after' => $retryAfter
], 429); ], 429);
return; return;
} }
$context->error(429, 'Too Many Requests'); $context->error(429, 'Too Many Requests');
return; return;
} }
// Increment attempts // Increment attempts
$context->session->set($key, $attempts + 1); $context->session->set($key, $attempts + 1);
// Add rate limit headers // Add rate limit headers
$context->response->header('X-RateLimit-Limit', (string)$maxAttempts); $context->response->header('X-RateLimit-Limit', (string)$maxAttempts);
$context->response->header('X-RateLimit-Remaining', (string)($maxAttempts - $attempts - 1)); $context->response->header('X-RateLimit-Remaining', (string)($maxAttempts - $attempts - 1));
$next(); $next();
}; };
} }
/** /**
* CSRF protection * CSRF protection
*/ */
public function verifyCsrf(): callable public function verifyCsrf(): callable
{ {
return function(Context $context, callable $next) { return function(Context $context, callable $next) {
// Skip CSRF for safe methods // Skip CSRF for safe methods
if (in_array($context->request->method, ['GET', 'HEAD', 'OPTIONS'])) { if (in_array($context->request->method, ['GET', 'HEAD', 'OPTIONS'])) {
$next(); $next();
return; return;
} }
$token = $context->request->input('_token') $token = $context->request->input('_token')
?? $context->request->header('X-CSRF-TOKEN') ?? $context->request->header('X-CSRF-TOKEN')
?? $context->request->header('X-XSRF-TOKEN'); ?? $context->request->header('X-XSRF-TOKEN');
if (!$context->session->validateCsrf($token)) { if (!$context->session->validateCsrf($token)) {
if ($context->request->expectsJson()) { if ($context->request->expectsJson()) {
$context->json(['error' => 'CSRF token mismatch'], 419); $context->json(['error' => 'CSRF token mismatch'], 419);
return; return;
} }
$context->error(419, 'CSRF token mismatch'); $context->error(419, 'CSRF token mismatch');
return; return;
} }
$next(); $next();
}; };
} }
/** /**
* Remember user from cookie * Remember user from cookie
*/ */
public function remember(): callable public function remember(): callable
{ {
return function(Context $context, callable $next) { return function(Context $context, callable $next) {
// Auth class already handles remember cookies in constructor // Auth class already handles remember cookies in constructor
// This middleware can be used to refresh the remember token if needed // This middleware can be used to refresh the remember token if needed
if ($this->auth->check()) { if ($this->auth->check()) {
$context->set('user', $this->auth->user()); $context->set('user', $this->auth->user());
} }
$next(); $next();
}; };
} }
} }

View File

@ -5,68 +5,68 @@
*/ */
class User class User
{ {
public int|string $id; public int|string $id;
public string $username; public string $username;
public string $email; public string $email;
public string $password; public string $password;
public ?string $rememberToken; public ?string $rememberToken;
public string $role; public string $role;
public ?string $lastLogin; public ?string $lastLogin;
public function __construct(array $data = []) public function __construct(array $data = [])
{ {
$this->id = $data['id'] ?? 0; $this->id = $data['id'] ?? 0;
$this->username = $data['username'] ?? ''; $this->username = $data['username'] ?? '';
$this->email = $data['email'] ?? ''; $this->email = $data['email'] ?? '';
$this->password = $data['password'] ?? ''; $this->password = $data['password'] ?? '';
$this->rememberToken = $data['remember_token'] ?? $data['rememberToken'] ?? null; $this->rememberToken = $data['remember_token'] ?? $data['rememberToken'] ?? null;
$this->role = $data['role'] ?? 'user'; $this->role = $data['role'] ?? 'user';
$this->lastLogin = $data['last_login'] ?? $data['lastLogin'] ?? null; $this->lastLogin = $data['last_login'] ?? $data['lastLogin'] ?? null;
} }
/** /**
* Get user identifier (email or username) * Get user identifier (email or username)
*/ */
public function getIdentifier(): string public function getIdentifier(): string
{ {
return $this->email ?: $this->username; return $this->email ?: $this->username;
} }
/** /**
* Get user ID * Get user ID
*/ */
public function getId(): int|string public function getId(): int|string
{ {
return $this->id; return $this->id;
} }
/** /**
* Convert to array * Convert to array
*/ */
public function toArray(): array public function toArray(): array
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'username' => $this->username, 'username' => $this->username,
'email' => $this->email, 'email' => $this->email,
'password' => $this->password, 'password' => $this->password,
'remember_token' => $this->rememberToken, 'remember_token' => $this->rememberToken,
'role' => $this->role, 'role' => $this->role,
'last_login' => $this->lastLogin, 'last_login' => $this->lastLogin,
]; ];
} }
/** /**
* Convert to safe array (without sensitive data) * Convert to safe array (without sensitive data)
*/ */
public function toSafeArray(): array public function toSafeArray(): array
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'username' => $this->username, 'username' => $this->username,
'email' => $this->email, 'email' => $this->email,
'role' => $this->role, 'role' => $this->role,
'last_login' => $this->lastLogin, 'last_login' => $this->lastLogin,
]; ];
} }
} }