Files
flash-call-otp/backend/api/request-otp.php
2026-05-23 18:23:34 +03:00

247 lines
8.1 KiB
PHP

<?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';
require_once __DIR__ . '/../includes/WhatsApp.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 3-digit OTP (cryptographically secure, always between 100 and 999)
$otpCode = (string) random_int(100, 999);
// Determine delivery method
$method = 'flash_call'; // Default fallback
$methodInput = isset($input['method']) ? strtolower(trim($input['method'])) : null;
if ($methodInput && in_array($methodInput, ['flash_call', 'sms', 'whatsapp', 'telegram'], true)) {
$method = $methodInput;
} else {
$whatsappAvailable = false;
try {
$whatsappAvailable = WhatsAppClient::isAvailable($phone);
} catch (\Throwable $e) {
error_log('WhatsApp check failed: ' . $e->getMessage());
}
if ($whatsappAvailable) {
$method = 'whatsapp';
} else {
$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 if ($method === 'whatsapp') {
// WhatsApp delivery
$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_whatsapp', 'whatsapp', ?)"
);
$stmt->execute([$phone, $otpCode, $expiresAt]);
$otpId = $db->lastInsertId();
// Try to generate premium dynamic base64 OTP image
$imagePngBase64 = null;
try {
$imagePngBase64 = WhatsAppClient::generateOtpImageBase64($otpCode);
} catch (\Throwable $e) {
error_log('Failed to generate OTP image: ' . $e->getMessage());
}
// Message caption / body
$messageText = "رمز التحقق الخاص بك هو: " . $otpCode;
$sent = false;
try {
if ($imagePngBase64) {
// Send premium image message with NO caption
$sent = WhatsAppClient::sendMessage($phone, "", $imagePngBase64);
} else {
// Fallback to text message
$sent = WhatsAppClient::sendMessage($phone, $messageText);
}
} catch (\Throwable $e) {
error_log('WhatsApp sendMessage error: ' . $e->getMessage());
}
if (!$sent) {
throw new \Exception('Failed to send OTP via WhatsApp');
}
} 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());
}