From a9a2c65beed9e5f0f138f8fe9e255f3f89339cf2 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 6 May 2026 05:11:51 +0300 Subject: [PATCH] Update: 2026-05-06 05:11:51 --- app/Core/Cache.php | 60 +++++++ app/Middleware/RateLimitMiddleware.php | 55 +++--- app/middleware/RateLimitMiddleware.php | 55 +++--- musadaq-app/lib/app/routes/app_pages.dart | 9 + musadaq-app/lib/app/routes/app_routes.dart | 1 + .../dashboard/views/dashboard_view.dart | 12 +- .../controllers/scanner_controller.dart | 52 ++++++ .../features/scanner/views/scanner_view.dart | 168 ++++++++++++++++++ 8 files changed, 359 insertions(+), 53 deletions(-) create mode 100644 app/Core/Cache.php create mode 100644 musadaq-app/lib/features/scanner/controllers/scanner_controller.dart create mode 100644 musadaq-app/lib/features/scanner/views/scanner_view.dart diff --git a/app/Core/Cache.php b/app/Core/Cache.php new file mode 100644 index 0000000..7e7efb4 --- /dev/null +++ b/app/Core/Cache.php @@ -0,0 +1,60 @@ + 'tcp', + 'host' => $host, + 'port' => $port, + 'password' => $pass, + ]); + self::$client->connect(); + } catch (\Exception $e) { + error_log("Redis Connection Error: " . $e->getMessage()); + return null; + } + } + return self::$client; + } + + public static function set(string $key, $value, int $ttl = 3600): bool + { + $redis = self::getInstance(); + if (!$redis) return false; + + $redis->setex($key, $ttl, serialize($value)); + return true; + } + + public static function get(string $key) + { + $redis = self::getInstance(); + if (!$redis) return false; + + $data = $redis->get($key); + return $data ? unserialize($data) : null; + } + + public static function delete(string $key): void + { + $redis = self::getInstance(); + if ($redis) $redis->del([$key]); + } +} diff --git a/app/Middleware/RateLimitMiddleware.php b/app/Middleware/RateLimitMiddleware.php index aee49bf..0f9d347 100644 --- a/app/Middleware/RateLimitMiddleware.php +++ b/app/Middleware/RateLimitMiddleware.php @@ -15,54 +15,61 @@ final class RateLimitMiddleware */ public static function check(int $maxRequests = 60, int $timeWindow = 60): void { - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $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); - 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; - } + if ($fp === false) return; try { - flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired - - $now = time(); - $content = stream_get_contents($fp); + flock($fp, LOCK_EX); + $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)) - ); + $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); diff --git a/app/middleware/RateLimitMiddleware.php b/app/middleware/RateLimitMiddleware.php index aee49bf..0f9d347 100644 --- a/app/middleware/RateLimitMiddleware.php +++ b/app/middleware/RateLimitMiddleware.php @@ -15,54 +15,61 @@ final class RateLimitMiddleware */ public static function check(int $maxRequests = 60, int $timeWindow = 60): void { - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $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); - 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; - } + if ($fp === false) return; try { - flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired - - $now = time(); - $content = stream_get_contents($fp); + flock($fp, LOCK_EX); + $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)) - ); + $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); diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index 8ab3917..751d211 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -5,6 +5,8 @@ import '../../features/auth/views/otp_verify_view.dart'; import '../../features/auth/views/biometric_setup_view.dart'; import '../../features/auth/views/biometric_auth_view.dart'; import '../../features/dashboard/views/dashboard_view.dart'; +import '../../features/scanner/views/scanner_view.dart'; +import '../../features/scanner/controllers/scanner_controller.dart'; import '../../core/storage/secure_storage.dart'; part 'app_routes.dart'; @@ -61,5 +63,12 @@ class AppPages { name: AppRoutes.DASHBOARD, page: () => DashboardView(), ), + GetPage( + name: AppRoutes.SCANNER, + page: () => const ScannerView(), + binding: BindingsBuilder(() { + Get.put(ScannerController()); + }), + ), ]; } diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index 3380f3e..9314a02 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -8,4 +8,5 @@ abstract class AppRoutes { static const BIOMETRIC_AUTH = '/biometric-auth'; static const LOGIN = '/login'; static const DASHBOARD = '/dashboard'; + static const SCANNER = '/scanner'; } diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index f564183..2d07414 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -43,11 +43,13 @@ class DashboardView extends StatelessWidget { const SizedBox(height: 48), ElevatedButton.icon( icon: const Icon(Icons.document_scanner), - label: const Text('المسح الضوئي (المرحلة 2)'), - onPressed: () { - Get.snackbar( - 'قريباً', 'سيتم برمجة هذه الميزة في المرحلة الثانية'); - }, + label: const Text('مسح فاتورة جديدة'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + onPressed: () => Get.toNamed(AppRoutes.SCANNER), ) ], ), diff --git a/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart new file mode 100644 index 0000000..d05810e --- /dev/null +++ b/musadaq-app/lib/features/scanner/controllers/scanner_controller.dart @@ -0,0 +1,52 @@ +import 'dart:io'; +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import '../../../core/utils/logger.dart'; +import '../../../core/utils/app_snackbar.dart'; + +class ScannerController extends GetxController { + var capturedImages = [].obs; + var isProcessing = false.obs; + + void addImage(String imagePath) { + capturedImages.add(File(imagePath)); + AppLogger.print('Added image to batch: $imagePath. Total: ${capturedImages.length}'); + } + + void removeImage(int index) { + if (index >= 0 && index < capturedImages.length) { + capturedImages.removeAt(index); + } + } + + Future uploadBatch() async { + if (capturedImages.isEmpty) { + AppSnackbar.showWarning('تنبيه', 'الرجاء تصوير فاتورة واحدة على الأقل'); + return; + } + + try { + isProcessing.value = true; + AppLogger.print('Uploading batch of ${capturedImages.length} images...'); + + // TODO: Implement actual upload logic with Dio + await Future.delayed(const Duration(seconds: 2)); // Simulate + + AppSnackbar.showSuccess('نجاح', 'تم رفع ${capturedImages.length} فواتير للمعالجة بنجاح'); + capturedImages.clear(); + Get.back(); // Go back to dashboard or previous screen + } catch (e) { + AppLogger.error('Failed to upload batch', e); + AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً'); + } finally { + isProcessing.value = false; + } + } + + Future getSavePath() async { + final directory = await getTemporaryDirectory(); + final fileName = 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg'; + return path.join(directory.path, fileName); + } +} diff --git a/musadaq-app/lib/features/scanner/views/scanner_view.dart b/musadaq-app/lib/features/scanner/views/scanner_view.dart new file mode 100644 index 0000000..00a75b1 --- /dev/null +++ b/musadaq-app/lib/features/scanner/views/scanner_view.dart @@ -0,0 +1,168 @@ +import 'package:camerawesome/camerawesome_plugin.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/scanner_controller.dart'; + +class ScannerView extends GetView { + const ScannerView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + // 1. Camera Layer + CameraAwesomeBuilder.awesome( + saveConfig: SaveConfig.photo( + pathBuilder: (sensors) async { + final path = await controller.getSavePath(); + if (sensors.length == 1) { + return SingleCaptureRequest(path, sensors.first); + } else { + // For multiple sensors, we take the first one for the path + return MultipleCaptureRequest({ + for (var sensor in sensors) sensor: path, + }); + } + }, + ), + onMediaTap: (media) { + final path = media.captureRequest.when( + single: (single) => single.file?.path, + multiple: (multiple) => + multiple.fileBySensor.values.first?.path, + ); + if (path != null) { + controller.addImage(path); + } + }, + onMediaCaptureEvent: (event) { + if (event.status == MediaCaptureStatus.success) { + final path = event.captureRequest.when( + single: (single) => single.file?.path, + multiple: (multiple) => + multiple.fileBySensor.values.first?.path, + ); + if (path != null) { + controller.addImage(path); + } + } + }, + topActionsBuilder: (state) => AwesomeTopActions( + state: state, + children: [ + AwesomeFlashButton(state: state), + const Spacer(), + TextButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.white), + label: const Text('إغلاق', + style: TextStyle(color: Colors.white)), + ), + ], + ), + middleContentBuilder: (state) => const Center( + child: Text( + 'قم بمحاذاة الفاتورة داخل الإطار', + style: TextStyle( + color: Colors.white, + fontSize: 16, + backgroundColor: Colors.black26, + ), + ), + ), + bottomActionsBuilder: (state) => AwesomeBottomActions( + state: state, + left: const SizedBox(), + right: AwesomeCameraSwitchButton(state: state), + ), + ), + + // 2. Batch Overlay (Bottom) + Positioned( + bottom: 120, + left: 0, + right: 0, + child: Obx(() => controller.capturedImages.isEmpty + ? const SizedBox() + : Container( + height: 100, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: controller.capturedImages.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + controller.capturedImages[index], + width: 80, + height: 100, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () => controller.removeImage(index), + child: Container( + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, + size: 16, color: Colors.white), + ), + ), + ), + ], + ), + ); + }, + ), + )), + ), + + // 3. Upload Button + Positioned( + top: 20, + left: 80, + right: 80, + child: Obx(() => controller.capturedImages.isEmpty + ? const SizedBox() + : ElevatedButton.icon( + onPressed: controller.isProcessing.value + ? null + : () => controller.uploadBatch(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30)), + ), + icon: controller.isProcessing.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.cloud_upload, color: Colors.white), + label: Text( + 'رفع ${controller.capturedImages.length} فواتير', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white), + ), + )), + ), + ], + ), + ); + } +}