fix: PSR-4 compliance — rename core/middleware/services to PascalCase for Linux server compatibility
This commit is contained in:
49
app/Middleware/AuthMiddleware.php
Normal file
49
app/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Simple Authentication Middleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\JWT;
|
||||||
|
|
||||||
|
final class AuthMiddleware
|
||||||
|
{
|
||||||
|
public static function check(): array
|
||||||
|
{
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||||
|
|
||||||
|
if (!str_starts_with($authHeader, 'Bearer ')) {
|
||||||
|
json_error('Unauthorized: Missing or invalid token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = substr($authHeader, 7);
|
||||||
|
$secret = env('JWT_SECRET');
|
||||||
|
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = JWT::decode($token, $secret);
|
||||||
|
|
||||||
|
if (!$decoded) {
|
||||||
|
// Check if it's specifically expired if your JWT class supports it,
|
||||||
|
// otherwise just send the standard 401 with a code.
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'انتهت صلاحية الجلسة',
|
||||||
|
'code' => 'TOKEN_EXPIRED',
|
||||||
|
'redirect'=> '/login.php'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Middleware/HmacMiddleware.php
Normal file
62
app/Middleware/HmacMiddleware.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HMAC Request Signature Middleware
|
||||||
|
*
|
||||||
|
* Verifies that incoming requests are signed with a shared secret,
|
||||||
|
* preventing replay attacks and ensuring request integrity.
|
||||||
|
*
|
||||||
|
* Client must send:
|
||||||
|
* X-Timestamp: Unix timestamp (seconds)
|
||||||
|
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
final class HmacMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
|
||||||
|
*/
|
||||||
|
public static function verify(int $maxAgeSeconds = 300): void
|
||||||
|
{
|
||||||
|
$headers = getallheaders();
|
||||||
|
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
||||||
|
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
||||||
|
|
||||||
|
// 1. Ensure both headers are present
|
||||||
|
if (empty($signature) || empty($timestamp)) {
|
||||||
|
json_error('Missing HMAC signature or timestamp', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate timestamp is numeric
|
||||||
|
if (!ctype_digit((string)$timestamp)) {
|
||||||
|
json_error('Invalid timestamp format', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Replay attack prevention — reject stale requests
|
||||||
|
$age = abs(time() - (int)$timestamp);
|
||||||
|
if ($age > $maxAgeSeconds) {
|
||||||
|
json_error('Request expired. Check your system clock.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the expected signature
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$payload = $timestamp . '.' . $body;
|
||||||
|
$secret = env('HMAC_SECRET_KEY');
|
||||||
|
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify using constant-time comparison (prevents timing attacks)
|
||||||
|
if (!Security::verifySignature($payload, $signature, $secret)) {
|
||||||
|
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
|
||||||
|
json_error('Invalid request signature', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
270
app/Middleware/QuotaMiddleware.php
Normal file
270
app/Middleware/QuotaMiddleware.php
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Quota Enforcement Middleware
|
||||||
|
*
|
||||||
|
* Checks tenant subscription limits before allowing resource creation.
|
||||||
|
* Automatically resets monthly counters when the billing period rolls over.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
final class QuotaMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the tenant can upload more invoices this month.
|
||||||
|
* Automatically resets the counter if the billing period has ended.
|
||||||
|
*
|
||||||
|
* @return array The current subscription data (for UI display)
|
||||||
|
*/
|
||||||
|
public static function checkInvoiceQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Fetch subscription with plan info
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
if ($sub['status'] === 'cancelled') {
|
||||||
|
json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sub['status'] === 'past_due') {
|
||||||
|
json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-reset monthly counter if billing period has ended
|
||||||
|
if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) {
|
||||||
|
$newStart = date('Y-m-d H:i:s');
|
||||||
|
$newEnd = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
|
||||||
|
$resetStmt = $db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET invoices_used_this_month = 0,
|
||||||
|
current_period_start = ?,
|
||||||
|
current_period_end = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
");
|
||||||
|
$resetStmt->execute([$newStart, $newEnd, $tenantId]);
|
||||||
|
|
||||||
|
$sub['invoices_used_this_month'] = 0;
|
||||||
|
$sub['current_period_start'] = $newStart;
|
||||||
|
$sub['current_period_end'] = $newEnd;
|
||||||
|
|
||||||
|
error_log("QuotaMiddleware: Auto-reset monthly counter for tenant {$tenantId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invoice quota
|
||||||
|
$used = (int)$sub['invoices_used_this_month'];
|
||||||
|
$limit = (int)$sub['max_invoices_per_month'];
|
||||||
|
|
||||||
|
if ($used >= $limit) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'invoices',
|
||||||
|
'used' => $used,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
'period_end' => $sub['current_period_end'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the monthly invoice counter after a successful upload.
|
||||||
|
*/
|
||||||
|
public static function incrementInvoiceUsage(string $tenantId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET invoices_used_this_month = invoices_used_this_month + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tenant can add more companies.
|
||||||
|
*/
|
||||||
|
public static function checkCompanyQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current active companies
|
||||||
|
$countStmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM companies
|
||||||
|
WHERE tenant_id = ? AND (deleted_at IS NULL)
|
||||||
|
");
|
||||||
|
$countStmt->execute([$tenantId]);
|
||||||
|
$currentCount = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$limit = (int)$sub['max_companies'];
|
||||||
|
|
||||||
|
if ($currentCount >= $limit) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'companies',
|
||||||
|
'used' => $currentCount,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the tenant can add more users.
|
||||||
|
*/
|
||||||
|
public static function checkUserQuota(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count current active users in this tenant
|
||||||
|
$countStmt = $db->prepare("
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1
|
||||||
|
");
|
||||||
|
$countStmt->execute([$tenantId]);
|
||||||
|
$currentCount = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$maxUsers = (int)($sub['max_users'] ?? 999);
|
||||||
|
|
||||||
|
if ($currentCount >= $maxUsers) {
|
||||||
|
json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [
|
||||||
|
'quota_type' => 'users',
|
||||||
|
'used' => $currentCount,
|
||||||
|
'limit' => $maxUsers,
|
||||||
|
'plan' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for a tenant (for dashboard display).
|
||||||
|
*/
|
||||||
|
public static function getUsageSummary(string $tenantId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Get subscription
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en,
|
||||||
|
sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN subscription_plans sp ON s.plan_id = sp.id
|
||||||
|
WHERE s.tenant_id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
$sub = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$sub) {
|
||||||
|
return [
|
||||||
|
'has_subscription' => false,
|
||||||
|
'plan' => 'none',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count companies
|
||||||
|
$compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||||
|
$compStmt->execute([$tenantId]);
|
||||||
|
$companiesUsed = (int)$compStmt->fetchColumn();
|
||||||
|
|
||||||
|
// Count users
|
||||||
|
$userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1");
|
||||||
|
$userStmt->execute([$tenantId]);
|
||||||
|
$usersUsed = (int)$userStmt->fetchColumn();
|
||||||
|
|
||||||
|
$invoicesUsed = (int)$sub['invoices_used_this_month'];
|
||||||
|
$invoicesLimit = (int)$sub['max_invoices_per_month'];
|
||||||
|
$companiesLimit = (int)$sub['max_companies'];
|
||||||
|
$usersLimit = (int)($sub['max_users'] ?? 999);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'has_subscription' => true,
|
||||||
|
'plan_id' => $sub['plan_id'] ?? 'free',
|
||||||
|
'plan_name' => $sub['plan_name'] ?? 'مجانية',
|
||||||
|
'plan_name_en' => $sub['plan_name_en'] ?? 'Free',
|
||||||
|
'plan_price' => (float)($sub['plan_price'] ?? 0),
|
||||||
|
'status' => $sub['status'],
|
||||||
|
'ai_features' => (bool)($sub['ai_features'] ?? false),
|
||||||
|
'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false),
|
||||||
|
|
||||||
|
'invoices' => [
|
||||||
|
'used' => $invoicesUsed,
|
||||||
|
'limit' => $invoicesLimit,
|
||||||
|
'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0,
|
||||||
|
'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
'companies' => [
|
||||||
|
'used' => $companiesUsed,
|
||||||
|
'limit' => $companiesLimit,
|
||||||
|
'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0,
|
||||||
|
'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
'users' => [
|
||||||
|
'used' => $usersUsed,
|
||||||
|
'limit' => $usersLimit,
|
||||||
|
'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0,
|
||||||
|
'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9,
|
||||||
|
],
|
||||||
|
|
||||||
|
'period_start' => $sub['current_period_start'],
|
||||||
|
'period_end' => $sub['current_period_end'],
|
||||||
|
'trial_ends_at' => $sub['trial_ends_at'],
|
||||||
|
'days_remaining' => !empty($sub['current_period_end'])
|
||||||
|
? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400))
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Middleware/RateLimitMiddleware.php
Normal file
71
app/Middleware/RateLimitMiddleware.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rate Limiting Middleware (File-based, Race-Condition Safe)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
final class RateLimitMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* File-based rate limiter with file-lock to prevent race conditions.
|
||||||
|
* For multi-server deployments, replace with Redis.
|
||||||
|
*/
|
||||||
|
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
||||||
|
{
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$cacheDir = STORAGE_PATH . '/cache';
|
||||||
|
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
||||||
|
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// M2 Fix: Use exclusive file lock to prevent race condition
|
||||||
|
$fp = fopen($cacheFile, 'c+');
|
||||||
|
if ($fp === false) {
|
||||||
|
// If we can't open the file, fail open (don't block all users)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$content = stream_get_contents($fp);
|
||||||
|
$requests = [];
|
||||||
|
|
||||||
|
if (!empty($content)) {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
// Keep only requests within the time window
|
||||||
|
$requests = array_values(
|
||||||
|
array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($requests) >= $maxRequests) {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
header('Retry-After: ' . $timeWindow);
|
||||||
|
json_error('Too Many Requests. Please slow down.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this request
|
||||||
|
$requests[] = $now;
|
||||||
|
|
||||||
|
// Write updated data back
|
||||||
|
ftruncate($fp, 0);
|
||||||
|
rewind($fp);
|
||||||
|
fwrite($fp, json_encode($requests));
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user