From c759c80c85d8529913d9a3e40a73bd193a445f5b Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 30 Jul 2026 10:49:39 +0300 Subject: [PATCH] Remove case-duplicate lowercase middleware paths from index --- app/middleware/AuthMiddleware.php | 56 ---- app/middleware/HmacMiddleware.php | 182 ------------- app/middleware/QuotaMiddleware.php | 337 ------------------------- app/middleware/RateLimitMiddleware.php | 78 ------ 4 files changed, 653 deletions(-) delete mode 100644 app/middleware/AuthMiddleware.php delete mode 100644 app/middleware/HmacMiddleware.php delete mode 100644 app/middleware/QuotaMiddleware.php delete mode 100644 app/middleware/RateLimitMiddleware.php diff --git a/app/middleware/AuthMiddleware.php b/app/middleware/AuthMiddleware.php deleted file mode 100644 index 48454d7..0000000 --- a/app/middleware/AuthMiddleware.php +++ /dev/null @@ -1,56 +0,0 @@ - false, - 'message' => 'انتهت صلاحية الجلسة', - 'code' => 'TOKEN_EXPIRED', - 'redirect'=> '/login.php' - ]); - exit; - } - - // Defence in depth for mobile requests: prove the caller also holds the - // per-device secret, so a stolen bearer token on its own is not enough. - // Controlled by HMAC_ENFORCE in .env - see HmacMiddleware. - if (($decoded['source'] ?? null) === 'mobile') { - HmacMiddleware::verify($decoded); - } - - return $decoded; - } -} diff --git a/app/middleware/HmacMiddleware.php b/app/middleware/HmacMiddleware.php deleted file mode 100644 index 457e81c..0000000 --- a/app/middleware/HmacMiddleware.php +++ /dev/null @@ -1,182 +0,0 @@ - a present signature is verified and a bad - * one is rejected, but a missing signature is - * allowed through. Lets older app builds keep - * working while the new build rolls out. - * HMAC_ENFORCE=true -> a signature is mandatory. - */ - -declare(strict_types=1); - -namespace App\Middleware; - -use App\Core\Database; -use App\Core\Encryption; - -final class HmacMiddleware -{ - /** - * @param array $decoded The decoded JWT payload from AuthMiddleware::check(). - * @param int $maxAgeSeconds Replay window (default: 5 minutes). - */ - public static function verify(array $decoded, int $maxAgeSeconds = 300): void - { - $enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true'; - - $headers = getallheaders(); - $signature = $headers['X-Signature'] ?? $headers['x-signature'] - ?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? ''; - $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? ''; - - // 1. Missing headers: hard fail only when enforcing. - if ($signature === '' || $timestamp === '') { - if ($enforce) { - json_error('Missing request signature', 401); - } - return; - } - - // 2. Validate timestamp format. - if (!ctype_digit((string)$timestamp)) { - json_error('Invalid timestamp format', 401); - } - - // 3. Replay prevention. The client sends milliseconds; older callers may - // send seconds, so normalise by magnitude rather than guessing. - $ts = (int)$timestamp; - $tsSeconds = $ts > 100000000000 ? intdiv($ts, 1000) : $ts; - - if (abs(time() - $tsSeconds) > $maxAgeSeconds) { - json_error('Request expired. Check your device clock.', 401); - } - - // 4. Look up this device's secret. - $deviceId = $decoded['device_id'] ?? null; - $userId = $decoded['user_id'] ?? null; - - if (!$deviceId || !$userId) { - if ($enforce) { - json_error('Signed requests require a registered device', 401); - } - return; - } - - $secret = self::deviceSecret((string)$userId, (string)$deviceId); - if ($secret === null) { - if ($enforce) { - json_error('Unknown device. Please sign in again.', 401); - } - return; - } - - // 5. Rebuild the signing payload exactly as the client does. - $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); - $body = file_get_contents('php://input'); - - // index.php accepts both clean URLs (/api/v1/batches/create) and the - // ?route=v1/batches/create form, which produce different REQUEST_URIs for - // the same endpoint. Accept either so the signature does not depend on - // how the deployment happens to rewrite URLs. - $paths = array_unique(array_filter([ - self::requestPath(), - self::normalisePath((string)($_GET['route'] ?? '')), - ])); - - $candidates = []; - foreach ($paths as $path) { - if ($body !== '' && $body !== false) { - $candidates[] = "{$method}:{$path}:{$timestamp}:{$body}"; - } - // GET/multipart requests sign without a body. - $candidates[] = "{$method}:{$path}:{$timestamp}"; - } - - foreach ($candidates as $payload) { - $expected = hash_hmac('sha256', $payload, $secret); - if (hash_equals($expected, strtolower($signature))) { - return; // Verified. - } - } - - error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? '')); - json_error('Invalid request signature', 401); - } - - /** - * The path the client signed. Dio signs options.path, i.e. the endpoint - * relative to the API base URL ("batches/finalize"), without a query string. - */ - private static function requestPath(): string - { - $uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: ''; - return self::normalisePath($uri); - } - - /** - * Strip the leading slash and any deployment prefix so the signed value - * matches what the client hashed. - */ - private static function normalisePath(string $raw): string - { - $path = ltrim(trim($raw), '/'); - - foreach (['api/v1/', 'api/', 'v1/'] as $prefix) { - if (str_starts_with($path, $prefix)) { - $path = substr($path, strlen($prefix)); - break; - } - } - - return $path; - } - - /** - * Decrypt the stored per-device secret, or null if the device is unknown. - */ - private static function deviceSecret(string $userId, string $deviceId): ?string - { - try { - $db = Database::getInstance(); - $stmt = $db->prepare(" - SELECT device_secret FROM user_devices - WHERE user_id = ? AND device_fingerprint = ? AND is_trusted = 1 - LIMIT 1 - "); - $stmt->execute([$userId, $deviceId]); - $stored = $stmt->fetchColumn(); - - if (!$stored) { - return null; - } - - // Legacy rows hold a bcrypt hash, which is one-way and therefore - // unusable for HMAC. Treat those devices as un-signable until the - // user logs in again on the new build. - if (str_starts_with((string)$stored, '$2y$') || str_starts_with((string)$stored, '$2a$')) { - return null; - } - - $plain = Encryption::decrypt((string)$stored); - return $plain === false ? null : $plain; - } catch (\Throwable $e) { - error_log('[HmacMiddleware] device secret lookup failed: ' . $e->getMessage()); - return null; - } - } -} diff --git a/app/middleware/QuotaMiddleware.php b/app/middleware/QuotaMiddleware.php deleted file mode 100644 index 5fec022..0000000 --- a/app/middleware/QuotaMiddleware.php +++ /dev/null @@ -1,337 +0,0 @@ -prepare(" - SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod - 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) { - Cache::set($cacheKey, $sub, 300); // Cache for 5 minutes - } - } - - 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 period 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'); - $cycle = $sub['billing_cycle'] ?? 'annual'; - $interval = ($cycle === 'monthly') ? '+1 month' : '+1 year'; - $newEnd = date('Y-m-d H:i:s', strtotime($interval)); - - $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; - - // The cached copy still holds the pre-reset counters. - Cache::delete($cacheKey); - - error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}"); - } - - // Check invoice quota. $needed lets callers reserve a whole batch at once - // instead of only asking "is there room for one more?". - $used = (int)$sub['invoices_used_this_month']; - $limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility - $needed = max(1, $needed); - - if (($used + $needed) > $limit) { - $remaining = max(0, $limit - $used); - $message = $remaining === 0 - ? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.' - : 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.'; - - json_error($message, 429, [ - 'quota_type' => 'invoices', - 'used' => $used, - 'limit' => $limit, - 'requested' => $needed, - 'remaining' => $remaining, - 'plan' => $sub['plan_id'] ?? 'free', - 'plan_name' => $sub['plan_name'] ?? 'مجانية', - 'period_end' => $sub['current_period_end'], - ]); - } - - return $sub; - } - - /** - * Non-fatal variant of checkInvoiceQuota() for background workers. - * - * Workers must never emit an HTTP response, so this returns a boolean - * instead of calling json_error(). - */ - public static function hasInvoiceQuota(string $tenantId, int $needed = 1): bool - { - try { - $db = Database::getInstance(); - $stmt = $db->prepare(" - SELECT invoices_used_this_month, max_invoices_per_month, status - FROM subscriptions WHERE tenant_id = ? - "); - $stmt->execute([$tenantId]); - $sub = $stmt->fetch(); - - if (!$sub) return false; - if (in_array($sub['status'], ['cancelled', 'past_due'], true)) return false; - - return ((int)$sub['invoices_used_this_month'] + max(1, $needed)) - <= (int)$sub['max_invoices_per_month']; - } catch (\Throwable $e) { - error_log('[QuotaMiddleware] hasInvoiceQuota failed: ' . $e->getMessage()); - return false; - } - } - - /** - * 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]); - - // Invalidate cache - Cache::delete("quota_sub_{$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); - - // Check for pending payment request - $stmt = $db->prepare("SELECT id, plan_id, internal_reference FROM payment_requests WHERE tenant_id = ? AND status = 'pending' LIMIT 1"); - $stmt->execute([$tenantId]); - $pendingPayment = $stmt->fetch(); - - 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), - 'pending_payment' => $pendingPayment ? [ - 'id' => $pendingPayment['id'], - 'plan_id' => $pendingPayment['plan_id'], - 'reference' => $pendingPayment['internal_reference'] - ] : null, - - '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, - ]; - } -} diff --git a/app/middleware/RateLimitMiddleware.php b/app/middleware/RateLimitMiddleware.php deleted file mode 100644 index 0f9d347..0000000 --- a/app/middleware/RateLimitMiddleware.php +++ /dev/null @@ -1,78 +0,0 @@ -get($key); - if ($count && (int)$count >= $maxRequests) { - header('Retry-After: ' . $timeWindow); - json_error('Too Many Requests. Please slow down.', 429); - } - - if (!$count) { - $redis->setex($key, $timeWindow, 1); - } else { - $redis->incr($key); - } - return; // Success with Redis - } catch (\Exception $e) { - // Fallback to file-based if Redis fails - } - } - - // 2. Fallback: File-based rate limiter (original logic) - $cacheDir = STORAGE_PATH . '/cache'; - $cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json'; - if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true); - - $fp = fopen($cacheFile, 'c+'); - if ($fp === false) return; - - try { - flock($fp, LOCK_EX); - $now = time(); - $content = stream_get_contents($fp); - $requests = []; - if (!empty($content)) { - $decoded = json_decode($content, true); - if (is_array($decoded)) { - $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); - } - - $requests[] = $now; - ftruncate($fp, 0); - rewind($fp); - fwrite($fp, json_encode($requests)); - } finally { - flock($fp, LOCK_UN); - fclose($fp); - } - } -}