first commit

This commit is contained in:
Hamza-Ayed
2026-10-04 00:19:45 +03:00
commit 05f1c9ec6b
93 changed files with 10782 additions and 0 deletions
+262
View File
@@ -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
+195
View File
@@ -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);
}
}
?>
+126
View File
@@ -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() {}
}
?>
+271
View File
@@ -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;
}
}
?>
+277
View File
@@ -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";
}
}
}
}
?>
+50
View File
@@ -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()]);
}
+306
View File
@@ -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();
}
?>
+150
View File
@@ -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);