Remove case-duplicate lowercase middleware paths from index
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* HMAC Request Signature Middleware (per-device)
|
||||
*
|
||||
* Proves a request came from a device that completed login on this account, and
|
||||
* that nobody altered it in flight. This is defence in depth on top of the JWT:
|
||||
* a stolen bearer token alone is not enough to sign a request.
|
||||
*
|
||||
* Client must send:
|
||||
* X-Timestamp: milliseconds since epoch
|
||||
* X-Signature: HMAC-SHA256("METHOD:path:timestamp[:json_body]", device_secret)
|
||||
*
|
||||
* The device_secret is issued at login and stored encrypted (reversibly) in
|
||||
* user_devices - it CANNOT be bcrypt-hashed, because the server has to be able
|
||||
* to recompute the same HMAC the client computed.
|
||||
*
|
||||
* Rollout: enforcement is controlled by HMAC_ENFORCE in .env.
|
||||
* HMAC_ENFORCE=false (default) -> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
<?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;
|
||||
use App\Core\Cache;
|
||||
|
||||
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, int $needed = 1): array
|
||||
{
|
||||
$cacheKey = "quota_sub_{$tenantId}";
|
||||
$sub = Cache::get($cacheKey);
|
||||
|
||||
// $db is needed by the auto-reset branch below regardless of whether the
|
||||
// subscription came from the cache, so resolve it unconditionally.
|
||||
$db = Database::getInstance();
|
||||
|
||||
if ($sub === false || $sub === null) {
|
||||
// Fetch subscription with plan info
|
||||
$stmt = $db->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?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';
|
||||
$key = 'rl:' . md5($ip);
|
||||
|
||||
// 1. Try Redis first
|
||||
$redis = \App\Core\Cache::getInstance();
|
||||
if ($redis) {
|
||||
try {
|
||||
$count = $redis->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user