Add SportPath auth APIs and training content foundation
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/Config.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/Database.php';
|
||||
|
||||
function api_json(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('X-Frame-Options: DENY');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function api_method(string $expected): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== $expected) {
|
||||
header('Allow: ' . $expected);
|
||||
api_json(['error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
if (!in_array($method, ['GET', 'PATCH'], true)) {
|
||||
header('Allow: GET, PATCH');
|
||||
api_json(['error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
$auth = ApiAuth::bearerClaims();
|
||||
ApiAuth::requireRole($auth, ['owner', 'content_manager']);
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
if ($method === 'GET') {
|
||||
$result = $db->getConnection()->query('SELECT setting_key, setting_value, is_public, revision, updated_at FROM app_settings ORDER BY setting_key');
|
||||
$settings = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$settings[] = [
|
||||
'key' => $row['setting_key'],
|
||||
'value' => json_decode($row['setting_value'], true),
|
||||
'is_public' => (bool) $row['is_public'],
|
||||
'revision' => (int) $row['revision'],
|
||||
'updated_at' => $row['updated_at'],
|
||||
];
|
||||
}
|
||||
api_json(['settings' => $settings]);
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
if (!is_array($body) || !is_array($body['settings'] ?? null) || count($body['settings']) > 100) {
|
||||
api_json(['error' => 'invalid_settings_payload'], 400);
|
||||
}
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
$read = $db->prepare('SELECT setting_value, is_public, revision FROM app_settings WHERE setting_key = ? FOR UPDATE');
|
||||
$write = $db->prepare('INSERT INTO app_settings (setting_key, setting_value, is_public, revision, updated_by) VALUES (?, ?, ?, 1, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), is_public = VALUES(is_public), revision = revision + 1, updated_by = VALUES(updated_by)');
|
||||
$audit = $db->prepare('INSERT INTO app_setting_audit (setting_key, previous_value, new_value, actor_user_id) VALUES (?, ?, ?, ?)');
|
||||
foreach ($body['settings'] as $item) {
|
||||
if (!is_array($item) || !is_string($item['key'] ?? null) || !preg_match('/^[a-z][a-z0-9_.-]{0,99}$/', $item['key']) || !array_key_exists('value', $item) || !is_bool($item['is_public'] ?? null)) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_setting'], 400);
|
||||
}
|
||||
$key = $item['key'];
|
||||
if (preg_match('/(secret|password|token|credential|private.?key|api.?key)/i', $key)) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'secrets_must_use_server_environment'], 400);
|
||||
}
|
||||
$encodedValue = json_encode($item['value'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($encodedValue === false || strlen($encodedValue) > 65535) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'setting_value_too_large'], 400);
|
||||
}
|
||||
$read->bind_param('s', $key);
|
||||
$read->execute();
|
||||
$previous = $read->get_result()->fetch_assoc();
|
||||
$isPublic = $item['is_public'] ? 1 : 0;
|
||||
$actor = $auth['user_id'];
|
||||
$write->bind_param('ssii', $key, $encodedValue, $isPublic, $actor);
|
||||
$write->execute();
|
||||
$previousValue = $previous['setting_value'] ?? null;
|
||||
$audit->bind_param('sssi', $key, $previousValue, $encodedValue, $actor);
|
||||
$audit->execute();
|
||||
}
|
||||
$read->close();
|
||||
$write->close();
|
||||
$audit->close();
|
||||
$connection->commit();
|
||||
api_json(['status' => 'updated']);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('Admin settings update failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
$auth = ApiAuth::bearerClaims();
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE session_uuid = ?');
|
||||
$stmt->bind_param('s', $auth['session_id']);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
api_json(['status' => 'signed_out']);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Session revocation failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
$refreshToken = is_array($body) ? ($body['refresh_token'] ?? null) : null;
|
||||
if (!is_string($refreshToken) || !preg_match('/^[a-f0-9]{96}$/', $refreshToken)) {
|
||||
api_json(['error' => 'invalid_refresh_token'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($jwtKey) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$db = Database::getInstance();
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
$digest = hash('sha256', $refreshToken);
|
||||
$query = $db->prepare('SELECT s.session_uuid, s.family_uuid, s.user_id, s.device_uuid, s.replaced_by, s.revoked_at, s.expires_at, u.uuid, u.phone_e164, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.refresh_token_digest = ? FOR UPDATE');
|
||||
$query->bind_param('s', $digest);
|
||||
$query->execute();
|
||||
$session = $query->get_result()->fetch_assoc();
|
||||
$query->close();
|
||||
if (!$session) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_refresh_token'], 401);
|
||||
}
|
||||
if ($session['replaced_by'] !== null) {
|
||||
$revoke = $db->prepare('UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP()) WHERE family_uuid = ?');
|
||||
$revoke->bind_param('s', $session['family_uuid']);
|
||||
$revoke->execute();
|
||||
$revoke->close();
|
||||
$connection->commit();
|
||||
api_json(['error' => 'refresh_token_reuse_detected'], 401);
|
||||
}
|
||||
if ($session['revoked_at'] !== null || $session['expires_at'] <= gmdate('Y-m-d H:i:s') || !(bool) $session['is_active']) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'session_expired'], 401);
|
||||
}
|
||||
|
||||
$newSessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$newRefresh = bin2hex(random_bytes(48));
|
||||
$newDigest = hash('sha256', $newRefresh);
|
||||
$refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30)));
|
||||
$insert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)');
|
||||
$insert->bind_param('ssissi', $newSessionId, $session['family_uuid'], $session['user_id'], $session['device_uuid'], $newDigest, $refreshDays);
|
||||
$insert->execute();
|
||||
$insert->close();
|
||||
$replace = $db->prepare('UPDATE auth_sessions SET last_used_at = UTC_TIMESTAMP(), replaced_by = ? WHERE session_uuid = ? AND replaced_by IS NULL');
|
||||
$replace->bind_param('ss', $newSessionId, $session['session_uuid']);
|
||||
$replace->execute();
|
||||
$replace->close();
|
||||
$connection->commit();
|
||||
|
||||
$ttl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900)));
|
||||
api_json([
|
||||
'user' => ['id' => (int) $session['user_id'], 'uuid' => $session['uuid'], 'phone_e164' => $session['phone_e164'], 'account_role' => $session['account_role']],
|
||||
'access_token' => JwtToken::issue((int) $session['user_id'], $newSessionId, $ttl),
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in_seconds' => $ttl,
|
||||
'refresh_token' => $newRefresh,
|
||||
'refresh_expires_in_days' => $refreshDays,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('Session refresh failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/OtpProvider.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
$phone = is_array($body) ? ($body['phone_e164'] ?? null) : null;
|
||||
$purpose = is_array($body) ? ($body['purpose'] ?? 'login') : 'login';
|
||||
$deviceUuid = is_array($body) ? ($body['device_uuid'] ?? null) : null;
|
||||
if (!is_string($phone) || !preg_match('/^\+[1-9][0-9]{7,14}$/', $phone)) {
|
||||
api_json(['error' => 'invalid_phone_e164'], 400);
|
||||
}
|
||||
if (!in_array($purpose, ['register', 'login'], true)) {
|
||||
api_json(['error' => 'invalid_purpose'], 400);
|
||||
}
|
||||
if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) {
|
||||
api_json(['error' => 'invalid_device_uuid'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
AppConfig::loadEnvironment();
|
||||
if (getenv('OTP_ENABLED') !== 'true') {
|
||||
api_json(['error' => 'otp_provider_not_configured'], 503);
|
||||
}
|
||||
$hashKey = AppConfig::required('OTP_HASH_KEY');
|
||||
if (strlen($hashKey) < 32) {
|
||||
throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes');
|
||||
}
|
||||
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($jwtKey) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$db = Database::getInstance();
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
|
||||
$rateBuckets = [
|
||||
['phone', hash_hmac('sha256', 'phone:' . $phone, $hashKey), 5, 3600],
|
||||
['ip', hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey), 20, 3600],
|
||||
];
|
||||
if ($deviceUuid !== null) {
|
||||
$rateBuckets[] = ['device', hash_hmac('sha256', 'device:' . $deviceUuid, $hashKey), 10, 3600];
|
||||
}
|
||||
foreach ($rateBuckets as [$bucketType, $digest, $limit, $windowSeconds]) {
|
||||
$stmt = $db->prepare('INSERT INTO auth_rate_limit_buckets (bucket_digest, bucket_type, window_started_at, request_count) VALUES (?, ?, UTC_TIMESTAMP(), 1) ON DUPLICATE KEY UPDATE request_count = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, 1, request_count + 1), window_started_at = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, UTC_TIMESTAMP(), window_started_at)');
|
||||
$stmt->bind_param('ssii', $digest, $bucketType, $windowSeconds, $windowSeconds);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
$check = $db->prepare('SELECT request_count FROM auth_rate_limit_buckets WHERE bucket_digest = ?');
|
||||
$check->bind_param('s', $digest);
|
||||
$check->execute();
|
||||
$count = (int) $check->get_result()->fetch_assoc()['request_count'];
|
||||
$check->close();
|
||||
if ($count > $limit) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'rate_limited'], 429);
|
||||
}
|
||||
}
|
||||
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$challengeUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$codeDigest = hash_hmac('sha256', $challengeUuid . '|' . $phone . '|' . $code, $hashKey);
|
||||
$ipDigest = hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey);
|
||||
$insert = $db->prepare('INSERT INTO otp_challenges (challenge_uuid, phone_e164, purpose, code_digest, request_ip_digest, device_uuid, expires_at) VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL 5 MINUTE)');
|
||||
$insert->bind_param('ssssss', $challengeUuid, $phone, $purpose, $codeDigest, $ipDigest, $deviceUuid);
|
||||
$insert->execute();
|
||||
$insert->close();
|
||||
$connection->commit();
|
||||
|
||||
(new ConfiguredHttpOtpProvider())->send($phone, $code);
|
||||
api_json(['challenge_id' => $challengeUuid, 'expires_in_seconds' => 300]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli && $connection->errno === 0) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('OTP request failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'otp_delivery_failed'], 503);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 4) . '/backend/OtpProvider.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
|
||||
require_once dirname(__DIR__) . '/_bootstrap.php';
|
||||
api_method('POST');
|
||||
|
||||
$body = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
$challengeId = is_array($body) ? ($body['challenge_id'] ?? null) : null;
|
||||
$code = is_array($body) ? ($body['code'] ?? null) : null;
|
||||
$displayName = is_array($body) ? ($body['display_name'] ?? null) : null;
|
||||
$deviceUuid = is_array($body) ? ($body['device_uuid'] ?? null) : null;
|
||||
$platform = is_array($body) ? ($body['platform'] ?? null) : null;
|
||||
if (!is_string($challengeId) || !preg_match('/^[0-9a-f-]{36}$/i', $challengeId) || !is_string($code) || !preg_match('/^[0-9]{6}$/', $code)) {
|
||||
api_json(['error' => 'invalid_verification_payload'], 400);
|
||||
}
|
||||
if ($displayName !== null && (!is_string($displayName) || mb_strlen($displayName) > 100)) {
|
||||
api_json(['error' => 'invalid_display_name'], 400);
|
||||
}
|
||||
if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) {
|
||||
api_json(['error' => 'invalid_device_uuid'], 400);
|
||||
}
|
||||
if ($platform !== null && !in_array($platform, ['ios', 'android', 'web'], true)) {
|
||||
api_json(['error' => 'invalid_platform'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
AppConfig::loadEnvironment();
|
||||
$hashKey = AppConfig::required('OTP_HASH_KEY');
|
||||
if (strlen($hashKey) < 32) {
|
||||
throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes');
|
||||
}
|
||||
$jwtKey = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($jwtKey) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$db = Database::getInstance();
|
||||
$connection = $db->getConnection();
|
||||
$connection->begin_transaction();
|
||||
$challengeQuery = $db->prepare('SELECT challenge_uuid, phone_e164, purpose, code_digest, attempt_count, max_attempts, device_uuid FROM otp_challenges WHERE challenge_uuid = ? AND consumed_at IS NULL AND expires_at > UTC_TIMESTAMP() FOR UPDATE');
|
||||
$challengeQuery->bind_param('s', $challengeId);
|
||||
$challengeQuery->execute();
|
||||
$challenge = $challengeQuery->get_result()->fetch_assoc();
|
||||
$challengeQuery->close();
|
||||
if (!$challenge || (int) $challenge['attempt_count'] >= (int) $challenge['max_attempts']) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_or_expired_challenge'], 400);
|
||||
}
|
||||
if ($deviceUuid !== null && $challenge['device_uuid'] !== null && !hash_equals($challenge['device_uuid'], $deviceUuid)) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_verification_payload'], 400);
|
||||
}
|
||||
|
||||
$expectedDigest = hash_hmac('sha256', $challengeId . '|' . $challenge['phone_e164'] . '|' . $code, $hashKey);
|
||||
if (!hash_equals($challenge['code_digest'], $expectedDigest)) {
|
||||
$fail = $db->prepare('UPDATE otp_challenges SET attempt_count = attempt_count + 1 WHERE challenge_uuid = ?');
|
||||
$fail->bind_param('s', $challengeId);
|
||||
$fail->execute();
|
||||
$fail->close();
|
||||
$connection->commit();
|
||||
api_json(['error' => 'invalid_code'], 400);
|
||||
}
|
||||
|
||||
$consume = $db->prepare('UPDATE otp_challenges SET consumed_at = UTC_TIMESTAMP() WHERE challenge_uuid = ? AND consumed_at IS NULL');
|
||||
$consume->bind_param('s', $challengeId);
|
||||
$consume->execute();
|
||||
if ($consume->affected_rows !== 1) {
|
||||
$consume->close();
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'invalid_or_expired_challenge'], 400);
|
||||
}
|
||||
$consume->close();
|
||||
|
||||
$userQuery = $db->prepare('SELECT id, uuid, account_role, is_active FROM users WHERE phone_e164 = ? LIMIT 1 FOR UPDATE');
|
||||
$userQuery->bind_param('s', $challenge['phone_e164']);
|
||||
$userQuery->execute();
|
||||
$user = $userQuery->get_result()->fetch_assoc();
|
||||
$userQuery->close();
|
||||
if (!$user && $challenge['purpose'] === 'login') {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'verification_failed'], 400);
|
||||
}
|
||||
if ($user && !(bool) $user['is_active']) {
|
||||
$connection->rollback();
|
||||
api_json(['error' => 'account_inactive'], 403);
|
||||
}
|
||||
if (!$user) {
|
||||
$userUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$userInsert = $db->prepare('INSERT INTO users (uuid, phone_e164, phone_verified_at, full_name, account_role, is_active) VALUES (?, ?, UTC_TIMESTAMP(), ?, \'member\', 1)');
|
||||
$fullName = $displayName ?: 'مستخدم SportPath';
|
||||
$userInsert->bind_param('sss', $userUuid, $challenge['phone_e164'], $fullName);
|
||||
$userInsert->execute();
|
||||
$userId = (int) $connection->insert_id;
|
||||
$userInsert->close();
|
||||
$user = ['id' => $userId, 'uuid' => $userUuid, 'account_role' => 'member'];
|
||||
} else {
|
||||
$userId = (int) $user['id'];
|
||||
$verified = $db->prepare('UPDATE users SET phone_verified_at = COALESCE(phone_verified_at, UTC_TIMESTAMP()) WHERE id = ?');
|
||||
$verified->bind_param('i', $userId);
|
||||
$verified->execute();
|
||||
$verified->close();
|
||||
}
|
||||
|
||||
$resolvedDevice = $deviceUuid ?: ($challenge['device_uuid'] ?: null);
|
||||
if ($resolvedDevice !== null) {
|
||||
$devicePlatform = $platform ?: 'web';
|
||||
$deviceInsert = $db->prepare('INSERT INTO user_devices (user_id, device_uuid, platform, display_name, last_seen_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE platform = VALUES(platform), revoked_at = NULL, last_seen_at = UTC_TIMESTAMP()');
|
||||
$deviceName = $displayName ?: null;
|
||||
$deviceInsert->bind_param('isss', $userId, $resolvedDevice, $devicePlatform, $deviceName);
|
||||
$deviceInsert->execute();
|
||||
$deviceInsert->close();
|
||||
}
|
||||
|
||||
$sessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff));
|
||||
$familyId = $sessionId;
|
||||
$refreshToken = bin2hex(random_bytes(48));
|
||||
$refreshDigest = hash('sha256', $refreshToken);
|
||||
$refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30)));
|
||||
$sessionInsert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)');
|
||||
$sessionInsert->bind_param('ssissi', $sessionId, $familyId, $userId, $resolvedDevice, $refreshDigest, $refreshDays);
|
||||
$sessionInsert->execute();
|
||||
$sessionInsert->close();
|
||||
$connection->commit();
|
||||
|
||||
$accessTtl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900)));
|
||||
api_json([
|
||||
'user' => ['id' => $userId, 'uuid' => $user['uuid'], 'phone_e164' => $challenge['phone_e164'], 'account_role' => $user['account_role']],
|
||||
'access_token' => JwtToken::issue($userId, $sessionId, $accessTtl),
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in_seconds' => $accessTtl,
|
||||
'refresh_token' => $refreshToken,
|
||||
'refresh_expires_in_days' => $refreshDays,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($connection) && $connection instanceof mysqli) {
|
||||
try { $connection->rollback(); } catch (Throwable $ignored) {}
|
||||
}
|
||||
error_log('OTP verification failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/_bootstrap.php';
|
||||
require_once dirname(__DIR__, 3) . '/backend/JwtToken.php';
|
||||
require_once dirname(__DIR__, 3) . '/backend/ApiAuth.php';
|
||||
api_method('GET');
|
||||
|
||||
try {
|
||||
$database = Database::getInstance();
|
||||
$statement = $database->prepare(
|
||||
'SELECT setting_key, setting_value, revision FROM app_settings WHERE is_public = 1 ORDER BY setting_key'
|
||||
);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result();
|
||||
$settings = [];
|
||||
$revision = 0;
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$decoded = json_decode($row['setting_value'], true);
|
||||
$settings[$row['setting_key']] = $decoded;
|
||||
$revision = max($revision, (int) $row['revision']);
|
||||
}
|
||||
|
||||
header('Cache-Control: public, max-age=60, stale-while-revalidate=300');
|
||||
api_json(['revision' => $revision, 'settings' => $settings]);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Public configuration unavailable: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/_bootstrap.php';
|
||||
api_method('GET');
|
||||
|
||||
try {
|
||||
AppConfig::loadEnvironment();
|
||||
Database::getInstance()->getConnection()->query('SELECT 1');
|
||||
api_json(['status' => 'ok', 'database' => 'ok']);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Health check dependency unavailable: ' . $exception->getMessage());
|
||||
api_json(['status' => 'unavailable'], 503);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/_bootstrap.php';
|
||||
api_method('GET');
|
||||
|
||||
$planUuid = $_GET['id'] ?? null;
|
||||
if ($planUuid !== null && (!is_string($planUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $planUuid))) {
|
||||
api_json(['error' => 'invalid_plan_id'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$database = Database::getInstance();
|
||||
$connection = $database->getConnection();
|
||||
if ($planUuid !== null) {
|
||||
$planQuery = $database->prepare(
|
||||
'SELECT plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, content_revision FROM training_plans WHERE is_published = 1 AND plan_uuid = ? LIMIT 1'
|
||||
);
|
||||
$planQuery->bind_param('s', $planUuid);
|
||||
} else {
|
||||
$planQuery = $database->prepare(
|
||||
'SELECT plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, content_revision FROM training_plans WHERE is_published = 1 ORDER BY title_ar'
|
||||
);
|
||||
}
|
||||
$planQuery->execute();
|
||||
$planResult = $planQuery->get_result();
|
||||
$plans = [];
|
||||
while ($plan = $planResult->fetch_assoc()) {
|
||||
$plan['weeks_duration'] = (int) $plan['weeks_duration'];
|
||||
$plan['content_revision'] = (int) $plan['content_revision'];
|
||||
$plan['source_notes'] = json_decode($plan['source_notes'] ?? '[]', true) ?? [];
|
||||
$plan['safety_notes_ar'] = json_decode($plan['safety_notes_ar'], true) ?? [];
|
||||
$plans[$plan['plan_uuid']] = $plan;
|
||||
}
|
||||
$planQuery->close();
|
||||
|
||||
if ($planUuid !== null && !$plans) {
|
||||
api_json(['error' => 'plan_not_found'], 404);
|
||||
}
|
||||
if (!$plans) {
|
||||
header('Cache-Control: public, max-age=60, stale-while-revalidate=300');
|
||||
api_json(['plans' => []]);
|
||||
}
|
||||
|
||||
$planUuids = array_keys($plans);
|
||||
$ids = array_fill(0, count($planUuids), '?');
|
||||
$sessionQuery = $database->prepare(
|
||||
'SELECT s.plan_id, p.plan_uuid, s.week_number, s.day_number, s.title_ar, s.session_type, s.duration_minutes, s.intensity, s.notes_ar, e.exercise_uuid, e.slug AS exercise_slug, e.title_ar AS exercise_title_ar, e.instructions_ar, e.target_muscles, e.equipment, e.gif_url, e.gif_poster_url, e.duration_seconds AS exercise_duration_seconds, e.repetitions, e.safety_notes_ar AS exercise_safety_notes_ar, alt.exercise_uuid AS alternative_exercise_uuid, se.sort_order AS exercise_sort_order, se.sets, se.reps, se.duration_seconds, se.rest_seconds FROM training_plan_sessions s JOIN training_plans p ON p.id = s.plan_id LEFT JOIN training_session_exercises se ON se.session_id = s.id LEFT JOIN exercises e ON e.id = se.exercise_id AND e.is_published = 1 LEFT JOIN exercises alt ON alt.id = e.alternative_exercise_id AND alt.is_published = 1 WHERE p.plan_uuid IN (' . implode(',', $ids) . ') AND p.is_published = 1 ORDER BY p.title_ar, s.week_number, s.day_number, se.sort_order'
|
||||
);
|
||||
$bindArguments = [str_repeat('s', count($planUuids))];
|
||||
foreach ($planUuids as $index => $_planUuid) {
|
||||
$bindArguments[] = &$planUuids[$index];
|
||||
}
|
||||
call_user_func_array([$sessionQuery, 'bind_param'], $bindArguments);
|
||||
$sessionQuery->execute();
|
||||
$sessionResult = $sessionQuery->get_result();
|
||||
|
||||
foreach ($plans as &$plan) {
|
||||
$plan['sessions'] = [];
|
||||
}
|
||||
unset($plan);
|
||||
|
||||
while ($row = $sessionResult->fetch_assoc()) {
|
||||
$uuid = $row['plan_uuid'];
|
||||
$week = (int) $row['week_number'];
|
||||
$day = (int) $row['day_number'];
|
||||
$key = $week . ':' . $day;
|
||||
if (!isset($plans[$uuid]['sessions'][$key])) {
|
||||
$plans[$uuid]['sessions'][$key] = [
|
||||
'week' => $week,
|
||||
'day' => $day,
|
||||
'title_ar' => $row['title_ar'],
|
||||
'type' => $row['session_type'],
|
||||
'duration_minutes' => (int) $row['duration_minutes'],
|
||||
'intensity' => $row['intensity'],
|
||||
'notes_ar' => $row['notes_ar'],
|
||||
'exercises' => [],
|
||||
];
|
||||
}
|
||||
if ($row['exercise_uuid'] !== null) {
|
||||
$plans[$uuid]['sessions'][$key]['exercises'][] = [
|
||||
'exercise_uuid' => $row['exercise_uuid'],
|
||||
'slug' => $row['exercise_slug'],
|
||||
'title_ar' => $row['exercise_title_ar'],
|
||||
'instructions_ar' => json_decode($row['instructions_ar'], true) ?? [],
|
||||
'target_muscles' => json_decode($row['target_muscles'], true) ?? [],
|
||||
'equipment' => json_decode($row['equipment'], true) ?? [],
|
||||
'gif_url' => $row['gif_url'],
|
||||
'gif_poster_url' => $row['gif_poster_url'],
|
||||
'duration_seconds' => $row['duration_seconds'] === null ? null : (int) $row['duration_seconds'],
|
||||
'repetitions' => $row['repetitions'],
|
||||
'safety_notes_ar' => json_decode($row['exercise_safety_notes_ar'] ?? '[]', true) ?? [],
|
||||
'alternative_exercise_uuid' => $row['alternative_exercise_uuid'],
|
||||
'prescription' => [
|
||||
'sets' => $row['sets'] === null ? null : (int) $row['sets'],
|
||||
'reps' => $row['reps'],
|
||||
'duration_seconds' => $row['duration_seconds'] === null ? null : (int) $row['duration_seconds'],
|
||||
'rest_seconds' => (int) $row['rest_seconds'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
$sessionQuery->close();
|
||||
|
||||
foreach ($plans as &$plan) {
|
||||
$plan['sessions'] = array_values($plan['sessions']);
|
||||
}
|
||||
unset($plan);
|
||||
header('Cache-Control: public, max-age=60, stale-while-revalidate=300');
|
||||
api_json(['plans' => array_values($plans)]);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Training plans unavailable: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
Reference in New Issue
Block a user