first commit
This commit is contained in:
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');
|
||||
}
|
||||
Reference in New Issue
Block a user