From 67d4f05342d9b15ff809b499a7a37ca8ce69f527 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Wed, 20 May 2026 16:33:57 +0200 Subject: [PATCH] feat: strava sync --- .env.example | 4 + .gitea/workflows/deploy.yml | 3 + app/Http/Controllers/StravaController.php | 154 ++++++++++++++++++ app/Http/Requests/StravaCallbackRequest.php | 26 +++ app/Http/Requests/StravaSyncRequest.php | 24 +++ app/Models/StravaConnection.php | 44 +++++ app/Models/User.php | 6 + app/Services/StravaClient.php | 100 ++++++++++++ config/deploy.yml | 3 + config/services.php | 7 + ...134741_create_strava_connections_table.php | 36 ++++ routes/api.php | 5 + tests/Feature/StravaConnectionTest.php | 153 +++++++++++++++++ 13 files changed, 565 insertions(+) create mode 100644 app/Http/Controllers/StravaController.php create mode 100644 app/Http/Requests/StravaCallbackRequest.php create mode 100644 app/Http/Requests/StravaSyncRequest.php create mode 100644 app/Models/StravaConnection.php create mode 100644 app/Services/StravaClient.php create mode 100644 database/migrations/2026_05_20_134741_create_strava_connections_table.php create mode 100644 tests/Feature/StravaConnectionTest.php diff --git a/.env.example b/.env.example index 3e0a901..6e499ec 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,10 @@ MAIL_FROM_NAME="${APP_NAME}" EXPO_ACCESS_TOKEN= +STRAVA_CLIENT_ID= +STRAVA_CLIENT_SECRET= +STRAVA_REDIRECT_URI= + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 36b6649..c75f8b8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -48,6 +48,9 @@ jobs: SSH_HOST: ${{ secrets.SSH_HOST }} SSH_USER: ${{ secrets.SSH_USER }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }} + STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }} + STRAVA_REDIRECT_URI: ${{ secrets.STRAVA_REDIRECT_URI }} run: | set -eu diff --git a/app/Http/Controllers/StravaController.php b/app/Http/Controllers/StravaController.php new file mode 100644 index 0000000..b32ddc0 --- /dev/null +++ b/app/Http/Controllers/StravaController.php @@ -0,0 +1,154 @@ +stateCacheKey($state), [ + 'user_id' => $request->user()->getKey(), + ], now()->addMinutes(10)); + + return response()->json([ + 'authorizationUrl' => $strava->authorizationUrl($state), + 'scope' => config('services.strava.scope', 'read,activity:read'), + ]); + } + + public function callback(StravaCallbackRequest $request, StravaClient $strava): JsonResponse + { + $data = $request->validated(); + $state = Cache::pull($this->stateCacheKey($data['state'])); + + if (! is_array($state) || $state['user_id'] !== $request->user()->getKey()) { + return response()->json([ + 'message' => 'La connexion Strava a expiré. Relance la connexion.', + ], 422); + } + + if (isset($data['error'])) { + return response()->json([ + 'message' => 'Connexion Strava refusée.', + ], 422); + } + + $payload = $strava->exchangeCode($data['code']); + $scopes = $this->normalizeScopes($payload['scope'] ?? $data['scope'] ?? ''); + + $connection = $request->user()->stravaConnection()->updateOrCreate([], [ + 'athlete_id' => data_get($payload, 'athlete.id'), + 'access_token' => $payload['access_token'], + 'refresh_token' => $payload['refresh_token'], + 'expires_at' => Carbon::createFromTimestamp((int) $payload['expires_at']), + 'scopes' => $scopes, + 'athlete' => $payload['athlete'] ?? null, + ]); + + return response()->json([ + 'data' => [ + 'connected' => true, + 'athleteId' => $connection->athlete_id, + 'scopes' => $connection->scopes, + 'expiresAt' => $connection->expires_at, + ], + ]); + } + + public function status(Request $request): JsonResponse + { + $connection = $request->user()->stravaConnection; + + return response()->json([ + 'data' => [ + 'connected' => $connection !== null, + 'athleteId' => $connection?->athlete_id, + 'scopes' => $connection?->scopes ?? [], + 'expiresAt' => $connection?->expires_at, + ], + ]); + } + + public function sync(StravaSyncRequest $request, StravaClient $strava): JsonResponse + { + $connection = $request->user()->stravaConnection; + + if (! $connection) { + return response()->json([ + 'message' => 'Connecte Strava avant de synchroniser tes workouts.', + ], 422); + } + + if (! $this->canReadActivities($connection->scopes ?? [])) { + return response()->json([ + 'message' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.', + ], 422); + } + + $activities = $strava->activities( + $connection, + $request->integer('page', 1), + $request->integer('per_page', 30), + ); + + $importedCount = collect($activities) + ->map(fn (array $activity): WorkoutSessions => $this->storeActivity($request, $activity)) + ->count(); + + return response()->json([ + 'data' => [ + 'importedCount' => $importedCount, + 'fetchedCount' => count($activities), + ], + ]); + } + + private function storeActivity(Request $request, array $activity): WorkoutSessions + { + return $request->user()->workouts()->updateOrCreate([ + 'source' => WorkoutSource::STRAVA, + 'external_id' => (string) $activity['id'], + ], [ + 'type' => $activity['sport_type'] ?? $activity['type'] ?? 'workout', + 'title' => $activity['name'] ?? 'Strava workout', + 'duration_seconds' => max(1, (int) ($activity['moving_time'] ?? $activity['elapsed_time'] ?? 0)), + 'calories_burned' => (int) round((float) ($activity['calories'] ?? 0)), + 'distance_meters' => (int) round((float) ($activity['distance'] ?? 0)), + 'started_at' => $activity['start_date'] ?? null, + ]); + } + + private function canReadActivities(array $scopes): bool + { + return in_array('activity:read', $scopes, true) + || in_array('activity:read_all', $scopes, true); + } + + private function normalizeScopes(string $scope): array + { + return Str::of($scope) + ->replace(',', ' ') + ->explode(' ') + ->filter() + ->values() + ->all(); + } + + private function stateCacheKey(string $state): string + { + return "strava:oauth-state:{$state}"; + } +} diff --git a/app/Http/Requests/StravaCallbackRequest.php b/app/Http/Requests/StravaCallbackRequest.php new file mode 100644 index 0000000..286df7f --- /dev/null +++ b/app/Http/Requests/StravaCallbackRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'code' => ['required_without:error', 'string'], + 'error' => ['nullable', 'string'], + 'scope' => ['nullable', 'string'], + 'state' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StravaSyncRequest.php b/app/Http/Requests/StravaSyncRequest.php new file mode 100644 index 0000000..163d58b --- /dev/null +++ b/app/Http/Requests/StravaSyncRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Models/StravaConnection.php b/app/Models/StravaConnection.php new file mode 100644 index 0000000..d990632 --- /dev/null +++ b/app/Models/StravaConnection.php @@ -0,0 +1,44 @@ + 'integer', + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + 'expires_at' => 'datetime', + 'scopes' => 'array', + 'athlete' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 70a59db..765d23f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -78,6 +79,11 @@ class User extends Authenticatable implements FilamentUser return $this->hasMany(WorkoutSessions::class); } + public function stravaConnection(): HasOne + { + return $this->hasOne(StravaConnection::class); + } + public function likedMealPosts(): BelongsToMany { return $this->belongsToMany(MealPosts::class, 'meal_post_likes', 'user_id', 'meal_posts_id') diff --git a/app/Services/StravaClient.php b/app/Services/StravaClient.php new file mode 100644 index 0000000..3a6a751 --- /dev/null +++ b/app/Services/StravaClient.php @@ -0,0 +1,100 @@ + $clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'approval_prompt' => 'auto', + 'scope' => config('services.strava.scope', 'read,activity:read'), + 'state' => $state, + ], '', '&', PHP_QUERY_RFC3986); + } + + public function exchangeCode(string $code): array + { + return $this->tokenRequest() + ->post('/oauth/token', [ + 'client_id' => config('services.strava.client_id'), + 'client_secret' => config('services.strava.client_secret'), + 'code' => $code, + 'grant_type' => 'authorization_code', + ]) + ->throw() + ->json(); + } + + public function refresh(StravaConnection $connection): StravaConnection + { + $payload = $this->tokenRequest() + ->post('/oauth/token', [ + 'client_id' => config('services.strava.client_id'), + 'client_secret' => config('services.strava.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $connection->refresh_token, + ]) + ->throw() + ->json(); + + $connection->update([ + 'access_token' => $payload['access_token'], + 'refresh_token' => $payload['refresh_token'], + 'expires_at' => Carbon::createFromTimestamp((int) $payload['expires_at']), + ]); + + return $connection->refresh(); + } + + public function activities(StravaConnection $connection, int $page, int $perPage): array + { + return Http::baseUrl('https://www.strava.com/api/v3') + ->withToken($this->accessToken($connection)) + ->timeout(10) + ->connectTimeout(3) + ->retry([100, 500, 1000]) + ->get('/athlete/activities', [ + 'page' => $page, + 'per_page' => $perPage, + ]) + ->throw() + ->json(); + } + + private function accessToken(StravaConnection $connection): string + { + if (! $connection->expires_at || $connection->expires_at->lte(now()->addMinutes(5))) { + $connection = $this->refresh($connection); + } + + return $connection->access_token; + } + + private function tokenRequest(): PendingRequest + { + return Http::baseUrl('https://www.strava.com') + ->asForm() + ->timeout(10) + ->connectTimeout(3) + ->retry([100, 500, 1000]); + } +} diff --git a/config/deploy.yml b/config/deploy.yml index 429083b..8d1a48c 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -76,6 +76,9 @@ env: - DB_USERNAME - DB_PASSWORD - MEILISEARCH_KEY + - STRAVA_CLIENT_ID + - STRAVA_CLIENT_SECRET + - STRAVA_REDIRECT_URI volumes: - bemeal_storage_data:/var/www/html/storage/app diff --git a/config/services.php b/config/services.php index 6c964cc..89aff53 100644 --- a/config/services.php +++ b/config/services.php @@ -26,6 +26,13 @@ return [ 'access_token' => env('EXPO_ACCESS_TOKEN'), ], + 'strava' => [ + 'client_id' => env('STRAVA_CLIENT_ID'), + 'client_secret' => env('STRAVA_CLIENT_SECRET'), + 'redirect_uri' => env('STRAVA_REDIRECT_URI'), + 'scope' => env('STRAVA_SCOPE', 'read,activity:read'), + ], + 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), diff --git a/database/migrations/2026_05_20_134741_create_strava_connections_table.php b/database/migrations/2026_05_20_134741_create_strava_connections_table.php new file mode 100644 index 0000000..df1a95c --- /dev/null +++ b/database/migrations/2026_05_20_134741_create_strava_connections_table.php @@ -0,0 +1,36 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('athlete_id')->nullable()->unique(); + $table->text('access_token'); + $table->text('refresh_token'); + $table->timestampTz('expires_at')->nullable()->index(); + $table->json('scopes')->nullable(); + $table->json('athlete')->nullable(); + $table->timestampsTz(); + + $table->unique('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('strava_connections'); + } +}; diff --git a/routes/api.php b/routes/api.php index c7afce8..77cc7eb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\DeviceTokenController; use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; use App\Http\Controllers\PostReviewsController; +use App\Http\Controllers\StravaController; use App\Http\Controllers\WorkoutSessionController; use Illuminate\Support\Facades\Route; @@ -35,6 +36,10 @@ Route::middleware('auth:sanctum')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void { Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index'); Route::post('workouts', [WorkoutSessionController::class, 'store'])->name('workouts.store'); + Route::get('strava/status', [StravaController::class, 'status'])->name('strava.status'); + Route::get('strava/authorization-url', [StravaController::class, 'authorizationUrl'])->name('strava.authorization-url'); + Route::post('strava/callback', [StravaController::class, 'callback'])->name('strava.callback'); + Route::post('strava/sync', [StravaController::class, 'sync'])->name('strava.sync'); }); // Meals diff --git a/tests/Feature/StravaConnectionTest.php b/tests/Feature/StravaConnectionTest.php new file mode 100644 index 0000000..a70ce1c --- /dev/null +++ b/tests/Feature/StravaConnectionTest.php @@ -0,0 +1,153 @@ +set('services.strava.client_id', '248440'); + config()->set('services.strava.client_secret', 'strava-secret'); + config()->set('services.strava.redirect_uri', 'dailymeal://strava/callback'); + config()->set('services.strava.scope', 'read,activity:read'); +}); + +it('returns a strava authorization url for the mobile app', function () { + Sanctum::actingAs(User::factory()->create()); + + $response = $this->getJson('/api/strava/authorization-url'); + + $authorizationUrl = $response + ->assertOk() + ->json('authorizationUrl'); + + expect($authorizationUrl)->toStartWith('https://www.strava.com/oauth/mobile/authorize?'); + + parse_str((string) parse_url($authorizationUrl, PHP_URL_QUERY), $query); + + expect($query['client_id'])->toBe('248440'); + expect($query['redirect_uri'])->toBe('dailymeal://strava/callback'); + expect($query['response_type'])->toBe('code'); + expect($query['approval_prompt'])->toBe('auto'); + expect($query['scope'])->toBe('read,activity:read'); + expect($query['state'])->not->toBeEmpty(); +}); + +it('stores strava tokens from an authorization code', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://www.strava.com/oauth/token' => Http::response([ + 'token_type' => 'Bearer', + 'expires_at' => now()->addHours(6)->timestamp, + 'expires_in' => 21600, + 'refresh_token' => 'refresh-token', + 'access_token' => 'access-token', + 'scope' => 'read activity:read', + 'athlete' => [ + 'id' => 123456, + 'firstname' => 'Leon', + ], + ]), + ]); + + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $authorizationUrl = $this->getJson('/api/strava/authorization-url')->json('authorizationUrl'); + parse_str((string) parse_url($authorizationUrl, PHP_URL_QUERY), $query); + + $response = $this->postJson('/api/strava/callback', [ + 'code' => 'authorization-code', + 'scope' => 'read activity:read', + 'state' => $query['state'], + ]); + + $response + ->assertOk() + ->assertJsonPath('data.connected', true) + ->assertJsonPath('data.athleteId', 123456) + ->assertJsonPath('data.scopes.1', 'activity:read'); + + $connection = $user->fresh()->stravaConnection; + + expect($connection)->not->toBeNull(); + expect($connection->access_token)->toBe('access-token'); + expect($connection->refresh_token)->toBe('refresh-token'); + + $this->assertDatabaseHas('strava_connections', [ + 'user_id' => $user->id, + 'athlete_id' => 123456, + ]); +}); + +it('syncs strava activities into workouts', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://www.strava.com/api/v3/athlete/activities*' => Http::response([ + [ + 'id' => 987654321, + 'name' => 'Morning run', + 'sport_type' => 'Run', + 'moving_time' => 1845, + 'calories' => 432.6, + 'distance' => 5275.4, + 'start_date' => '2026-05-19T07:30:00Z', + ], + ]), + ]); + + $user = User::factory()->create(); + StravaConnection::create([ + 'user_id' => $user->id, + 'athlete_id' => 123456, + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => now()->addHour(), + 'scopes' => ['read', 'activity:read'], + 'athlete' => ['id' => 123456], + ]); + + Sanctum::actingAs($user); + + $response = $this->postJson('/api/strava/sync'); + + $response + ->assertOk() + ->assertJsonPath('data.importedCount', 1) + ->assertJsonPath('data.fetchedCount', 1); + + $this->assertDatabaseHas('workout_sessions', [ + 'user_id' => $user->id, + 'source' => WorkoutSource::STRAVA->value, + 'external_id' => '987654321', + 'type' => 'Run', + 'title' => 'Morning run', + 'duration_seconds' => 1845, + 'calories_burned' => 433, + 'distance_meters' => 5275, + ]); +}); + +it('requires an activity read scope before syncing strava activities', function () { + $user = User::factory()->create(); + StravaConnection::create([ + 'user_id' => $user->id, + 'athlete_id' => 123456, + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => now()->addHour(), + 'scopes' => ['read'], + 'athlete' => ['id' => 123456], + ]); + + Sanctum::actingAs($user); + + $this + ->postJson('/api/strava/sync') + ->assertUnprocessable() + ->assertJsonPath('message', 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.'); +});