Clone Nabih Pure PHP Architecture

This commit is contained in:
Hamza-Ayed
2026-08-26 17:10:57 +03:00
parent 1e21847ed0
commit df5d8dfc60
28 changed files with 2791 additions and 267 deletions
@@ -0,0 +1,43 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
class AuthMiddleware
{
/**
* Verifies the JWT token and populates request properties.
*/
public function handle(Request $request, Response $response): void
{
$authHeader = $request->getHeader('authorization', '');
if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
$response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401);
exit;
}
$token = $matches[1];
$payload = Security::verifyJWT($token);
if (!$payload) {
$response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401);
exit;
}
// Validate required custom payload elements
if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) {
$response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401);
exit;
}
// Attach user info to the Request instance dynamically so controllers can use it
$request->user_id = $payload['user_id'];
$request->company_id = $payload['company_id'];
$request->role = $payload['role'];
$request->is_super_admin = (int)$payload['company_id'] === 1;
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
/**
* Rate Limit Middleware
* Limits the number of requests per IP address using file-based counters.
* Protects sensitive endpoints (login, register) from Brute Force attacks.
*/
class RateLimitMiddleware
{
/**
* Maximum allowed requests within the time window
*/
private int $maxAttempts;
/**
* Time window in seconds
*/
private int $decaySeconds;
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
{
$this->maxAttempts = $maxAttempts;
$this->decaySeconds = $decaySeconds;
}
public function handle(Request $request, Response $response): void
{
$ip = $this->getClientIp();
$key = 'rate_' . md5($ip . '_' . $request->getPath());
$storageDir = APP_ROOT . '/storage/rate_limits';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0750, true);
}
$filePath = $storageDir . '/' . $key . '.json';
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
if (file_exists($filePath)) {
$raw = json_decode(file_get_contents($filePath), true);
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
// Window still active — use existing data
$data = $raw;
}
// If window expired, fall through and reset (overwrite with fresh data below)
}
$data['count']++;
if ($data['count'] > $this->maxAttempts) {
$retryAfter = max(0, $data['expires_at'] - time());
$response->setHeader('Retry-After', (string)$retryAfter);
$response->json([
'error' => 'Too Many Requests',
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
], 429);
return;
}
// Persist the updated counter
file_put_contents($filePath, json_encode($data), LOCK_EX);
}
/**
* Get real client IP, accounting for proxies
*/
private function getClientIp(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR'
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For can be a comma-separated list; take first
$ip = trim(explode(',', $_SERVER[$header])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '0.0.0.0';
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
class SecurityMiddleware
{
/**
* Applies OWASP security headers and sanitizes incoming body/query parameters
* to protect against XSS and basic Injection.
*/
public function handle(Request $request, Response $response): void
{
// 1. Set OWASP Security Headers
$response->setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking
$response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS)
$response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS
$response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; object-src 'none';"); // CSP
// 2. Input Sanitization to prevent XSS (Recursive)
$body = $request->getBody();
if (is_array($body)) {
$request->setBody($this->sanitizeArray($body));
}
$query = $request->getQueryParams();
if (is_array($query)) {
$request->setQueryParams($this->sanitizeArray($query));
}
}
/**
* Recursively trim input arrays
*/
private function sanitizeArray(array $data): array
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value);
} elseif (is_string($value)) {
$sanitized[$key] = trim($value);
} else {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Models\CompanySubscription;
use App\Models\CompanySubscriptionUsage;
/**
* SubscriptionMiddleware
* Validates company subscription validity and request quotas before processing operations.
*/
class SubscriptionMiddleware
{
public function handle(Request $request, Response $response): void
{
// 1. Get company ID (populated by AuthMiddleware)
$companyId = $request->company_id ?? null;
if (!$companyId) {
$response->json(['error' => 'Unauthorized', 'message' => 'Company details not found in request Context'], 401);
exit;
}
// Allow Company 1 (Intaleq admin/demo) to bypass limits temporarily or have unlimited
if ($companyId === 1) {
return;
}
// 2. Fetch active subscription
$activeSub = CompanySubscription::findActiveByCompany($companyId);
if (!$activeSub) {
$response->json([
'error' => 'Payment Required',
'message' => 'This account does not have an active subscription or the current subscription has expired. Please subscribe to a plan to continue.'
], 402);
exit;
}
// 3. Verify total requests limit
$hasQuota = CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request');
if (!$hasQuota) {
$response->json([
'error' => 'Quota Exceeded',
'message' => 'You have exceeded the monthly request quota for your plan (' . $activeSub['max_requests'] . ' requests). Please upgrade your subscription.'
], 403);
exit;
}
}
}