2226 lines
73 KiB
Markdown
2226 lines
73 KiB
Markdown
# Musadaq Project Documentation
|
||
|
||
This file contains the complete source code of the project (excluding dependencies and sensitive data).
|
||
|
||
## File: `push.sh`
|
||
|
||
```sh
|
||
#!/bin/bash
|
||
|
||
# Get current timestamp
|
||
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
|
||
|
||
echo "🚀 Starting Git Push Process..."
|
||
echo "📅 Timestamp: $TIMESTAMP"
|
||
|
||
# Add all changes
|
||
git add .
|
||
|
||
# Commit with timestamp
|
||
git commit -m "Update: $TIMESTAMP"
|
||
|
||
# Push to origin main explicitly
|
||
git push origin main
|
||
|
||
echo "✅ Done!"
|
||
|
||
```
|
||
|
||
## File: `composer.json`
|
||
|
||
```json
|
||
{
|
||
"name": "musadaq/platform",
|
||
"description": "Jordanian E-Invoicing Automation SaaS",
|
||
"type": "project",
|
||
"license": "proprietary",
|
||
"require": {
|
||
"php": ">=8.4",
|
||
"ext-pdo": "*",
|
||
"ext-pdo_mysql": "*",
|
||
"ext-openssl": "*",
|
||
"ext-sodium": "*",
|
||
"ext-curl": "*",
|
||
"ext-mbstring": "*",
|
||
"ext-json": "*",
|
||
"vlucas/phpdotenv": "^5.6",
|
||
"monolog/monolog": "^3.5",
|
||
"firebase/php-jwt": "^6.10",
|
||
"ramsey/uuid": "^4.7",
|
||
"nikic/fast-route": "^1.3",
|
||
"predis/predis": "^2.2",
|
||
"guzzlehttp/guzzle": "^7.9",
|
||
"respect/validation": "^2.3",
|
||
"league/flysystem": "^3.28",
|
||
"symfony/mailer": "^7.1"
|
||
},
|
||
"require-dev": {
|
||
"phpunit/phpunit": "^11.0",
|
||
"phpstan/phpstan": "^1.12",
|
||
"squizlabs/php_codesniffer": "^3.10"
|
||
},
|
||
"autoload": {
|
||
"psr-4": { "App\\": "app/" }
|
||
},
|
||
"config": {
|
||
"optimize-autoloader": true,
|
||
"sort-packages": true
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/auth/login.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Auth Login Endpoint
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Core\JWT;
|
||
use App\Core\Validator;
|
||
|
||
use App\Middleware\RateLimitMiddleware;
|
||
use App\Core\Security;
|
||
|
||
// 0. Rate Limiting (5 attempts per minute per IP)
|
||
RateLimitMiddleware::check(5, 60);
|
||
|
||
$data = Security::sanitize(input());
|
||
|
||
// 1. Validation
|
||
$errors = Validator::validate($data, [
|
||
'email' => 'required|email',
|
||
'password' => 'required'
|
||
]);
|
||
|
||
if ($errors) {
|
||
json_error('Validation Failed', 422, $errors);
|
||
}
|
||
|
||
$email = $data['email'];
|
||
$password = $data['password'];
|
||
|
||
// 2. DB Check (Using hash for lookup since email is encrypted)
|
||
$db = Database::getInstance();
|
||
$emailHash = hash('sha256', strtolower($email));
|
||
$stmt = $db->prepare("SELECT * FROM users WHERE email_hash = ? LIMIT 1");
|
||
$stmt->execute([$emailHash]);
|
||
$user = $stmt->fetch();
|
||
|
||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||
json_error('بيانات الدخول غير صحيحة', 401);
|
||
}
|
||
|
||
// 3. Issue Token
|
||
$secret = env('JWT_SECRET');
|
||
if (!$secret || strlen($secret) < 32) {
|
||
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||
json_error('Server configuration error', 500);
|
||
}
|
||
$payload = [
|
||
'user_id' => $user['id'],
|
||
'tenant_id' => $user['tenant_id'],
|
||
'role' => $user['role'],
|
||
'exp' => time() + (15 * 60) // 15 minutes
|
||
];
|
||
|
||
$token = JWT::encode($payload, $secret);
|
||
|
||
// 4. Update Refresh Token (Hashed before storage for security)
|
||
$refreshToken = bin2hex(random_bytes(32));
|
||
$refreshTokenHash = hash('sha256', $refreshToken);
|
||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
||
$stmt->execute([$refreshTokenHash, $user['id']]);
|
||
|
||
json_success([
|
||
'access_token' => $token,
|
||
'refresh_token' => $refreshToken,
|
||
'user' => [
|
||
'id' => $user['id'],
|
||
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
|
||
'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email'])
|
||
]
|
||
], 'تم تسجيل الدخول بنجاح');
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/auth/logout.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Auth Logout Endpoint
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Middleware\AuthMiddleware;
|
||
|
||
// 1. Check Authentication
|
||
$decoded = AuthMiddleware::check();
|
||
$userId = $decoded['user_id'];
|
||
|
||
// 2. Invalidate Refresh Token
|
||
$db = Database::getInstance();
|
||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?");
|
||
$stmt->execute([$userId]);
|
||
|
||
json_success(null, 'تم تسجيل الخروج بنجاح');
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/auth/refresh.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Auth Refresh Endpoint
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Core\JWT;
|
||
|
||
$data = input();
|
||
$refreshToken = $data['refresh_token'] ?? null;
|
||
|
||
if (!$refreshToken) {
|
||
json_error('Refresh token is required', 400);
|
||
}
|
||
|
||
$db = Database::getInstance();
|
||
$refreshTokenHash = hash('sha256', $refreshToken);
|
||
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
|
||
$stmt->execute([$refreshTokenHash]);
|
||
$user = $stmt->fetch();
|
||
|
||
if (!$user) {
|
||
json_error('Invalid refresh token', 401);
|
||
}
|
||
|
||
$secret = env('JWT_SECRET');
|
||
if (!$secret || strlen($secret) < 32) {
|
||
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||
json_error('Server configuration error', 500);
|
||
}
|
||
$payload = [
|
||
'user_id' => $user['id'],
|
||
'role' => $user['role'],
|
||
'exp' => time() + (15 * 60)
|
||
];
|
||
|
||
$newToken = JWT::encode($payload, $secret);
|
||
$newRefreshToken = bin2hex(random_bytes(32));
|
||
$newRefreshTokenHash = hash('sha256', $newRefreshToken);
|
||
|
||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
||
$stmt->execute([$newRefreshTokenHash, $user['id']]);
|
||
|
||
json_success([
|
||
'access_token' => $newToken,
|
||
'refresh_token' => $newRefreshToken
|
||
], 'تم تجديد الجلسة بنجاح');
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/dashboard/stats.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Dashboard Stats Endpoint
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Middleware\AuthMiddleware;
|
||
|
||
// 1. Auth Check
|
||
AuthMiddleware::check();
|
||
|
||
$db = Database::getInstance();
|
||
|
||
try {
|
||
// Total Invoices
|
||
$stmt = $db->query("SELECT COUNT(*) FROM invoices");
|
||
$total = $stmt->fetchColumn();
|
||
|
||
// Pending Invoices
|
||
$stmt = $db->query("SELECT COUNT(*) FROM invoices WHERE status = 'pending'");
|
||
$pending = $stmt->fetchColumn();
|
||
|
||
// Approved Invoices
|
||
$stmt = $db->query("SELECT COUNT(*) FROM invoices WHERE status = 'approved'");
|
||
$approved = $stmt->fetchColumn();
|
||
} catch (\Exception $e) {
|
||
// Fallback if table doesn't exist yet
|
||
$total = 0;
|
||
$pending = 0;
|
||
$approved = 0;
|
||
}
|
||
|
||
json_success([
|
||
'total' => $total,
|
||
'pending' => $pending,
|
||
'approved' => $approved
|
||
]);
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/users/index.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Users List Endpoint (with Decryption)
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Core\Encryption;
|
||
use App\Middleware\AuthMiddleware;
|
||
|
||
// 1. Auth Check
|
||
$decoded = AuthMiddleware::check();
|
||
|
||
// 2. Simple Role-Based Access Control (RBAC)
|
||
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
||
json_error('غير مصرح لك بالوصول لهذه البيانات', 403);
|
||
}
|
||
|
||
// 3. Fetch Data
|
||
$db = Database::getInstance();
|
||
$stmt = $db->prepare("SELECT id, name, email, role, is_active, created_at FROM users");
|
||
$stmt->execute();
|
||
$users = $stmt->fetchAll();
|
||
|
||
// 4. Decrypt sensitive data for the UI
|
||
foreach ($users as &$user) {
|
||
// Try to decrypt. If it fails (e.g. data was plain text), keep original.
|
||
$decryptedName = Encryption::decrypt($user['name']);
|
||
$user['name'] = $decryptedName !== false ? $decryptedName : $user['name'];
|
||
|
||
$decryptedEmail = Encryption::decrypt($user['email']);
|
||
$user['email'] = $decryptedEmail !== false ? $decryptedEmail : $user['email'];
|
||
}
|
||
|
||
json_success($users);
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/users/create.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Create User Endpoint (with Encryption)
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Core\Encryption;
|
||
use App\Core\Validator;
|
||
use App\Middleware\AuthMiddleware;
|
||
|
||
// 1. Auth Check (Only super_admin or admin can create users)
|
||
$decoded = AuthMiddleware::check();
|
||
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
||
json_error('Unauthorized', 403);
|
||
}
|
||
|
||
$data = input();
|
||
|
||
// 2. Validation
|
||
$errors = Validator::validate($data, [
|
||
'name' => 'required',
|
||
'email' => 'required|email',
|
||
'password' => 'required',
|
||
'role' => 'required'
|
||
]);
|
||
|
||
if ($errors) {
|
||
json_error('Validation Failed', 422, $errors);
|
||
}
|
||
|
||
$db = Database::getInstance();
|
||
|
||
// 3. Encrypt sensitive data
|
||
$encryptedName = Encryption::encrypt($data['name']);
|
||
$encryptedEmail = Encryption::encrypt($data['email']);
|
||
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
|
||
|
||
// 4. Save to Database
|
||
try {
|
||
$stmt = $db->prepare("INSERT INTO users (tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||
$stmt->execute([
|
||
$decoded['tenant_id'],
|
||
$encryptedName,
|
||
$encryptedEmail,
|
||
$emailHash,
|
||
password_hash($data['password'], PASSWORD_DEFAULT),
|
||
$data['role'],
|
||
date('Y-m-d H:i:s')
|
||
]);
|
||
|
||
json_success(null, 'تم إضافة المستخدم بنجاح');
|
||
} catch (\Exception $e) {
|
||
if (str_contains($e->getMessage(), 'Duplicate entry')) {
|
||
json_error('البريد الإلكتروني مسجل مسبقاً', 409);
|
||
}
|
||
json_error('حدث خطأ أثناء حفظ البيانات', 500);
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/companies/index.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* List Companies Endpoint (Synchronized Schema)
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Core\Encryption;
|
||
use App\Middleware\AuthMiddleware;
|
||
|
||
$decoded = AuthMiddleware::check();
|
||
$db = Database::getInstance();
|
||
|
||
// 1. Super Admin sees ALL companies
|
||
if ($decoded['role'] === 'super_admin') {
|
||
$stmt = $db->query("SELECT * FROM companies WHERE deleted_at IS NULL");
|
||
}
|
||
// 2. Admin sees all companies in their tenant
|
||
else if ($decoded['role'] === 'admin') {
|
||
$stmt = $db->prepare("SELECT * FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
|
||
$stmt->execute([$decoded['tenant_id']]);
|
||
}
|
||
// 3. Others (accountant, etc) see only their assigned company
|
||
else {
|
||
// Need to get their assigned company_id from users table first
|
||
$stmtUser = $db->prepare("SELECT company_id FROM users WHERE id = ?");
|
||
$stmtUser->execute([$decoded['user_id']]);
|
||
$assignedCompanyId = $stmtUser->fetchColumn();
|
||
|
||
$stmt = $db->prepare("SELECT * FROM companies WHERE id = ? AND deleted_at IS NULL");
|
||
$stmt->execute([$assignedCompanyId]);
|
||
}
|
||
|
||
$companies = $stmt->fetchAll();
|
||
|
||
// 3. Decrypt fields
|
||
foreach ($companies as &$company) {
|
||
// Decrypt Name
|
||
$decryptedName = Encryption::decrypt($company['name']);
|
||
$company['name'] = $decryptedName !== false ? $decryptedName : $company['name'];
|
||
|
||
// Decrypt Name EN
|
||
if (!empty($company['name_en'])) {
|
||
$decryptedNameEn = Encryption::decrypt($company['name_en']);
|
||
$company['name_en'] = $decryptedNameEn !== false ? $decryptedNameEn : $company['name_en'];
|
||
}
|
||
|
||
// Redact JoFotara secrets if returned to UI (or just don't return them)
|
||
unset($company['jofotara_client_id_encrypted']);
|
||
unset($company['jofotara_secret_key_encrypted']);
|
||
unset($company['certificate_password_encrypted']);
|
||
}
|
||
|
||
json_success($companies);
|
||
|
||
```
|
||
|
||
## File: `app/modules_app/companies/create.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Create Company Endpoint (Synchronized Schema)
|
||
*/
|
||
|
||
use App\Core\Database;
|
||
use App\Core\Encryption;
|
||
use App\Core\Validator;
|
||
use App\Middleware\AuthMiddleware;
|
||
|
||
$decoded = AuthMiddleware::check();
|
||
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
|
||
json_error('Unauthorized', 403);
|
||
}
|
||
|
||
$data = input();
|
||
|
||
// 1. Validation
|
||
$errors = Validator::validate($data, [
|
||
'name' => 'required',
|
||
'tax_identification_number' => 'required'
|
||
]);
|
||
|
||
if ($errors) {
|
||
json_error('Validation Failed', 422, $errors);
|
||
}
|
||
|
||
$db = Database::getInstance();
|
||
|
||
try {
|
||
$db->beginTransaction();
|
||
|
||
// 2. Encrypt sensitive fields
|
||
$encryptedName = Encryption::encrypt($data['name']);
|
||
$encryptedNameEn = !empty($data['name_en']) ? Encryption::encrypt($data['name_en']) : null;
|
||
|
||
// Encrypt JoFotara keys if provided
|
||
$jofotaraClientId = !empty($data['jofotara_client_id']) ? Encryption::encrypt($data['jofotara_client_id']) : null;
|
||
$jofotaraSecretKey = !empty($data['jofotara_secret_key']) ? Encryption::encrypt($data['jofotara_secret_key']) : null;
|
||
|
||
// 3. Save to Database
|
||
$stmt = $db->prepare("
|
||
INSERT INTO companies (
|
||
tenant_id, name, name_en, tax_identification_number, commercial_registration_number,
|
||
city, address, contact_email, contact_phone,
|
||
jofotara_client_id_encrypted, jofotara_secret_key_encrypted,
|
||
created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
");
|
||
|
||
$stmt->execute([
|
||
$decoded['user_id'], // Using current admin as tenant_id
|
||
$encryptedName,
|
||
$encryptedNameEn,
|
||
$data['tax_identification_number'],
|
||
$data['commercial_registration_number'] ?? null,
|
||
$data['city'] ?? null,
|
||
$data['address'] ?? null,
|
||
$data['contact_email'] ?? null,
|
||
$data['contact_phone'] ?? null,
|
||
$jofotaraClientId,
|
||
$jofotaraSecretKey,
|
||
date('Y-m-d H:i:s')
|
||
]);
|
||
|
||
$db->commit();
|
||
json_success(null, 'تم إنشاء الشركة بنجاح');
|
||
|
||
} catch (\Exception $e) {
|
||
$db->rollBack();
|
||
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/middleware/HmacMiddleware.php`
|
||
|
||
```php
|
||
<?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);
|
||
}
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/middleware/AuthMiddleware.php`
|
||
|
||
```php
|
||
<?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) {
|
||
json_error('Unauthorized: Invalid or expired token', 401);
|
||
}
|
||
|
||
return $decoded;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/middleware/RateLimitMiddleware.php`
|
||
|
||
```php
|
||
<?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);
|
||
}
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/core/Validator.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Simple Data Validator
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Core;
|
||
|
||
final class Validator
|
||
{
|
||
public static function validate(array $data, array $rules): array
|
||
{
|
||
$errors = [];
|
||
foreach ($rules as $field => $rule) {
|
||
if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) {
|
||
$errors[$field] = "The {$field} field is required.";
|
||
}
|
||
if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
|
||
$errors[$field] = "The {$field} must be a valid email address.";
|
||
}
|
||
}
|
||
return $errors;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/core/Encryption.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Advanced Encryption (AES-256-GCM) - System Level
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Core;
|
||
|
||
final class Encryption
|
||
{
|
||
private const CIPHER = 'aes-256-gcm';
|
||
|
||
/**
|
||
* Encrypts data using the system's ENCRYPTION_KEY from .env
|
||
*/
|
||
public static function encrypt(string $data): string
|
||
{
|
||
$key = env('ENCRYPTION_KEY');
|
||
if (!$key) {
|
||
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
|
||
}
|
||
|
||
$encryptionKey = hash('sha256', $key, true);
|
||
$iv = random_bytes(openssl_cipher_iv_length(self::CIPHER));
|
||
|
||
$tag = '';
|
||
$ciphertext = openssl_encrypt($data, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
|
||
|
||
if ($ciphertext === false) {
|
||
throw new \RuntimeException('Encryption failed');
|
||
}
|
||
|
||
return base64_encode($iv . $tag . $ciphertext);
|
||
}
|
||
|
||
/**
|
||
* Decrypts AES-256-GCM encrypted data using the system's ENCRYPTION_KEY
|
||
*/
|
||
public static function decrypt(string $encryptedData): string|false
|
||
{
|
||
$key = env('ENCRYPTION_KEY');
|
||
if (!$key) {
|
||
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
|
||
}
|
||
|
||
$encryptionKey = hash('sha256', $key, true);
|
||
$decoded = base64_decode($encryptedData);
|
||
|
||
if ($decoded === false) return false;
|
||
|
||
$ivLength = openssl_cipher_iv_length(self::CIPHER);
|
||
$tagLength = 16;
|
||
|
||
if (strlen($decoded) < $ivLength + $tagLength) return false;
|
||
|
||
$iv = substr($decoded, 0, $ivLength);
|
||
$tag = substr($decoded, $ivLength, $tagLength);
|
||
$ciphertext = substr($decoded, $ivLength + $tagLength);
|
||
|
||
return openssl_decrypt($ciphertext, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/core/Database.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Simple PDO Database Wrapper
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Core;
|
||
|
||
use PDO;
|
||
use PDOException;
|
||
|
||
final class Database
|
||
{
|
||
private static ?PDO $instance = null;
|
||
|
||
public static function getInstance(): PDO
|
||
{
|
||
if (self::$instance === null) {
|
||
$config = require APP_PATH . '/config/database.php';
|
||
|
||
$dsn = sprintf(
|
||
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
|
||
$config['host'],
|
||
$config['port'],
|
||
$config['database'],
|
||
$config['charset']
|
||
);
|
||
|
||
try {
|
||
self::$instance = new PDO($dsn, $config['username'], $config['password'], [
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
PDO::ATTR_EMULATE_PREPARES => false,
|
||
]);
|
||
} catch (PDOException $e) {
|
||
http_response_code(500);
|
||
header('Content-Type: application/json');
|
||
echo json_encode(['success' => false, 'message' => 'Database connection failed']);
|
||
exit;
|
||
}
|
||
}
|
||
|
||
return self::$instance;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/core/Security.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Simple Security Helpers
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Core;
|
||
|
||
final class Security
|
||
{
|
||
/**
|
||
* Recursively sanitize input data (strings and arrays)
|
||
*/
|
||
public static function sanitize($data)
|
||
{
|
||
if (is_array($data)) {
|
||
foreach ($data as $key => $value) {
|
||
$data[$key] = self::sanitize($value);
|
||
}
|
||
} else if (is_string($data)) {
|
||
$data = htmlspecialchars(strip_tags(trim($data)), ENT_QUOTES, 'UTF-8');
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
public static function generateRandomString(int $length = 64): string
|
||
{
|
||
return bin2hex(random_bytes($length / 2));
|
||
}
|
||
|
||
public static function sign(string $data, string $secret): string
|
||
{
|
||
return hash_hmac('sha256', $data, $secret);
|
||
}
|
||
|
||
public static function verifySignature(string $data, string $signature, string $secret): bool
|
||
{
|
||
$expected = self::sign($data, $secret);
|
||
return hash_equals($expected, $signature);
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/core/JWT.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Simple JWT (HMAC SHA256)
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Core;
|
||
|
||
final class JWT
|
||
{
|
||
private static function base64UrlEncode(string $data): string
|
||
{
|
||
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
|
||
}
|
||
|
||
private static function base64UrlDecode(string $data): string
|
||
{
|
||
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
|
||
}
|
||
|
||
public static function encode(array $payload, string $secret): string
|
||
{
|
||
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
|
||
$base64UrlHeader = self::base64UrlEncode($header);
|
||
$base64UrlPayload = self::base64UrlEncode(json_encode($payload));
|
||
|
||
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
|
||
$base64UrlSignature = self::base64UrlEncode($signature);
|
||
|
||
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
|
||
}
|
||
|
||
public static function decode(string $token, string $secret): ?array
|
||
{
|
||
$parts = explode('.', $token);
|
||
if (count($parts) !== 3) return null;
|
||
|
||
[$header, $payload, $signature] = $parts;
|
||
|
||
$expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $header . "." . $payload, $secret, true));
|
||
|
||
if (!hash_equals($expectedSignature, $signature)) return null;
|
||
|
||
$decodedPayload = json_decode(self::base64UrlDecode($payload), true);
|
||
|
||
// Check expiry
|
||
if (isset($decodedPayload['exp']) && $decodedPayload['exp'] < time()) return null;
|
||
|
||
return $decodedPayload;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/bootstrap/init.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Application Bootstrap Initialization
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
// 1. Basic Constants
|
||
define('ROOT_PATH', dirname(__DIR__, 2));
|
||
define('APP_PATH', ROOT_PATH . '/app');
|
||
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
||
|
||
// 2. Load Environment & Helpers FIRST
|
||
require_once APP_PATH . '/bootstrap/env.php';
|
||
require_once APP_PATH . '/helpers/helpers.php';
|
||
|
||
// 3. Error Reporting (Secure for production)
|
||
if (env('APP_DEBUG', 'false') === 'true') {
|
||
error_reporting(E_ALL);
|
||
ini_set('display_errors', '1');
|
||
} else {
|
||
error_reporting(0);
|
||
ini_set('display_errors', '0');
|
||
}
|
||
|
||
// 4. H2 Fix: CORS — Whitelist only known origins
|
||
$allowedOrigins = array_filter(array_map('trim', explode(',', env('CORS_ORIGIN', 'https://musadaq.intaleqapp.com'))));
|
||
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||
|
||
if (in_array($requestOrigin, $allowedOrigins, true)) {
|
||
header("Access-Control-Allow-Origin: {$requestOrigin}");
|
||
} else {
|
||
// Fallback to first allowed origin (for non-browser API clients)
|
||
header("Access-Control-Allow-Origin: " . ($allowedOrigins[0] ?? ''));
|
||
}
|
||
|
||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-HMAC-Signature, X-Timestamp");
|
||
header("Access-Control-Allow-Credentials: true");
|
||
header("Vary: Origin");
|
||
|
||
// Handle CORS preflight
|
||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||
http_response_code(204);
|
||
exit;
|
||
}
|
||
|
||
// 5. Security Headers
|
||
header("X-Content-Type-Options: nosniff");
|
||
header("X-Frame-Options: DENY");
|
||
header("X-XSS-Protection: 1; mode=block");
|
||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||
header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS
|
||
|
||
// 6. Intelligent Autoloader (Case-Insensitive for directories)
|
||
spl_autoload_register(function ($class) {
|
||
$prefix = 'App\\';
|
||
$base_dir = APP_PATH . '/';
|
||
|
||
$len = strlen($prefix);
|
||
if (strncmp($prefix, $class, $len) !== 0) return;
|
||
|
||
$relative_class = substr($class, $len);
|
||
|
||
$parts = explode('\\', $relative_class);
|
||
$filename = array_pop($parts) . '.php';
|
||
$dir = strtolower(implode('/', $parts));
|
||
|
||
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
|
||
|
||
if (file_exists($file)) {
|
||
require $file;
|
||
}
|
||
});
|
||
|
||
// 7. Response Utility
|
||
require_once APP_PATH . '/bootstrap/response.php';
|
||
|
||
// 8. Global Auth Helper
|
||
require_once APP_PATH . '/bootstrap/auth.php';
|
||
|
||
```
|
||
|
||
## File: `app/bootstrap/auth.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Global Auth State (Optional Helper)
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
// This can be used to store the current user globally if needed
|
||
// after successful middleware check.
|
||
|
||
$GLOBALS['current_user'] = null;
|
||
|
||
function current_user() {
|
||
return $GLOBALS['current_user'];
|
||
}
|
||
|
||
function set_current_user(array $user) {
|
||
$GLOBALS['current_user'] = $user;
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/bootstrap/response.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Standardized JSON Responses with Secure Logging
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
function json_response(bool $success, $data = null, ?string $message = null, int $code = 200) {
|
||
|
||
// H3 Fix: Redact sensitive fields before logging
|
||
$safeData = $data;
|
||
if (is_array($safeData)) {
|
||
$sensitiveKeys = ['access_token', 'refresh_token', 'password', 'password_hash', 'refresh_token_hash', 'token'];
|
||
array_walk_recursive($safeData, function (&$value, $key) use ($sensitiveKeys) {
|
||
if (in_array(strtolower((string)$key), $sensitiveKeys, true)) {
|
||
$value = '[REDACTED]';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Log (safe — no secrets)
|
||
$logEntry = sprintf(
|
||
"API %s %s | %d | %s | %s",
|
||
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||
$_SERVER['REQUEST_URI'] ?? '',
|
||
$code,
|
||
$success ? 'OK' : 'FAIL',
|
||
$message ?? 'N/A'
|
||
);
|
||
|
||
error_log($logEntry);
|
||
|
||
// Try custom log file
|
||
$logDir = STORAGE_PATH . '/logs';
|
||
$logFile = $logDir . '/app.log';
|
||
|
||
try {
|
||
if (!is_dir($logDir)) {
|
||
@mkdir($logDir, 0775, true);
|
||
}
|
||
if (is_writable($logDir) || is_writable($logFile)) {
|
||
@file_put_contents(
|
||
$logFile,
|
||
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
|
||
FILE_APPEND
|
||
);
|
||
}
|
||
} catch (\Exception $e) {
|
||
// Fallback silently
|
||
}
|
||
|
||
// HTTP Response
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
http_response_code($code);
|
||
|
||
echo json_encode([
|
||
'success' => $success,
|
||
'data' => $data, // Return real data to client
|
||
'message' => $message,
|
||
'timestamp' => date('c')
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
exit;
|
||
}
|
||
|
||
function json_error(string $message, int $code = 400, $errors = null) {
|
||
json_response(false, $errors, $message, $code);
|
||
}
|
||
|
||
function json_success($data = null, ?string $message = 'Success', int $code = 200) {
|
||
json_response(true, $data, $message, $code);
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/bootstrap/env.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Simple .env Loader
|
||
*/
|
||
|
||
// Primary environment file path as requested
|
||
$envFile = '/home/intaleqapp-musadaq/env/.env';
|
||
|
||
// Fallback for local development if the primary server path doesn't exist
|
||
if (!file_exists($envFile)) {
|
||
$envFile = ROOT_PATH . '/.env';
|
||
}
|
||
|
||
if (file_exists($envFile)) {
|
||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
foreach ($lines as $line) {
|
||
if (str_starts_with(trim($line), '#')) continue;
|
||
|
||
$parts = explode('=', $line, 2);
|
||
if (count($parts) !== 2) continue;
|
||
|
||
$name = trim($parts[0]);
|
||
$value = trim($parts[1], " \t\n\r\0\x0B\"'");
|
||
|
||
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
||
putenv(sprintf('%s=%s', $name, $value));
|
||
$_ENV[$name] = $value;
|
||
$_SERVER[$name] = $value;
|
||
}
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `app/config/database.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Database Configuration
|
||
*/
|
||
|
||
return [
|
||
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
|
||
'port' => $_ENV['DB_PORT'] ?? '3306',
|
||
'database' => $_ENV['DB_DATABASE'] ?? 'musadaqDb',
|
||
'username' => $_ENV['DB_USERNAME'] ?? 'musadaqUser',
|
||
'password' => $_ENV['DB_PASSWORD'] ?? '',
|
||
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4',
|
||
];
|
||
|
||
```
|
||
|
||
## File: `app/helpers/helpers.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Global Helper Functions
|
||
*/
|
||
|
||
if (!function_exists('env')) {
|
||
function env(string $key, $default = null) {
|
||
return $_ENV[$key] ?? $default;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('input')) {
|
||
function input(string $key = null, $default = null) {
|
||
static $inputData = null;
|
||
if ($inputData === null) {
|
||
$json = file_get_contents('php://input');
|
||
$inputData = array_merge($_GET, $_POST, json_decode($json, true) ?? []);
|
||
}
|
||
|
||
if ($key === null) return $inputData;
|
||
return $inputData[$key] ?? $default;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('dd')) {
|
||
// M3 Fix: Guard dd() so it never leaks data in production
|
||
function dd(...$vars) {
|
||
if (env('APP_DEBUG', 'false') !== 'true') {
|
||
error_log('dd() called in production — suppressed. Check your code.');
|
||
json_error('Internal Server Error', 500);
|
||
}
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
foreach ($vars as $v) {
|
||
echo "<pre style='background:#1e1e1e;color:#d4d4d4;padding:1rem;border-radius:4px;'>";
|
||
var_dump($v);
|
||
echo "</pre>";
|
||
}
|
||
die();
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `public/login.php`
|
||
|
||
```php
|
||
<!DOCTYPE html>
|
||
<html lang="ar" dir="rtl" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>تسجيل الدخول | مُصادَق</title>
|
||
|
||
<!-- Fonts -->
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
|
||
<!-- Tailwind CSS -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
|
||
<!-- Alpine.js -->
|
||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--emerald: #10b981;
|
||
--bg-base: #080c14;
|
||
--bg-surface: #0d1424;
|
||
--border-default: rgba(255,255,255,0.10);
|
||
--text-primary: #f0f6fc;
|
||
--text-secondary: #8b949e;
|
||
}
|
||
|
||
body {
|
||
font-family: 'IBM Plex Sans Arabic', sans-serif;
|
||
background-color: var(--bg-base);
|
||
color: var(--text-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100vh;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.login-card {
|
||
background-color: var(--bg-surface);
|
||
border: 1px solid var(--border-default);
|
||
padding: 3rem;
|
||
width: 100%;
|
||
max-width: 450px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.input-field {
|
||
background-color: #05080f;
|
||
border: 1px solid var(--border-default);
|
||
color: white;
|
||
width: 100%;
|
||
padding: 0.75rem 1rem;
|
||
border-radius: 4px;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
.input-field:focus {
|
||
border-color: var(--emerald);
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: var(--emerald);
|
||
color: #000;
|
||
width: 100%;
|
||
padding: 0.75rem;
|
||
border-radius: 4px;
|
||
font-weight: 700;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.glow {
|
||
position: absolute;
|
||
width: 500px;
|
||
height: 500px;
|
||
background: radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, rgba(0, 0, 0, 0) 70%);
|
||
z-index: -1;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body x-data="loginForm">
|
||
<div class="glow"></div>
|
||
|
||
<div class="login-card">
|
||
<div class="text-center mb-10">
|
||
<div class="w-16 h-16 bg-emerald-500 rounded-xl flex items-center justify-center text-white text-3xl font-bold mx-auto mb-4 shadow-lg shadow-emerald-500/20">م</div>
|
||
<h1 class="text-2xl font-bold mb-2">مرحباً بك في مُصادَق</h1>
|
||
<p class="text-sm text-gray-500">نظام أتمتة الفواتير الضريبية الذكي</p>
|
||
</div>
|
||
|
||
<form @submit.prevent="submitLogin">
|
||
<div class="mb-6">
|
||
<label class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2">البريد الإلكتروني</label>
|
||
<input type="email" x-model="email" class="input-field" placeholder="name@company.com" required>
|
||
</div>
|
||
|
||
<div class="mb-8">
|
||
<label class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2">كلمة المرور</label>
|
||
<input type="password" x-model="password" class="input-field" placeholder="••••••••" required>
|
||
</div>
|
||
|
||
<template x-if="error">
|
||
<div class="mb-6 p-3 bg-red-950/50 border border-red-500/50 text-red-200 text-sm rounded" x-text="error"></div>
|
||
</template>
|
||
|
||
<button type="submit" class="btn-primary flex items-center justify-center gap-2" :disabled="loading">
|
||
<span x-show="loading" class="animate-spin text-lg">↻</span>
|
||
<span x-text="loading ? 'جاري التحقق...' : 'دخول النظام ↵'"></span>
|
||
</button>
|
||
</form>
|
||
|
||
<div class="mt-8 pt-8 border-t border-gray-800 text-center">
|
||
<p class="text-sm text-gray-500">ليس لديك حساب؟ <a href="#" class="text-emerald-500 hover:underline">ابدأ التجربة المجانية</a></p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('loginForm', () => ({
|
||
email: '',
|
||
password: '',
|
||
error: '',
|
||
loading: false,
|
||
|
||
async submitLogin() {
|
||
this.loading = true;
|
||
this.error = '';
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
email: this.email,
|
||
password: this.password
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
localStorage.setItem('access_token', result.data.access_token);
|
||
localStorage.setItem('user', JSON.stringify(result.data.user));
|
||
window.location.href = '/';
|
||
} else {
|
||
this.error = result.message || 'خطأ في تسجيل الدخول';
|
||
}
|
||
} catch (e) {
|
||
this.error = 'حدث خطأ في الاتصال بالخادم';
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
}
|
||
}));
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
```
|
||
|
||
## File: `public/index.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Simple Router & Entry Point
|
||
*/
|
||
|
||
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||
|
||
// Global Request Logging (non-sensitive)
|
||
error_log("Incoming Request: " . ($_SERVER['REQUEST_METHOD'] ?? 'GET') . " " . ($_SERVER['REQUEST_URI'] ?? '/'));
|
||
|
||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||
$route = $_GET['route'] ?? str_replace('/api/', '', $uri);
|
||
$route = trim($route, '/');
|
||
|
||
error_log("Router: Resolved route '{$route}'");
|
||
|
||
// Route map: route => [allowed_method, module_file]
|
||
$routes = [
|
||
'v1/auth/login' => ['POST', 'auth/login.php'],
|
||
'v1/auth/refresh' => ['POST', 'auth/refresh.php'],
|
||
'v1/auth/logout' => ['POST', 'auth/logout.php'],
|
||
'v1/users' => ['GET', 'users/index.php'],
|
||
'v1/users/create' => ['POST', 'users/create.php'],
|
||
'v1/companies' => ['GET', 'companies/index.php'],
|
||
'v1/companies/create' => ['POST', 'companies/create.php'],
|
||
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],
|
||
];
|
||
|
||
if (isset($routes[$route])) {
|
||
[$allowedMethod, $moduleFile] = $routes[$route];
|
||
|
||
// H1 Fix: Enforce HTTP Method
|
||
if ($_SERVER['REQUEST_METHOD'] !== $allowedMethod) {
|
||
header("Allow: {$allowedMethod}");
|
||
json_error("Method Not Allowed. Use {$allowedMethod}.", 405);
|
||
}
|
||
|
||
$file = APP_PATH . '/modules_app/' . $moduleFile;
|
||
if (file_exists($file)) {
|
||
require_once $file;
|
||
} else {
|
||
json_error("Endpoint file missing: {$route}", 500);
|
||
}
|
||
} else {
|
||
if (str_starts_with($route, 'v1/')) {
|
||
json_error("Not Found: {$route}", 404);
|
||
} else {
|
||
include __DIR__ . '/shell.php';
|
||
exit;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
## File: `public/tool-encrypt.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Quick Encryption Tool
|
||
*/
|
||
|
||
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||
|
||
use App\Core\Encryption;
|
||
|
||
$input = $_GET['text'] ?? '';
|
||
$encrypted = $input ? Encryption::encrypt($input) : '';
|
||
$hash = $input ? hash('sha256', strtolower($input)) : '';
|
||
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="ar" dir="rtl">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>أداة التشفير | مُصادَق</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
</head>
|
||
<body class="bg-slate-900 text-white p-10">
|
||
<div class="max-w-2xl mx-auto bg-slate-800 p-8 rounded-lg shadow-xl">
|
||
<h1 class="text-2xl font-bold mb-6 text-emerald-500">أداة تشفير البيانات</h1>
|
||
|
||
<form method="GET" class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-slate-400 mb-1">النص المطلوب تشفيره (اسم أو بريد إلكتروني):</label>
|
||
<input type="text" name="text" value="<?= htmlspecialchars($input) ?>" class="w-full bg-slate-900 border border-slate-700 p-3 rounded text-white outline-none focus:border-emerald-500">
|
||
</div>
|
||
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-6 py-2 rounded font-bold transition">تشفير الآن 🔒</button>
|
||
</form>
|
||
|
||
<?php if ($encrypted): ?>
|
||
<div class="mt-10 space-y-6">
|
||
<div>
|
||
<label class="block text-sm text-slate-400 mb-1">النص المشفّر (للحفظ في عمود name أو email):</label>
|
||
<textarea readonly class="w-full bg-slate-900 border border-slate-700 p-3 rounded text-emerald-400 font-mono text-xs h-24"><?= $encrypted ?></textarea>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-slate-400 mb-1">الـ Hash (للحفظ في عمود email_hash - للبريد فقط):</label>
|
||
<input readonly type="text" value="<?= $hash ?>" class="w-full bg-slate-900 border border-slate-700 p-3 rounded text-emerald-400 font-mono text-xs">
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="mt-8 pt-6 border-t border-slate-700 text-sm text-slate-500">
|
||
استخدم هذه القيم لتحديث مستخدمي النظام الحاليين يدوياً في قاعدة البيانات إذا أردت.
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
|
||
```
|
||
|
||
## File: `public/shell.php`
|
||
|
||
```php
|
||
<!DOCTYPE html>
|
||
<html lang="ar" dir="rtl" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>مُصادَق | لوحة التحكم</title>
|
||
|
||
<!-- Fonts -->
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--emerald: #10b981;
|
||
--bg-base: #080c14;
|
||
--bg-surface: #0d1424;
|
||
--border-default: rgba(255,255,255,0.1);
|
||
--text-primary: #f0f6fc;
|
||
}
|
||
body { font-family: 'IBM Plex Sans Arabic', sans-serif; background-color: var(--bg-base); color: var(--text-primary); }
|
||
</style>
|
||
</head>
|
||
<body x-data="app" x-init="init()">
|
||
<div class="flex h-screen overflow-hidden">
|
||
<!-- Sidebar -->
|
||
<aside class="w-64 bg-surface border-l border-gray-800 flex flex-col">
|
||
<div class="p-6">
|
||
<h1 class="text-xl font-bold text-emerald-500">مُصادَق</h1>
|
||
</div>
|
||
<nav class="flex-1 px-4 space-y-2">
|
||
<a href="#" @click="page='dashboard'" class="block p-3 rounded hover:bg-gray-800" :class="page==='dashboard'?'bg-emerald-900/20 text-emerald-500':''">📊 لوحة التحكم</a>
|
||
<a href="#" @click="page='companies'" class="block p-3 rounded hover:bg-gray-800" :class="page==='companies'?'bg-emerald-900/20 text-emerald-500':''">🏢 الشركات</a>
|
||
<a href="#" @click="page='users'" class="block p-3 rounded hover:bg-gray-800" :class="page==='users'?'bg-emerald-900/20 text-emerald-500':''">👥 المستخدمون</a>
|
||
</nav>
|
||
<div class="p-6 border-t border-gray-800">
|
||
<button @click="logout()" class="w-full text-right text-red-400 text-sm">🚪 تسجيل الخروج</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main -->
|
||
<main class="flex-1 overflow-y-auto p-10">
|
||
<header class="mb-10 flex justify-between items-center">
|
||
<h2 class="text-2xl font-bold" x-text="title()"></h2>
|
||
<div class="flex items-center gap-4">
|
||
<button x-show="page==='users'" @click="showAddModal = true" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded text-sm font-bold transition">➕ إضافة مستخدم</button>
|
||
<button x-show="page==='companies'" @click="showAddCompanyModal = true" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded text-sm font-bold transition">➕ إضافة شركة</button>
|
||
<div class="text-sm text-gray-500" x-text="user?.name"></div>
|
||
</div>
|
||
</header>
|
||
|
||
<div id="content">
|
||
<!-- Dashboard -->
|
||
<div x-show="page === 'dashboard'">
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
<div class="p-6 bg-surface border border-gray-800 rounded">
|
||
<p class="text-gray-500 text-sm">إجمالي الفواتير</p>
|
||
<p class="text-3xl font-bold mt-2" x-text="stats.total"></p>
|
||
</div>
|
||
<div class="p-6 bg-surface border border-gray-800 rounded">
|
||
<p class="text-gray-500 text-sm">قيد المعالجة</p>
|
||
<p class="text-3xl font-bold mt-2 text-yellow-500" x-text="stats.pending"></p>
|
||
</div>
|
||
<div class="p-6 bg-surface border border-gray-800 rounded">
|
||
<p class="text-gray-500 text-sm">تم الاعتماد</p>
|
||
<p class="text-3xl font-bold mt-2 text-emerald-500" x-text="stats.approved"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Companies -->
|
||
<div x-show="page === 'companies'">
|
||
<div class="bg-surface border border-gray-800 rounded overflow-hidden">
|
||
<table class="w-full text-right">
|
||
<thead class="bg-gray-900/50">
|
||
<tr>
|
||
<th class="p-4">اسم الشركة</th>
|
||
<th class="p-4">الرقم الضريبي</th>
|
||
<th class="p-4">رقم التسجيل</th>
|
||
<th class="p-4">تاريخ الإضافة</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="c in companies" :key="c.id">
|
||
<tr class="border-t border-gray-800">
|
||
<td class="p-4 font-bold text-emerald-500" x-text="c.name"></td>
|
||
<td class="p-4" x-text="c.tax_number"></td>
|
||
<td class="p-4" x-text="c.registration_number"></td>
|
||
<td class="p-4 text-xs text-gray-500" x-text="c.created_at"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Users -->
|
||
<div x-show="page === 'users'">
|
||
<div class="bg-surface border border-gray-800 rounded overflow-hidden">
|
||
<table class="w-full text-right">
|
||
<thead class="bg-gray-900/50">
|
||
<tr>
|
||
<th class="p-4">الاسم</th>
|
||
<th class="p-4">البريد الإلكتروني</th>
|
||
<th class="p-4">الدور</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-for="u in users" :key="u.id">
|
||
<tr class="border-t border-gray-800">
|
||
<td class="p-4" x-text="u.name"></td>
|
||
<td class="p-4" x-text="u.email"></td>
|
||
<td class="p-4 text-xs uppercase text-gray-500">
|
||
<span class="px-2 py-1 bg-gray-800 rounded" x-text="u.role"></span>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Add User Modal -->
|
||
<div x-show="showAddModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50" x-cloak>
|
||
<div class="bg-surface border border-gray-800 w-full max-w-md p-8 rounded-lg shadow-2xl" @click.away="showAddModal = false">
|
||
<h3 class="text-xl font-bold mb-6">إضافة مستخدم جديد 👤</h3>
|
||
<form @submit.prevent="createUser" class="space-y-4">
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">الاسم الكامل</label>
|
||
<input type="text" x-model="newUser.name" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">البريد الإلكتروني</label>
|
||
<input type="email" x-model="newUser.email" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">كلمة المرور</label>
|
||
<input type="password" x-model="newUser.password" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">الدور</label>
|
||
<select x-model="newUser.role" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
|
||
<option value="employee">موظف</option>
|
||
<option value="accountant">محاسب</option>
|
||
<option value="admin">مدير نظام</option>
|
||
</select>
|
||
</div>
|
||
<div class="pt-4 flex gap-3">
|
||
<button type="submit" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded font-bold transition">حفظ المستخدم</button>
|
||
<button type="button" @click="showAddModal = false" class="px-6 py-3 border border-gray-800 rounded hover:bg-gray-800 transition">إلغاء</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Company Modal -->
|
||
<div x-show="showAddCompanyModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50" x-cloak>
|
||
<div class="bg-surface border border-gray-800 w-full max-w-md p-8 rounded-lg shadow-2xl" @click.away="showAddCompanyModal = false">
|
||
<h3 class="text-xl font-bold mb-6">إنشاء شركة جديدة 🏢</h3>
|
||
<form @submit.prevent="createCompany" class="space-y-4">
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">اسم الشركة</label>
|
||
<input type="text" x-model="newCompany.name" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">الرقم الضريبي</label>
|
||
<input type="text" x-model="newCompany.tax_identification_number" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">رقم التسجيل</label>
|
||
<input type="text" x-model="newCompany.commercial_registration_number" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-gray-500 uppercase mb-1">العنوان</label>
|
||
<textarea x-model="newCompany.address" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500"></textarea>
|
||
</div>
|
||
<div class="pt-4 flex gap-3">
|
||
<button type="submit" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded font-bold transition">حفظ الشركة</button>
|
||
<button type="button" @click="showAddCompanyModal = false" class="px-6 py-3 border border-gray-800 rounded hover:bg-gray-800 transition">إلغاء</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('app', () => ({
|
||
user: JSON.parse(localStorage.getItem('user')),
|
||
page: 'dashboard',
|
||
users: [],
|
||
companies: [],
|
||
stats: { total: 0, pending: 0, approved: 0 },
|
||
showAddModal: false,
|
||
showAddCompanyModal: false,
|
||
newUser: { name: '', email: '', password: '', role: 'employee' },
|
||
newCompany: { name: '', tax_identification_number: '', commercial_registration_number: '', address: '' },
|
||
|
||
init() {
|
||
if (!this.user) window.location.href = '/login.php';
|
||
this.loadUsers();
|
||
this.loadStats();
|
||
this.loadCompanies();
|
||
},
|
||
|
||
title() {
|
||
return { dashboard: 'لوحة التحكم', users: 'المستخدمون', companies: 'الشركات' }[this.page];
|
||
},
|
||
|
||
async loadUsers() {
|
||
const res = await fetch('/api/v1/users', {
|
||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
|
||
});
|
||
if (res.status === 401) this.logout();
|
||
const json = await res.json();
|
||
if (json.success) this.users = json.data;
|
||
},
|
||
|
||
async loadCompanies() {
|
||
const res = await fetch('/api/v1/companies', {
|
||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
|
||
});
|
||
const json = await res.json();
|
||
if (json.success) this.companies = json.data;
|
||
},
|
||
|
||
async loadStats() {
|
||
const res = await fetch('/api/v1/dashboard/stats', {
|
||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
|
||
});
|
||
const json = await res.json();
|
||
if (json.success) this.stats = json.data;
|
||
},
|
||
|
||
async createUser() {
|
||
const res = await fetch('/api/v1/users/create', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
|
||
},
|
||
body: JSON.stringify(this.newUser)
|
||
});
|
||
const json = await res.json();
|
||
if (json.success) {
|
||
this.showAddModal = false;
|
||
this.newUser = { name: '', email: '', password: '', role: 'employee' };
|
||
this.loadUsers();
|
||
alert('تم إضافة المستخدم بنجاح');
|
||
} else {
|
||
alert(json.message);
|
||
}
|
||
},
|
||
|
||
async createCompany() {
|
||
const res = await fetch('/api/v1/companies/create', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
|
||
},
|
||
body: JSON.stringify(this.newCompany)
|
||
});
|
||
const json = await res.json();
|
||
if (json.success) {
|
||
this.showAddCompanyModal = false;
|
||
this.newCompany = { name: '', tax_identification_number: '', commercial_registration_number: '', address: '' };
|
||
this.loadCompanies();
|
||
alert('تم إنشاء الشركة بنجاح');
|
||
} else {
|
||
alert(json.message);
|
||
}
|
||
},
|
||
|
||
logout() {
|
||
localStorage.clear();
|
||
window.location.href = '/login.php';
|
||
}
|
||
}));
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
```
|
||
|
||
## File: `public/register.php`
|
||
|
||
```php
|
||
<!DOCTYPE html>
|
||
<html lang="ar" dir="rtl" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>إنشاء حساب | مُصادَق</title>
|
||
|
||
<!-- Fonts -->
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
|
||
<!-- Tailwind CSS -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
|
||
<!-- Alpine.js -->
|
||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--emerald: #10b981;
|
||
--bg-base: #080c14;
|
||
--bg-surface: #0d1424;
|
||
--border-default: rgba(255,255,255,0.10);
|
||
--text-primary: #f0f6fc;
|
||
}
|
||
|
||
body {
|
||
font-family: 'IBM Plex Sans Arabic', sans-serif;
|
||
background-color: var(--bg-base);
|
||
color: var(--text-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
padding: 2rem 0;
|
||
}
|
||
|
||
.login-card {
|
||
background-color: var(--bg-surface);
|
||
border: 1px solid var(--border-default);
|
||
padding: 2.5rem;
|
||
width: 100%;
|
||
max-width: 500px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.input-field {
|
||
background-color: #05080f;
|
||
border: 1px solid var(--border-default);
|
||
color: white;
|
||
width: 100%;
|
||
padding: 0.6rem 1rem;
|
||
border-radius: 4px;
|
||
outline: none;
|
||
}
|
||
|
||
.input-field:focus {
|
||
border-color: var(--emerald);
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: var(--emerald);
|
||
color: #000;
|
||
width: 100%;
|
||
padding: 0.75rem;
|
||
border-radius: 4px;
|
||
font-weight: 700;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body x-data="registerForm">
|
||
<div class="login-card">
|
||
<div class="text-center mb-8">
|
||
<h1 class="text-2xl font-bold mb-2">ابدأ مع مُصادَق</h1>
|
||
<p class="text-sm text-gray-500">سجل شركتك الآن وابدأ أتمتة فواتيرك</p>
|
||
</div>
|
||
|
||
<form @submit.prevent="submitRegister">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">اسم الشركة</label>
|
||
<input type="text" x-model="tenant_name" class="input-field" placeholder="شركة المثال" required>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">اسم المسؤول</label>
|
||
<input type="text" x-model="user_name" class="input-field" placeholder="حمزة" required>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">البريد الإلكتروني</label>
|
||
<input type="email" x-model="email" class="input-field" placeholder="admin@company.com" required>
|
||
</div>
|
||
|
||
<div class="mb-6">
|
||
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">كلمة المرور</label>
|
||
<input type="password" x-model="password" class="input-field" placeholder="••••••••" required>
|
||
</div>
|
||
|
||
<template x-if="error">
|
||
<div class="mb-6 p-3 bg-red-950/50 border border-red-500/50 text-red-200 text-sm rounded" x-text="error"></div>
|
||
</template>
|
||
|
||
<button type="submit" class="btn-primary" :disabled="loading">
|
||
<span x-text="loading ? 'جاري إنشاء الحساب...' : 'إنشاء الحساب والبدء ↵'"></span>
|
||
</button>
|
||
</form>
|
||
|
||
<div class="mt-8 pt-8 border-t border-gray-800 text-center text-sm">
|
||
<p class="text-gray-500">لديك حساب بالفعل؟ <a href="/login.php" class="text-emerald-500 hover:underline">تسجيل الدخول</a></p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('registerForm', () => ({
|
||
tenant_name: '',
|
||
user_name: '',
|
||
email: '',
|
||
password: '',
|
||
error: '',
|
||
loading: false,
|
||
|
||
async submitRegister() {
|
||
this.loading = true;
|
||
this.error = '';
|
||
|
||
try {
|
||
const response = await fetch('/api/v1/auth/register', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tenant_name: this.tenant_name,
|
||
user_name: this.user_name,
|
||
email: this.email,
|
||
password: this.password
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
localStorage.setItem('access_token', result.data.access_token);
|
||
localStorage.setItem('user', JSON.stringify(result.data.user));
|
||
window.location.href = '/';
|
||
} else {
|
||
this.error = result.message || 'خطأ في إنشاء الحساب';
|
||
}
|
||
} catch (e) {
|
||
this.error = 'حدث خطأ في الاتصال بالخادم';
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
}
|
||
}));
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
```
|
||
|
||
## File: `public/api.php`
|
||
|
||
```php
|
||
|
||
```
|
||
|
||
## File: `scripts/schema.sql`
|
||
|
||
```sql
|
||
-- ════════════════════════════════════════════════════════════
|
||
-- مُصادَق — Database Schema v1.0
|
||
-- ════════════════════════════════════════════════════════════
|
||
|
||
-- Tenants (Accounting Offices)
|
||
CREATE TABLE tenants (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
name VARCHAR(255) NOT NULL,
|
||
email VARCHAR(255) NOT NULL UNIQUE,
|
||
phone VARCHAR(20),
|
||
status ENUM('active', 'suspended', 'trial') DEFAULT 'trial',
|
||
trial_ends_at DATETIME,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- Users
|
||
CREATE TABLE users (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
tenant_id CHAR(36) NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
email VARCHAR(255) NOT NULL,
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
role ENUM('super_admin','admin','accountant','viewer') NOT NULL,
|
||
company_id CHAR(36) NULL, -- assigned company for accountant
|
||
refresh_token_hash VARCHAR(255) NULL,
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
last_login_at DATETIME NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uq_tenant_email (tenant_id, email),
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- API Keys (for external integrations / mobile scanner)
|
||
CREATE TABLE api_keys (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
tenant_id CHAR(36) NOT NULL,
|
||
user_id CHAR(36) NOT NULL,
|
||
name VARCHAR(100) NOT NULL,
|
||
public_key VARCHAR(64) NOT NULL UNIQUE,
|
||
secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash of secret
|
||
last_used_at DATETIME NULL,
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- Companies
|
||
CREATE TABLE companies (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
tenant_id CHAR(36) NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
name_en VARCHAR(255) NULL,
|
||
tax_identification_number VARCHAR(20) NOT NULL,
|
||
address TEXT NULL,
|
||
jofotara_client_id_encrypted TEXT NULL,
|
||
jofotara_secret_key_encrypted TEXT NULL,
|
||
jofotara_income_source_sequence VARCHAR(50) NULL,
|
||
certificate_path VARCHAR(255) NULL,
|
||
certificate_password_encrypted VARCHAR(500) NULL,
|
||
is_active BOOLEAN DEFAULT TRUE,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
INDEX idx_name (name),
|
||
INDEX idx_tin (tax_identification_number),
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- Subscriptions
|
||
CREATE TABLE subscriptions (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
tenant_id CHAR(36) NOT NULL UNIQUE,
|
||
plan ENUM('basic','office','pro','enterprise') NOT NULL DEFAULT 'basic',
|
||
max_companies INT NOT NULL DEFAULT 5,
|
||
max_invoices_per_month INT NOT NULL DEFAULT 100,
|
||
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||
invoices_used_this_month INT NOT NULL DEFAULT 0,
|
||
status ENUM('active','past_due','cancelled') DEFAULT 'active',
|
||
current_period_start DATETIME NULL,
|
||
current_period_end DATETIME NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- Invoices
|
||
CREATE TABLE invoices (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
tenant_id CHAR(36) NOT NULL,
|
||
company_id CHAR(36) NOT NULL,
|
||
invoice_number VARCHAR(100) NULL,
|
||
invoice_date DATE NULL,
|
||
invoice_type ENUM('cash','credit') DEFAULT 'cash',
|
||
ubl_type_code CHAR(3) DEFAULT '388',
|
||
payment_method_code CHAR(3) DEFAULT '013',
|
||
supplier_tin VARCHAR(20) NULL,
|
||
supplier_name VARCHAR(255) NULL,
|
||
supplier_address TEXT NULL,
|
||
buyer_tin VARCHAR(20) NULL,
|
||
buyer_national_id VARCHAR(20) NULL,
|
||
buyer_name VARCHAR(255) NULL,
|
||
subtotal DECIMAL(15,3) DEFAULT 0,
|
||
discount_total DECIMAL(15,3) DEFAULT 0,
|
||
tax_amount DECIMAL(15,3) DEFAULT 0,
|
||
grand_total DECIMAL(15,3) DEFAULT 0,
|
||
currency_code CHAR(3) DEFAULT 'JOD',
|
||
status ENUM('uploaded','extracting','extracted','validated','validation_failed','submitting','approved','rejected') DEFAULT 'uploaded',
|
||
original_file_path TEXT NULL,
|
||
invoice_category VARCHAR(20) DEFAULT 'simplified',
|
||
validation_errors JSON NULL,
|
||
qr_code TEXT NULL,
|
||
ai_confidence_score DECIMAL(4,3) NULL,
|
||
ai_prompt_tokens INT DEFAULT 0,
|
||
ai_completion_tokens INT DEFAULT 0,
|
||
ai_total_cost DECIMAL(10,6) DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
INDEX idx_tenant (tenant_id),
|
||
INDEX idx_company (company_id),
|
||
INDEX idx_status (status),
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- Invoice Lines
|
||
CREATE TABLE invoice_lines (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
invoice_id CHAR(36) NOT NULL,
|
||
line_number INT NOT NULL,
|
||
description TEXT NOT NULL,
|
||
quantity DECIMAL(15,3) NOT NULL,
|
||
unit_price DECIMAL(15,3) NOT NULL,
|
||
discount DECIMAL(15,3) DEFAULT 0,
|
||
tax_rate DECIMAL(5,4) NOT NULL,
|
||
line_total DECIMAL(15,3) NOT NULL,
|
||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
||
);
|
||
|
||
-- Audit Logs
|
||
CREATE TABLE audit_logs (
|
||
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||
tenant_id CHAR(36) NULL,
|
||
user_id CHAR(36) NULL,
|
||
action VARCHAR(100) NOT NULL,
|
||
entity_type VARCHAR(50) NULL,
|
||
entity_id CHAR(36) NULL,
|
||
old_data JSON NULL,
|
||
new_data JSON NULL,
|
||
ip_address VARCHAR(45) NULL,
|
||
user_agent VARCHAR(500) NULL,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_tenant (tenant_id),
|
||
INDEX idx_action (action),
|
||
INDEX idx_created (created_at)
|
||
);
|
||
```
|
||
|
||
## File: `scripts/migrate.php`
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* Advanced Migration Script: Schema Update + Data Encryption
|
||
*/
|
||
|
||
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||
|
||
use App\Core\Database;
|
||
use App\Core\Encryption;
|
||
|
||
$db = Database::getInstance();
|
||
|
||
echo "--- Starting Security Migration ---\n";
|
||
|
||
// 1. Add email_hash column if it doesn't exist
|
||
try {
|
||
$db->exec("ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) AFTER email, ADD INDEX (email_hash)");
|
||
echo "[OK] Added email_hash column and index.\n";
|
||
} catch (\Exception $e) {
|
||
echo "[SKIP] email_hash column might already exist.\n";
|
||
}
|
||
|
||
// 2. Fetch all users to encrypt their data
|
||
$stmt = $db->query("SELECT id, name, email FROM users");
|
||
$users = $stmt->fetchAll();
|
||
|
||
echo "Found " . count($users) . " users. Starting encryption...\n";
|
||
|
||
$updateStmt = $db->prepare("UPDATE users SET name = ?, email = ?, email_hash = ? WHERE id = ?");
|
||
|
||
foreach ($users as $user) {
|
||
// Check if data is already encrypted (to avoid double encryption)
|
||
$isAlreadyEncrypted = Encryption::decrypt($user['email']) !== false;
|
||
|
||
if ($isAlreadyEncrypted) {
|
||
echo "User ID {$user['id']} is already encrypted. Skipping.\n";
|
||
continue;
|
||
}
|
||
|
||
// Encrypt Name
|
||
$encryptedName = Encryption::encrypt($user['name']);
|
||
|
||
// Encrypt Email
|
||
$encryptedEmail = Encryption::encrypt($user['email']);
|
||
|
||
// Generate Hash for lookup
|
||
$emailHash = hash('sha256', strtolower($user['email']));
|
||
|
||
$updateStmt->execute([
|
||
$encryptedName,
|
||
$encryptedEmail,
|
||
$emailHash,
|
||
$user['id']
|
||
]);
|
||
|
||
echo "User ID {$user['id']} migrated successfully.\n";
|
||
}
|
||
|
||
// (Table creation logic removed because it is properly handled by schema.sql)
|
||
|
||
echo "--- Migration Complete ---\n";
|
||
|
||
```
|
||
|