first commit
This commit is contained in:
27
backend/.htaccess
Normal file
27
backend/.htaccess
Normal 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
121
backend/api/call-done.php
Normal 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());
|
||||
}
|
||||
124
backend/api/pending-call.php
Normal file
124
backend/api/pending-call.php
Normal 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
121
backend/api/pending-sms.php
Normal 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());
|
||||
}
|
||||
120
backend/api/register-device.php
Normal file
120
backend/api/register-device.php
Normal 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
190
backend/api/request-otp.php
Normal 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
120
backend/api/sms-done.php
Normal 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
154
backend/api/verify-otp.php
Normal 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
43
backend/config.php
Normal 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
110
backend/database.sql
Normal 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
96
backend/includes/Auth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
49
backend/includes/Database.php
Normal file
49
backend/includes/Database.php
Normal 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");
|
||||
}
|
||||
}
|
||||
61
backend/includes/Logger.php
Normal file
61
backend/includes/Logger.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
backend/includes/RateLimit.php
Normal file
62
backend/includes/RateLimit.php
Normal 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);
|
||||
}
|
||||
}
|
||||
55
backend/includes/Redis.php
Normal file
55
backend/includes/Redis.php
Normal 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
0
backend/logs/.gitkeep
Normal file
95
backend/nginx.conf
Normal file
95
backend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user