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

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');
}