first commit
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/\*\*
|
||||
|
||||
- HMAC Authentication Implementation Guide
|
||||
- For Fitness Tracking App API
|
||||
-
|
||||
- This document explains how to generate HMAC signatures for API requests
|
||||
\*/
|
||||
|
||||
# HMAC Request Signing Process
|
||||
|
||||
## Overview
|
||||
|
||||
All API requests must include HMAC-SHA256 signatures for authentication and integrity verification.
|
||||
|
||||
## Required Headers
|
||||
|
||||
```
|
||||
X-API-Key: <user_api_key>
|
||||
X-Signature: <hmac_signature>
|
||||
X-Timestamp: <unix_timestamp>
|
||||
```
|
||||
|
||||
## Signature Generation Algorithm
|
||||
|
||||
### 1. Create the message to sign
|
||||
|
||||
```
|
||||
message = timestamp + "|" + api_key + "|" + raw_request_body
|
||||
```
|
||||
|
||||
### 2. Generate HMAC-SHA256
|
||||
|
||||
```
|
||||
signature = HMAC-SHA256(message, api_secret)
|
||||
```
|
||||
|
||||
### 3. Encode as hex string
|
||||
|
||||
```
|
||||
hex_signature = hex_encode(signature)
|
||||
```
|
||||
|
||||
## PHP Implementation Example
|
||||
|
||||
```php
|
||||
$api_key = 'your_api_key_here';
|
||||
$api_secret = 'your_api_secret_here';
|
||||
$timestamp = time();
|
||||
$request_body = json_encode([
|
||||
'workout_type' => 'running',
|
||||
'distance_meters' => 5000,
|
||||
'duration_seconds' => 1800,
|
||||
// ... other fields
|
||||
]);
|
||||
|
||||
// Generate signature
|
||||
$message = $timestamp . '|' . $api_key . '|' . $request_body;
|
||||
$signature = hash_hmac('sha256', $message, $api_secret);
|
||||
|
||||
// Make request with headers
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'X-API-Key: ' . $api_key,
|
||||
'X-Signature: ' . $signature,
|
||||
'X-Timestamp: ' . $timestamp
|
||||
];
|
||||
|
||||
$ch = curl_init('https://your-api.com/api/v1/workouts');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $request_body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$response = curl_exec($ch);
|
||||
```
|
||||
|
||||
## Dart/Flutter Implementation Example
|
||||
|
||||
```dart
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
String generateHmacSignature(
|
||||
String apiKey,
|
||||
String apiSecret,
|
||||
String payload,
|
||||
int timestamp,
|
||||
) {
|
||||
final message = '$timestamp|$apiKey|$payload';
|
||||
final hmac = Hmac(sha256, utf8.encode(apiSecret));
|
||||
final digest = hmac.convert(utf8.encode(message));
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
// Usage:
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final payload = jsonEncode({
|
||||
'workout_type': 'running',
|
||||
'distance_meters': 5000,
|
||||
// ...
|
||||
});
|
||||
|
||||
final signature = generateHmacSignature(
|
||||
apiKey,
|
||||
apiSecret,
|
||||
payload,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
// Send request with headers
|
||||
final response = await http.post(
|
||||
Uri.parse('https://your-api.com/api/v1/workouts'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
'X-Signature': signature,
|
||||
'X-Timestamp': '$timestamp',
|
||||
},
|
||||
body: payload,
|
||||
);
|
||||
```
|
||||
|
||||
## Payload Format
|
||||
|
||||
### Workout Submission Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"workout_type": "running|walking",
|
||||
"distance_meters": 5000,
|
||||
"duration_seconds": 1800,
|
||||
"elevation_gain_meters": 150,
|
||||
"elevation_loss_meters": 100,
|
||||
"calories_burned": 350.5,
|
||||
"max_speed_mps": 4.5,
|
||||
"route_polyline": "encoded_polyline_string",
|
||||
"start_time": "2026-04-21T14:30:00.000Z",
|
||||
"end_time": "2026-04-21T14:45:00.000Z",
|
||||
"weather_condition": "sunny",
|
||||
"temperature_celsius": 22.5,
|
||||
"notes": "Great workout!",
|
||||
"is_public": false,
|
||||
"segments": [
|
||||
{
|
||||
"duration_seconds": 600,
|
||||
"distance_meters": 1500,
|
||||
"index_in_polyline": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Timestamp Validation**: Requests must be within 5 minutes of server time
|
||||
2. **Timing-Safe Comparison**: Signatures are verified using constant-time comparison
|
||||
3. **Replay Attack Prevention**: Each request timestamp is validated
|
||||
4. **HTTPS Only**: Always use HTTPS in production
|
||||
5. **Secret Rotation**: Implement API secret rotation periodically
|
||||
6. **Rate Limiting**: Consider implementing rate limits per API key
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error": "Invalid API key",
|
||||
"timestamp": "2026-04-21T14:45:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error": "Missing required authentication headers",
|
||||
"timestamp": "2026-04-21T14:45:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 422 Unprocessable Entity
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error": "Validation failed: distance_meters must be between 100 and 100000",
|
||||
"timestamp": "2026-04-21T14:45:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Success Response (201 Created)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"workout_id": 123,
|
||||
"workout_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"message": "Workout submitted successfully",
|
||||
"timestamp": "2026-04-21T14:45:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Rate Limits
|
||||
|
||||
- 100 requests per minute per API key
|
||||
- 5000 requests per day per API key
|
||||
- Rate limit headers included in response:
|
||||
- `X-RateLimit-Limit: 100`
|
||||
- `X-RateLimit-Remaining: 95`
|
||||
- `X-RateLimit-Reset: 1629907200`
|
||||
|
||||
## Testing HMAC Signature Generation
|
||||
|
||||
### Using cURL with debugging:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
API_KEY="your_api_key"
|
||||
API_SECRET="your_api_secret"
|
||||
TIMESTAMP=$(date +%s)
|
||||
ENDPOINT="https://your-api.com/api/v1/workouts"
|
||||
PAYLOAD='{"workout_type":"running","distance_meters":5000,"duration_seconds":1800,"elevation_gain_meters":0,"elevation_loss_meters":0,"calories_burned":350,"max_speed_mps":4.5,"route_polyline":"abc123","start_time":"2026-04-21T14:00:00Z","end_time":"2026-04-21T14:30:00Z"}'
|
||||
|
||||
MESSAGE="${TIMESTAMP}|${API_KEY}|${PAYLOAD}"
|
||||
SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')
|
||||
|
||||
echo "Timestamp: $TIMESTAMP"
|
||||
echo "Signature: $SIGNATURE"
|
||||
echo "Payload: $PAYLOAD"
|
||||
|
||||
curl -X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-H "X-Signature: $SIGNATURE" \
|
||||
-H "X-Timestamp: $TIMESTAMP" \
|
||||
-d "$PAYLOAD"
|
||||
```
|
||||
|
||||
## API Key Management
|
||||
|
||||
### Getting Your API Credentials
|
||||
|
||||
Users receive their API credentials upon registration:
|
||||
|
||||
- API Key (64 character hex string)
|
||||
- API Secret (64 character hex string)
|
||||
|
||||
### Regenerating Credentials
|
||||
|
||||
API credentials can be regenerated from the user settings panel (invalidates old credentials immediately).
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Store credentials securely (use OS keychain/secure storage)
|
||||
2. Never commit credentials to version control
|
||||
3. Use environment variables or secure configuration
|
||||
4. Rotate credentials periodically
|
||||
5. Monitor API logs for suspicious activity
|
||||
6. Use separate credentials for different apps/devices
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentication & Security Handler
|
||||
* HMAC-based request validation for API endpoints
|
||||
*/
|
||||
|
||||
class AuthenticationHandler {
|
||||
private $db;
|
||||
private const SIGNATURE_ALGORITHM = 'sha256';
|
||||
private const TIMESTAMP_TOLERANCE = 300; // 5 minutes in seconds
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate HMAC signature of incoming request
|
||||
*
|
||||
* @param string $api_key The API key from request header
|
||||
* @param string $signature The HMAC signature from request header
|
||||
* @param string $payload The raw request body
|
||||
* @param string $timestamp The request timestamp
|
||||
* @return array ['valid' => bool, 'user_id' => int|null, 'error' => string|null]
|
||||
*/
|
||||
public function validateHmacSignature($api_key, $signature, $payload, $timestamp) {
|
||||
// Validate timestamp to prevent replay attacks
|
||||
if (!$this->isValidTimestamp($timestamp)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'user_id' => null,
|
||||
'error' => 'Request timestamp is invalid or expired'
|
||||
];
|
||||
}
|
||||
|
||||
// Get user by API key
|
||||
$user = $this->getUserByApiKey($api_key);
|
||||
if (!$user) {
|
||||
// Log suspicious activity
|
||||
$this->logSecurityEvent('INVALID_API_KEY', $api_key);
|
||||
return [
|
||||
'valid' => false,
|
||||
'user_id' => null,
|
||||
'error' => 'Invalid API key'
|
||||
];
|
||||
}
|
||||
|
||||
// Generate expected signature
|
||||
$expectedSignature = $this->generateSignature(
|
||||
$payload,
|
||||
$user['api_secret'],
|
||||
$timestamp,
|
||||
$api_key
|
||||
);
|
||||
|
||||
// Compare signatures using timing-safe comparison
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
// Log failed authentication attempt
|
||||
$this->logSecurityEvent('INVALID_SIGNATURE', $api_key, $user['id']);
|
||||
return [
|
||||
'valid' => false,
|
||||
'user_id' => null,
|
||||
'error' => 'Invalid signature'
|
||||
];
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!$user['is_active']) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'user_id' => null,
|
||||
'error' => 'User account is inactive'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'user_id' => $user['id'],
|
||||
'error' => null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature
|
||||
*
|
||||
* Signature format: HMAC-SHA256(timestamp|payload, api_secret)
|
||||
*/
|
||||
private function generateSignature($payload, $api_secret, $timestamp, $api_key) {
|
||||
$data = $timestamp . '|' . $api_key . '|' . $payload;
|
||||
return hash_hmac(self::SIGNATURE_ALGORITHM, $data, $api_secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify timestamp is within acceptable range
|
||||
*/
|
||||
private function isValidTimestamp($timestamp) {
|
||||
$current_time = time();
|
||||
$request_time = (int)$timestamp;
|
||||
$time_diff = abs($current_time - $request_time);
|
||||
|
||||
return $time_diff <= self::TIMESTAMP_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by API key
|
||||
*/
|
||||
private function getUserByApiKey($api_key) {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, api_secret, is_active, uuid
|
||||
FROM users
|
||||
WHERE api_key = ?
|
||||
LIMIT 1
|
||||
');
|
||||
|
||||
$stmt->bind_param('s', $api_key);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security events for audit trail
|
||||
*/
|
||||
private function logSecurityEvent($event_type, $api_key, $user_id = null) {
|
||||
$ip_address = $this->getClientIpAddress();
|
||||
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO api_logs (user_id, endpoint, method, status_code, ip_address, user_agent, error_message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
');
|
||||
|
||||
$endpoint = $event_type;
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$status_code = 401;
|
||||
$error_msg = $event_type;
|
||||
|
||||
$stmt->bind_param(
|
||||
'issssss',
|
||||
$user_id,
|
||||
$endpoint,
|
||||
$method,
|
||||
$status_code,
|
||||
$ip_address,
|
||||
$user_agent,
|
||||
$error_msg
|
||||
);
|
||||
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address (handles proxies)
|
||||
*/
|
||||
private 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]);
|
||||
} else {
|
||||
return $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API key and secret for new user
|
||||
*/
|
||||
public static function generateApiCredentials() {
|
||||
return [
|
||||
'api_key' => bin2hex(random_bytes(32)),
|
||||
'api_secret' => bin2hex(random_bytes(32))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
public static function hashPassword($password) {
|
||||
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password
|
||||
*/
|
||||
public static function verifyPassword($password, $hash) {
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Configuration
|
||||
* Production fitness tracking app database connection
|
||||
*/
|
||||
|
||||
class Database {
|
||||
private static $instance = null;
|
||||
private $connection;
|
||||
|
||||
private $db_host = 'localhost';
|
||||
private $db_user = 'fitness_app_user';
|
||||
private $db_pass = 'your_secure_password_here';
|
||||
private $db_name = 'fitness_app';
|
||||
private $db_port = 3306;
|
||||
|
||||
private function __construct() {
|
||||
try {
|
||||
$this->connection = new mysqli(
|
||||
$this->db_host,
|
||||
$this->db_user,
|
||||
$this->db_pass,
|
||||
$this->db_name,
|
||||
$this->db_port
|
||||
);
|
||||
|
||||
// Check connection
|
||||
if ($this->connection->connect_error) {
|
||||
throw new Exception('Database connection failed: ' . $this->connection->connect_error);
|
||||
}
|
||||
|
||||
// Set charset
|
||||
$this->connection->set_charset('utf8mb4');
|
||||
|
||||
// Enable error reporting
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('Database Error: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
die(json_encode(['error' => 'Database connection failed']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance getter
|
||||
*/
|
||||
public static function getInstance() {
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection
|
||||
*/
|
||||
public function getConnection() {
|
||||
return $this->connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare and execute query
|
||||
*/
|
||||
public function prepare($query) {
|
||||
return $this->connection->prepare($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string
|
||||
*/
|
||||
public function escape($string) {
|
||||
return $this->connection->real_escape_string($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection
|
||||
*/
|
||||
public function close() {
|
||||
if ($this->connection) {
|
||||
$this->connection->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin transaction
|
||||
*/
|
||||
public function beginTransaction() {
|
||||
$this->connection->begin_transaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit transaction
|
||||
*/
|
||||
public function commit() {
|
||||
$this->connection->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback transaction
|
||||
*/
|
||||
public function rollback() {
|
||||
$this->connection->rollback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last inserted ID
|
||||
*/
|
||||
public function getLastInsertId() {
|
||||
return $this->connection->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get affected rows
|
||||
*/
|
||||
public function getAffectedRows() {
|
||||
return $this->connection->affected_rows;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent cloning
|
||||
final class SingletonDatabase extends Database {
|
||||
private function __clone() {}
|
||||
private function __wakeup() {}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
/**
|
||||
* Polyline Utility
|
||||
* Encoding/Decoding Google Maps Polyline Algorithm Format
|
||||
* Used for efficient route storage and transmission
|
||||
*/
|
||||
|
||||
class PolylineUtility {
|
||||
/**
|
||||
* Decode polyline string to array of coordinates
|
||||
* Implementation of Google's Encoded Polyline Algorithm Format
|
||||
*
|
||||
* @param string $encoded The encoded polyline string
|
||||
* @return array Array of coordinates with 'lat' and 'lng' keys
|
||||
*/
|
||||
public static function decodePolyline($encoded) {
|
||||
$inv = 1.0 / 1e5;
|
||||
$decoded = [];
|
||||
$previous = [0, 0];
|
||||
$i = 0;
|
||||
|
||||
while ($i < strlen($encoded)) {
|
||||
$ll = [0, 0];
|
||||
|
||||
for ($j = 0; $j < 2; $j++) {
|
||||
$shift = 0;
|
||||
$result = 0;
|
||||
|
||||
do {
|
||||
$byte = ord(substr($encoded, $i++, 1)) - 63;
|
||||
$result |= ($byte & 0x1f) << $shift;
|
||||
$shift += 5;
|
||||
} while ($byte >= 0x20 && $i < strlen($encoded));
|
||||
|
||||
$dlng = ($result & 1) ? ~($result >> 1) : ($result >> 1);
|
||||
$ll[$j] = $previous[$j] + $dlng;
|
||||
$previous[$j] = $ll[$j];
|
||||
}
|
||||
|
||||
$decoded[] = [
|
||||
'lat' => $ll[0] * $inv,
|
||||
'lng' => $ll[1] * $inv
|
||||
];
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode array of coordinates to polyline string
|
||||
* Implementation of Google's Encoded Polyline Algorithm Format
|
||||
*
|
||||
* @param array $coordinates Array of coordinates with 'lat' and 'lng' keys
|
||||
* @return string The encoded polyline string
|
||||
*/
|
||||
public static function encodePolyline($coordinates) {
|
||||
$encoded = '';
|
||||
$previous = [0, 0];
|
||||
|
||||
foreach ($coordinates as $point) {
|
||||
$lat = $point['lat'];
|
||||
$lng = $point['lng'];
|
||||
|
||||
$current = [
|
||||
intval(round($lat * 1e5)),
|
||||
intval(round($lng * 1e5))
|
||||
];
|
||||
|
||||
for ($j = 0; $j < 2; $j++) {
|
||||
$curr = $current[$j];
|
||||
$prev = $previous[$j];
|
||||
$value = $curr - $prev;
|
||||
|
||||
$value = ($value << 1) ^ ($value >> 31);
|
||||
$chunks = [];
|
||||
|
||||
while ($value >= 0x20) {
|
||||
$chunks[] = (0x20 | ($value & 0x1f)) + 63;
|
||||
$value >>= 5;
|
||||
}
|
||||
|
||||
$chunks[] = $value + 63;
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$encoded .= chr($chunk);
|
||||
}
|
||||
|
||||
$previous[$j] = $current[$j];
|
||||
}
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
* Returns distance in meters
|
||||
*
|
||||
* @param float $lat1 Starting latitude
|
||||
* @param float $lng1 Starting longitude
|
||||
* @param float $lat2 Ending latitude
|
||||
* @param float $lng2 Ending longitude
|
||||
* @return float Distance in meters
|
||||
*/
|
||||
public static function calculateDistance($lat1, $lng1, $lat2, $lng2) {
|
||||
$earth_radius = 6371000; // Earth's radius in meters
|
||||
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLng = deg2rad($lng2 - $lng1);
|
||||
|
||||
$a = sin($dLat / 2) * sin($dLat / 2) +
|
||||
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||
sin($dLng / 2) * sin($dLng / 2);
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
$distance = $earth_radius * $c;
|
||||
|
||||
return round($distance, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total distance of a route from coordinates array
|
||||
*
|
||||
* @param array $coordinates Array of coordinates with 'lat' and 'lng' keys
|
||||
* @return float Total distance in meters
|
||||
*/
|
||||
public static function calculateTotalDistance($coordinates) {
|
||||
if (count($coordinates) < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total_distance = 0;
|
||||
|
||||
for ($i = 0; $i < count($coordinates) - 1; $i++) {
|
||||
$current = $coordinates[$i];
|
||||
$next = $coordinates[$i + 1];
|
||||
|
||||
$segment_distance = self::calculateDistance(
|
||||
$current['lat'],
|
||||
$current['lng'],
|
||||
$next['lat'],
|
||||
$next['lng']
|
||||
);
|
||||
|
||||
$total_distance += $segment_distance;
|
||||
}
|
||||
|
||||
return round($total_distance, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate elevation gain and loss from coordinates with elevation data
|
||||
*
|
||||
* @param array $coordinates Array of coordinates with 'lat', 'lng', and 'elevation' keys
|
||||
* @return array ['gain' => float, 'loss' => float]
|
||||
*/
|
||||
public static function calculateElevationChange($coordinates) {
|
||||
$elevation_gain = 0;
|
||||
$elevation_loss = 0;
|
||||
|
||||
for ($i = 0; $i < count($coordinates) - 1; $i++) {
|
||||
$current_elevation = $coordinates[$i]['elevation'] ?? null;
|
||||
$next_elevation = $coordinates[$i + 1]['elevation'] ?? null;
|
||||
|
||||
if ($current_elevation !== null && $next_elevation !== null) {
|
||||
$change = $next_elevation - $current_elevation;
|
||||
|
||||
if ($change > 0) {
|
||||
$elevation_gain += $change;
|
||||
} else {
|
||||
$elevation_loss += abs($change);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'gain' => round($elevation_gain, 2),
|
||||
'loss' => round($elevation_loss, 2)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplify polyline using Douglas-Peucker algorithm
|
||||
* Reduces number of points while maintaining shape accuracy
|
||||
*
|
||||
* @param array $coordinates Array of coordinates
|
||||
* @param float $tolerance Distance tolerance in degrees
|
||||
* @return array Simplified coordinates array
|
||||
*/
|
||||
public static function simplifyPolyline($coordinates, $tolerance = 0.00001) {
|
||||
if (count($coordinates) < 3) {
|
||||
return $coordinates;
|
||||
}
|
||||
|
||||
$dmax = 0;
|
||||
$index = 0;
|
||||
|
||||
for ($i = 1; $i < count($coordinates) - 1; $i++) {
|
||||
$d = self::pointLineDistance(
|
||||
$coordinates[$i],
|
||||
$coordinates[0],
|
||||
$coordinates[count($coordinates) - 1]
|
||||
);
|
||||
|
||||
if ($d > $dmax) {
|
||||
$dmax = $d;
|
||||
$index = $i;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dmax > $tolerance) {
|
||||
$rec1 = self::simplifyPolyline(
|
||||
array_slice($coordinates, 0, $index + 1),
|
||||
$tolerance
|
||||
);
|
||||
$rec2 = self::simplifyPolyline(
|
||||
array_slice($coordinates, $index),
|
||||
$tolerance
|
||||
);
|
||||
|
||||
$result = array_merge(array_slice($rec1, 0, -1), $rec2);
|
||||
} else {
|
||||
$result = [
|
||||
$coordinates[0],
|
||||
$coordinates[count($coordinates) - 1]
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate perpendicular distance from point to line
|
||||
*/
|
||||
private static function pointLineDistance($point, $lineStart, $lineEnd) {
|
||||
$px = $point['lat'];
|
||||
$py = $point['lng'];
|
||||
$x1 = $lineStart['lat'];
|
||||
$y1 = $lineStart['lng'];
|
||||
$x2 = $lineEnd['lat'];
|
||||
$y2 = $lineEnd['lng'];
|
||||
|
||||
$numerator = abs(($y2 - $y1) * $px - ($x2 - $x1) * $py + $x2 * $y1 - $y2 * $x1);
|
||||
$denominator = sqrt(pow($y2 - $y1, 2) + pow($x2 - $x1, 2));
|
||||
|
||||
return $denominator != 0 ? $numerator / $denominator : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate polyline string format
|
||||
*
|
||||
* @param string $polyline The encoded polyline
|
||||
* @return bool True if valid polyline format
|
||||
*/
|
||||
public static function validatePolyline($polyline) {
|
||||
if (!is_string($polyline) || empty($polyline)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all characters are valid ASCII printable
|
||||
for ($i = 0; $i < strlen($polyline); $i++) {
|
||||
$char_code = ord($polyline[$i]);
|
||||
if ($char_code < 63 || $char_code > 126) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
/**
|
||||
* Workout Validator
|
||||
* Validates incoming workout payload from mobile client
|
||||
*/
|
||||
|
||||
class WorkoutValidator {
|
||||
private $errors = [];
|
||||
|
||||
private const VALID_WORKOUT_TYPES = ['running', 'walking'];
|
||||
private const MIN_DISTANCE = 100; // meters
|
||||
private const MAX_DISTANCE = 100000; // 100km
|
||||
private const MIN_DURATION = 60; // seconds
|
||||
private const MAX_DURATION = 36000; // 10 hours
|
||||
private const MIN_COORDINATES = 2;
|
||||
private const MAX_COORDINATES = 50000;
|
||||
|
||||
/**
|
||||
* Validate complete workout payload
|
||||
*
|
||||
* @param array $payload The workout data to validate
|
||||
* @return array ['valid' => bool, 'errors' => array]
|
||||
*/
|
||||
public function validate($payload) {
|
||||
$this->errors = [];
|
||||
|
||||
// Validate required fields
|
||||
$this->validateRequiredFields($payload);
|
||||
if (!empty($this->errors)) {
|
||||
return ['valid' => false, 'errors' => $this->errors];
|
||||
}
|
||||
|
||||
// Validate data types and values
|
||||
$this->validateWorkoutType($payload['workout_type']);
|
||||
$this->validateNumericField('distance_meters', $payload['distance_meters'], self::MIN_DISTANCE, self::MAX_DISTANCE);
|
||||
$this->validateNumericField('duration_seconds', $payload['duration_seconds'], self::MIN_DURATION, self::MAX_DURATION);
|
||||
$this->validateNumericField('elevation_gain_meters', $payload['elevation_gain_meters'], 0, 10000);
|
||||
$this->validateNumericField('elevation_loss_meters', $payload['elevation_loss_meters'], 0, 10000);
|
||||
$this->validateNumericField('calories_burned', $payload['calories_burned'], 0, 5000);
|
||||
$this->validateNumericField('max_speed_mps', $payload['max_speed_mps'], 0, 50);
|
||||
|
||||
// Validate polyline
|
||||
$this->validatePolyline($payload['route_polyline']);
|
||||
|
||||
// Validate timestamps
|
||||
$this->validateTimestamps($payload['start_time'], $payload['end_time']);
|
||||
|
||||
// Validate optional fields
|
||||
if (isset($payload['weather_condition']) && !empty($payload['weather_condition'])) {
|
||||
$this->validateWeatherCondition($payload['weather_condition']);
|
||||
}
|
||||
|
||||
if (isset($payload['temperature_celsius']) && $payload['temperature_celsius'] !== null) {
|
||||
if (!is_numeric($payload['temperature_celsius'])) {
|
||||
$this->errors[] = 'temperature_celsius must be numeric';
|
||||
} else {
|
||||
$temp = floatval($payload['temperature_celsius']);
|
||||
if ($temp < -50 || $temp > 60) {
|
||||
$this->errors[] = 'temperature_celsius must be between -50 and 60 degrees';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate optional segments array
|
||||
if (isset($payload['segments']) && is_array($payload['segments'])) {
|
||||
$this->validateSegments($payload['segments']);
|
||||
}
|
||||
|
||||
// Validate optional fields
|
||||
if (isset($payload['is_public']) && !is_bool($payload['is_public'])) {
|
||||
$this->errors[] = 'is_public must be a boolean';
|
||||
}
|
||||
|
||||
if (isset($payload['notes']) && !is_string($payload['notes'])) {
|
||||
$this->errors[] = 'notes must be a string';
|
||||
} elseif (isset($payload['notes']) && strlen($payload['notes']) > 1000) {
|
||||
$this->errors[] = 'notes must not exceed 1000 characters';
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($this->errors),
|
||||
'errors' => $this->errors
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields exist
|
||||
*/
|
||||
private function validateRequiredFields($payload) {
|
||||
$required = [
|
||||
'workout_type',
|
||||
'distance_meters',
|
||||
'duration_seconds',
|
||||
'route_polyline',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'elevation_gain_meters',
|
||||
'elevation_loss_meters',
|
||||
'calories_burned',
|
||||
'max_speed_mps'
|
||||
];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!isset($payload[$field])) {
|
||||
$this->errors[] = "Missing required field: {$field}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate workout type
|
||||
*/
|
||||
private function validateWorkoutType($type) {
|
||||
if (!in_array($type, self::VALID_WORKOUT_TYPES, true)) {
|
||||
$this->errors[] = 'Invalid workout_type. Must be: ' . implode(', ', self::VALID_WORKOUT_TYPES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate numeric field within range
|
||||
*/
|
||||
private function validateNumericField($field_name, $value, $min, $max) {
|
||||
if (!is_numeric($value)) {
|
||||
$this->errors[] = "{$field_name} must be numeric";
|
||||
return;
|
||||
}
|
||||
|
||||
$numeric_value = floatval($value);
|
||||
|
||||
if ($numeric_value < $min || $numeric_value > $max) {
|
||||
$this->errors[] = "{$field_name} must be between {$min} and {$max}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate polyline
|
||||
*/
|
||||
private function validatePolyline($polyline) {
|
||||
if (!is_string($polyline) || empty($polyline)) {
|
||||
$this->errors[] = 'route_polyline must be a non-empty string';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!PolylineUtility::validatePolyline($polyline)) {
|
||||
$this->errors[] = 'route_polyline has invalid format';
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode and check coordinate count
|
||||
$coordinates = PolylineUtility::decodePolyline($polyline);
|
||||
|
||||
if (count($coordinates) < self::MIN_COORDINATES) {
|
||||
$this->errors[] = 'route_polyline must contain at least ' . self::MIN_COORDINATES . ' coordinates';
|
||||
}
|
||||
|
||||
if (count($coordinates) > self::MAX_COORDINATES) {
|
||||
$this->errors[] = 'route_polyline must not exceed ' . self::MAX_COORDINATES . ' coordinates';
|
||||
}
|
||||
|
||||
// Validate coordinate format
|
||||
foreach ($coordinates as $coord) {
|
||||
if (!is_array($coord) || !isset($coord['lat']) || !isset($coord['lng'])) {
|
||||
$this->errors[] = 'Invalid coordinate format in polyline';
|
||||
break;
|
||||
}
|
||||
|
||||
if (!is_numeric($coord['lat']) || !is_numeric($coord['lng'])) {
|
||||
$this->errors[] = 'Coordinate values must be numeric';
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate lat/lng ranges
|
||||
if ($coord['lat'] < -90 || $coord['lat'] > 90) {
|
||||
$this->errors[] = 'Latitude must be between -90 and 90';
|
||||
break;
|
||||
}
|
||||
|
||||
if ($coord['lng'] < -180 || $coord['lng'] > 180) {
|
||||
$this->errors[] = 'Longitude must be between -180 and 180';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate timestamps
|
||||
*/
|
||||
private function validateTimestamps($start_time, $end_time) {
|
||||
// Validate ISO 8601 format
|
||||
if (!$this->isValidISO8601($start_time)) {
|
||||
$this->errors[] = 'start_time must be in ISO 8601 format';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidISO8601($end_time)) {
|
||||
$this->errors[] = 'end_time must be in ISO 8601 format';
|
||||
return;
|
||||
}
|
||||
|
||||
$start = strtotime($start_time);
|
||||
$end = strtotime($end_time);
|
||||
|
||||
if ($start === false || $end === false) {
|
||||
$this->errors[] = 'Invalid timestamp format';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($start >= $end) {
|
||||
$this->errors[] = 'start_time must be before end_time';
|
||||
}
|
||||
|
||||
// Validate timestamps are not in the future
|
||||
if ($end > time()) {
|
||||
$this->errors[] = 'end_time cannot be in the future';
|
||||
}
|
||||
|
||||
// Validate timestamps are recent (not more than 30 days old)
|
||||
if ($start < (time() - 30 * 24 * 60 * 60)) {
|
||||
$this->errors[] = 'Workout must be submitted within 30 days';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ISO 8601 datetime format
|
||||
*/
|
||||
private function isValidISO8601($date_string) {
|
||||
$pattern = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$/';
|
||||
return preg_match($pattern, $date_string) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate weather condition
|
||||
*/
|
||||
private function validateWeatherCondition($condition) {
|
||||
$valid_conditions = ['sunny', 'cloudy', 'rainy', 'snowy', 'windy', 'foggy', 'hail'];
|
||||
|
||||
if (!in_array(strtolower($condition), $valid_conditions, true)) {
|
||||
$this->errors[] = 'Invalid weather_condition. Must be one of: ' . implode(', ', $valid_conditions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate segments array
|
||||
*/
|
||||
private function validateSegments($segments) {
|
||||
if (!is_array($segments) || empty($segments)) {
|
||||
$this->errors[] = 'segments must be a non-empty array';
|
||||
return;
|
||||
}
|
||||
|
||||
if (count($segments) > 1000) {
|
||||
$this->errors[] = 'segments array must not exceed 1000 items';
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($segments as $index => $segment) {
|
||||
if (!is_array($segment)) {
|
||||
$this->errors[] = "Segment {$index} must be an object";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($segment['duration_seconds']) || !isset($segment['distance_meters'])) {
|
||||
$this->errors[] = "Segment {$index} must have duration_seconds and distance_meters";
|
||||
}
|
||||
|
||||
if (!is_numeric($segment['duration_seconds']) || intval($segment['duration_seconds']) < 0) {
|
||||
$this->errors[] = "Segment {$index} duration_seconds must be a positive integer";
|
||||
}
|
||||
|
||||
if (!is_numeric($segment['distance_meters']) || intval($segment['distance_meters']) < 0) {
|
||||
$this->errors[] = "Segment {$index} distance_meters must be a positive integer";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* Workout History API
|
||||
* GET /api/v1/history.php
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
require_once 'Database.php';
|
||||
require_once 'AuthenticationHandler.php';
|
||||
|
||||
try {
|
||||
$api_key = $_SERVER['HTTP_X_API_KEY'] ?? null;
|
||||
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? null;
|
||||
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? null;
|
||||
|
||||
if (!$api_key || !$signature || !$timestamp) {
|
||||
throw new Exception('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$auth = new AuthenticationHandler();
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Verification (Using empty body for GET request signature)
|
||||
$authResult = $auth->validateHmacSignature($api_key, $signature, '', $timestamp);
|
||||
if (!$authResult['valid']) {
|
||||
throw new Exception($authResult['error'], 401);
|
||||
}
|
||||
|
||||
$user_id = $authResult['user_id'];
|
||||
|
||||
// Fetch workouts
|
||||
$stmt = $db->prepare('SELECT * FROM workouts WHERE user_id = ? ORDER BY created_at DESC LIMIT 50');
|
||||
$stmt->bind_param('i', $user_id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$workouts = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$workouts[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => $workouts
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code($e->getCode() ?: 500);
|
||||
echo json_encode(['status' => 'error', 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?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, 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 'Database.php';
|
||||
require_once 'AuthenticationHandler.php';
|
||||
require_once 'PolylineUtility.php';
|
||||
require_once 'WorkoutValidator.php';
|
||||
|
||||
try {
|
||||
// Get request headers
|
||||
$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', 400);
|
||||
}
|
||||
|
||||
// Get raw request body for signature verification
|
||||
$rawBody = file_get_contents('php://input');
|
||||
if (empty($rawBody)) {
|
||||
throw new Exception('Empty request body', 400);
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
$auth = new AuthenticationHandler();
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Validate HMAC signature
|
||||
$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, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
// 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;
|
||||
|
||||
// Bind parameters
|
||||
$stmt->bind_param(
|
||||
'sisissiiiddidddssdssi',
|
||||
$workout_uuid,
|
||||
$user_id,
|
||||
$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'],
|
||||
$payload['start_time'],
|
||||
$payload['end_time'],
|
||||
$payload['weather_condition'],
|
||||
$payload['temperature_celsius'],
|
||||
$payload['notes'],
|
||||
$payload['is_public']
|
||||
);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Failed to insert workout: ' . $stmt->error, 500);
|
||||
}
|
||||
|
||||
$workout_id = $db->getLastInsertId();
|
||||
$stmt->close();
|
||||
|
||||
// Process and store segments if provided
|
||||
if (!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
|
||||
updateUserStatsCache($db, $user_id);
|
||||
|
||||
// Log successful submission
|
||||
$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(201);
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'workout_id' => $workout_id,
|
||||
'workout_uuid' => $workout_uuid,
|
||||
'message' => 'Workout submitted successfully',
|
||||
'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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Fitness Tracking App - Production Database Schema
|
||||
-- Database: fitness_app
|
||||
-- Created: 2026-04-21
|
||||
|
||||
-- Create Database
|
||||
CREATE DATABASE IF NOT EXISTS fitness_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE fitness_app;
|
||||
|
||||
-- Users Table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the user',
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT 'bcrypt hash',
|
||||
api_key VARCHAR(64) UNIQUE NOT NULL COMMENT 'API key for client authentication',
|
||||
api_secret VARCHAR(64) NOT NULL COMMENT 'Secret for HMAC signature',
|
||||
full_name VARCHAR(100),
|
||||
avatar_url VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_uuid (uuid),
|
||||
INDEX idx_api_key (api_key),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Workouts Table
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
workout_uuid CHAR(36) UNIQUE NOT NULL COMMENT 'Unique identifier for the workout',
|
||||
user_id INT NOT NULL,
|
||||
workout_type ENUM('running', 'walking') NOT NULL,
|
||||
distance_meters INT NOT NULL COMMENT 'Total distance in meters',
|
||||
duration_seconds INT NOT NULL COMMENT 'Total duration in seconds',
|
||||
elevation_gain_meters INT DEFAULT 0 COMMENT 'Elevation gain in meters',
|
||||
elevation_loss_meters INT DEFAULT 0 COMMENT 'Elevation loss in meters',
|
||||
calories_burned FLOAT DEFAULT 0,
|
||||
average_pace_mps FLOAT COMMENT 'Average pace in meters per second',
|
||||
max_speed_mps FLOAT DEFAULT 0 COMMENT 'Max speed in meters per second',
|
||||
route_polyline LONGTEXT NOT NULL COMMENT 'Encoded polyline format (Google Maps Polyline Algorithm)',
|
||||
coordinate_count INT NOT NULL COMMENT 'Total number of GPS coordinates recorded',
|
||||
start_lat DECIMAL(10, 8) NOT NULL,
|
||||
start_lng DECIMAL(11, 8) NOT NULL,
|
||||
end_lat DECIMAL(10, 8) NOT NULL,
|
||||
end_lng DECIMAL(11, 8) NOT NULL,
|
||||
start_time DATETIME NOT NULL COMMENT 'ISO 8601 datetime when workout started',
|
||||
end_time DATETIME NOT NULL COMMENT 'ISO 8601 datetime when workout ended',
|
||||
weather_condition VARCHAR(50),
|
||||
temperature_celsius FLOAT,
|
||||
notes TEXT,
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_workout_uuid (workout_uuid),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_workout_type (workout_type),
|
||||
INDEX idx_synced_at (synced_at),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_user_created (user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Workout Segments Table (for detailed route tracking if needed)
|
||||
CREATE TABLE IF NOT EXISTS workout_segments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
workout_id INT NOT NULL,
|
||||
segment_order INT NOT NULL COMMENT 'Order of segment in workout',
|
||||
duration_seconds INT,
|
||||
distance_meters INT,
|
||||
average_pace_mps FLOAT,
|
||||
index_in_polyline INT COMMENT 'Start index in polyline',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (workout_id) REFERENCES workouts(id) ON DELETE CASCADE,
|
||||
INDEX idx_workout_id (workout_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- API Logs Table (for debugging and analytics)
|
||||
CREATE TABLE IF NOT EXISTS api_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
endpoint VARCHAR(255),
|
||||
method VARCHAR(10),
|
||||
status_code INT,
|
||||
request_hash VARCHAR(64) COMMENT 'Hash of request for deduplication',
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(255),
|
||||
response_time_ms INT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_endpoint (endpoint),
|
||||
INDEX idx_request_hash (request_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Statistics Cache Table (for performance optimization)
|
||||
CREATE TABLE IF NOT EXISTS user_stats_cache (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNIQUE NOT NULL,
|
||||
total_workouts INT DEFAULT 0,
|
||||
total_distance_meters INT DEFAULT 0,
|
||||
total_duration_seconds INT DEFAULT 0,
|
||||
total_calories_burned FLOAT DEFAULT 0,
|
||||
average_pace_mps FLOAT,
|
||||
last_workout_date DATETIME,
|
||||
streak_days INT DEFAULT 0,
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_cached_at (cached_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Create Triggers for audit trail
|
||||
DELIMITER $$
|
||||
|
||||
CREATE TRIGGER workout_audit_insert AFTER INSERT ON workouts
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
INSERT INTO api_logs (endpoint, method, status_code, error_message, created_at)
|
||||
VALUES ('workouts', 'INSERT', 201, NULL, NOW());
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- Stored Procedure to get user workout statistics
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE GetUserStats(IN p_user_id INT)
|
||||
BEGIN
|
||||
SELECT
|
||||
COUNT(*) as total_workouts,
|
||||
SUM(distance_meters) as total_distance,
|
||||
SUM(duration_seconds) as total_duration,
|
||||
SUM(calories_burned) as total_calories,
|
||||
AVG(average_pace_mps) as avg_pace,
|
||||
MAX(created_at) as last_workout,
|
||||
workout_type
|
||||
FROM workouts
|
||||
WHERE user_id = p_user_id
|
||||
GROUP BY workout_type
|
||||
ORDER BY created_at DESC;
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- Initial indexes for optimal query performance
|
||||
CREATE INDEX idx_workouts_stats ON workouts(user_id, workout_type, created_at);
|
||||
CREATE INDEX idx_users_active ON users(is_active, created_at);
|
||||
Reference in New Issue
Block a user