341 lines
12 KiB
PHP
341 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Workout Submission API Endpoint
|
|
* POST /api/v1/workouts
|
|
*
|
|
* Receives completed workout data from mobile client
|
|
* Validates HMAC signature, processes polyline data, and stores in database
|
|
*/
|
|
|
|
header('Content-Type: application/json');
|
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key, X-Signature, X-Timestamp');
|
|
|
|
// Handle CORS preflight
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(200);
|
|
exit(json_encode(['status' => 'ok']));
|
|
}
|
|
|
|
// Only allow POST
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
die(json_encode(['error' => 'Method not allowed']));
|
|
}
|
|
|
|
require_once __DIR__ . '/Database.php';
|
|
require_once __DIR__ . '/Config.php';
|
|
require_once __DIR__ . '/AuthenticationHandler.php';
|
|
require_once __DIR__ . '/JwtToken.php';
|
|
require_once __DIR__ . '/ApiAuth.php';
|
|
require_once __DIR__ . '/PolylineUtility.php';
|
|
require_once __DIR__ . '/WorkoutValidator.php';
|
|
require_once dirname(__DIR__) . '/public/api/v1/_bootstrap.php';
|
|
|
|
try {
|
|
// Get raw request body for signature verification
|
|
$rawBody = file_get_contents('php://input');
|
|
if (empty($rawBody)) {
|
|
throw new Exception('Empty request body', 400);
|
|
}
|
|
|
|
$db = Database::getInstance();
|
|
if (isset($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Bearer\s+/i', $_SERVER['HTTP_AUTHORIZATION'])) {
|
|
$user_id = ApiAuth::bearerClaims()['user_id'];
|
|
} else {
|
|
AppConfig::loadEnvironment();
|
|
if (getenv('LEGACY_HMAC_ENABLED') !== 'true') {
|
|
throw new Exception('Bearer authentication required', 401);
|
|
}
|
|
$api_key = getHeader('X-API-Key');
|
|
$signature = getHeader('X-Signature');
|
|
$timestamp = getHeader('X-Timestamp');
|
|
if (!$api_key || !$signature || !$timestamp) {
|
|
throw new Exception('Missing required authentication headers', 401);
|
|
}
|
|
$auth = new AuthenticationHandler();
|
|
$authResult = $auth->validateHmacSignature($api_key, $signature, $rawBody, $timestamp);
|
|
if (!$authResult['valid']) {
|
|
throw new Exception($authResult['error'], 401);
|
|
}
|
|
$user_id = $authResult['user_id'];
|
|
}
|
|
|
|
// Parse and validate JSON payload
|
|
$payload = json_decode($rawBody, true);
|
|
if (!is_array($payload)) {
|
|
throw new Exception('Invalid JSON payload', 400);
|
|
}
|
|
|
|
// Validate workout data
|
|
$validator = new WorkoutValidator();
|
|
$validation = $validator->validate($payload);
|
|
if (!$validation['valid']) {
|
|
throw new Exception('Validation failed: ' . implode(', ', $validation['errors']), 422);
|
|
}
|
|
|
|
// Decode and validate polyline
|
|
$coordinates = PolylineUtility::decodePolyline($payload['route_polyline']);
|
|
if (empty($coordinates)) {
|
|
throw new Exception('Invalid or empty polyline', 400);
|
|
}
|
|
|
|
// Begin transaction
|
|
$db->beginTransaction();
|
|
|
|
try {
|
|
// Generate UUID for workout
|
|
$workout_uuid = generateUUID();
|
|
|
|
// Prepare workout insert statement
|
|
$stmt = $db->prepare('
|
|
INSERT INTO workouts (
|
|
workout_uuid, user_id, client_workout_uuid, workout_type, distance_meters,
|
|
duration_seconds, elevation_gain_meters, elevation_loss_meters,
|
|
calories_burned, average_pace_mps, max_speed_mps,
|
|
route_polyline, coordinate_count, start_lat, start_lng,
|
|
end_lat, end_lng, start_time, end_time, weather_condition,
|
|
temperature_celsius, notes, is_public
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
|
|
');
|
|
|
|
// Extract start and end coordinates
|
|
$start_coord = $coordinates[0];
|
|
$end_coord = $coordinates[count($coordinates) - 1];
|
|
|
|
// Calculate average pace
|
|
$average_pace = $payload['duration_seconds'] > 0
|
|
? $payload['distance_meters'] / $payload['duration_seconds']
|
|
: 0;
|
|
$client_workout_uuid = $payload['client_workout_id'] ?? null;
|
|
$start_time_utc = normalizeUtcDateTime($payload['start_time']);
|
|
$end_time_utc = normalizeUtcDateTime($payload['end_time']);
|
|
|
|
// Bind parameters
|
|
$stmt->bind_param(
|
|
'sissiiiidddsiddddsssdsi',
|
|
$workout_uuid,
|
|
$user_id,
|
|
$client_workout_uuid,
|
|
$payload['workout_type'],
|
|
$payload['distance_meters'],
|
|
$payload['duration_seconds'],
|
|
$payload['elevation_gain_meters'],
|
|
$payload['elevation_loss_meters'],
|
|
$payload['calories_burned'],
|
|
$average_pace,
|
|
$payload['max_speed_mps'],
|
|
$payload['route_polyline'],
|
|
$coordinates_count = count($coordinates),
|
|
$start_coord['lat'],
|
|
$start_coord['lng'],
|
|
$end_coord['lat'],
|
|
$end_coord['lng'],
|
|
$start_time_utc,
|
|
$end_time_utc,
|
|
$payload['weather_condition'],
|
|
$payload['temperature_celsius'],
|
|
$payload['notes'],
|
|
$payload['is_public']
|
|
);
|
|
|
|
if (!$stmt->execute()) {
|
|
throw new Exception('Failed to insert workout: ' . $stmt->error, 500);
|
|
}
|
|
|
|
$was_inserted = $stmt->affected_rows === 1;
|
|
$workout_id = $db->getLastInsertId();
|
|
$stmt->close();
|
|
|
|
if (!$was_inserted) {
|
|
$existing_stmt = $db->prepare('SELECT workout_uuid FROM workouts WHERE id = ? AND user_id = ?');
|
|
$existing_stmt->bind_param('ii', $workout_id, $user_id);
|
|
$existing_stmt->execute();
|
|
$existing_result = $existing_stmt->get_result();
|
|
$existing_workout = $existing_result->fetch_assoc();
|
|
$existing_stmt->close();
|
|
if (!$existing_workout) {
|
|
throw new Exception('Unable to confirm idempotent workout submission', 500);
|
|
}
|
|
$workout_uuid = $existing_workout['workout_uuid'];
|
|
}
|
|
|
|
// Process and store segments if provided
|
|
if ($was_inserted && !empty($payload['segments'])) {
|
|
$segment_stmt = $db->prepare('
|
|
INSERT INTO workout_segments (workout_id, segment_order, duration_seconds, distance_meters, average_pace_mps, index_in_polyline)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
');
|
|
|
|
foreach ($payload['segments'] as $index => $segment) {
|
|
$segment_order = $index;
|
|
$segment_pace = $segment['duration_seconds'] > 0
|
|
? $segment['distance_meters'] / $segment['duration_seconds']
|
|
: 0;
|
|
|
|
$segment_stmt->bind_param(
|
|
'iiiddi',
|
|
$workout_id,
|
|
$segment_order,
|
|
$segment['duration_seconds'],
|
|
$segment['distance_meters'],
|
|
$segment_pace,
|
|
$segment['index_in_polyline']
|
|
);
|
|
|
|
if (!$segment_stmt->execute()) {
|
|
throw new Exception('Failed to insert segment', 500);
|
|
}
|
|
}
|
|
$segment_stmt->close();
|
|
}
|
|
|
|
// Update or create user stats cache
|
|
if ($was_inserted) {
|
|
updateUserStatsCache($db, $user_id);
|
|
}
|
|
|
|
// Log successful submission
|
|
if ($was_inserted) {
|
|
$logStmt = $db->prepare('
|
|
INSERT INTO api_logs (user_id, endpoint, method, status_code, ip_address, user_agent, response_time_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
');
|
|
|
|
$endpoint = '/api/v1/workouts';
|
|
$method = 'POST';
|
|
$status = 201;
|
|
$ip = getClientIpAddress();
|
|
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
|
$response_time = (int)((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000);
|
|
|
|
$logStmt->bind_param(
|
|
'ississi',
|
|
$user_id,
|
|
$endpoint,
|
|
$method,
|
|
$status,
|
|
$ip,
|
|
$user_agent,
|
|
$response_time
|
|
);
|
|
$logStmt->execute();
|
|
$logStmt->close();
|
|
}
|
|
|
|
// Commit transaction
|
|
$db->commit();
|
|
|
|
// Return success response
|
|
http_response_code($was_inserted ? 201 : 200);
|
|
echo json_encode([
|
|
'status' => 'success',
|
|
'data' => [
|
|
'workout_id' => $workout_id,
|
|
'workout_uuid' => $workout_uuid,
|
|
'message' => $was_inserted ? 'Workout submitted successfully' : 'Workout was already received',
|
|
'timestamp' => date('c')
|
|
]
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
// Rollback on error
|
|
$db->rollback();
|
|
throw $e;
|
|
}
|
|
|
|
} catch (Exception $e) {
|
|
// Determine HTTP status code
|
|
$status_code = intval($e->getCode());
|
|
if ($status_code < 100 || $status_code >= 600) {
|
|
$status_code = 500;
|
|
}
|
|
|
|
http_response_code($status_code);
|
|
echo json_encode([
|
|
'status' => 'error',
|
|
'error' => $e->getMessage(),
|
|
'timestamp' => date('c')
|
|
]);
|
|
|
|
} finally {
|
|
// Ensure database connection is closed
|
|
if (isset($db)) {
|
|
$db->close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function to get HTTP header value
|
|
*/
|
|
function getHeader($header_name) {
|
|
$header_key = 'HTTP_' . strtoupper(str_replace('-', '_', $header_name));
|
|
return $_SERVER[$header_key] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get client IP address
|
|
*/
|
|
function getClientIpAddress() {
|
|
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
|
return $_SERVER['HTTP_CLIENT_IP'];
|
|
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
|
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
|
return trim($ips[0]);
|
|
}
|
|
return $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Generate UUID v4
|
|
*/
|
|
function generateUUID() {
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
|
}
|
|
|
|
/** Store ISO-8601 client timestamps as UTC MySQL DATETIME values. */
|
|
function normalizeUtcDateTime($value) {
|
|
$date = new DateTimeImmutable($value);
|
|
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
|
}
|
|
|
|
/**
|
|
* Update user stats cache
|
|
*/
|
|
function updateUserStatsCache($db, $user_id) {
|
|
$stmt = $db->prepare('
|
|
INSERT INTO user_stats_cache (
|
|
user_id, total_workouts, total_distance_meters,
|
|
total_duration_seconds, total_calories_burned, average_pace_mps, last_workout_date
|
|
)
|
|
SELECT
|
|
? as user_id,
|
|
COUNT(*) as total_workouts,
|
|
COALESCE(SUM(distance_meters), 0) as total_distance,
|
|
COALESCE(SUM(duration_seconds), 0) as total_duration,
|
|
COALESCE(SUM(calories_burned), 0) as total_calories,
|
|
COALESCE(AVG(average_pace_mps), 0) as avg_pace,
|
|
MAX(created_at) as last_workout
|
|
FROM workouts
|
|
WHERE user_id = ?
|
|
ON DUPLICATE KEY UPDATE
|
|
total_workouts = VALUES(total_workouts),
|
|
total_distance_meters = VALUES(total_distance_meters),
|
|
total_duration_seconds = VALUES(total_duration_seconds),
|
|
total_calories_burned = VALUES(total_calories_burned),
|
|
average_pace_mps = VALUES(average_pace_mps),
|
|
last_workout_date = VALUES(last_workout_date),
|
|
cached_at = NOW()
|
|
');
|
|
|
|
$stmt->bind_param('ii', $user_id, $user_id);
|
|
$stmt->execute();
|
|
$stmt->close();
|
|
}
|
|
?>
|