🚀 مُصادَق: تحديث برمجي جديد 2026-05-03 13:19

This commit is contained in:
Hamza-Ayed
2026-05-03 13:19:45 +03:00
parent cf68007ef1
commit 2de6a0adfd
32 changed files with 1133 additions and 102 deletions

View File

@@ -11,14 +11,13 @@ final class Application
{
private Container $container;
private Router $router;
public static ?array $config = null;
public function __construct(string $basePath)
{
// 1. Load Environment Variables (Point to the specific 'env' folder outside htdocs)
// Path: /home/intaleqapp-musadaq/env/
$envPath = dirname(dirname(dirname($basePath))) . '/env';
$dotenv = Dotenv::createImmutable($envPath);
// 1. Load Environment Variables
// In local dev, .env is in the project root. In production, it might be moved.
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->load();
// 2. Set Timezone
@@ -26,6 +25,10 @@ final class Application
// 3. Initialize Core Components
$this->container = new Container();
// 4. Load Configurations
$this->loadConfigs($basePath);
$this->router = new Router($this->container);
// Register core services in container
@@ -33,6 +36,20 @@ final class Application
$this->container->set(Router::class, $this->router);
}
private function loadConfigs(string $basePath): void
{
$configPath = $basePath . '/config';
$configs = [];
foreach (glob($configPath . '/*.php') as $file) {
$key = basename($file, '.php');
$configs[$key] = require $file;
}
self::$config = $configs;
$this->container->set('config', $configs);
}
public function getRouter(): Router
{
return $this->router;
@@ -40,6 +57,16 @@ final class Application
public function run(): void
{
// 1. Security Headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header('Content-Security-Policy: default-src \'self\'; script-src \'self\' cdn.tailwindcss.com unpkg.com; style-src \'self\' \'unsafe-inline\' fonts.googleapis.com; font-src fonts.gstatic.com');
header_remove('X-Powered-By');
try {
$request = new Request();
$this->router->dispatch($request, $this->container);

View File

@@ -67,8 +67,12 @@ final class Router
array_reverse($middlewares),
function ($next, $middleware) use ($container) {
return function ($request) use ($next, $middleware, $container) {
$instance = $container->get($middleware);
return $instance->handle($request, $next);
$parts = explode(':', $middleware);
$className = $parts[0];
$args = isset($parts[1]) ? explode(',', $parts[1]) : [];
$instance = $container->get($className);
return $instance->handle($request, $next, ...$args);
};
},
function ($request) use ($handler, $container, $vars) {

37
app/Core/helpers.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
if (!function_exists('config')) {
/**
* Get a configuration value using dot notation.
* Example: config('app.name')
*/
function config(string $key, mixed $default = null): mixed
{
$configs = \App\Core\Application::$config;
if ($configs === null) {
return $default;
}
$parts = explode('.', $key);
$value = $configs;
foreach ($parts as $part) {
if (!isset($value[$part])) {
return $default;
}
$value = $value[$part];
}
return $value;
}
}
if (!function_exists('env')) {
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response};
final class CsrfMiddleware
{
public function handle(Request $request, callable $next): mixed
{
// Skip CSRF check for safe methods
if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
return $next($request);
}
// For APIs, we often use a custom header or check origin
// If we use sessions for tokens:
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = $request->getHeader('X-CSRF-TOKEN') ?: ($request->getBody()['_csrf'] ?? null);
$sessionToken = $_SESSION['csrf_token'] ?? null;
if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) {
// For now, if we are purely API with Bearer token, we might skip this.
// But if the request has a session or cookie, it's mandatory.
// If the Authorization header is present, we might assume it's an API call
// that is naturally protected against CSRF if not using cookies for Auth.
if ($request->getHeader('Authorization')) {
return $next($request);
}
Response::error('رمز الحماية (CSRF) غير صالح أو مفقود', 'CSRF_INVALID', 403);
return null;
}
return $next($request);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response};
final class RoleMiddleware
{
/**
* Handle the request.
*
* @param Request $request
* @param callable $next
* @param string ...$roles
* @return mixed
*/
public function handle(Request $request, callable $next, string ...$roles): mixed
{
$user = $request->user ?? null;
if (!$user) {
Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401);
return null;
}
// Check if user role is in the allowed roles
// $user->role is an object property since we cast it in AuthMiddleware
if (!in_array($user->role, $roles)) {
Response::error('غير مسموح لك بالقيام بهذا الإجراء', 'FORBIDDEN', 403);
return null;
}
return $next($request);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response, Database};
final class TenantMiddleware
{
public function handle(Request $request, callable $next): mixed
{
$tenantId = $request->tenantId ?? null;
if (!$tenantId) {
Response::error('المستأجر غير معروف', 'TENANT_NOT_FOUND', 400);
return null;
}
// Check if tenant exists and is active
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT status FROM tenants WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
$tenant = $stmt->fetch();
if (!$tenant) {
Response::error('المستأجر غير موجود', 'TENANT_NOT_FOUND', 404);
return null;
}
if ($tenant['status'] === 'suspended') {
Response::error('تم إيقاف حساب المستأجر', 'TENANT_SUSPENDED', 403);
return null;
}
} catch (\Exception $e) {
Response::error('خطأ في الاتصال بقاعدة البيانات', 'DATABASE_ERROR', 500);
return null;
}
return $next($request);
}
}

View File

@@ -53,4 +53,79 @@ final class AuthController
'data' => $request->user
]);
}
public function logout(Request $request): void
{
// Clear refresh token cookie
setcookie('refresh_token', '', [
'expires' => time() - 3600,
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
Response::json([
'success' => true,
'message' => 'تم تسجيل الخروج بنجاح'
]);
}
public function refresh(Request $request): void
{
$refreshToken = $_COOKIE['refresh_token'] ?? null;
if (!$refreshToken) {
Response::error('رمز التجديد مفقود', 'UNAUTHORIZED', 401);
return;
}
try {
$result = $this->authService->refresh($refreshToken);
// Set new refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
unset($result['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'message' => 'تم تجديد الجلسة بنجاح'
]);
} catch (Throwable $e) {
Response::error($e->getMessage(), 'REFRESH_FAILED', 401);
}
}
public function register(Request $request): void
{
try {
$result = $this->authService->register($request->getBody());
// Set refresh token in HttpOnly cookie
setcookie('refresh_token', $result['refresh_token'], [
'expires' => time() + (60 * 60 * 24 * 7),
'path' => '/api/v1/auth/refresh',
'httponly' => true,
'samesite' => 'Strict',
'secure' => true
]);
unset($result['refresh_token']);
Response::json([
'success' => true,
'data' => $result,
'message' => 'تم إنشاء الحساب وتسجيل الدخول بنجاح'
]);
} catch (Throwable $e) {
Response::error($e->getMessage(), 'REGISTRATION_FAILED', 400);
}
}
}

View File

@@ -5,14 +5,19 @@ declare(strict_types=1);
namespace App\Modules\Auth;
use App\Modules\Users\UserModel;
use App\Modules\Tenants\TenantModel;
use App\Modules\Subscriptions\SubscriptionModel;
use App\Services\Security\JwtService;
use Ramsey\Uuid\Uuid;
use Exception;
final class AuthService
{
public function __construct(
private readonly UserModel $userModel,
private readonly JwtService $jwtService
private readonly JwtService $jwtService,
private readonly TenantModel $tenantModel,
private readonly SubscriptionModel $subscriptionModel
) {}
public function login(string $email, string $password): array
@@ -55,4 +60,88 @@ final class AuthService
]
];
}
public function refresh(string $refreshToken): array
{
$parts = explode('.', $refreshToken);
if (count($parts) !== 2) {
throw new Exception("رمز التجديد غير صالحة");
}
[$userId, $random] = $parts;
$user = $this->userModel->find($userId);
if (!$user || !$user['is_active']) {
throw new Exception("المستخدم غير موجود أو معطل");
}
if (!$user['refresh_token_hash'] || !password_verify($refreshToken, $user['refresh_token_hash'])) {
throw new Exception("جلسة العمل منتهية، يرجى تسجيل الدخول مرة أخرى");
}
$accessToken = $this->jwtService->issueAccessToken([
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]);
$newRefreshToken = $this->jwtService->issueRefreshToken($user['id']);
$this->userModel->update($user['id'], [
'refresh_token_hash' => password_hash($newRefreshToken, PASSWORD_BCRYPT)
]);
return [
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]
];
}
public function register(array $data): array
{
// 1. Check if tenant already exists
if ($this->tenantModel->findByEmail($data['email'])) {
throw new Exception("هذا البريد الإلكتروني مسجل مسبقاً");
}
$tenantId = Uuid::uuid4()->toString();
$userId = Uuid::uuid4()->toString();
// 2. Create Tenant
$this->tenantModel->create([
'id' => $tenantId,
'name' => $data['tenant_name'],
'email' => $data['email'],
'status' => 'trial',
'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days'))
]);
// 3. Create Subscription
$this->subscriptionModel->create([
'tenant_id' => $tenantId,
'plan' => 'basic',
'status' => 'trial'
]);
// 4. Create User
$this->userModel->create([
'id' => $userId,
'tenant_id' => $tenantId,
'name' => $data['user_name'],
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'role' => 'admin',
'is_active' => 1
]);
return $this->login($data['email'], $data['password']);
}
}

View File

@@ -72,38 +72,18 @@ final class InvoiceController
'idempotency_key' => bin2hex(random_bytes(16))
]);
// Attempt AI Extraction
try {
$mimeType = mime_content_type($filePath);
$extractedData = $this->aiExtraction->extractInvoiceData($filePath, $mimeType);
// Update Invoice with extracted data
$this->invoiceModel->update($invoiceId, [
'status' => 'extracted', // Match schema ENUM
'ai_raw_response' => json_encode($extractedData, JSON_UNESCAPED_UNICODE)
]);
// Push to Queue for AI Extraction
\App\Services\QueueService::push('invoice_extraction', [
'invoice_id' => $invoiceId,
'file_path' => $filePath,
'mime_type' => mime_content_type($filePath)
]);
Response::json([
'success' => true,
'data' => [
'invoice_id' => $invoiceId,
'extracted_data' => $extractedData
],
'message' => 'تم رفع الفاتورة واستخراج البيانات بنجاح بالذكاء الاصطناعي'
]);
} catch (Throwable $aiError) {
// Keep it uploaded, maybe manual retry later
$this->invoiceModel->update($invoiceId, [
'status' => 'validation_failed' // Match schema fallback
]);
Response::json([
'success' => true,
'data' => ['invoice_id' => $invoiceId],
'message' => 'تم الرفع ولكن فشل استخراج البيانات. ' . $aiError->getMessage()
]);
}
Response::json([
'success' => true,
'data' => ['invoice_id' => $invoiceId],
'message' => 'تم رفع الفاتورة بنجاح وجاري استخراج البيانات بالذكاء الاصطناعي'
], 202);
} catch (Throwable $e) {
Response::error($e->getMessage(), 'UPLOAD_FAILED', 500);

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions;
use App\Core\{Request, Response};
use App\Modules\Subscriptions\SubscriptionModel;
final class SubscriptionController
{
public function __construct(private readonly SubscriptionModel $subscriptionModel) {}
public function me(Request $request): void
{
$tenantId = $request->tenantId;
$subscription = $this->subscriptionModel->findByTenantId($tenantId);
if (!$subscription) {
Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $subscription
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions;
use App\Models\BaseModel;
final class SubscriptionModel extends BaseModel
{
protected string $table = 'subscriptions';
public function findByTenantId(string $tenantId): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1");
$stmt->execute([$tenantId]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants;
use App\Core\{Request, Response};
use App\Modules\Tenants\TenantModel;
final class TenantController
{
public function __construct(private readonly TenantModel $tenantModel) {}
public function me(Request $request): void
{
$tenantId = $request->tenantId;
$tenant = $this->tenantModel->find($tenantId);
if (!$tenant) {
Response::error('المستأجر غير موجود', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $tenant
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Modules\Tenants;
use App\Models\BaseModel;
final class TenantModel extends BaseModel
{
protected string $table = 'tenants';
public function findByEmail(string $email): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$email]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Core\{Request, Response};
use App\Modules\Users\UserModel;
final class UserController
{
public function __construct(private readonly UserModel $userModel) {}
public function index(Request $request): void
{
$tenantId = $request->tenantId;
$users = $this->userModel->findAllByTenant($tenantId);
Response::json([
'success' => true,
'data' => $users
]);
}
public function detail(Request $request, array $vars): void
{
$tenantId = $request->tenantId;
$userId = $vars['id'];
$user = $this->userModel->findById($userId, $tenantId);
if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $user
]);
}
}

View File

@@ -24,4 +24,18 @@ final class UserModel extends BaseModel
$stmt->execute($params);
return $stmt->fetch() ?: null;
}
public function findAllByTenant(string $tenantId): array
{
$stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
public function findById(string $id, string $tenantId): ?array
{
$stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$id, $tenantId]);
return $stmt->fetch() ?: null;
}
}

View File

@@ -32,8 +32,9 @@ final class JwtService
public function issueRefreshToken(string $userId): string
{
// Refresh token is a random string stored in DB (hashed)
return bin2hex(random_bytes(64));
// Refresh token is a random string prefixed with userId for lookup
$random = bin2hex(random_bytes(32));
return $userId . '.' . $random;
}
public function verifyToken(string $token): array