Complete Phase 1: MVC, DB migrations, Auth, RBAC, Security, and Views

This commit is contained in:
Hamza-Ayed
2026-06-05 00:56:41 +03:00
parent 7ffbc8bafa
commit bed7624ae9
51 changed files with 3295 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
use App\Core\Request;
use App\Core\Response;
class DashboardController extends Controller
{
/**
* Display admin dashboard.
*/
public function index(Request $request, Response $response): string
{
$user = $request->routeParam('_authenticated_user');
return $this->render('admin/dashboard', [
'user' => $user,
'title' => 'Dashboard',
], 'admin');
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Services\Auth\AuthService;
use App\Services\Database\ActivityLogger;
use Throwable;
class AuthController extends Controller
{
private AuthService $authService;
private ActivityLogger $logger;
public function __construct(AuthService $authService, ActivityLogger $logger)
{
parent::__construct();
$this->authService = $authService;
$this->logger = $logger;
}
/**
* Render the login page.
*/
public function showLogin(Request $request, Response $response): string
{
if ($this->session->get('user_id')) {
$response->redirect('/admin/dashboard');
}
return $this->render('auth/login', [], 'auth');
}
/**
* Handle login requests.
*/
public function login(Request $request, Response $response): void
{
$email = $request->post('email', '');
$password = $request->post('password', '');
try {
$user = $this->authService->login($email, $password);
$this->session->set('user_id', $user['id']);
$this->session->set('user_name', $user['name']);
$this->session->set('user_email', $user['email']);
// Security log
$this->logger->log($user['id'], 'user_login', 'User logged in successfully via Web.');
$this->session->setFlash('success', 'Welcome back, ' . $user['name'] . '!');
$response->redirect('/admin/dashboard');
} catch (Throwable $e) {
$this->session->setFlash('error', $e->getMessage());
$response->redirect('/login');
}
}
/**
* Render registration page.
*/
public function showRegister(Request $request, Response $response): string
{
if ($this->session->get('user_id')) {
$response->redirect('/admin/dashboard');
}
return $this->render('auth/register', [], 'auth');
}
/**
* Handle registration requests.
*/
public function register(Request $request, Response $response): void
{
$name = $request->post('name', '');
$email = $request->post('email', '');
$password = $request->post('password', '');
try {
if (empty($name) || empty($email) || empty($password)) {
throw new \Exception("All fields are required.");
}
$user = $this->authService->register($name, $email, $password);
$this->session->set('user_id', $user['id']);
$this->session->set('user_name', $user['name']);
$this->session->set('user_email', $user['email']);
// Security log
$this->logger->log($user['id'], 'user_register', 'User registered and logged in.');
$this->session->setFlash('success', 'Registration successful! Welcome to ScoutIQ.');
$response->redirect('/admin/dashboard');
} catch (Throwable $e) {
$this->session->setFlash('error', $e->getMessage());
$response->redirect('/register');
}
}
/**
* Destroy user sessions and logout.
*/
public function logout(Request $request, Response $response): void
{
$userId = $this->session->get('user_id');
if ($userId) {
$this->logger->log($userId, 'user_logout', 'User logged out.');
}
$this->session->destroy();
$response->redirect('/login');
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Controllers;
use App\Core\App;
use App\Core\Session;
abstract class Controller
{
protected Session $session;
public function __construct()
{
$this->session = App::$app->session;
}
/**
* Render a view within a layout.
*/
protected function render(string $view, array $data = [], string $layout = 'app'): string
{
$viewFile = __DIR__ . "/../../resources/views/{$view}.php";
if (!file_exists($viewFile)) {
throw new \Exception("View template {$view} not found.");
}
// Extract variables to local scope
extract($data);
// Capture inner view content
ob_start();
include $viewFile;
$content = ob_get_clean();
// Capture layout content wrapping the inner view
$layoutFile = __DIR__ . "/../../resources/views/layouts/{$layout}.php";
if (!file_exists($layoutFile)) {
return $content;
}
ob_start();
include $layoutFile;
return ob_get_clean();
}
/**
* Escape string values for rendering safely.
*/
protected function escape(mixed $data): string
{
return htmlspecialchars((string)$data, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
class HomeController extends Controller
{
/**
* Handle root url routing.
*/
public function index(Request $request, Response $response): void
{
if ($this->session->get('user_id')) {
$response->redirect('/admin/dashboard');
return;
}
$response->redirect('/login');
}
}

123
app/Core/App.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
namespace App\Core;
use Throwable;
use App\Middleware\MiddlewareInterface;
class App
{
public static App $app;
public Container $container;
public Request $request;
public Response $response;
public Session $session;
public Router $router;
public function __construct(Container $container)
{
self::$app = $this;
$this->container = $container;
$this->request = new Request();
$this->response = new Response();
$this->session = new Session();
$this->router = new Router();
}
/**
* Boot and run the application.
*/
public function run(): void
{
try {
// Match request to route
$routeInfo = $this->router->resolve($this->request);
$callback = $routeInfo['callback'];
$middlewares = $routeInfo['middleware'];
$params = $routeInfo['params'];
// Inject matched route parameters into Request
$this->request->setRouteParams($params);
// Run Middleware Chain
$this->executeMiddlewareChain($middlewares, function() use ($callback) {
// Execute Route action
if (is_callable($callback)) {
$response = $callback($this->request, $this->response);
} else {
[$controllerClass, $method] = $callback;
$controller = $this->container->get($controllerClass);
$response = $controller->$method($this->request, $this->response);
}
// Auto-output string responses as HTML
if (is_string($response)) {
$this->response->html($response);
}
});
} catch (Throwable $e) {
$this->handleException($e);
}
}
/**
* Executes the array of middlewares sequentially before firing destination action.
*/
private function executeMiddlewareChain(array $middlewares, callable $destination): void
{
$runner = function (int $index) use ($middlewares, $destination, &$runner) {
if ($index >= count($middlewares)) {
$destination();
return;
}
$middlewareClass = $middlewares[$index];
/** @var MiddlewareInterface $middleware */
$middleware = $this->container->get($middlewareClass);
$middleware->handle($this->request, $this->response, function() use ($runner, $index) {
$runner($index + 1);
});
};
$runner(0);
}
/**
* Global exception handler.
*/
private function handleException(Throwable $e): void
{
$code = $e->getCode();
if ($code < 100 || $code > 599) {
$code = 500;
}
// Return API error JSON for API routes or JSON requests
if ($this->request->isJson() || str_starts_with($this->request->getPath(), '/api')) {
$this->response->json([
'success' => false,
'error' => $e->getMessage(),
'code' => $code
], $code);
return;
}
// Render error templates for Web requests
$viewPath = __DIR__ . "/../../resources/views/errors/{$code}.php";
if (!file_exists($viewPath)) {
$viewPath = __DIR__ . '/../../resources/views/errors/500.php';
}
if (file_exists($viewPath)) {
ob_start();
$message = $e->getMessage();
include $viewPath;
$content = ob_get_clean();
$this->response->html($content, $code);
} else {
$this->response->html("<h1>Error {$code}</h1><p>{$e->getMessage()}</p>", $code);
}
}
}

116
app/Core/Container.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Core;
use Psr\Container\ContainerInterface;
use Exception;
class Container implements ContainerInterface
{
private array $bindings = [];
private array $instances = [];
/**
* Bind a dependency to the container.
*/
public function bind(string $id, callable|string $concrete, bool $singleton = false): void
{
$this->bindings[$id] = [
'concrete' => $concrete,
'singleton' => $singleton
];
}
/**
* Bind a singleton dependency.
*/
public function singleton(string $id, callable|string $concrete): void
{
$this->bind($id, $concrete, true);
}
/**
* Get a dependency from the container.
*/
public function get(string $id): mixed
{
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
if (!$this->has($id)) {
return $this->resolve($id);
}
$binding = $this->bindings[$id];
$concrete = $binding['concrete'];
$object = $this->resolve($concrete);
if ($binding['singleton']) {
$this->instances[$id] = $object;
}
return $object;
}
/**
* Check if a dependency is bound.
*/
public function has(string $id): bool
{
return isset($this->bindings[$id]) || isset($this->instances[$id]) || class_exists($id);
}
/**
* Resolve a concrete class or binding.
*/
private function resolve(mixed $concrete): mixed
{
if ($concrete instanceof \Closure || is_callable($concrete)) {
return $concrete($this);
}
if (is_string($concrete)) {
if (!class_exists($concrete)) {
return $concrete;
}
$reflector = new \ReflectionClass($concrete);
if (!$reflector->isInstantiable()) {
throw new Exception("Class {$concrete} is not instantiable.");
}
$constructor = $reflector->getConstructor();
if (null === $constructor) {
return new $concrete();
}
$parameters = $constructor->getParameters();
$dependencies = [];
foreach ($parameters as $parameter) {
$type = $parameter->getType();
if (!$type) {
if ($parameter->isDefaultValueAvailable()) {
$dependencies[] = $parameter->getDefaultValue();
} else {
throw new Exception("Cannot resolve parameter {$parameter->getName()} in class {$concrete}");
}
} elseif ($type instanceof \ReflectionUnionType) {
throw new Exception("Union types in constructor injection not supported for class {$concrete}");
} else {
$typeName = $type->getName();
$dependencies[] = $this->get($typeName);
}
}
return $reflector->newInstanceArgs($dependencies);
}
return $concrete;
}
}

215
app/Core/Request.php Normal file
View File

@@ -0,0 +1,215 @@
<?php
namespace App\Core;
class Request
{
private array $get;
private array $post;
private array $server;
private array $files;
private array $cookies;
private ?array $json = null;
private array $routeParams = [];
public function __construct()
{
$this->get = $_GET;
$this->post = $_POST;
$this->server = $_SERVER;
$this->files = $_FILES;
$this->cookies = $_COOKIE;
}
/**
* Get request path without query string.
*/
public function getPath(): string
{
$uri = $this->server['REQUEST_URI'] ?? '/';
$position = strpos($uri, '?');
if ($position === false) {
return $uri;
}
return substr($uri, 0, $position);
}
/**
* Get request HTTP method (e.g., GET, POST, PUT, DELETE).
*/
public function getMethod(): string
{
return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
}
public function isGet(): bool
{
return $this->getMethod() === 'GET';
}
public function isPost(): bool
{
return $this->getMethod() === 'POST';
}
public function isPut(): bool
{
return $this->getMethod() === 'PUT';
}
public function isDelete(): bool
{
return $this->getMethod() === 'DELETE';
}
/**
* Set route parameters (extracted by Router).
*/
public function setRouteParams(array $params): void
{
$this->routeParams = $params;
}
/**
* Get route parameters or specific key.
*/
public function getRouteParams(): array
{
return $this->routeParams;
}
public function routeParam(string $key, mixed $default = null): mixed
{
return $this->routeParams[$key] ?? $default;
}
/**
* Fetch GET parameter.
*/
public function get(string $key, mixed $default = null): mixed
{
return $this->get[$key] ?? $default;
}
/**
* Fetch POST parameter.
*/
public function post(string $key, mixed $default = null): mixed
{
return $this->post[$key] ?? $default;
}
/**
* Fetch all request parameters parsed.
*/
public function getBody(): array
{
if ($this->isJson()) {
return $this->getJsonBody();
}
$body = [];
if ($this->isGet()) {
foreach ($this->get as $key => $value) {
$body[$key] = filter_input(INPUT_GET, $key, FILTER_DEFAULT);
}
}
if ($this->isPost()) {
foreach ($this->post as $key => $value) {
$body[$key] = filter_input(INPUT_POST, $key, FILTER_DEFAULT);
}
}
return $body;
}
/**
* Check if request is JSON.
*/
public function isJson(): bool
{
$contentType = $this->server['CONTENT_TYPE'] ?? $this->server['HTTP_CONTENT_TYPE'] ?? '';
return str_contains($contentType, 'application/json');
}
/**
* Parse raw JSON request payload.
*/
public function getJsonBody(): array
{
if ($this->json === null) {
$rawInput = file_get_contents('php://input');
$this->json = json_decode($rawInput, true) ?? [];
}
return $this->json;
}
/**
* Get a specific input value (works for GET, POST, and JSON).
*/
public function input(string $key, mixed $default = null): mixed
{
$body = $this->getBody();
return $body[$key] ?? $default;
}
/**
* Get all request headers.
*/
public function getHeaders(): array
{
$headers = [];
foreach ($this->server as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
$headers[$name] = $value;
} elseif ($key === 'CONTENT_TYPE') {
$headers['Content-Type'] = $value;
} elseif ($key === 'CONTENT_LENGTH') {
$headers['Content-Length'] = $value;
}
}
return $headers;
}
/**
* Get specific request header.
*/
public function getHeader(string $name, ?string $default = null): ?string
{
$headers = $this->getHeaders();
// Match case-insensitively
foreach ($headers as $key => $val) {
if (strtolower($key) === strtolower($name)) {
return $val;
}
}
return $default;
}
/**
* Get Client IP address.
*/
public function getIp(): string
{
return $this->server['HTTP_CLIENT_IP']
?? $this->server['HTTP_X_FORWARDED_FOR']
?? $this->server['REMOTE_ADDR']
?? '127.0.0.1';
}
/**
* Get User Agent.
*/
public function getUserAgent(): string
{
return $this->server['HTTP_USER_AGENT'] ?? 'Unknown';
}
/**
* Get uploaded file array.
*/
public function file(string $key): ?array
{
return $this->files[$key] ?? null;
}
}

89
app/Core/Response.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
namespace App\Core;
class Response
{
private int $statusCode = 200;
private array $headers = [];
/**
* Set the HTTP status code.
*/
public function setStatusCode(int $code): self
{
$this->statusCode = $code;
return $this;
}
/**
* Get the HTTP status code.
*/
public function getStatusCode(): int
{
return $this->statusCode;
}
/**
* Add a header.
*/
public function header(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
/**
* Set redirect path.
*/
public function redirect(string $url): void
{
header("Location: " . $url);
exit;
}
/**
* Output JSON data.
*/
public function json(mixed $data, int $code = 200): void
{
$this->setStatusCode($code);
$this->header('Content-Type', 'application/json; charset=utf-8');
$this->sendHeaders();
http_response_code($this->statusCode);
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Output raw HTML or string content.
*/
public function html(string $content, int $code = 200): void
{
$this->setStatusCode($code);
if (!isset($this->headers['Content-Type'])) {
$this->header('Content-Type', 'text/html; charset=utf-8');
}
$this->sendHeaders();
http_response_code($this->statusCode);
echo $content;
}
/**
* Send all buffered headers.
*/
public function sendHeaders(): void
{
if (headers_sent()) {
return;
}
foreach ($this->headers as $name => $value) {
header("$name: $value");
}
}
}

113
app/Core/Router.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
namespace App\Core;
use Exception;
class Router
{
private array $routes = [];
private array $groupMiddleware = [];
private string $groupPrefix = '';
/**
* Map a route.
*/
public function addRoute(string $method, string $path, array|callable $callback, array $middleware = []): void
{
$prefix = '/' . trim($this->groupPrefix, '/');
$prefix = $prefix === '/' ? '' : $prefix;
$trimmedPath = '/' . trim($path, '/');
$trimmedPath = $trimmedPath === '/' ? '' : $trimmedPath;
$fullPath = $prefix . $trimmedPath;
$fullPath = $fullPath === '' ? '/' : $fullPath;
$this->routes[strtoupper($method)][] = [
'path' => $fullPath,
'callback' => $callback,
'middleware' => array_merge($this->groupMiddleware, $middleware)
];
}
public function get(string $path, array|callable $callback, array $middleware = []): void
{
$this->addRoute('GET', $path, $callback, $middleware);
}
public function post(string $path, array|callable $callback, array $middleware = []): void
{
$this->addRoute('POST', $path, $callback, $middleware);
}
public function put(string $path, array|callable $callback, array $middleware = []): void
{
$this->addRoute('PUT', $path, $callback, $middleware);
}
public function delete(string $path, array|callable $callback, array $middleware = []): void
{
$this->addRoute('DELETE', $path, $callback, $middleware);
}
/**
* Group routes under attributes like prefix and middleware.
*/
public function group(array $attributes, callable $callback): void
{
$previousPrefix = $this->groupPrefix;
$previousMiddleware = $this->groupMiddleware;
if (isset($attributes['prefix'])) {
$this->groupPrefix = $previousPrefix . '/' . trim($attributes['prefix'], '/');
}
if (isset($attributes['middleware'])) {
$this->groupMiddleware = array_merge($previousMiddleware, (array)$attributes['middleware']);
}
$callback($this);
$this->groupPrefix = $previousPrefix;
$this->groupMiddleware = $previousMiddleware;
}
/**
* Match path against configured routes.
*/
public function resolve(Request $request): array
{
$method = $request->getMethod();
$path = '/' . trim($request->getPath(), '/');
$routes = $this->routes[$method] ?? [];
foreach ($routes as $route) {
$pattern = $this->compilePattern($route['path']);
if (preg_match($pattern, $path, $matches)) {
// Filter out non-string keys from named capture groups
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return [
'callback' => $route['callback'],
'middleware' => $route['middleware'],
'params' => $params
];
}
}
throw new Exception("Route not found", 404);
}
/**
* Convert route notation {param} into regex capture groups.
*/
private function compilePattern(string $path): string
{
$cleanPath = '/' . trim($path, '/');
$cleanPath = $cleanPath === '/' ? '' : $cleanPath;
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $cleanPath);
return '#^' . ($pattern === '' ? '/' : $pattern) . '$#';
}
}

148
app/Core/Session.php Normal file
View File

@@ -0,0 +1,148 @@
<?php
namespace App\Core;
class Session
{
private const FLASH_KEY = 'flash_messages';
public function __construct()
{
if (session_status() === PHP_SESSION_NONE) {
// Set secure session parameters
session_start([
'cookie_httponly' => true,
'cookie_secure' => false, // Set to true if HTTPS is enforced (e.g. on production)
'cookie_samesite' => 'Lax',
]);
}
// Mark existing flash messages to be deleted next request
$this->ageFlashMessages();
}
/**
* Set a session value.
*/
public function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
/**
* Get a session value.
*/
public function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
/**
* Remove a session value.
*/
public function remove(string $key): void
{
unset($_SESSION[$key]);
}
/**
* Check if a session key exists.
*/
public function has(string $key): bool
{
return isset($_SESSION[$key]);
}
/**
* Set a flash message (available only for the next request).
*/
public function setFlash(string $key, string $message): void
{
$_SESSION[self::FLASH_KEY][$key] = [
'value' => $message,
'remove' => false
];
}
/**
* Get a flash message.
*/
public function getFlash(string $key, ?string $default = null): ?string
{
return $_SESSION[self::FLASH_KEY][$key]['value'] ?? $default;
}
/**
* Get all flash messages.
*/
public function getFlashes(): array
{
$flashes = [];
foreach ($_SESSION[self::FLASH_KEY] ?? [] as $key => $flash) {
$flashes[$key] = $flash['value'];
}
return $flashes;
}
/**
* Age flash messages at start of request.
*/
private function ageFlashMessages(): void
{
$flashMessages = $_SESSION[self::FLASH_KEY] ?? [];
foreach ($flashMessages as $key => &$flash) {
if ($flash['remove']) {
unset($flashMessages[$key]);
} else {
$flash['remove'] = true;
}
}
$_SESSION[self::FLASH_KEY] = $flashMessages;
}
/**
* Generate or fetch CSRF token.
*/
public function getCsrfToken(): string
{
$token = $this->get('csrf_token');
if (!$token) {
$token = bin2hex(random_bytes(32));
$this->set('csrf_token', $token);
}
return $token;
}
/**
* Validate CSRF token.
*/
public function validateCsrfToken(?string $token): bool
{
if (!$token) {
return false;
}
$storedToken = $this->get('csrf_token');
return hash_equals($storedToken, $token);
}
/**
* Destroy the session.
*/
public function destroy(): void
{
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params["path"],
$params["domain"],
$params["secure"],
$params["httponly"]
);
}
session_destroy();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
use App\Services\Auth\AuthService;
use Exception;
class Authenticate implements MiddlewareInterface
{
private Session $session;
private AuthService $authService;
public function __construct(Session $session, AuthService $authService)
{
$this->session = $session;
$this->authService = $authService;
}
/**
* Authenticate session or JWT bearer token.
*/
public function handle(Request $request, Response $response, callable $next): void
{
$path = $request->getPath();
// 1. API Route Authentication (JWT verification)
if (str_starts_with($path, '/api')) {
$authHeader = $request->getHeader('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
throw new Exception("Unauthorized. Bearer token missing.", 401);
}
$token = substr($authHeader, 7);
$user = $this->authService->verifyJwt($token);
if (!$user) {
throw new Exception("Unauthorized. Invalid or expired token.", 401);
}
// Inject the authenticated user into route parameters for controller access
$request->setRouteParams(array_merge($request->getRouteParams(), ['_authenticated_user' => $user]));
$next();
return;
}
// 2. Web Route Authentication (Session verification)
$userId = $this->session->get('user_id');
if (!$userId) {
$this->session->setFlash('error', 'Please login to access this page.');
$response->redirect('/login');
return;
}
$user = $this->authService->getUserById($userId);
if (!$user) {
$this->session->destroy();
$response->redirect('/login');
return;
}
// Inject the authenticated user
$request->setRouteParams(array_merge($request->getRouteParams(), ['_authenticated_user' => $user]));
$next();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
use Exception;
class CsrfProtection implements MiddlewareInterface
{
private Session $session;
public function __construct(Session $session)
{
$this->session = $session;
}
/**
* Handle CSRF token validation.
*/
public function handle(Request $request, Response $response, callable $next): void
{
// Skip validation for read-only requests
if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
$next();
return;
}
// Retrieve token from request parameters or custom header
$token = $request->input('_csrf') ?? $request->getHeader('X-CSRF-Token');
if (!$this->session->validateCsrfToken($token)) {
throw new Exception("CSRF token validation failed. Request untrusted.", 403);
}
$next();
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
interface MiddlewareInterface
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Response $response
* @param callable $next The next middleware/action in the chain
*/
public function handle(Request $request, Response $response, callable $next): void;
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use Predis\Client as RedisClient;
use Exception;
use Throwable;
class RateLimit implements MiddlewareInterface
{
private ?RedisClient $redis = null;
private int $limit = 100; // Allow 100 requests
private int $window = 60; // Per 60 seconds
public function __construct()
{
$config = require __DIR__ . '/../../config/redis.php';
if (!empty($config['host'])) {
try {
$this->redis = new RedisClient([
'scheme' => 'tcp',
'host' => $config['host'],
'port' => $config['port'],
'password' => $config['password'],
'timeout' => 0.5, // 500ms connection timeout to fail fast
]);
$this->redis->connect();
} catch (Throwable $e) {
// Degrade gracefully if Redis server is down
$this->redis = null;
}
}
}
/**
* Handle rate limiting logic.
*/
public function handle(Request $request, Response $response, callable $next): void
{
if ($this->redis === null) {
// Redis unavailable, skip throttle check to avoid service outage
$next();
return;
}
$ip = $request->getIp();
$path = $request->getPath();
$key = "rate_limit:" . md5($ip . ":" . $path);
try {
$current = $this->redis->get($key);
if ($current !== null && (int)$current >= $this->limit) {
$ttl = $this->redis->ttl($key);
$response->header('Retry-After', (string)max(1, $ttl));
throw new Exception("Too Many Requests. Rate limit exceeded.", 429);
}
if ($current === null) {
// First request in the time frame window
$this->redis->setex($key, $this->window, 1);
$current = 0;
} else {
$this->redis->incr($key);
}
// Set rate limit headers
$remaining = $this->limit - ((int)$current + 1);
$response->header('X-RateLimit-Limit', (string)$this->limit);
$response->header('X-RateLimit-Remaining', (string)max(0, $remaining));
} catch (Throwable $e) {
if ($e->getCode() === 429) {
throw $e;
}
// Logging or catching connection dropping mid-request
}
$next();
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
class SecurityHeaders implements MiddlewareInterface
{
/**
* Add security-hardening headers to the response.
*/
public function handle(Request $request, Response $response, callable $next): void
{
// Enforce frame options to avoid clickjacking
$response->header('X-Frame-Options', 'SAMEORIGIN');
// Prevent MIME type sniffing
$response->header('X-Content-Type-Options', 'nosniff');
// Referrer policy
$response->header('Referrer-Policy', 'no-referrer-when-downgrade');
// Cross-Site Scripting protection
$response->header('X-XSS-Protection', '1; mode=block');
// HTTP Strict Transport Security (HSTS) - force HTTPS
$response->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Content Security Policy (CSP)
// Allow scripts from self, google fonts, CDN js, styles from self/fonts
$csp = "default-src 'self'; " .
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " .
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " .
"font-src 'self' https://fonts.gstatic.com; " .
"img-src 'self' data: https:; " .
"connect-src 'self'; " .
"frame-ancestors 'none'; " .
"base-uri 'self'; " .
"form-action 'self';";
$response->header('Content-Security-Policy', $csp);
$next();
}
}

152
app/Models/Model.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
namespace App\Models;
use App\Core\App;
use App\Services\Database\Connection;
use PDO;
abstract class Model
{
protected static ?PDO $db = null;
protected string $table;
protected string $primaryKey = 'id';
protected array $attributes = [];
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
if (self::$db === null && isset(App::$app)) {
$connection = App::$app->container->get(Connection::class);
self::$db = $connection->getPdo();
}
}
/**
* Get active database connection.
*/
public static function getDb(): PDO
{
if (self::$db === null) {
$connection = App::$app->container->get(Connection::class);
self::$db = $connection->getPdo();
}
return self::$db;
}
/**
* Magic getter for attributes.
*/
public function __get(string $name)
{
return $this->attributes[$name] ?? null;
}
/**
* Magic setter for attributes.
*/
public function __set(string $name, $value): void
{
$this->attributes[$name] = $value;
}
public function getAttributes(): array
{
return $this->attributes;
}
/**
* Find a record by ID.
*/
public static function find(int|string $id): ?static
{
$instance = new static();
$db = self::getDb();
$sql = "SELECT * FROM {$instance->table} WHERE {$instance->primaryKey} = :id AND deleted_at IS NULL LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->execute(['id' => $id]);
$data = $stmt->fetch();
if (!$data) {
return null;
}
return new static($data);
}
/**
* Fetch all active records.
*/
public static function all(): array
{
$instance = new static();
$db = self::getDb();
$sql = "SELECT * FROM {$instance->table} WHERE deleted_at IS NULL";
$stmt = $db->query($sql);
$results = [];
while ($row = $stmt->fetch()) {
$results[] = new static($row);
}
return $results;
}
/**
* Save the active record (INSERT or UPDATE).
*/
public function save(): bool
{
$db = self::getDb();
$id = $this->attributes[$this->primaryKey] ?? null;
if ($id) {
// Update flow
$fields = [];
$params = [];
foreach ($this->attributes as $key => $value) {
if ($key === $this->primaryKey || $key === 'created_at' || $key === 'updated_at') {
continue;
}
$fields[] = "`{$key}` = :{$key}";
$params[$key] = $value;
}
$params[$this->primaryKey] = $id;
$sql = "UPDATE `{$this->table}` SET " . implode(', ', $fields) . " WHERE `{$this->primaryKey}` = :{$this->primaryKey}";
$stmt = $db->prepare($sql);
return $stmt->execute($params);
} else {
// Insert flow
$keys = array_keys($this->attributes);
$placeholders = array_map(fn($key) => ":{$key}", $keys);
$sql = "INSERT INTO `{$this->table}` (" . implode(', ', array_map(fn($k) => "`{$k}`", $keys)) . ") VALUES (" . implode(', ', $placeholders) . ")";
$stmt = $db->prepare($sql);
$success = $stmt->execute($this->attributes);
if ($success) {
$this->attributes[$this->primaryKey] = (int)$db->lastInsertId();
}
return $success;
}
}
/**
* Soft delete active record.
*/
public function delete(): bool
{
$db = self::getDb();
$id = $this->attributes[$this->primaryKey] ?? null;
if (!$id) {
return false;
}
$sql = "UPDATE `{$this->table}` SET deleted_at = NOW() WHERE `{$this->primaryKey}` = :id";
$stmt = $db->prepare($sql);
return $stmt->execute(['id' => $id]);
}
}

8
app/Models/User.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace App\Models;
class User extends Model
{
protected string $table = 'users';
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Services\Auth;
use App\Services\Database\Connection;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use PDO;
use Exception;
use Throwable;
class AuthService
{
private PDO $pdo;
private array $jwtConfig;
public function __construct(Connection $connection)
{
$this->pdo = $connection->getPdo();
$aiConfig = require __DIR__ . '/../../../config/ai.php';
$this->jwtConfig = $aiConfig['jwt'];
}
/**
* Register a new user.
*/
public function register(string $name, string $email, string $password): array
{
// Check for duplicates
$stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
throw new Exception("Email already registered.");
}
$passwordHash = password_hash($password, PASSWORD_BCRYPT);
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare("INSERT INTO users (name, email, password_hash, status) VALUES (?, ?, ?, 'active')");
$stmt->execute([$name, $email, $passwordHash]);
$userId = (int)$this->pdo->lastInsertId();
// Count users to assign role: first user gets Admin, others get Member
$stmt = $this->pdo->query("SELECT COUNT(*) FROM users");
$count = (int)$stmt->fetchColumn();
$roleCode = $count === 1 ? 'admin' : 'member';
$stmt = $this->pdo->prepare("SELECT id FROM roles WHERE code = ?");
$stmt->execute([$roleCode]);
$roleId = $stmt->fetchColumn();
if ($roleId) {
$stmt = $this->pdo->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$userId, $roleId]);
}
$this->pdo->commit();
return [
'id' => $userId,
'name' => $name,
'email' => $email,
'status' => 'active'
];
} catch (Throwable $e) {
$this->pdo->rollBack();
throw new Exception("Registration failed: " . $e->getMessage());
}
}
/**
* Authenticate a user by email and password.
*/
public function login(string $email, string $password): array
{
$stmt = $this->pdo->prepare("SELECT id, name, email, password_hash, status FROM users WHERE email = ? AND deleted_at IS NULL");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new Exception("Invalid email or password.");
}
if ($user['status'] !== 'active') {
throw new Exception("User account is inactive.");
}
unset($user['password_hash']);
return $user;
}
/**
* Get active user by ID.
*/
public function getUserById(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT id, name, email, status FROM users WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$id]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Generate JWT for APIs.
*/
public function generateJwt(array $user): string
{
$issuedAt = time();
$expire = $issuedAt + $this->jwtConfig['expires_in'];
$payload = [
'iss' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com',
'aud' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com',
'iat' => $issuedAt,
'exp' => $expire,
'sub' => $user['id'],
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email']
]
];
return JWT::encode($payload, $this->jwtConfig['secret'], $this->jwtConfig['algorithm']);
}
/**
* Decode and verify JWT.
*/
public function verifyJwt(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key($this->jwtConfig['secret'], $this->jwtConfig['algorithm']));
$payload = (array)$decoded;
if (isset($payload['user'])) {
return (array)$payload['user'];
}
return $this->getUserById((int)$payload['sub']);
} catch (Throwable $e) {
return null;
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Auth;
use App\Services\Database\Connection;
use PDO;
class RBAC
{
private PDO $pdo;
private array $permissionCache = [];
public function __construct(Connection $connection)
{
$this->pdo = $connection->getPdo();
}
/**
* Check if a user has a specific permission.
*/
public function hasPermission(int $userId, string $permissionCode): bool
{
$permissions = $this->getUserPermissions($userId);
return in_array($permissionCode, $permissions);
}
/**
* Get all unique permissions code associated with the user's roles.
*/
public function getUserPermissions(int $userId): array
{
if (isset($this->permissionCache[$userId])) {
return $this->permissionCache[$userId];
}
$sql = "SELECT DISTINCT p.code
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = :user_id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $userId]);
$permissions = $stmt->fetchAll(PDO::FETCH_COLUMN);
$permissions = $permissions ?: [];
$this->permissionCache[$userId] = $permissions;
return $permissions;
}
/**
* Get user roles.
*/
public function getUserRoles(int $userId): array
{
$sql = "SELECT r.code
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = :user_id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $userId]);
$roles = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $roles ?: [];
}
/**
* Assign a role to a user.
*/
public function assignRoleToUser(int $userId, int $roleId): void
{
$stmt = $this->pdo->prepare("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$userId, $roleId]);
// Invalidate in-memory permission cache for this user
unset($this->permissionCache[$userId]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Database;
use App\Core\Request;
use PDO;
class ActivityLogger
{
private PDO $pdo;
private Request $request;
public function __construct(Connection $connection, Request $request)
{
$this->pdo = $connection->getPdo();
$this->request = $request;
}
/**
* Log user or system activity.
*/
public function log(?int $userId, string $action, ?string $description = null): void
{
$ip = $this->request->getIp();
$ua = $this->request->getUserAgent();
$sql = "INSERT INTO activity_logs (user_id, action, description, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$userId, $action, $description, $ip, $ua]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services\Database;
use PDO;
use Exception;
class Connection
{
private ?PDO $pdo = null;
public function __construct()
{
$config = require __DIR__ . '/../../../config/database.php';
$dsn = sprintf(
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
try {
$this->pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (\PDOException $e) {
throw new Exception("Database connection failed: " . $e->getMessage());
}
}
/**
* Get active PDO instance.
*/
public function getPdo(): PDO
{
return $this->pdo;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services\Database;
use PDO;
class MigrationRunner
{
private PDO $pdo;
private string $migrationsDir;
public function __construct(Connection $connection)
{
$this->pdo = $connection->getPdo();
$this->migrationsDir = __DIR__ . '/../../../database/migrations';
}
/**
* Run all pending migrations.
*/
public function migrate(): void
{
$this->createMigrationsTable();
$executed = $this->getExecutedMigrations();
if (!is_dir($this->migrationsDir)) {
mkdir($this->migrationsDir, 0755, true);
}
$files = glob($this->migrationsDir . '/*.sql');
if ($files === false) {
$files = [];
}
sort($files);
$count = 0;
foreach ($files as $file) {
$name = basename($file);
if (!in_array($name, $executed)) {
echo "Running migration: {$name}...\n";
$this->executeSqlFile($file);
$this->logMigration($name);
echo "Successfully ran: {$name}\n";
$count++;
}
}
if ($count === 0) {
echo "Nothing to migrate. Database is up to date!\n";
}
}
/**
* Create the tracking migrations table if it does not exist.
*/
private function createMigrationsTable(): void
{
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$this->pdo->exec($sql);
}
/**
* Get list of already executed migrations.
*/
private function getExecutedMigrations(): array
{
$stmt = $this->pdo->query("SELECT migration FROM migrations");
$results = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $results ?: [];
}
/**
* Execute SQL file.
*/
private function executeSqlFile(string $filePath): void
{
$sql = file_get_contents($filePath);
if (!$sql) {
return;
}
try {
// Run SQL commands
$this->pdo->exec($sql);
} catch (\PDOException $e) {
echo "Error running migration from file: " . basename($filePath) . "\n";
echo "Details: " . $e->getMessage() . "\n";
throw $e;
}
}
/**
* Log executed migration.
*/
private function logMigration(string $name): void
{
$stmt = $this->pdo->prepare("INSERT INTO migrations (migration) VALUES (?)");
$stmt->execute([$name]);
}
}