Initial commit: Uruk Prize Platform architecture, backend core, mobile app, gateway caller & deployment pipeline
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
APP_NAME="Uruk International Prize"
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://api.urukprize.iq
|
||||
APP_SECRET=your_super_secret_64_char_key_here_for_hmac_and_tokens
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=uruk_prize_db
|
||||
DB_USERNAME=uruk_user
|
||||
DB_PASSWORD=uruk_secure_password_2026
|
||||
|
||||
# OTPIQ Service Credentials (Iraq SMS & WhatsApp Gateway)
|
||||
OTPIQ_API_KEY=your_otpiq_api_key
|
||||
OTPIQ_SENDER_ID=URUK-PRIZE
|
||||
OTPIQ_ENDPOINT=https://api.otpiq.com/api/sms
|
||||
|
||||
# SwiftPayIQ Credentials (Aggregator Sandbox / Production)
|
||||
SWIFTPAY_API_KEY=your_swiftpay_key
|
||||
SWIFTPAY_SECRET=your_swiftpay_secret
|
||||
SWIFTPAY_ENDPOINT=https://api.swiftpayiq.com/v1
|
||||
|
||||
# SuperQi / ZainCash Notification Listener Secret
|
||||
NOTIFICATION_INGEST_SECRET=secure_ingest_key_for_android_bridge_listener
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class AdminController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/admin/partners/add
|
||||
* Directly add a new hospital or hotel contract on the fly.
|
||||
*/
|
||||
public function addPartner(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$type = strtoupper(trim((string)$request->get('type', 'HOSPITAL')));
|
||||
$nameAr = trim((string)$request->get('name_ar'));
|
||||
$nameEn = trim((string)$request->get('name_en', ''));
|
||||
$category = trim((string)$request->get('category', 'عام'));
|
||||
$country = trim((string)$request->get('country', 'العراق'));
|
||||
$city = trim((string)$request->get('city', 'بغداد'));
|
||||
$discount = (float)$request->get('discount_percentage', $type === 'HOSPITAL' ? 50.00 : 50.00);
|
||||
$phone = trim((string)$request->get('phone', ''));
|
||||
$address = trim((string)$request->get('address', ''));
|
||||
|
||||
if (empty($nameAr)) {
|
||||
Response::error('Partner Arabic name is required.');
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO partners (type, name_ar, name_en, category, country, city, discount_percentage, address, phone, is_active, added_by_admin, created_at)
|
||||
VALUES (:type, :name_ar, :name_en, :category, :country, :city, :discount, :address, :phone, 1, :admin_id, NOW())
|
||||
');
|
||||
$stmt->execute([
|
||||
':type' => $type,
|
||||
':name_ar' => $nameAr,
|
||||
':name_en' => $nameEn,
|
||||
':category' => $category,
|
||||
':country' => $country,
|
||||
':city' => $city,
|
||||
':discount' => $discount,
|
||||
':address' => $address,
|
||||
':phone' => $phone,
|
||||
':admin_id' => $userId,
|
||||
]);
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
Response::success([
|
||||
'partner_id' => $newId,
|
||||
'name_ar' => $nameAr,
|
||||
'discount' => $discount,
|
||||
'message' => 'تمت إضافة الشريك بنجاح ومتاح فورياً لجميع المشتركين.',
|
||||
], 'Partner created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/payments/pending
|
||||
*/
|
||||
public function listPendingPayments(Request $request): void
|
||||
{
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->query('
|
||||
SELECT t.id, t.subscription_id, t.reference_number, t.amount, t.currency, t.method, t.receipt_image_url, t.created_at,
|
||||
u.id AS user_id, u.full_name, u.phone, s.membership_number
|
||||
FROM transactions t
|
||||
JOIN subscriptions s ON s.id = t.subscription_id
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.status = "SUBMITTED"
|
||||
ORDER BY t.id DESC
|
||||
LIMIT 50
|
||||
');
|
||||
$pending = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::success($pending, 'Pending payments list retrieved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/payments/approve
|
||||
*/
|
||||
public function approvePayment(Request $request): void
|
||||
{
|
||||
$adminId = (int)$request->getHeader('x-user-id');
|
||||
$transactionId = (int)$request->get('transaction_id');
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare('SELECT id, subscription_id, user_id FROM transactions WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $transactionId]);
|
||||
$trans = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$trans) {
|
||||
Response::notFound('Transaction not found.');
|
||||
}
|
||||
|
||||
// Update transaction
|
||||
$updTrans = $pdo->prepare('UPDATE transactions SET status = "VERIFIED_ADMIN", verified_at = NOW() WHERE id = :id');
|
||||
$updTrans->execute([':id' => $transactionId]);
|
||||
|
||||
// Activate subscription
|
||||
$updSub = $pdo->prepare('
|
||||
UPDATE subscriptions
|
||||
SET status = "ACTIVE", starts_at = CURDATE(), expires_at = DATE_ADD(CURDATE(), INTERVAL 2 YEAR), activated_by = :admin_id
|
||||
WHERE id = :sub_id
|
||||
');
|
||||
$updSub->execute([
|
||||
':admin_id' => $adminId,
|
||||
':sub_id' => $trans['subscription_id'],
|
||||
]);
|
||||
|
||||
// Activate user
|
||||
$updUser = $pdo->prepare('UPDATE users SET status = "ACTIVE" WHERE id = :uid');
|
||||
$updUser->execute([':uid' => $trans['user_id']]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
Response::success([
|
||||
'transaction_id' => $transactionId,
|
||||
'subscription_id' => $trans['subscription_id'],
|
||||
'status' => 'ACTIVE',
|
||||
], 'Payment approved and membership activated successfully.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
Response::error('Approval failed: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/stats
|
||||
*/
|
||||
public function getStats(Request $request): void
|
||||
{
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$membersCount = (int)$pdo->query('SELECT COUNT(*) FROM subscriptions WHERE status = "ACTIVE"')->fetchColumn();
|
||||
$pendingCount = (int)$pdo->query('SELECT COUNT(*) FROM subscriptions WHERE status = "PENDING_PAYMENT"')->fetchColumn();
|
||||
$totalRevenue = (float)$pdo->query('SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE status IN ("VERIFIED_AUTO", "VERIFIED_ADMIN")')->fetchColumn();
|
||||
$hospitalsCount = (int)$pdo->query('SELECT COUNT(*) FROM partners WHERE type = "HOSPITAL" AND is_active = 1')->fetchColumn();
|
||||
$hotelsCount = (int)$pdo->query('SELECT COUNT(*) FROM partners WHERE type = "HOTEL" AND is_active = 1')->fetchColumn();
|
||||
|
||||
Response::success([
|
||||
'active_members' => $membersCount,
|
||||
'pending_verifications' => $pendingCount,
|
||||
'total_revenue_iqd' => $totalRevenue,
|
||||
'total_revenue_usd' => round($totalRevenue / 1320, 2),
|
||||
'active_hospitals' => $hospitalsCount,
|
||||
'active_hotels' => $hotelsCount,
|
||||
], 'System statistics retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Security;
|
||||
use Core\Database;
|
||||
use App\Services\OtpIqService;
|
||||
use PDO;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/auth/request-otp
|
||||
*/
|
||||
public function requestOtp(Request $request): void
|
||||
{
|
||||
$phone = trim((string)$request->get('phone'));
|
||||
if (empty($phone) || strlen($phone) < 8) {
|
||||
Response::error('Phone number is required.');
|
||||
}
|
||||
|
||||
// Generate 6-digit cryptographic OTP code
|
||||
$otp = (string)random_int(100000, 999999);
|
||||
|
||||
// Store OTP temporarily in DB or cache
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (user_id, action, endpoint, method, ip_address, response_code, payload_snippet, created_at)
|
||||
VALUES (0, "OTP_REQUESTED", "/api/v1/auth/request-otp", "POST", :ip, 200, :payload, NOW())
|
||||
');
|
||||
$stmt->execute([
|
||||
':ip' => $request->getIp(),
|
||||
':payload' => json_encode(['phone' => $phone, 'otp' => $otp]),
|
||||
]);
|
||||
|
||||
$otpService = new OtpIqService();
|
||||
$res = $otpService->sendOtp($phone, $otp);
|
||||
|
||||
Response::success([
|
||||
'phone' => $phone,
|
||||
'channels' => ['whatsapp', 'sms'],
|
||||
'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null,
|
||||
], 'Verification OTP dispatched via OTPIQ Smart Fallback.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/verify-otp
|
||||
* Receives phone, otp, device_fingerprint, device_model, and optional full_name.
|
||||
*/
|
||||
public function verifyOtp(Request $request): void
|
||||
{
|
||||
$phone = trim((string)$request->get('phone'));
|
||||
$otp = trim((string)$request->get('otp'));
|
||||
$fingerprint = trim((string)$request->get('device_fingerprint'));
|
||||
$model = trim((string)$request->get('device_model', 'Unknown Device'));
|
||||
$fullName = trim((string)$request->get('full_name', 'عضو جائزة أوروك'));
|
||||
|
||||
if (empty($phone) || empty($otp) || empty($fingerprint)) {
|
||||
Response::error('Phone, OTP, and Device Fingerprint are mandatory.');
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// Check or find user
|
||||
$stmt = $pdo->prepare('SELECT id, phone, full_name, role, status FROM users WHERE phone = :phone LIMIT 1');
|
||||
$stmt->execute([':phone' => $phone]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
// Register new user
|
||||
$ins = $pdo->prepare('
|
||||
INSERT INTO users (phone, full_name, role, status, created_at)
|
||||
VALUES (:phone, :name, "MEMBER", "ACTIVE", NOW())
|
||||
');
|
||||
$ins->execute([':phone' => $phone, ':name' => $fullName]);
|
||||
$userId = (int)$pdo->lastInsertId();
|
||||
$role = 'MEMBER';
|
||||
} else {
|
||||
$userId = (int)$user['id'];
|
||||
$role = $user['role'];
|
||||
}
|
||||
|
||||
// Generate per-device secret key and session token
|
||||
$deviceSecret = Security::generateRandomHex(32);
|
||||
$sessionToken = Security::generateRandomHex(32);
|
||||
|
||||
// Bind device fingerprint and store secret in user_security table
|
||||
$secStmt = $pdo->prepare('
|
||||
INSERT INTO user_security (user_id, device_fingerprint, device_secret, device_model, last_token, last_ip, last_active_at)
|
||||
VALUES (:uid, :fp, :secret, :model, :token, :ip, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
device_secret = VALUES(device_secret),
|
||||
device_model = VALUES(device_model),
|
||||
last_token = VALUES(last_token),
|
||||
last_ip = VALUES(last_ip),
|
||||
last_active_at = NOW(),
|
||||
is_locked = 0
|
||||
');
|
||||
$secStmt->execute([
|
||||
':uid' => $userId,
|
||||
':fp' => $fingerprint,
|
||||
':secret' => $deviceSecret,
|
||||
':model' => $model,
|
||||
':token' => $sessionToken,
|
||||
':ip' => $request->getIp(),
|
||||
]);
|
||||
|
||||
Response::success([
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'phone' => $phone,
|
||||
'full_name' => $user['full_name'] ?? $fullName,
|
||||
'role' => $role,
|
||||
],
|
||||
'token' => $sessionToken,
|
||||
'device_secret' => $deviceSecret,
|
||||
'device_fingerprint' => $fingerprint,
|
||||
], 'Authentication successful. Device bound and secured.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/profile
|
||||
*/
|
||||
public function getProfile(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT u.id, u.phone, u.full_name, u.role, u.status, u.created_at,
|
||||
s.membership_number, s.status AS subscription_status, s.expires_at
|
||||
FROM users u
|
||||
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = "ACTIVE"
|
||||
WHERE u.id = :uid
|
||||
ORDER BY s.id DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':uid' => $userId]);
|
||||
$profile = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::success($profile, 'User profile retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class GatewayController
|
||||
{
|
||||
private string $configuredAppKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configuredAppKey = getenv('GATEWAY_APP_KEY') ?: 'uruk_gateway_secret_2026';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate gateway device app_key
|
||||
*/
|
||||
private function authenticate(string $appKey): bool
|
||||
{
|
||||
return hash_equals($this->configuredAppKey, $appKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gateway/pending-call or /pending-call.php
|
||||
*/
|
||||
public function pendingCall(Request $request): void
|
||||
{
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
|
||||
if (!$this->authenticate($appKey) || empty($deviceId)) {
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
'error' => 'Unauthorized or missing device_id'
|
||||
], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// Update device heartbeat
|
||||
$this->heartbeat($pdo, $deviceId);
|
||||
|
||||
// Fetch oldest pending FLASH_CALL task
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, target_phone, otp_code, timeout_seconds
|
||||
FROM gateway_tasks
|
||||
WHERE task_type = "FLASH_CALL"
|
||||
AND (assigned_device_id = :dev OR assigned_device_id IS NULL)
|
||||
AND status = "PENDING"
|
||||
AND created_at >= NOW() - INTERVAL 60 SECOND
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':dev' => $deviceId]);
|
||||
$task = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($task) {
|
||||
// Lock task to this device
|
||||
$upd = $pdo->prepare('UPDATE gateway_tasks SET status = "PROCESSING", assigned_device_id = :dev WHERE id = :id');
|
||||
$upd->execute([':dev' => $deviceId, ':id' => $task['id']]);
|
||||
|
||||
Response::json([
|
||||
'task_id' => (int)$task['id'],
|
||||
'phone' => $task['target_phone'],
|
||||
'caller_id' => null,
|
||||
'otp' => $task['otp_code'],
|
||||
'timeout_seconds' => (int)($task['timeout_seconds'] ?: 25),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gateway/pending-sms or /pending-sms.php
|
||||
*/
|
||||
public function pendingSms(Request $request): void
|
||||
{
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
|
||||
if (!$this->authenticate($appKey) || empty($deviceId)) {
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
'error' => 'Unauthorized or missing device_id'
|
||||
], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$this->heartbeat($pdo, $deviceId);
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, target_phone, otp_code, timeout_seconds
|
||||
FROM gateway_tasks
|
||||
WHERE task_type = "SMS"
|
||||
AND (assigned_device_id = :dev OR assigned_device_id IS NULL)
|
||||
AND status = "PENDING"
|
||||
AND created_at >= NOW() - INTERVAL 120 SECOND
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':dev' => $deviceId]);
|
||||
$task = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($task) {
|
||||
$upd = $pdo->prepare('UPDATE gateway_tasks SET status = "PROCESSING", assigned_device_id = :dev WHERE id = :id');
|
||||
$upd->execute([':dev' => $deviceId, ':id' => $task['id']]);
|
||||
|
||||
Response::json([
|
||||
'task_id' => (int)$task['id'],
|
||||
'phone' => $task['target_phone'],
|
||||
'caller_id' => null,
|
||||
'otp' => $task['otp_code'],
|
||||
'timeout_seconds' => (int)($task['timeout_seconds'] ?: 30),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'task_id' => null,
|
||||
'phone' => null,
|
||||
'caller_id' => null,
|
||||
'otp' => null,
|
||||
'timeout_seconds' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/gateway/call-done or /call-done.php
|
||||
*/
|
||||
public function callDone(Request $request): void
|
||||
{
|
||||
$taskId = (int)$request->get('task_id');
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
$result = (string)$request->get('result');
|
||||
|
||||
if (!$this->authenticate($appKey)) {
|
||||
Response::json(['success' => false, 'message' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$newStatus = (strtoupper($result) === 'SUCCESS' || strtoupper($result) === 'DONE') ? 'COMPLETED' : 'FAILED';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE gateway_tasks
|
||||
SET status = :st, completed_at = NOW()
|
||||
WHERE id = :id AND assigned_device_id = :dev
|
||||
');
|
||||
$stmt->execute([':st' => $newStatus, ':id' => $taskId, ':dev' => $deviceId]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => "Call task {$taskId} recorded as {$newStatus}.",
|
||||
'device_id' => $deviceId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/gateway/sms-done or /sms-done.php
|
||||
*/
|
||||
public function smsDone(Request $request): void
|
||||
{
|
||||
$taskId = (int)$request->get('task_id');
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$appKey = (string)$request->get('app_key');
|
||||
$result = (string)$request->get('result');
|
||||
|
||||
if (!$this->authenticate($appKey)) {
|
||||
Response::json(['success' => false, 'message' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$newStatus = (strtoupper($result) === 'SUCCESS' || strtoupper($result) === 'SENT') ? 'COMPLETED' : 'FAILED';
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE gateway_tasks
|
||||
SET status = :st, completed_at = NOW()
|
||||
WHERE id = :id AND assigned_device_id = :dev
|
||||
');
|
||||
$stmt->execute([':st' => $newStatus, ':id' => $taskId, ':dev' => $deviceId]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => "SMS task {$taskId} recorded as {$newStatus}.",
|
||||
'device_id' => $deviceId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/gateway/register-device or /register-device.php
|
||||
*/
|
||||
public function registerDevice(Request $request): void
|
||||
{
|
||||
$deviceId = (string)$request->get('device_id');
|
||||
$phoneNumber = (string)$request->get('phone_number');
|
||||
$simSlot = (int)$request->get('sim_slot', 1);
|
||||
$appKey = (string)$request->get('app_key');
|
||||
|
||||
if (!$this->authenticate($appKey) || empty($deviceId)) {
|
||||
Response::json(['success' => false, 'message' => 'Unauthorized or missing device_id'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO gateway_devices (device_id, phone_number, sim_slot, status, last_heartbeat)
|
||||
VALUES (:did, :phone, :slot, "ACTIVE", NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
phone_number = VALUES(phone_number),
|
||||
sim_slot = VALUES(sim_slot),
|
||||
status = "ACTIVE",
|
||||
last_heartbeat = NOW()
|
||||
');
|
||||
$stmt->execute([
|
||||
':did' => $deviceId,
|
||||
':phone' => $phoneNumber,
|
||||
':slot' => $simSlot,
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => 'Gateway device registered and active.',
|
||||
'device_id' => $deviceId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function heartbeat(PDO $pdo, string $deviceId): void
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO gateway_devices (device_id, status, last_heartbeat)
|
||||
VALUES (:did, "ACTIVE", NOW())
|
||||
ON DUPLICATE KEY UPDATE status = "ACTIVE", last_heartbeat = NOW()
|
||||
');
|
||||
$stmt->execute([':did' => $deviceId]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore heartbeat failures if table schema is pending
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class PartnerController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/partners
|
||||
* Query params: type (HOSPITAL, HOTEL), country, city, search
|
||||
*/
|
||||
public function listPartners(Request $request): void
|
||||
{
|
||||
$type = strtoupper(trim((string)$request->getQuery('type', '')));
|
||||
$country = trim((string)$request->getQuery('country', ''));
|
||||
$city = trim((string)$request->getQuery('city', ''));
|
||||
$search = trim((string)$request->getQuery('search', ''));
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$sql = 'SELECT id, type, name_ar, name_en, category, country, city, discount_percentage, address, phone, latitude, longitude
|
||||
FROM partners
|
||||
WHERE is_active = 1';
|
||||
$params = [];
|
||||
|
||||
if (!empty($type)) {
|
||||
$sql .= ' AND type = :type';
|
||||
$params[':type'] = $type;
|
||||
}
|
||||
|
||||
if (!empty($country)) {
|
||||
$sql .= ' AND country = :country';
|
||||
$params[':country'] = $country;
|
||||
}
|
||||
|
||||
if (!empty($city)) {
|
||||
$sql .= ' AND city = :city';
|
||||
$params[':city'] = $city;
|
||||
}
|
||||
|
||||
if (!empty($search)) {
|
||||
$sql .= ' AND (name_ar LIKE :search OR name_en LIKE :search OR category LIKE :search)';
|
||||
$params[':search'] = '%' . $search . '%';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY discount_percentage DESC, id DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$partners = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::success($partners, 'Partners directory retrieved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/partners/{id}
|
||||
*/
|
||||
public function getPartner(Request $request, array $params): void
|
||||
{
|
||||
$partnerId = (int)($params['id'] ?? 0);
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM partners WHERE id = :id AND is_active = 1 LIMIT 1');
|
||||
$stmt->execute([':id' => $partnerId]);
|
||||
$partner = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$partner) {
|
||||
Response::notFound('Partner not found.');
|
||||
}
|
||||
|
||||
Response::success($partner, 'Partner details retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use App\Services\SuperQiParserService;
|
||||
|
||||
class PaymentController
|
||||
{
|
||||
/**
|
||||
* POST /api/v1/payments/ingest-notification
|
||||
* Invoked securely by the Android Bridge Listener app when a SuperQi/ZainCash notification arrives.
|
||||
*/
|
||||
public function ingestNotification(Request $request): void
|
||||
{
|
||||
$secret = $request->getHeader('x-listener-secret');
|
||||
$expectedSecret = getenv('NOTIFICATION_INGEST_SECRET') ?: 'secure_ingest_key_for_android_bridge_listener';
|
||||
|
||||
if ($secret !== $expectedSecret) {
|
||||
Response::forbidden('Invalid listener secret.');
|
||||
}
|
||||
|
||||
$body = $request->getBody();
|
||||
$result = SuperQiParserService::processIngestedNotification($body);
|
||||
|
||||
if (!$result['success']) {
|
||||
Response::error($result['message'] ?? 'Ingestion failed', 400);
|
||||
}
|
||||
|
||||
Response::success($result, 'Notification ingested and processed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/payments/swiftpay-webhook
|
||||
*/
|
||||
public function swiftPayWebhook(Request $request): void
|
||||
{
|
||||
// Handle SwiftPayIQ transaction webhook
|
||||
$payload = $request->getBody();
|
||||
Response::success(['status' => 'received'], 'Webhook processed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use App\Services\QrTokenService;
|
||||
|
||||
class QrController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/qr/dynamic-token
|
||||
* User's Flutter app requests a rotating QR token.
|
||||
*/
|
||||
public function getDynamicToken(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$res = QrTokenService::generateToken($userId);
|
||||
|
||||
if (!$res['success']) {
|
||||
Response::forbidden($res['message']);
|
||||
}
|
||||
|
||||
Response::success($res, 'Dynamic QR token generated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/qr/verify-token
|
||||
* Scanned by hospital/hotel front desk or verification portal.
|
||||
*/
|
||||
public function verifyToken(Request $request): void
|
||||
{
|
||||
$token = trim((string)$request->get('token'));
|
||||
$partnerId = (int)$request->get('partner_id', 1);
|
||||
$staffName = trim((string)$request->get('staff_name', 'موظف الاستقبال'));
|
||||
|
||||
if (empty($token)) {
|
||||
Response::error('QR token is required for verification.');
|
||||
}
|
||||
|
||||
$res = QrTokenService::verifyScannedToken($token, $partnerId, $staffName);
|
||||
|
||||
if (!$res['success']) {
|
||||
Response::error($res['message'], 400);
|
||||
}
|
||||
|
||||
Response::success($res, 'Member verified successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Security;
|
||||
use Core\Database;
|
||||
use App\Services\SuperQiParserService;
|
||||
use PDO;
|
||||
|
||||
class SubscriptionController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/subscription/plans
|
||||
*/
|
||||
public function getPlans(Request $request): void
|
||||
{
|
||||
$appConfig = require __DIR__ . '/../../config/app.php';
|
||||
|
||||
$plans = [
|
||||
[
|
||||
'id' => 'uruk-2year-standard',
|
||||
'name' => 'عضوية جائزة أوروك التنفيذية (سنتان)',
|
||||
'duration_years' => 2,
|
||||
'price_usd' => (float)$appConfig['membership_price_usd'],
|
||||
'price_iqd' => (float)$appConfig['membership_price_iqd'],
|
||||
'benefits' => [
|
||||
'خصم 50% في شبكة المستشفيات والمراكز التخصصية في العراق والأردن ومصر ولبنان',
|
||||
'خصومات فندقية وسياحية تتراوح بين 40% و60% في أرقى الفنادق الشريكة',
|
||||
'وصول مجاني لأكثر من 30 تخصصاً تدريبياً معتمداً حضورياً وعبر المنصة الرقمية',
|
||||
'شهادات معتمدة ومسجلة رسمياً باسم المشترك',
|
||||
'هوية رقمية ذكية مع رمز QR مشفر للتحقق الفوري',
|
||||
],
|
||||
'official_activation_date' => '2026-01-01',
|
||||
'early_registration_active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
Response::success($plans, 'Available subscription plans retrieved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/subscription/submit
|
||||
* User submits payment details (Method, Ref Number, Receipt).
|
||||
*/
|
||||
public function submitSubscription(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$method = strtoupper(trim((string)$request->get('method', 'SUPER_QI')));
|
||||
$refNumber = trim((string)$request->get('reference_number'));
|
||||
$receiptUrl = trim((string)$request->get('receipt_url', ''));
|
||||
|
||||
if (empty($refNumber)) {
|
||||
Response::error('Payment transaction reference number is required.');
|
||||
}
|
||||
|
||||
$appConfig = require __DIR__ . '/../../config/app.php';
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// 1. Generate unique membership number (e.g. URUK-2026-XXXX)
|
||||
$memNumber = 'URUK-' . date('Y') . '-' . strtoupper(substr(uniqid(), -5));
|
||||
$qrSeed = Security::generateRandomHex(16);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Create subscription record with PENDING_PAYMENT
|
||||
$subStmt = $pdo->prepare('
|
||||
INSERT INTO subscriptions (user_id, membership_number, plan_name, price_usd, price_local, currency, starts_at, expires_at, status, qr_seed, created_at)
|
||||
VALUES (:uid, :mem, "2-Year Executive Membership", :usd, :iqd, "IQD", CURDATE(), DATE_ADD(CURDATE(), INTERVAL 2 YEAR), "PENDING_PAYMENT", :seed, NOW())
|
||||
');
|
||||
$subStmt->execute([
|
||||
':uid' => $userId,
|
||||
':mem' => $memNumber,
|
||||
':usd' => $appConfig['membership_price_usd'],
|
||||
':iqd' => $appConfig['membership_price_iqd'],
|
||||
':seed' => $qrSeed,
|
||||
]);
|
||||
$subscriptionId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Create transaction record
|
||||
$transStmt = $pdo->prepare('
|
||||
INSERT INTO transactions (subscription_id, user_id, method, reference_number, amount, currency, status, receipt_image_url, created_at)
|
||||
VALUES (:sub_id, :uid, :method, :ref, :amount, "IQD", "SUBMITTED", :receipt, NOW())
|
||||
');
|
||||
$transStmt->execute([
|
||||
':sub_id' => $subscriptionId,
|
||||
':uid' => $userId,
|
||||
':method' => $method,
|
||||
':ref' => $refNumber,
|
||||
':amount' => $appConfig['membership_price_iqd'],
|
||||
':receipt' => $receiptUrl,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// 2. Check if a matching SuperQi / ZainCash notification was already ingested by the Android listener
|
||||
$checkStmt = $pdo->prepare('
|
||||
SELECT id, amount, status FROM transactions
|
||||
WHERE reference_number = :ref AND id != :current_id AND status = "VERIFIED_AUTO"
|
||||
LIMIT 1
|
||||
');
|
||||
$checkStmt->execute([':ref' => $refNumber, ':current_id' => (int)$pdo->lastInsertId()]);
|
||||
$preExistingPayment = $checkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($preExistingPayment) {
|
||||
// Instantly activate!
|
||||
$actSub = $pdo->prepare('UPDATE subscriptions SET status = "ACTIVE" WHERE id = :id');
|
||||
$actSub->execute([':id' => $subscriptionId]);
|
||||
|
||||
$actTrans = $pdo->prepare('UPDATE transactions SET status = "VERIFIED_AUTO", verified_at = NOW() WHERE subscription_id = :id');
|
||||
$actTrans->execute([':id' => $subscriptionId]);
|
||||
|
||||
Response::success([
|
||||
'subscription_id' => $subscriptionId,
|
||||
'membership_number' => $memNumber,
|
||||
'status' => 'ACTIVE',
|
||||
'message' => 'تم التحقق من الحوالة آلياً بنجاح وتفعيل العضوية فورياً!',
|
||||
], 'Membership activated automatically via SuperQi verification.');
|
||||
}
|
||||
|
||||
Response::success([
|
||||
'subscription_id' => $subscriptionId,
|
||||
'membership_number' => $memNumber,
|
||||
'status' => 'PENDING_PAYMENT',
|
||||
'message' => 'تم استلام طلبك بنجاح وجارٍ التحقق من الحوالة وتفعيل بطاقتك.',
|
||||
], 'Subscription request registered. Verification in progress.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
Response::error('Failed to submit subscription: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/subscription/my-card
|
||||
*/
|
||||
public function getMyCard(Request $request): void
|
||||
{
|
||||
$userId = (int)$request->getHeader('x-user-id');
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT s.id, s.membership_number, s.plan_name, s.status, s.starts_at, s.expires_at,
|
||||
u.full_name, u.phone, u.avatar_url
|
||||
FROM subscriptions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.user_id = :uid
|
||||
ORDER BY s.id DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':uid' => $userId]);
|
||||
$card = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$card) {
|
||||
Response::notFound('No subscription card found for this user.');
|
||||
}
|
||||
|
||||
Response::success($card, 'Digital card details retrieved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use Core\Middleware;
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class AdminMiddleware extends Middleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): void
|
||||
{
|
||||
$userId = $request->getHeader('x-user-id');
|
||||
|
||||
try {
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('SELECT role FROM users WHERE id = :user_id LIMIT 1');
|
||||
$stmt->execute([':user_id' => $userId]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || !in_array($user['role'], ['ADMIN', 'SUPER_ADMIN'], true)) {
|
||||
Response::forbidden('Administrative privileges required to access this resource.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Response::error('Role verification failure: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
$next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use Core\Middleware;
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class AuthMiddleware extends Middleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): void
|
||||
{
|
||||
$authHeader = $request->getHeader('authorization');
|
||||
if (empty($authHeader) || !str_starts_with($authHeader, 'Bearer ')) {
|
||||
Response::unauthorized('Bearer token required in Authorization header.');
|
||||
}
|
||||
|
||||
$token = substr($authHeader, 7);
|
||||
$userId = $request->getHeader('x-user-id');
|
||||
|
||||
try {
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT u.id, u.phone, u.full_name, u.role, u.status, us.last_token
|
||||
FROM users u
|
||||
JOIN user_security us ON us.user_id = u.id
|
||||
WHERE u.id = :user_id AND us.last_token = :token
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([
|
||||
':user_id' => $userId,
|
||||
':token' => $token,
|
||||
]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
Response::unauthorized('Invalid or expired authentication session token.');
|
||||
}
|
||||
|
||||
if ($user['status'] !== 'ACTIVE' && $user['status'] !== 'PENDING_VERIFICATION') {
|
||||
Response::forbidden('User account is not currently active.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Response::error('Authentication verification failure: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
$next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use Core\Middleware;
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Security;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class SecurityHeaderMiddleware extends Middleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): void
|
||||
{
|
||||
$userId = $request->getHeader('x-user-id');
|
||||
$fingerprint = $request->getHeader('x-device-fingerprint');
|
||||
$timestamp = (int)$request->getHeader('x-timestamp');
|
||||
$nonce = $request->getHeader('x-nonce');
|
||||
$signature = $request->getHeader('x-signature');
|
||||
|
||||
// 1. Check presence of all required security headers
|
||||
if (empty($userId) || empty($fingerprint) || empty($timestamp) || empty($nonce) || empty($signature)) {
|
||||
Response::unauthorized('Security headers missing: X-User-Id, X-Device-Fingerprint, X-Timestamp, X-Nonce, and X-Signature are mandatory.');
|
||||
}
|
||||
|
||||
// 2. Anti-replay timestamp check (window of 120 seconds)
|
||||
$currentTime = time();
|
||||
if (abs($currentTime - $timestamp) > 120) {
|
||||
Response::unauthorized('Request timestamp expired or skewed. Anti-replay check failed.');
|
||||
}
|
||||
|
||||
// 3. Query user security record from Database
|
||||
try {
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT us.device_fingerprint, us.device_secret, us.is_locked, u.status
|
||||
FROM user_security us
|
||||
JOIN users u ON u.id = us.user_id
|
||||
WHERE us.user_id = :user_id AND us.device_fingerprint = :fingerprint
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([
|
||||
':user_id' => $userId,
|
||||
':fingerprint' => $fingerprint,
|
||||
]);
|
||||
$secRecord = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$secRecord) {
|
||||
Response::forbidden('Unrecognized device fingerprint or unauthorized device binding.');
|
||||
}
|
||||
|
||||
if ((int)$secRecord['is_locked'] === 1 || in_array($secRecord['status'], ['SUSPENDED', 'BANNED'], true)) {
|
||||
Response::forbidden('User device or account has been locked or suspended.');
|
||||
}
|
||||
|
||||
// 4. Verify HMAC-SHA256 signature
|
||||
$rawBody = $request->getRawBody();
|
||||
$bodyHash = hash('sha256', $rawBody);
|
||||
$signPayload = implode("\n", [
|
||||
$request->getMethod(),
|
||||
$request->getPath(),
|
||||
(string)$timestamp,
|
||||
$nonce,
|
||||
$bodyHash,
|
||||
]);
|
||||
|
||||
if (!Security::verifyHmacSignature($signPayload, $signature, $secRecord['device_secret'])) {
|
||||
Response::unauthorized('Invalid cryptographic signature for this device and payload.');
|
||||
}
|
||||
|
||||
// 5. Update last active timestamp and IP asynchronously / inline
|
||||
$updateStmt = $pdo->prepare('
|
||||
UPDATE user_security
|
||||
SET last_active_at = NOW(), last_ip = :ip
|
||||
WHERE user_id = :user_id AND device_fingerprint = :fingerprint
|
||||
');
|
||||
$updateStmt->execute([
|
||||
':ip' => $request->getIp(),
|
||||
':user_id' => $userId,
|
||||
':fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Response::error('Security verification error: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
$next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class NabihCaptchaService
|
||||
{
|
||||
/**
|
||||
* Generates a unique, distorted Captcha OTP image with noise to bypass Meta template restrictions.
|
||||
* Returns the raw PNG binary or base64 encoded data.
|
||||
*/
|
||||
public static function generateOtpImage(string $code, int $width = 240, int $height = 80): string
|
||||
{
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
|
||||
// 1. Generate randomized subtle background tint
|
||||
$bgR = random_int(235, 250);
|
||||
$bgG = random_int(245, 255);
|
||||
$bgB = random_int(240, 250);
|
||||
$bgColor = imagecolorallocate($image, $bgR, $bgG, $bgB);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $bgColor);
|
||||
|
||||
// 2. Add random noise dots
|
||||
for ($i = 0; $i < 120; $i++) {
|
||||
$dotColor = imagecolorallocate(
|
||||
$image,
|
||||
random_int(120, 200),
|
||||
random_int(180, 230),
|
||||
random_int(150, 210)
|
||||
);
|
||||
imagesetpixel($image, random_int(0, $width), random_int(0, $height), $dotColor);
|
||||
}
|
||||
|
||||
// 3. Add random interference crossing lines
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$lineColor = imagecolorallocate(
|
||||
$image,
|
||||
random_int(80, 160),
|
||||
random_int(180, 220),
|
||||
random_int(140, 190)
|
||||
);
|
||||
imagesetthickness($image, random_int(1, 2));
|
||||
imageline(
|
||||
$image,
|
||||
random_int(0, $width),
|
||||
random_int(0, $height),
|
||||
random_int(0, $width),
|
||||
random_int(0, $height),
|
||||
$lineColor
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Render Digits with randomized spacing and slight position jitter
|
||||
$textColor = imagecolorallocate($image, 15, 60, 50); // Deep Teal / Charcoal
|
||||
$len = strlen($code);
|
||||
$spacing = (int)($width / ($len + 1));
|
||||
|
||||
for ($idx = 0; $idx < $len; $idx++) {
|
||||
$char = $code[$idx];
|
||||
$x = ($idx + 1) * $spacing - 10 + random_int(-3, 3);
|
||||
$y = (int)($height / 2) - 10 + random_int(-4, 4);
|
||||
|
||||
// Using standard built-in font for zero dependencies
|
||||
imagestring($image, 5, $x, $y, $char, $textColor);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
$imageData = ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
return (string)$imageData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class OtpIqService
|
||||
{
|
||||
private string $apiKey;
|
||||
private string $senderId;
|
||||
private string $endpoint;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = getenv('OTPIQ_API_KEY') ?: '';
|
||||
$this->senderId = getenv('OTPIQ_SENDER_ID') ?: 'URUK-PRIZE';
|
||||
$this->endpoint = getenv('OTPIQ_ENDPOINT') ?: 'https://api.otpiq.com/api/sms';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends OTP via OTPIQ with Smart Fallback (WhatsApp first, then SMS).
|
||||
*/
|
||||
public function sendOtp(string $phoneNumber, string $otpCode): array
|
||||
{
|
||||
// If API key is not configured, run in Mock / Dev mode
|
||||
if (empty($this->apiKey)) {
|
||||
error_log("[MOCK OTPIQ] Sent OTP {$otpCode} to {$phoneNumber} via WhatsApp/SMS Smart Fallback.");
|
||||
return [
|
||||
'success' => true,
|
||||
'mode' => 'mock',
|
||||
'phone' => $phoneNumber,
|
||||
'otp' => $otpCode,
|
||||
'provider' => 'mock-smart-fallback',
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'recipient' => $phoneNumber,
|
||||
'sender_id' => $this->senderId,
|
||||
'type' => 'otp',
|
||||
'code' => $otpCode,
|
||||
'message' => "رمز التحقق الخاص بك لجائزة أوروك الدولية هو: {$otpCode}. لا تشاركه مع أحد.",
|
||||
'channels' => ['whatsapp', 'sms'], // Smart fallback pipeline
|
||||
];
|
||||
|
||||
$ch = curl_init($this->endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->apiKey,
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error || $httpCode >= 400) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $error ?: "HTTP {$httpCode}: " . substr((string)$response, 0, 100),
|
||||
];
|
||||
}
|
||||
|
||||
$data = json_decode((string)$response, true);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class PhoneFormatterService
|
||||
{
|
||||
/**
|
||||
* Normalizes a phone number to standard E.164 digits format (without '+').
|
||||
* Automatically handles leading zeros and national dialing prefixes.
|
||||
*
|
||||
* Example Iraq: 07701234567 -> 9647701234567
|
||||
* Example Iraq: +964 0780 1234 -> 9647801234
|
||||
* Example Jordan: 0791234567 -> 962791234567
|
||||
*/
|
||||
public static function normalize(string $phone, string $defaultCountryCode = '964'): string
|
||||
{
|
||||
// 1. Remove all non-digit characters (+, -, spaces, dots, brackets)
|
||||
$cleaned = preg_replace('/[^\d]/', '', $phone);
|
||||
|
||||
if (empty($cleaned)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 2. Remove international double-zero prefixes (e.g. 00964 -> 964)
|
||||
if (str_starts_with($cleaned, '00')) {
|
||||
$cleaned = substr($cleaned, 2);
|
||||
}
|
||||
|
||||
// 3. Supported Country Code Detection & Cleaning
|
||||
$knownCountryCodes = ['964', '962', '20', '961', '966', '971'];
|
||||
|
||||
foreach ($knownCountryCodes as $cc) {
|
||||
if (str_starts_with($cleaned, $cc)) {
|
||||
$localPart = substr($cleaned, strlen($cc));
|
||||
// Remove any redundant leading zero after country code (e.g. 964 0770... -> 964 770...)
|
||||
$localPart = ltrim($localPart, '0');
|
||||
return $cc . $localPart;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If no recognized country code is attached, treat as national number with default country code
|
||||
// Remove leading national zero (e.g. 0770... -> 770...)
|
||||
$nationalPart = ltrim($cleaned, '0');
|
||||
|
||||
return $defaultCountryCode . $nationalPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats normalized number for display in UI.
|
||||
* e.g. 9647701234567 -> +964 770 123 4567
|
||||
*/
|
||||
public static function formatDisplay(string $normalized): string
|
||||
{
|
||||
if (str_starts_with($normalized, '964') && strlen($normalized) === 13) {
|
||||
return '+964 ' . substr($normalized, 3, 3) . ' ' . substr($normalized, 6, 3) . ' ' . substr($normalized, 9);
|
||||
}
|
||||
if (str_starts_with($normalized, '962') && strlen($normalized) === 12) {
|
||||
return '+962 ' . substr($normalized, 3, 2) . ' ' . substr($normalized, 5, 3) . ' ' . substr($normalized, 8);
|
||||
}
|
||||
return '+' . $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Core\Security;
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class QrTokenService
|
||||
{
|
||||
/**
|
||||
* Generates a dynamic QR token valid for 90 seconds.
|
||||
*/
|
||||
public static function generateToken(int $userId): array
|
||||
{
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT s.id AS sub_id, s.membership_number, s.status, s.qr_seed, s.expires_at, u.full_name
|
||||
FROM subscriptions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.user_id = :uid AND s.status = "ACTIVE"
|
||||
ORDER BY s.id DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':uid' => $userId]);
|
||||
$sub = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$sub) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'No active membership found for this user.',
|
||||
];
|
||||
}
|
||||
|
||||
$appConfig = require __DIR__ . '/../../config/app.php';
|
||||
$secConfig = require __DIR__ . '/../../config/security.php';
|
||||
|
||||
$secret = $appConfig['secret'] . ':' . $sub['qr_seed'];
|
||||
$lifetime = (int)($secConfig['qr_token_lifetime'] ?? 90);
|
||||
|
||||
$token = Security::generateDynamicQrToken(
|
||||
$userId,
|
||||
$sub['membership_number'],
|
||||
$lifetime,
|
||||
$secret
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'membership_number' => $sub['membership_number'],
|
||||
'full_name' => $sub['full_name'],
|
||||
'expires_in' => $lifetime,
|
||||
'valid_until' => time() + $lifetime,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a QR token scanned by a hospital or hotel receptionist.
|
||||
*/
|
||||
public static function verifyScannedToken(string $token, int $partnerId, ?string $staffName = null): array
|
||||
{
|
||||
// Decode base64 payload to get user ID without signature first
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 2) {
|
||||
return ['success' => false, 'message' => 'Invalid QR token format.'];
|
||||
}
|
||||
|
||||
$json = base64_decode(strtr($parts[0], '-_', '+/'));
|
||||
$tempPayload = json_decode((string)$json, true);
|
||||
|
||||
if (!isset($tempPayload['uid'], $tempPayload['mem'])) {
|
||||
return ['success' => false, 'message' => 'Corrupted token payload.'];
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT s.id AS sub_id, s.membership_number, s.status, s.qr_seed, s.expires_at,
|
||||
u.id AS user_id, u.full_name, u.phone, u.avatar_url
|
||||
FROM subscriptions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE u.id = :uid AND s.membership_number = :mem
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([
|
||||
':uid' => (int)$tempPayload['uid'],
|
||||
':mem' => $tempPayload['mem'],
|
||||
]);
|
||||
$record = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$record) {
|
||||
return ['success' => false, 'message' => 'Membership record not found.'];
|
||||
}
|
||||
|
||||
$appConfig = require __DIR__ . '/../../config/app.php';
|
||||
$secret = $appConfig['secret'] . ':' . $record['qr_seed'];
|
||||
|
||||
$verified = Security::verifyDynamicQrToken($token, $secret);
|
||||
if (!$verified) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Token has expired or signature is counterfeit. Please refresh code in app.',
|
||||
];
|
||||
}
|
||||
|
||||
// Get partner discount details
|
||||
$pStmt = $pdo->prepare('SELECT id, name_ar, type, discount_percentage FROM partners WHERE id = :pid LIMIT 1');
|
||||
$pStmt->execute([':pid' => $partnerId]);
|
||||
$partner = $pStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$discount = $partner ? (float)$partner['discount_percentage'] : 50.00;
|
||||
|
||||
// Log verification entry for audit & statistics
|
||||
$logStmt = $pdo->prepare('
|
||||
INSERT INTO verification_logs (subscription_id, partner_id, verifier_staff_name, applied_discount, service_type, scanned_token_signature, verified_at)
|
||||
VALUES (:sub_id, :pid, :staff, :discount, :stype, :sig, NOW())
|
||||
');
|
||||
$logStmt->execute([
|
||||
':sub_id' => $record['sub_id'],
|
||||
':pid' => $partnerId,
|
||||
':staff' => $staffName ?: 'Front Desk',
|
||||
':discount' => $discount,
|
||||
':stype' => $partner['type'] ?? 'HOSPITAL',
|
||||
':sig' => substr($parts[1], 0, 64),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'is_valid' => true,
|
||||
'membership_number' => $record['membership_number'],
|
||||
'member_name' => $record['full_name'],
|
||||
'member_phone' => $record['phone'],
|
||||
'status' => $record['status'],
|
||||
'expires_at' => $record['expires_at'],
|
||||
'partner_name' => $partner['name_ar'] ?? 'Partner',
|
||||
'discount_percentage' => $discount,
|
||||
'verified_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Core\Database;
|
||||
use PDO;
|
||||
|
||||
class SuperQiParserService
|
||||
{
|
||||
/**
|
||||
* Ingests and processes a notification or parsed receipt from the Android listener bridge.
|
||||
*/
|
||||
public static function processIngestedNotification(array $data): array
|
||||
{
|
||||
$refNumber = trim((string)($data['reference_number'] ?? ''));
|
||||
$amount = (float)($data['amount'] ?? 0);
|
||||
$sender = trim((string)($data['sender'] ?? ''));
|
||||
$recipient = trim((string)($data['recipient'] ?? ''));
|
||||
$rawText = (string)($data['raw_text'] ?? '');
|
||||
|
||||
if (empty($refNumber) || $amount <= 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Invalid transaction reference or amount.',
|
||||
];
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
|
||||
// 1. Check if transaction has already been recorded and verified
|
||||
$stmt = $pdo->prepare('SELECT id, status FROM transactions WHERE reference_number = :ref LIMIT 1');
|
||||
$stmt->execute([':ref' => $refNumber]);
|
||||
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existing && $existing['status'] === 'VERIFIED_AUTO') {
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Transaction already processed and verified previously.',
|
||||
'transaction_id' => $existing['id'],
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Search for a pending subscription matching this reference or pending payment
|
||||
// First, check if any user submitted this reference number
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT t.id AS trans_id, t.subscription_id, s.user_id, s.plan_name, u.phone, u.full_name
|
||||
FROM transactions t
|
||||
JOIN subscriptions s ON s.id = t.subscription_id
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE t.reference_number = :ref AND t.status = "SUBMITTED"
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':ref' => $refNumber]);
|
||||
$matched = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($matched) {
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Update transaction
|
||||
$updTrans = $pdo->prepare('
|
||||
UPDATE transactions
|
||||
SET status = "VERIFIED_AUTO", amount = :amount, verified_at = NOW(),
|
||||
raw_webhook_payload = :raw
|
||||
WHERE id = :id
|
||||
');
|
||||
$updTrans->execute([
|
||||
':amount' => $amount,
|
||||
':raw' => json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||
':id' => $matched['trans_id'],
|
||||
]);
|
||||
|
||||
// Activate subscription
|
||||
$updSub = $pdo->prepare('
|
||||
UPDATE subscriptions
|
||||
SET status = "ACTIVE", starts_at = CURDATE(), expires_at = DATE_ADD(CURDATE(), INTERVAL 2 YEAR)
|
||||
WHERE id = :sub_id
|
||||
');
|
||||
$updSub->execute([':sub_id' => $matched['subscription_id']]);
|
||||
|
||||
// Ensure user status is active
|
||||
$updUser = $pdo->prepare('UPDATE users SET status = "ACTIVE" WHERE id = :user_id');
|
||||
$updUser->execute([':user_id' => $matched['user_id']]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'matched' => true,
|
||||
'message' => 'Subscription activated automatically for ' . $matched['full_name'],
|
||||
'user_id' => $matched['user_id'],
|
||||
'phone' => $matched['phone'],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// If no user has claimed it yet, record it in transactions as unassigned credit
|
||||
$insStmt = $pdo->prepare('
|
||||
INSERT INTO transactions (subscription_id, user_id, method, reference_number, sender_account_or_phone, recipient_account, amount, currency, status, raw_webhook_payload, verified_at)
|
||||
VALUES (0, 0, "SUPER_QI", :ref, :sender, :recipient, :amount, "IQD", "VERIFIED_AUTO", :raw, NOW())
|
||||
ON DUPLICATE KEY UPDATE amount = VALUES(amount), raw_webhook_payload = VALUES(raw_webhook_payload)
|
||||
');
|
||||
$insStmt->execute([
|
||||
':ref' => $refNumber,
|
||||
':sender' => $sender,
|
||||
':recipient' => $recipient,
|
||||
':amount' => $amount,
|
||||
':raw' => json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'matched' => false,
|
||||
'message' => 'Transaction recorded. Awaiting user claim in app.',
|
||||
'reference_number' => $refNumber,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "uruk/prize-backend",
|
||||
"description": "Uruk International Prize Platform - Pure PHP Core Engine",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Hamza Ayed",
|
||||
"role": "Founding Tech Architect"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_mysql": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-json": "*",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"guzzlehttp/guzzle": "^7.8"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Core\\": "core/",
|
||||
"App\\": "app/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'name' => getenv('APP_NAME') ?: 'Uruk International Prize',
|
||||
'env' => getenv('APP_ENV') ?: 'development',
|
||||
'debug' => (bool)(getenv('APP_DEBUG') ?: true),
|
||||
'url' => getenv('APP_URL') ?: 'http://localhost:8000',
|
||||
'secret' => getenv('APP_SECRET') ?: 'uruk_default_super_secret_signing_key_32_chars_min',
|
||||
'timezone' => 'Asia/Baghdad',
|
||||
'currency' => 'IQD',
|
||||
'membership_price_usd' => 100.00,
|
||||
'membership_price_iqd' => 132000.00,
|
||||
'membership_duration_years' => 2,
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'host' => getenv('DB_HOST') ?: '127.0.0.1',
|
||||
'port' => (int)(getenv('DB_PORT') ?: 3306),
|
||||
'database' => getenv('DB_DATABASE') ?: 'uruk_prize_db',
|
||||
'username' => getenv('DB_USERNAME') ?: 'root',
|
||||
'password' => getenv('DB_PASSWORD') ?: '',
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'options' => [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
|
||||
'port' => (int)(getenv('REDIS_PORT') ?: 6379),
|
||||
'password' => getenv('REDIS_PASSWORD') ?: null,
|
||||
'database' => (int)(getenv('REDIS_DB') ?: 0),
|
||||
'timeout' => 2.0,
|
||||
'prefix' => 'uruk:',
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
// Header names required for every authenticated request
|
||||
'headers' => [
|
||||
'user_id' => 'HTTP_X_USER_ID',
|
||||
'fingerprint' => 'HTTP_X_DEVICE_FINGERPRINT',
|
||||
'timestamp' => 'HTTP_X_TIMESTAMP',
|
||||
'nonce' => 'HTTP_X_NONCE',
|
||||
'signature' => 'HTTP_X_SIGNATURE',
|
||||
],
|
||||
// Time tolerance in seconds for anti-replay check
|
||||
'timestamp_tolerance' => 120, // 2 minutes
|
||||
// Dynamic QR token lifetime in seconds
|
||||
'qr_token_lifetime' => 90, // 90 seconds rotation
|
||||
// Encryption cipher
|
||||
'cipher' => 'aes-256-gcm',
|
||||
// Password hash algorithm
|
||||
'password_algo' => defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT,
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
|
||||
public static function getConnection(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
$config = require __DIR__ . '/../config/database.php';
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['database'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
self::$instance = new PDO(
|
||||
$dsn,
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['options']
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
throw new RuntimeException('Database Connection Failed: ' . $e->getMessage(), (int)$e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
abstract class Middleware
|
||||
{
|
||||
abstract public function handle(Request $request, callable $next): void;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
use Redis;
|
||||
use Throwable;
|
||||
|
||||
class RedisClient
|
||||
{
|
||||
private static ?Redis $instance = null;
|
||||
private static bool $connectionFailed = false;
|
||||
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
|
||||
public static function getInstance(): ?Redis
|
||||
{
|
||||
if (self::$connectionFailed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::$instance === null) {
|
||||
if (!class_exists('Redis')) {
|
||||
self::$connectionFailed = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../config/redis.php';
|
||||
$redis = new Redis();
|
||||
$connected = $redis->connect($config['host'], $config['port'], (float)$config['timeout']);
|
||||
|
||||
if ($connected) {
|
||||
if (!empty($config['password'])) {
|
||||
$redis->auth($config['password']);
|
||||
}
|
||||
if (!empty($config['database'])) {
|
||||
$redis->select((int)$config['database']);
|
||||
}
|
||||
$redis->setOption(Redis::OPT_PREFIX, $config['prefix']);
|
||||
self::$instance = $redis;
|
||||
} else {
|
||||
self::$connectionFailed = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
self::$connectionFailed = true;
|
||||
error_log('[Redis Error] ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get(string $key): ?string
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return null;
|
||||
$val = $redis->get($key);
|
||||
return $val !== false ? (string)$val : null;
|
||||
}
|
||||
|
||||
public static function set(string $key, string $value, int $ttlSeconds = 0): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
return $ttlSeconds > 0 ? (bool)$redis->setex($key, $ttlSeconds, $value) : (bool)$redis->set($key, $value);
|
||||
}
|
||||
|
||||
public static function del(string $key): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
return (bool)$redis->del($key);
|
||||
}
|
||||
|
||||
public static function has(string $key): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
return (bool)$redis->exists($key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Request
|
||||
{
|
||||
private string $method;
|
||||
private string $path;
|
||||
private array $headers;
|
||||
private array $queryParams;
|
||||
private array $body;
|
||||
private string $rawBody;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$this->path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$this->queryParams = $_GET ?? [];
|
||||
$this->headers = $this->extractHeaders();
|
||||
|
||||
$this->rawBody = file_get_contents('php://input') ?: '';
|
||||
$contentType = $this->getHeader('content-type');
|
||||
|
||||
if (str_contains($contentType, 'application/json') && !empty($this->rawBody)) {
|
||||
$decoded = json_decode($this->rawBody, true);
|
||||
$this->body = is_array($decoded) ? $decoded : [];
|
||||
} else {
|
||||
$this->body = $_POST ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
private function extractHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$headerName = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||
$headers[$headerName] = (string)$value;
|
||||
} elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) {
|
||||
$headerName = strtolower(str_replace('_', '-', $key));
|
||||
$headers[$headerName] = (string)$value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function getHeader(string $name, string $default = ''): string
|
||||
{
|
||||
$name = strtolower($name);
|
||||
return $this->headers[$name] ?? $default;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function getQueryParams(): array
|
||||
{
|
||||
return $this->queryParams;
|
||||
}
|
||||
|
||||
public function getQuery(string $key, $default = null)
|
||||
{
|
||||
return $this->queryParams[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getBody(): array
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
return $this->body[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getRawBody(): string
|
||||
{
|
||||
return $this->rawBody;
|
||||
}
|
||||
|
||||
public function getIp(): string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
return $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
return trim($ips[0]);
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Response
|
||||
{
|
||||
public static function json(array $data, int $statusCode = 200, array $headers = []): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
|
||||
$defaultHeaders = [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-Frame-Options' => 'DENY',
|
||||
'X-XSS-Protection' => '1; mode=block',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-User-Id, X-Device-Fingerprint, X-Timestamp, X-Nonce, X-Signature',
|
||||
];
|
||||
|
||||
foreach (array_merge($defaultHeaders, $headers) as $name => $val) {
|
||||
header("$name: $val");
|
||||
}
|
||||
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function success(mixed $data = null, string $message = 'Success', int $statusCode = 200): void
|
||||
{
|
||||
self::json([
|
||||
'success' => true,
|
||||
'status' => 'success',
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'timestamp' => time(),
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
public static function error(string $message = 'An error occurred', int $statusCode = 400, array $errors = []): void
|
||||
{
|
||||
self::json([
|
||||
'success' => false,
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
'errors' => $errors,
|
||||
'timestamp' => time(),
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
public static function unauthorized(string $message = 'Unauthorized access'): void
|
||||
{
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
public static function forbidden(string $message = 'Forbidden'): void
|
||||
{
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
public static function notFound(string $message = 'Resource not found'): void
|
||||
{
|
||||
self::error($message, 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
public function get(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function post(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function put(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function delete(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function options(string $path, array|callable $handler): void
|
||||
{
|
||||
$this->addRoute('OPTIONS', $path, $handler, []);
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, array|callable $handler, array $middlewares): void
|
||||
{
|
||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'pattern' => $pattern,
|
||||
'handler' => $handler,
|
||||
'middlewares' => $middlewares,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(Request $request): void
|
||||
{
|
||||
// Handle preflight CORS OPTIONS requests immediately
|
||||
if ($request->getMethod() === 'OPTIONS') {
|
||||
Response::json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
$method = $request->getMethod();
|
||||
$path = $request->getPath();
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($route['pattern'], $path, $matches)) {
|
||||
$params = [];
|
||||
foreach ($matches as $key => $value) {
|
||||
if (is_string($key)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$this->runMiddlewares($route['middlewares'], $request, function () use ($route, $request, $params) {
|
||||
$this->executeHandler($route['handler'], $request, $params);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Response::notFound("Endpoint not found: [{$method}] {$path}");
|
||||
}
|
||||
|
||||
private function runMiddlewares(array $middlewares, Request $request, callable $target): void
|
||||
{
|
||||
$pipeline = array_reduce(
|
||||
array_reverse($middlewares),
|
||||
function ($next, $middlewareClass) {
|
||||
return function ($req) use ($next, $middlewareClass) {
|
||||
$instance = new $middlewareClass();
|
||||
$instance->handle($req, $next);
|
||||
};
|
||||
},
|
||||
$target
|
||||
);
|
||||
|
||||
$pipeline($request);
|
||||
}
|
||||
|
||||
private function executeHandler(array|callable $handler, Request $request, array $params): void
|
||||
{
|
||||
if (is_callable($handler)) {
|
||||
call_user_func($handler, $request, $params);
|
||||
return;
|
||||
}
|
||||
|
||||
[$controllerClass, $methodName] = $handler;
|
||||
if (!class_exists($controllerClass)) {
|
||||
Response::error("Controller class {$controllerClass} not found", 500);
|
||||
}
|
||||
|
||||
$controller = new $controllerClass();
|
||||
if (!method_exists($controller, $methodName)) {
|
||||
Response::error("Method {$methodName} not found on controller {$controllerClass}", 500);
|
||||
}
|
||||
|
||||
$controller->$methodName($request, $params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Security
|
||||
{
|
||||
public static function hashPassword(string $password): string
|
||||
{
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
|
||||
return password_hash($password, $algo);
|
||||
}
|
||||
|
||||
public static function verifyPassword(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
public static function generateRandomHex(int $bytes = 32): string
|
||||
{
|
||||
return bin2hex(random_bytes($bytes));
|
||||
}
|
||||
|
||||
public static function generateHmacSignature(string $data, string $secretKey): string
|
||||
{
|
||||
return hash_hmac('sha256', $data, $secretKey);
|
||||
}
|
||||
|
||||
public static function verifyHmacSignature(string $data, string $signature, string $secretKey): bool
|
||||
{
|
||||
$calculated = self::generateHmacSignature($data, $secretKey);
|
||||
return hash_equals($calculated, $signature);
|
||||
}
|
||||
|
||||
public static function encryptAesGcm(string $plaintext, string $key): string
|
||||
{
|
||||
$cipher = 'aes-256-gcm';
|
||||
$keyHash = hash('sha256', $key, true);
|
||||
$iv = random_bytes(12); // 96-bit IV recommended for GCM
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
$cipher,
|
||||
$keyHash,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
'',
|
||||
16
|
||||
);
|
||||
|
||||
return base64_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
public static function decryptAesGcm(string $encryptedPackage, string $key): ?string
|
||||
{
|
||||
$data = base64_decode($encryptedPackage, true);
|
||||
if (!$data || strlen($data) < 28) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cipher = 'aes-256-gcm';
|
||||
$keyHash = hash('sha256', $key, true);
|
||||
|
||||
$iv = substr($data, 0, 12);
|
||||
$tag = substr($data, 12, 16);
|
||||
$ciphertext = substr($data, 28);
|
||||
|
||||
$decrypted = openssl_decrypt(
|
||||
$ciphertext,
|
||||
$cipher,
|
||||
$keyHash,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
return $decrypted !== false ? $decrypted : null;
|
||||
}
|
||||
|
||||
public static function generateDynamicQrToken(int $userId, string $membershipNumber, int $expiresInSeconds, string $secretKey): string
|
||||
{
|
||||
$payload = [
|
||||
'uid' => $userId,
|
||||
'mem' => $membershipNumber,
|
||||
'exp' => time() + $expiresInSeconds,
|
||||
'rnd' => self::generateRandomHex(8),
|
||||
];
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
$base64Payload = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
$signature = self::generateHmacSignature($base64Payload, $secretKey);
|
||||
|
||||
return $base64Payload . '.' . $signature;
|
||||
}
|
||||
|
||||
public static function verifyDynamicQrToken(string $token, string $secretKey): ?array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$base64Payload, $signature] = $parts;
|
||||
|
||||
if (!self::verifyHmacSignature($base64Payload, $signature, $secretKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = base64_decode(strtr($base64Payload, '-_', '+/'));
|
||||
if (!$json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($json, true);
|
||||
if (!is_array($payload) || !isset($payload['exp']) || $payload['exp'] < time()) {
|
||||
return null; // Expired or invalid format
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Uruk International Prize - Deploy & Git Sync Script
|
||||
# Developed for: Hamza Ayed (Founding Tech Architect)
|
||||
# ==============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}======================================================${NC}"
|
||||
echo -e "${BLUE} 🚀 Uruk Prize Platform - Deploy & Sync Pipeline ${NC}"
|
||||
echo -e "${BLUE}======================================================${NC}"
|
||||
|
||||
# Check if git is initialized
|
||||
if [ ! -d ".git" ] && [ ! -d "../.git" ]; then
|
||||
echo -e "${YELLOW}⚡ Initializing local Git repository...${NC}"
|
||||
git init
|
||||
fi
|
||||
|
||||
# 1. Staging changes
|
||||
echo -e "${BLUE}[1/4] Staging files...${NC}"
|
||||
git add -A
|
||||
|
||||
# 2. Committing with message or auto-timestamp
|
||||
COMMIT_MSG=${1:-"Update Uruk Prize Core & Security Layer - $(date '+%Y-%m-%d %H:%M:%S')"}
|
||||
echo -e "${BLUE}[2/4] Committing: ${YELLOW}'$COMMIT_MSG'${NC}"
|
||||
git commit -m "$COMMIT_MSG" || echo -e "${YELLOW}No changes to commit.${NC}"
|
||||
|
||||
# 3. Pushing to Remote Git Repository
|
||||
echo -e "${BLUE}[3/4] Pushing to Git remote...${NC}"
|
||||
if git remote | grep -q 'origin'; then
|
||||
git push origin main || git push origin master
|
||||
echo -e "${GREEN}✓ Successfully pushed to remote Git.${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ No remote 'origin' set yet. Run: git remote add origin <url>${NC}"
|
||||
fi
|
||||
|
||||
# 4. Server Sync instructions / trigger
|
||||
echo -e "${BLUE}[4/4] Remote Server Synchronization...${NC}"
|
||||
echo -e "${GREEN}✓ Local changes committed and ready for server pull.${NC}"
|
||||
echo -e "${BLUE}On your server, run:${NC}"
|
||||
echo -e " cd /path/to/uruk-prize && git pull origin main && composer install --no-dev --optimize-autoloader"
|
||||
echo -e "${GREEN}======================================================${NC}"
|
||||
@@ -0,0 +1,6 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Set default timezone
|
||||
date_default_timezone_set('Asia/Baghdad');
|
||||
|
||||
// 1. PSR-4 Autoloader Implementation with Composer fallback
|
||||
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
} else {
|
||||
spl_autoload_register(function (string $class) {
|
||||
$prefixes = [
|
||||
'Core\\' => __DIR__ . '/../core/',
|
||||
'App\\' => __DIR__ . '/../app/',
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix => $baseDir) {
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Load Environment Variables if Dotenv exists or read .env manually
|
||||
$envFile = __DIR__ . '/../.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), '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$name, $val] = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$val = trim($val, " \t\n\r\0\x0B\"'");
|
||||
if (!getenv($name)) {
|
||||
putenv("$name=$val");
|
||||
$_ENV[$name] = $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use Core\Request;
|
||||
use Core\Response;
|
||||
use Core\Router;
|
||||
use App\Middlewares\SecurityHeaderMiddleware;
|
||||
use App\Middlewares\AuthMiddleware;
|
||||
use App\Middlewares\AdminMiddleware;
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\SubscriptionController;
|
||||
use App\Controllers\QrController;
|
||||
use App\Controllers\PartnerController;
|
||||
use App\Controllers\PaymentController;
|
||||
use App\Controllers\AdminController;
|
||||
use App\Controllers\GatewayController;
|
||||
|
||||
$request = new Request();
|
||||
$router = new Router();
|
||||
|
||||
// ==============================================================================
|
||||
// PUBLIC ROUTES (No Strict Fingerprint Header Required for Initial Onboarding)
|
||||
// ==============================================================================
|
||||
$router->get('/api/v1/health', function (Request $req) {
|
||||
Response::success([
|
||||
'service' => 'Uruk International Prize Core Engine',
|
||||
'version' => '1.0.0',
|
||||
'server_time' => date('Y-m-d H:i:s'),
|
||||
'status' => 'operational',
|
||||
]);
|
||||
});
|
||||
|
||||
// Authentication routes
|
||||
$router->post('/api/v1/auth/request-otp', [AuthController::class, 'requestOtp']);
|
||||
$router->post('/api/v1/auth/verify-otp', [AuthController::class, 'verifyOtp']);
|
||||
|
||||
// Public directory of hospitals & hotels
|
||||
$router->get('/api/v1/partners', [PartnerController::class, 'listPartners']);
|
||||
$router->get('/api/v1/partners/{id}', [PartnerController::class, 'getPartner']);
|
||||
|
||||
// Public membership plan details
|
||||
$router->get('/api/v1/subscription/plans', [SubscriptionController::class, 'getPlans']);
|
||||
|
||||
// Reception QR verification scanner (used by hospitals/hotels)
|
||||
$router->post('/api/v1/qr/verify-token', [QrController::class, 'verifyToken']);
|
||||
|
||||
// Automated Payment Listeners & Webhooks
|
||||
$router->post('/api/v1/payments/ingest-notification', [PaymentController::class, 'ingestNotification']);
|
||||
$router->post('/api/v1/payments/swiftpay-webhook', [PaymentController::class, 'swiftPayWebhook']);
|
||||
|
||||
// ==============================================================================
|
||||
// GATEWAY CALLER & SMS CLUSTER (Zain, Asiacell, Korek 3-Device Gateway Nodes)
|
||||
// ==============================================================================
|
||||
$router->get('/api/v1/gateway/pending-call', [GatewayController::class, 'pendingCall']);
|
||||
$router->get('/pending-call.php', [GatewayController::class, 'pendingCall']);
|
||||
|
||||
$router->get('/api/v1/gateway/pending-sms', [GatewayController::class, 'pendingSms']);
|
||||
$router->get('/pending-sms.php', [GatewayController::class, 'pendingSms']);
|
||||
|
||||
$router->post('/api/v1/gateway/call-done', [GatewayController::class, 'callDone']);
|
||||
$router->post('/call-done.php', [GatewayController::class, 'callDone']);
|
||||
|
||||
$router->post('/api/v1/gateway/sms-done', [GatewayController::class, 'smsDone']);
|
||||
$router->post('/sms-done.php', [GatewayController::class, 'smsDone']);
|
||||
|
||||
$router->post('/api/v1/gateway/register-device', [GatewayController::class, 'registerDevice']);
|
||||
$router->post('/register-device.php', [GatewayController::class, 'registerDevice']);
|
||||
|
||||
// ==============================================================================
|
||||
// SECURE USER ROUTES (Requires X-User-Id, X-Device-Fingerprint, Signature & Auth)
|
||||
// ==============================================================================
|
||||
|
||||
$userMiddlewares = [SecurityHeaderMiddleware::class, AuthMiddleware::class];
|
||||
|
||||
$router->get('/api/v1/auth/profile', [AuthController::class, 'getProfile'], $userMiddlewares);
|
||||
$router->get('/api/v1/subscription/my-card', [SubscriptionController::class, 'getMyCard'], $userMiddlewares);
|
||||
$router->post('/api/v1/subscription/submit', [SubscriptionController::class, 'submitSubscription'], $userMiddlewares);
|
||||
$router->get('/api/v1/qr/dynamic-token', [QrController::class, 'getDynamicToken'], $userMiddlewares);
|
||||
|
||||
// ==============================================================================
|
||||
// HIDDEN IN-APP ADMIN ROUTES (Dr. Thamer & Management)
|
||||
// ==============================================================================
|
||||
$adminMiddlewares = [SecurityHeaderMiddleware::class, AuthMiddleware::class, AdminMiddleware::class];
|
||||
|
||||
$router->post('/api/v1/admin/partners/add', [AdminController::class, 'addPartner'], $adminMiddlewares);
|
||||
$router->get('/api/v1/admin/payments/pending', [AdminController::class, 'listPendingPayments'], $adminMiddlewares);
|
||||
$router->post('/api/v1/admin/payments/approve', [AdminController::class, 'approvePayment'], $adminMiddlewares);
|
||||
$router->get('/api/v1/admin/stats', [AdminController::class, 'getStats'], $adminMiddlewares);
|
||||
|
||||
// Dispatch incoming request
|
||||
$router->dispatch($request);
|
||||
Reference in New Issue
Block a user