first commit

This commit is contained in:
Hamza-Ayed
2026-05-23 16:17:20 +03:00
commit 2bbaa1ee16
195 changed files with 11126 additions and 0 deletions

27
backend/.htaccess Normal file
View File

@@ -0,0 +1,27 @@
# Protect includes directory
<IfModule mod_rewrite.c>
RewriteEngine On
# Block direct access to includes/
RewriteRule ^includes/ - [F,L]
# Block access to config files
RewriteRule ^config\.php$ - [F,L]
# Block access to hidden files
RewriteRule (^|/)\. - [F,L]
# Block access to SQL files
RewriteRule \.sql$ - [F,L]
# Block access to log files
RewriteRule \.log$ - [F,L]
</IfModule>
# Disable directory listing
Options -Indexes
# Prevent script execution in includes
<Directory "includes">
php_flag engine off
</Directory>

121
backend/api/call-done.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
/**
* POST /api/call-done
*
* Called by Caller Android App after a flash call attempt.
*
* Request body:
* {
* "task_id": 42,
* "device_id": "DEVICE_XXX",
* "app_key": "SECRET_DEVICE_KEY",
* "result": "success" | "failed" | "busy" | "no_answer"
* }
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires device key
Auth::requireAuth('device');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['task_id']) || !isset($input['device_id']) || !isset($input['result'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_required_fields']);
RequestLogger::log('call-done', 'POST', $input, 400, 'missing_fields');
exit;
}
$taskId = (int) $input['task_id'];
$deviceId = trim($input['device_id']);
$result = trim($input['result']);
// Validate result
$validResults = ['success', 'failed', 'busy', 'no_answer'];
if (!in_array($result, $validResults, true)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_result_value']);
RequestLogger::log('call-done', 'POST', $input, 400, 'invalid_result');
exit;
}
$db = Database::getInstance();
try {
// Verify this task belongs to this device
$stmt = $db->prepare(
"SELECT id, status FROM otp_requests WHERE id = ? AND device_id = ?"
);
$stmt->execute([$taskId, $deviceId]);
$task = $stmt->fetch();
if (!$task) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'task_not_found']);
RequestLogger::log('call-done', 'POST', $input, 404, 'task_not_found');
exit;
}
if ($task['status'] !== 'calling') {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'task_not_in_calling_state']);
RequestLogger::log('call-done', 'POST', $input, 409, 'wrong_status');
exit;
}
// Map result to new status
$newStatus = ($result === 'success') ? 'completed' : 'failed';
$db->beginTransaction();
// Update OTP request status
$stmt = $db->prepare(
"UPDATE otp_requests
SET status = ?, updated_at = NOW()
WHERE id = ? AND device_id = ?"
);
$stmt->execute([$newStatus, $taskId, $deviceId]);
// Increment calls_today for the device
$stmt = $db->prepare(
"UPDATE caller_devices
SET calls_today = calls_today + 1
WHERE device_id = ?"
);
$stmt->execute([$deviceId]);
$db->commit();
echo json_encode([
'success' => true,
'status' => $newStatus,
]);
RequestLogger::log('call-done', 'POST', $input, 200);
} catch (\Throwable $e) {
$db->rollBack();
error_log('call-done error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'internal_error']);
RequestLogger::log('call-done', 'POST', $input, 500, $e->getMessage());
}

View File

@@ -0,0 +1,124 @@
<?php
/**
* GET /api/pending-call
*
* Called by Caller Android App every 3 seconds to fetch the next pending flash call task.
*
* Query params:
* device_id — Registered device identifier
* app_key — Device authentication key
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires device key (Caller Android App)
Auth::requireAuth('device');
$deviceId = $_GET['device_id'] ?? null;
if (!$deviceId) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_device_id']);
RequestLogger::log('pending-call', 'GET', $_GET, 400, 'missing_device_id');
exit;
}
$db = Database::getInstance();
try {
// Verify device exists and is active
$stmt = $db->prepare(
"SELECT id, device_id, is_active FROM caller_devices WHERE device_id = ?"
);
$stmt->execute([$deviceId]);
$device = $stmt->fetch();
if (!$device || !$device['is_active']) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'device_not_registered_or_inactive']);
RequestLogger::log('pending-call', 'GET', $_GET, 403, 'invalid_device');
exit;
}
// Update last_seen
$stmt = $db->prepare("UPDATE caller_devices SET last_seen = NOW() WHERE device_id = ?");
$stmt->execute([$deviceId]);
// Find oldest pending flash call task
// Priority: tasks assigned to this device first, then unassigned tasks
$stmt = $db->prepare(
"SELECT id, phone, caller_id, expires_at
FROM otp_requests
WHERE method = 'flash_call'
AND status = 'pending'
AND expires_at > NOW()
AND (device_id IS NULL OR device_id = ?)
ORDER BY
CASE WHEN device_id = ? THEN 0 ELSE 1 END,
created_at ASC
LIMIT 1"
);
$stmt->execute([$deviceId, $deviceId]);
$task = $stmt->fetch();
if (!$task) {
echo json_encode(['success' => true, 'task_id' => null]);
RequestLogger::log('pending-call', 'GET', $_GET, 200);
exit;
}
// Claim this task — update status and assign device
$stmt = $db->prepare(
"UPDATE otp_requests
SET status = 'calling', device_id = ?, updated_at = NOW()
WHERE id = ? AND status = 'pending'"
);
$stmt->execute([$deviceId, $task['id']]);
// Check if update affected any row (race condition handling)
if ($stmt->rowCount() === 0) {
// Another device claimed it first
echo json_encode(['success' => true, 'task_id' => null]);
RequestLogger::log('pending-call', 'GET', $_GET, 200);
exit;
}
// Calculate remaining timeout
$expiresAt = new \DateTime($task['expires_at']);
$now = new \DateTime();
$timeoutSeconds = max(10, $expiresAt->getTimestamp() - $now->getTimestamp());
echo json_encode([
'success' => true,
'task_id' => (int) $task['id'],
'phone' => $task['phone'],
'caller_id' => $task['caller_id'],
'timeout_seconds' => $timeoutSeconds,
]);
RequestLogger::log('pending-call', 'GET', $_GET, 200);
} catch (\Throwable $e) {
error_log('pending-call error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'internal_error']);
RequestLogger::log('pending-call', 'GET', $_GET, 500, $e->getMessage());
}

121
backend/api/pending-sms.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
/**
* GET /api/pending-sms
*
* Called by Caller Android App every 3 seconds to fetch the next pending SMS task.
*
* Query params:
* device_id — Registered device identifier
* app_key — Device authentication key
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires device key
Auth::requireAuth('device');
$deviceId = $_GET['device_id'] ?? null;
if (!$deviceId) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_device_id']);
RequestLogger::log('pending-sms', 'GET', $_GET, 400, 'missing_device_id');
exit;
}
$db = Database::getInstance();
try {
// Verify device exists and is active
$stmt = $db->prepare(
"SELECT id, device_id, is_active FROM caller_devices WHERE device_id = ?"
);
$stmt->execute([$deviceId]);
$device = $stmt->fetch();
if (!$device || !$device['is_active']) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'device_not_registered_or_inactive']);
RequestLogger::log('pending-sms', 'GET', $_GET, 403, 'invalid_device');
exit;
}
// Update last_seen
$stmt = $db->prepare("UPDATE caller_devices SET last_seen = NOW() WHERE device_id = ?");
$stmt->execute([$deviceId]);
// Find oldest pending SMS task
$stmt = $db->prepare(
"SELECT id, phone, otp_code, expires_at
FROM otp_requests
WHERE method = 'sms'
AND status = 'pending_sms'
AND expires_at > NOW()
AND (device_id IS NULL OR device_id = ?)
ORDER BY
CASE WHEN device_id = ? THEN 0 ELSE 1 END,
created_at ASC
LIMIT 1"
);
$stmt->execute([$deviceId, $deviceId]);
$task = $stmt->fetch();
if (!$task) {
echo json_encode(['success' => true, 'task_id' => null]);
RequestLogger::log('pending-sms', 'GET', $_GET, 200);
exit;
}
// Claim this task
$stmt = $db->prepare(
"UPDATE otp_requests
SET status = 'calling', device_id = ?, updated_at = NOW()
WHERE id = ? AND status = 'pending_sms'"
);
$stmt->execute([$deviceId, $task['id']]);
if ($stmt->rowCount() === 0) {
echo json_encode(['success' => true, 'task_id' => null]);
RequestLogger::log('pending-sms', 'GET', $_GET, 200);
exit;
}
// Calculate remaining timeout
$expiresAt = new \DateTime($task['expires_at']);
$now = new \DateTime();
$timeoutSeconds = max(10, $expiresAt->getTimestamp() - $now->getTimestamp());
echo json_encode([
'success' => true,
'task_id' => (int) $task['id'],
'phone' => $task['phone'],
'otp' => $task['otp_code'],
'timeout_seconds' => $timeoutSeconds,
]);
RequestLogger::log('pending-sms', 'GET', $_GET, 200);
} catch (\Throwable $e) {
error_log('pending-sms error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'internal_error']);
RequestLogger::log('pending-sms', 'GET', $_GET, 500, $e->getMessage());
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* POST /api/register-device
*
* Register a Caller Android device.
*
* Request body:
* {
* "device_id": "DEVICE_XXX",
* "phone_number": "+9627XXXXXXXX",
* "sim_slot": 0,
* "app_key": "SECRET_DEVICE_KEY"
* }
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires device key
Auth::requireAuth('device');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['device_id']) || !isset($input['phone_number'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_required_fields']);
RequestLogger::log('register-device', 'POST', $input, 400, 'missing_fields');
exit;
}
$deviceId = trim($input['device_id']);
$phoneNumber = trim($input['phone_number']);
$simSlot = isset($input['sim_slot']) ? (int) $input['sim_slot'] : 0;
// Validate device_id
if (strlen($deviceId) < 5 || strlen($deviceId) > 50) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_device_id_length']);
RequestLogger::log('register-device', 'POST', $input, 400, 'invalid_device_id');
exit;
}
// Validate phone format
if (!preg_match('/^\+[1-9]\d{6,14}$/', $phoneNumber)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_phone_format']);
RequestLogger::log('register-device', 'POST', $input, 400, 'invalid_phone');
exit;
}
// Validate sim_slot
if ($simSlot < 0 || $simSlot > 3) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_sim_slot']);
RequestLogger::log('register-device', 'POST', $input, 400, 'invalid_sim_slot');
exit;
}
$db = Database::getInstance();
try {
// Check if device already registered
$stmt = $db->prepare("SELECT id, is_active FROM caller_devices WHERE device_id = ?");
$stmt->execute([$deviceId]);
$existing = $stmt->fetch();
if ($existing) {
// Update existing device (re-registration)
$stmt = $db->prepare(
"UPDATE caller_devices
SET phone_number = ?, sim_slot = ?, is_active = 1, last_seen = NOW()
WHERE device_id = ?"
);
$stmt->execute([$phoneNumber, $simSlot, $deviceId]);
echo json_encode([
'success' => true,
'message' => 'device_updated',
'device_id' => $deviceId,
]);
} else {
// Insert new device
$stmt = $db->prepare(
"INSERT INTO caller_devices (device_id, phone_number, sim_slot, is_active, last_seen, calls_today, created_at)
VALUES (?, ?, ?, 1, NOW(), 0, NOW())"
);
$stmt->execute([$deviceId, $phoneNumber, $simSlot]);
echo json_encode([
'success' => true,
'message' => 'device_registered',
'device_id' => $deviceId,
]);
}
RequestLogger::log('register-device', 'POST', $input, 200);
} catch (\Throwable $e) {
error_log('register-device error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'internal_error']);
RequestLogger::log('register-device', 'POST', $input, 500, $e->getMessage());
}

190
backend/api/request-otp.php Normal file
View File

@@ -0,0 +1,190 @@
<?php
/**
* POST /api/request-otp
*
* Request an OTP to be delivered via flash call (Android) or SMS (iOS).
*
* Request body:
* {
* "phone": "+9627XXXXXXXX",
* "app_key": "SECRET_APP_KEY",
* "device_type": "android" | "ios" (optional, defaults to "android")
* }
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Redis.php';
require_once __DIR__ . '/../includes/RateLimit.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires app key (Flutter app)
Auth::requireAuth('app');
// Parse request body
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['phone'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_phone']);
RequestLogger::log('request-otp', 'POST', $input, 400, 'missing_phone');
exit;
}
$phone = trim($input['phone']);
$deviceType = isset($input['device_type']) ? strtolower(trim($input['device_type'])) : 'android';
// Validate device_type
if (!in_array($deviceType, ['android', 'ios'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_device_type']);
RequestLogger::log('request-otp', 'POST', $input, 400, 'invalid_device_type');
exit;
}
// Validate phone format (E.164)
if (!preg_match('/^\+[1-9]\d{6,14}$/', $phone)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => 'invalid_phone_format',
'hint' => 'Phone must be in E.164 format, e.g. +9627XXXXXXXX',
]);
RequestLogger::log('request-otp', 'POST', $input, 400, 'invalid_phone_format');
exit;
}
// Rate limit check: max 3 requests per phone per 10 minutes
$rateLimit = new RateLimit();
if (!$rateLimit->check("otp:{$phone}")) {
$remaining = $rateLimit->remaining("otp:{$phone}");
$ttl = $rateLimit->ttl("otp:{$phone}");
http_response_code(429);
echo json_encode([
'success' => false,
'message' => 'rate_limit_exceeded',
'retry_after' => $ttl,
'remaining' => $remaining,
]);
RequestLogger::log('request-otp', 'POST', $input, 429, 'rate_limit_exceeded');
exit;
}
// IP-based rate limiting
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (!$rateLimit->checkIp($clientIp, 'request-otp', 30, 60)) {
http_response_code(429);
echo json_encode(['success' => false, 'message' => 'ip_rate_limit_exceeded']);
RequestLogger::log('request-otp', 'POST', $input, 429, 'ip_rate_limit');
exit;
}
// Generate 4-digit OTP (cryptographically secure)
$otpCode = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
// Determine delivery method
$method = ($deviceType === 'ios') ? 'sms' : 'flash_call';
$db = Database::getInstance();
$redis = RedisClient::getInstance();
try {
$db->beginTransaction();
if ($method === 'flash_call') {
// Find available caller device (round-robin)
$stmt = $db->prepare(
"SELECT device_id, phone_number, sim_slot
FROM caller_devices
WHERE is_active = 1
ORDER BY calls_today ASC, last_seen DESC
LIMIT 1"
);
$stmt->execute();
$device = $stmt->fetch();
if (!$device) {
$db->rollBack();
http_response_code(503);
echo json_encode([
'success' => false,
'message' => 'no_caller_devices_available',
]);
RequestLogger::log('request-otp', 'POST', $input, 503, 'no_caller_devices');
exit;
}
// Build caller_id: +96279XX{OTP}
$randomDigits = str_pad((string) random_int(0, 99), 2, '0', STR_PAD_LEFT);
$callerId = CALLER_ID_PREFIX . $randomDigits . $otpCode;
// Insert OTP request
$expiresAt = date('Y-m-d H:i:s', time() + OTP_EXPIRE_SECONDS);
$stmt = $db->prepare(
"INSERT INTO otp_requests (phone, otp_code, caller_id, status, device_id, method, expires_at)
VALUES (?, ?, ?, 'pending', ?, 'flash_call', ?)"
);
$stmt->execute([$phone, $otpCode, $callerId, $device['device_id'], $expiresAt]);
} else {
// SMS delivery — no specific caller_id needed for the OTP request
$expiresAt = date('Y-m-d H:i:s', time() + OTP_EXPIRE_SECONDS);
$stmt = $db->prepare(
"INSERT INTO otp_requests (phone, otp_code, caller_id, status, method, expires_at)
VALUES (?, ?, '', 'pending_sms', 'sms', ?)"
);
$stmt->execute([$phone, $otpCode, $expiresAt]);
}
$otpId = $db->lastInsertId();
$db->commit();
// Store OTP in Redis with TTL
$redisKey = "otp:{$phone}";
$redis->setex($redisKey, OTP_EXPIRE_SECONDS, json_encode([
'otp' => $otpCode,
'method' => $method,
'attempts' => 0,
'created' => time(),
]));
// Response
$response = [
'success' => true,
'otp_id' => $otpId,
'otp' => $otpCode,
'expires_in' => OTP_EXPIRE_SECONDS,
'method' => $method,
];
if ($method === 'flash_call' && isset($device)) {
$response['caller_device_id'] = $device['device_id'];
}
echo json_encode($response);
RequestLogger::log('request-otp', 'POST', $input, 200);
} catch (\Throwable $e) {
$db->rollBack();
error_log('request-otp error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'internal_error']);
RequestLogger::log('request-otp', 'POST', $input, 500, $e->getMessage());
}

120
backend/api/sms-done.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
/**
* POST /api/sms-done
*
* Called by Caller Android App after sending an SMS OTP.
*
* Request body:
* {
* "task_id": 43,
* "device_id": "DEVICE_XXX",
* "app_key": "SECRET_DEVICE_KEY",
* "result": "success" | "failed"
* }
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires device key
Auth::requireAuth('device');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['task_id']) || !isset($input['device_id']) || !isset($input['result'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_required_fields']);
RequestLogger::log('sms-done', 'POST', $input, 400, 'missing_fields');
exit;
}
$taskId = (int) $input['task_id'];
$deviceId = trim($input['device_id']);
$result = trim($input['result']);
// Validate result
$validResults = ['success', 'failed'];
if (!in_array($result, $validResults, true)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_result_value']);
RequestLogger::log('sms-done', 'POST', $input, 400, 'invalid_result');
exit;
}
$db = Database::getInstance();
try {
// Verify this task belongs to this device
$stmt = $db->prepare(
"SELECT id, status, method FROM otp_requests WHERE id = ? AND device_id = ?"
);
$stmt->execute([$taskId, $deviceId]);
$task = $stmt->fetch();
if (!$task) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'task_not_found']);
RequestLogger::log('sms-done', 'POST', $input, 404, 'task_not_found');
exit;
}
if ($task['status'] !== 'calling') {
http_response_code(409);
echo json_encode(['success' => false, 'message' => 'task_not_in_calling_state']);
RequestLogger::log('sms-done', 'POST', $input, 409, 'wrong_status');
exit;
}
$newStatus = ($result === 'success') ? 'completed' : 'failed';
$db->beginTransaction();
// Update OTP request status
$stmt = $db->prepare(
"UPDATE otp_requests
SET status = ?, updated_at = NOW()
WHERE id = ? AND device_id = ?"
);
$stmt->execute([$newStatus, $taskId, $deviceId]);
// Increment calls_today (counts both calls and SMS)
$stmt = $db->prepare(
"UPDATE caller_devices
SET calls_today = calls_today + 1
WHERE device_id = ?"
);
$stmt->execute([$deviceId]);
$db->commit();
echo json_encode([
'success' => true,
'status' => $newStatus,
]);
RequestLogger::log('sms-done', 'POST', $input, 200);
} catch (\Throwable $e) {
$db->rollBack();
error_log('sms-done error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'internal_error']);
RequestLogger::log('sms-done', 'POST', $input, 500, $e->getMessage());
}

154
backend/api/verify-otp.php Normal file
View File

@@ -0,0 +1,154 @@
<?php
/**
* POST /api/verify-otp
*
* Verify an OTP code submitted by the user (Flutter app).
*
* Request body:
* {
* "phone": "+9627XXXXXXXX",
* "otp": "4829",
* "app_key": "SECRET_APP_KEY"
* }
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Redis.php';
require_once __DIR__ . '/../includes/RateLimit.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires app key
Auth::requireAuth('app');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['phone']) || !isset($input['otp'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_phone_or_otp']);
RequestLogger::log('verify-otp', 'POST', $input, 400, 'missing_fields');
exit;
}
$phone = trim($input['phone']);
$otp = trim($input['otp']);
// Validate phone format
if (!preg_match('/^\+[1-9]\d{6,14}$/', $phone)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_phone_format']);
RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_phone');
exit;
}
// Validate OTP format (4 digits)
if (!preg_match('/^\d{4}$/', $otp)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_otp_format']);
RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_otp_format');
exit;
}
$redis = RedisClient::getInstance();
$db = Database::getInstance();
$redisKey = "otp:{$phone}";
// Check if OTP exists in Redis
$stored = $redis->get($redisKey);
if ($stored === false || $stored === null) {
// OTP expired or never existed
echo json_encode([
'success' => false,
'message' => 'expired',
]);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'expired');
exit;
}
// Decode stored data
if (is_string($stored)) {
$otpData = json_decode($stored, true);
} else {
$otpData = $stored;
}
if (!is_array($otpData) || !isset($otpData['otp'])) {
// Corrupted data — clean up
$redis->del($redisKey);
echo json_encode(['success' => false, 'message' => 'expired']);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'corrupted_data');
exit;
}
// Check max attempts
if (isset($otpData['attempts']) && (int) $otpData['attempts'] >= MAX_OTP_ATTEMPTS) {
$redis->del($redisKey);
echo json_encode([
'success' => false,
'message' => 'max_attempts',
]);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'max_attempts');
exit;
}
// Increment attempt counter
$otpData['attempts'] = (int) ($otpData['attempts'] ?? 0) + 1;
$ttl = $redis->ttl($redisKey);
if ($ttl > 0) {
$redis->setex($redisKey, $ttl, json_encode($otpData));
}
// Timing-safe comparison
if (hash_equals($otpData['otp'], $otp)) {
// Success — clean up Redis
$redis->del($redisKey);
// Update database
try {
$stmt = $db->prepare(
"UPDATE otp_requests
SET verified_at = NOW(), status = 'verified'
WHERE phone = ? AND otp_code = ? AND verified_at IS NULL
ORDER BY created_at DESC
LIMIT 1"
);
$stmt->execute([$phone, $otp]);
} catch (\Throwable $e) {
// Non-critical — OTP is already verified via Redis
error_log('verify-otp DB update error: ' . $e->getMessage());
}
echo json_encode([
'success' => true,
'message' => 'verified',
]);
RequestLogger::log('verify-otp', 'POST', $input, 200);
} else {
// Wrong OTP
$remainingAttempts = MAX_OTP_ATTEMPTS - (int) $otpData['attempts'];
echo json_encode([
'success' => false,
'message' => 'invalid_otp',
'remaining_attempts' => max(0, $remainingAttempts),
]);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'invalid_otp');
}

43
backend/config.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
/**
* Flash Call OTP System — Configuration
* Domain: otp.intaleqapp.com
*/
// Database
define('DB_HOST', 'localhost');
define('DB_NAME', 'otp_db');
define('DB_USER', 'otp_user');
define('DB_PASS', 'STRONG_PASSWORD');
// Redis
define('REDIS_HOST', '127.0.0.1');
define('REDIS_PORT', 6379);
define('REDIS_PASSWORD', null); // Set if Redis requires auth
define('REDIS_DB', 1); // Isolated DB for OTP system
// Application Keys
define('APP_KEY', 'f3a9e7c1b8d5f2a4c6e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1');
define('DEVICE_KEY', 'er4er4');
// SMS Sender ID (for iOS SMS delivery)
define('SMS_SENDER_ID', 'انطلق');
// OTP Settings
define('OTP_EXPIRE_SECONDS', 120);
define('MAX_OTP_ATTEMPTS', 5);
// Rate Limiting
define('RATE_LIMIT_WINDOW', 600); // 10 minutes in seconds
define('RATE_LIMIT_MAX', 3); // Max OTP requests per phone per window
// Caller ID Configuration
// Format: +96279XX{OTP} — XX = random 2 digits
define('CALLER_ID_PREFIX', '+96279');
// Logging
define('LOG_REQUESTS', true);
define('LOG_FILE', __DIR__ . '/logs/api.log');
// Timezone
date_default_timezone_set('Asia/Amman');

110
backend/database.sql Normal file
View File

@@ -0,0 +1,110 @@
-- ============================================
-- Flash Call OTP System — Database Setup
-- Domain: otp.intaleqapp.com
-- ============================================
CREATE DATABASE IF NOT EXISTS otpDb
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE otpDb;
-- OTP Requests Table
CREATE TABLE IF NOT EXISTS otp_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(20) NOT NULL,
otp_code VARCHAR(6) NOT NULL,
caller_id VARCHAR(20) NOT NULL DEFAULT '',
status ENUM('pending','pending_sms','calling','completed','failed','expired','verified') NOT NULL DEFAULT 'pending',
method ENUM('flash_call','sms') NOT NULL DEFAULT 'flash_call',
device_id VARCHAR(50) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
verified_at TIMESTAMP NULL,
INDEX idx_phone (phone),
INDEX idx_status (status),
INDEX idx_method_status (method, status),
INDEX idx_device_id (device_id),
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Caller Devices Table
CREATE TABLE IF NOT EXISTS caller_devices (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id VARCHAR(50) UNIQUE NOT NULL,
phone_number VARCHAR(20) NOT NULL,
sim_slot TINYINT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
last_seen TIMESTAMP NULL,
calls_today INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_active (is_active),
INDEX idx_calls_today (calls_today)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- API Request Logs Table
CREATE TABLE IF NOT EXISTS api_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
endpoint VARCHAR(50) NOT NULL,
method VARCHAR(10) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(500) NULL,
request_body TEXT NULL,
response_code SMALLINT NOT NULL DEFAULT 200,
error VARCHAR(500) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_endpoint (endpoint),
INDEX idx_created (created_at),
INDEX idx_ip (ip_address)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- Scheduled Cleanup (add to MySQL Event Scheduler)
-- ============================================
-- Enable event scheduler
SET GLOBAL event_scheduler = ON;
-- Expire old pending requests every 30 seconds
DELIMITER //
CREATE EVENT IF NOT EXISTS expire_otp_requests
ON SCHEDULE EVERY 30 SECOND
DO
BEGIN
UPDATE otp_requests
SET status = 'expired', updated_at = NOW()
WHERE status IN ('pending', 'pending_sms', 'calling')
AND expires_at < NOW();
END //
DELIMITER ;
-- Reset calls_today counter daily at midnight (Asia/Amman = UTC+3)
DELIMITER //
CREATE EVENT IF NOT EXISTS reset_daily_calls
ON SCHEDULE EVERY 1 DAY
STARTS CONCAT(CURDATE() + INTERVAL 1 DAY, ' 00:00:00')
DO
BEGIN
UPDATE caller_devices SET calls_today = 0;
END //
DELIMITER ;
-- Clean up old API logs (older than 30 days)
DELIMITER //
CREATE EVENT IF NOT EXISTS cleanup_api_logs
ON SCHEDULE EVERY 1 DAY
STARTS CONCAT(CURDATE() + INTERVAL 1 DAY, ' 03:00:00')
DO
BEGIN
DELETE FROM api_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);
END //
DELIMITER ;
-- ============================================
-- Grant permissions
-- ============================================
-- Run this separately with root access:
-- GRANT SELECT, INSERT, UPDATE, DELETE ON otpDb.* TO 'otpUser'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD';
-- FLUSH PRIVILEGES;
sfvo055OJV7VqPW25VEQ

96
backend/includes/Auth.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
/**
* Authentication — App Key Validation
*/
require_once __DIR__ . '/../config.php';
class Auth
{
/**
* Validate the app_key from request.
* Supports both Flutter app key and Caller device key.
*
* @param string|null $key The key provided in request
* @param string $required Which key type is required: 'app' | 'device' | 'any'
* @return bool
*/
public static function validate(?string $key, string $required = 'any'): bool
{
if ($key === null || $key === '') {
return false;
}
switch ($required) {
case 'app':
return hash_equals(APP_KEY, $key);
case 'device':
return hash_equals(DEVICE_KEY, $key);
case 'any':
return hash_equals(APP_KEY, $key) || hash_equals(DEVICE_KEY, $key);
default:
return false;
}
}
/**
* Extract app_key from request (header or body).
*/
public static function getKeyFromRequest(): ?string
{
// Check header first
$headerKey = $_SERVER['HTTP_X_APP_KEY']
?? $_SERVER['HTTP_APP_KEY']
?? null;
if ($headerKey !== null) {
return $headerKey;
}
// Check JSON body
$body = json_decode(file_get_contents('php://input'), true);
if (is_array($body) && isset($body['app_key'])) {
return $body['app_key'];
}
// Check POST data
if (isset($_POST['app_key'])) {
return $_POST['app_key'];
}
return null;
}
/**
* Require authentication — sends 401 and exits on failure.
*/
public static function requireAuth(string $required = 'any'): void
{
$key = self::getKeyFromRequest();
if (!self::validate($key, $required)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'invalid_app_key',
]);
exit;
}
}
/**
* Determine if the provided key is the device key.
*/
public static function isDeviceKey(?string $key): bool
{
return $key !== null && hash_equals(DEVICE_KEY, $key);
}
/**
* Determine if the provided key is the app key.
*/
public static function isAppKey(?string $key): bool
{
return $key !== null && hash_equals(APP_KEY, $key);
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* Database Singleton — PDO Connection
*/
require_once __DIR__ . '/../config.php';
class Database
{
private static ?PDO $instance = null;
private function __construct()
{
try {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
DB_HOST,
DB_NAME
);
self::$instance = new PDO($dsn, DB_USER, DB_PASS, [
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 time_zone = '+03:00'",
]);
} catch (PDOException $e) {
error_log('Database connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'database_error']);
exit;
}
}
public static function getInstance(): PDO
{
if (self::$instance === null) {
new self();
}
return self::$instance;
}
/** Prevent cloning */
private function __clone() {}
public function __wakeup()
{
throw new \Exception("Cannot unserialize singleton");
}
}

View File

@@ -0,0 +1,61 @@
<?php
/**
* Request Logger — Logs all API requests to MySQL
*/
require_once __DIR__ . '/Database.php';
class RequestLogger
{
/**
* Log an API request.
*/
public static function log(
string $endpoint,
string $method,
?array $requestBody = null,
int $responseCode = 200,
?string $error = null
): void {
if (!LOG_REQUESTS) {
return;
}
try {
$db = Database::getInstance();
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
$body = $requestBody ? json_encode($requestBody) : null;
// Mask sensitive fields
if ($body) {
$body = self::maskSensitive($body);
}
$stmt = $db->prepare(
"INSERT INTO api_logs (endpoint, method, ip_address, user_agent, request_body, response_code, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())"
);
$stmt->execute([$endpoint, $method, $ip, $userAgent, $body, $responseCode, $error]);
} catch (\Throwable $e) {
// Logging should never break the app
error_log("RequestLogger error: " . $e->getMessage());
}
}
/**
* Mask sensitive fields in request body.
*/
private static function maskSensitive(string $body): string
{
$sensitive = ['app_key', 'password', 'otp', 'otp_code'];
foreach ($sensitive as $field) {
$body = preg_replace(
'/"' . $field . '"\s*:\s*"[^"]*"/',
'"' . $field . '":"***"',
$body
);
}
return $body;
}
}

View File

@@ -0,0 +1,62 @@
<?php
/**
* Rate Limiting via Redis
*/
require_once __DIR__ . '/Redis.php';
class RateLimit
{
private \Redis $redis;
public function __construct()
{
$this->redis = RedisClient::getInstance();
}
/**
* Check and increment rate limit counter.
*
* @param string $key Identifier (e.g. "otp:+9627XXXXXXXX")
* @param int $limit Max requests allowed
* @param int $window Time window in seconds
* @return bool true = allowed, false = rate limited
*/
public function check(string $key, int $limit = RATE_LIMIT_MAX, int $window = RATE_LIMIT_WINDOW): bool
{
return true; // Disabled for stress testing
}
/**
* Get remaining requests for a key.
*/
public function remaining(string $key, int $limit = RATE_LIMIT_MAX): int
{
$redisKey = "rate_limit:{$key}";
$current = (int) $this->redis->get($redisKey);
return max(0, $limit - $current);
}
/**
* Get TTL of rate limit key.
*/
public function ttl(string $key): int
{
$redisKey = "rate_limit:{$key}";
return max(0, (int) $this->redis->ttl($redisKey));
}
/**
* General IP-based rate limiting for API endpoints.
*
* @param string $ip Client IP
* @param string $endpoint Endpoint name
* @param int $limit Max requests
* @param int $window Time window in seconds
* @return bool
*/
public function checkIp(string $ip, string $endpoint, int $limit = 60, int $window = 60): bool
{
return $this->check("ip:{$endpoint}:{$ip}", $limit, $window);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/**
* Redis Singleton — phpredis Extension
*/
require_once __DIR__ . '/../config.php';
class RedisClient
{
private static ?\Redis $instance = null;
private function __construct()
{
try {
self::$instance = new \Redis();
self::$instance->connect(REDIS_HOST, REDIS_PORT, 2.0);
if (REDIS_PASSWORD !== null) {
self::$instance->auth(REDIS_PASSWORD);
}
if (REDIS_DB > 0) {
self::$instance->select(REDIS_DB);
}
self::$instance->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_JSON);
} catch (\RedisException $e) {
$errorMsg = $e->getMessage();
error_log('Redis connection failed: ' . $errorMsg);
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'cache_error', 'error' => $errorMsg]);
exit;
} catch (\Exception $e) {
$errorMsg = $e->getMessage();
error_log('Unexpected error in Redis: ' . $errorMsg);
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'cache_error', 'error' => $errorMsg]);
exit;
}
}
public static function getInstance(): \Redis
{
if (self::$instance === null) {
new self();
}
return self::$instance;
}
private function __clone() {}
public function __wakeup()
{
throw new \Exception("Cannot unserialize singleton");
}
}

0
backend/logs/.gitkeep Normal file
View File

95
backend/nginx.conf Normal file
View File

@@ -0,0 +1,95 @@
server {
listen 80;
server_name otp.intaleqapp.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name otp.intaleqapp.com;
# SSL — Let's Encrypt
ssl_certificate /etc/letsencrypt/live/otp.intaleqapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/otp.intaleqapp.com/privkey.pem;
# SSL Hardening
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Document root
root /var/www/otp.intaleqapp.com/public;
index index.php;
# Logging
access_log /var/log/nginx/otp.intaleqapp.com.access.log;
error_log /var/log/nginx/otp.intaleqapp.com.error.log;
# API endpoints — route to PHP files
location /api/ {
try_files $uri $uri/ /api$uri.php?$query_string;
# PHP handling
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 30;
}
}
# Block direct access to includes/
location /includes/ {
deny all;
return 404;
}
# Block access to config
location /config.php {
deny all;
return 404;
}
# Block access to hidden files
location ~ /\. {
deny all;
return 404;
}
# Block access to SQL files
location ~ \.sql$ {
deny all;
return 404;
}
# Block access to log files
location ~ \.log$ {
deny all;
return 404;
}
# Health check endpoint
location /health {
access_log off;
return 200 '{"status":"ok"}';
add_header Content-Type application/json;
}
# Default PHP handling
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Deny everything else
location / {
return 404;
}
}