From 984f8f3a0bd03e4d8983ea7fee0aa507753b2176 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 01:26:23 +0100 Subject: [PATCH] feat: sync from GitHub and save the lane color from the API The mobile app's Account screen now works like the web's profile settings, so it needs the same two writes. PUT /api/v1/me/github runs UpdateUserProfile, like Sync from GitHub. It shares the web's profile-syncs limit of one a minute, which now answers API requests with a rate_limited JSON error instead of a redirect. When GitHub can't be reached the app gets a 503 with github_sync_failed and the web's message. PUT /api/v1/me/lane runs UpdateUserLane with the web's rules: a lane from 0 to 5, null for Automatic, or a custom color as lowercase #rrggbb with a null lane, as the web's color picker does. Both answer like GET /api/v1/me. That answer, and the one after signing in, now also carry preferred_lane (a lane, a custom color or null), automatic_lane, sponsors_url and github_synced_at, which only the signed-in user gets. --- README.md | 5 +- app/Enums/ApiErrorCode.php | 2 + app/Exceptions/ApiException.php | 8 ++ .../Controllers/Api/CurrentUserController.php | 6 +- app/Http/Controllers/Api/DeviceController.php | 6 +- .../Controllers/Api/UserLaneController.php | 27 +++++ .../Controllers/Api/UserProfileController.php | 31 ++++++ .../Requests/UpdateApiUserLaneRequest.php | 42 +++++++ .../Requests/UpdateApiUserProfileRequest.php | 23 ++++ app/Http/Resources/CurrentUserResource.php | 33 ++++++ app/Providers/AppServiceProvider.php | 9 +- routes/api.php | 10 ++ .../Api/CurrentUserControllerTest.php | 38 +++++++ .../Controllers/Api/DeviceControllerTest.php | 1 + .../Api/UserLaneControllerTest.php | 104 ++++++++++++++++++ .../Api/UserProfileControllerTest.php | 86 +++++++++++++++ 16 files changed, 422 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Api/UserLaneController.php create mode 100644 app/Http/Controllers/Api/UserProfileController.php create mode 100644 app/Http/Requests/UpdateApiUserLaneRequest.php create mode 100644 app/Http/Requests/UpdateApiUserProfileRequest.php create mode 100644 app/Http/Resources/CurrentUserResource.php create mode 100644 tests/Feature/Controllers/Api/UserLaneControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserProfileControllerTest.php diff --git a/README.md b/README.md index 53718050e..16ae376b7 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `none`, or a comma-separated list of `replies`, `quotes`, `stars`, `mentions`, `follows` and `moderation`. The response echoes it as `filters.type`. Listing them marks every notification as read, whatever the filter, and `read_at` says when, so the groups read at or after it are the new ones. `GET /api/v1/notifications/{id}/group` lists the notifications of one group, with the same filter, the way the web expands it. +`GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. + Each request sends three headers: | Header | Value | @@ -124,7 +126,8 @@ An error is JSON with a `message` to show the user. The errors the app has to te | `401` | `device_not_approved` | Nobody approved the key as a device, or it was revoked. | | `403` | `user_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. | | `403` | `user_unavailable` | The profile isn't available to the user, because it blocked them. | -| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | +| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. | +| `503` | `github_sync_failed` | `PUT /api/v1/me/github` only: GitHub couldn't be reached, or didn't answer as expected. Nothing changed. | Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for one that doesn't exist. Unsigned requests to the public endpoints get the same errors as before, without codes. diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php index 05650da54..9bf9aed3e 100644 --- a/app/Enums/ApiErrorCode.php +++ b/app/Enums/ApiErrorCode.php @@ -16,6 +16,7 @@ enum ApiErrorCode: string case UserBlocked = 'user_blocked'; case UserUnavailable = 'user_unavailable'; case RateLimited = 'rate_limited'; + case GitHubSyncFailed = 'github_sync_failed'; public function status(): int { @@ -24,6 +25,7 @@ public function status(): int self::InvalidSignature, self::DeviceNotApproved => 401, self::UserBlocked, self::UserUnavailable => 403, self::RateLimited => 429, + self::GitHubSyncFailed => 503, }; } } diff --git a/app/Exceptions/ApiException.php b/app/Exceptions/ApiException.php index 1b146089c..8d097b335 100644 --- a/app/Exceptions/ApiException.php +++ b/app/Exceptions/ApiException.php @@ -43,6 +43,14 @@ public static function invalidGrant(): self return new self(ApiErrorCode::InvalidGrant, __('This sign-in has expired or was already used. Sign in again in the app.')); } + /** + * The profile could not be mirrored from GitHub again, as the web says it. + */ + public static function gitHubSyncFailed(): self + { + return new self(ApiErrorCode::GitHubSyncFailed, __('Could not sync from GitHub. Try again in a minute.')); + } + public function render(): JsonResponse { return response()->json([ diff --git a/app/Http/Controllers/Api/CurrentUserController.php b/app/Http/Controllers/Api/CurrentUserController.php index beaae6724..442077293 100644 --- a/app/Http/Controllers/Api/CurrentUserController.php +++ b/app/Http/Controllers/Api/CurrentUserController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api; -use App\Http\Resources\UserResource; +use App\Http\Resources\CurrentUserResource; use App\Models\User; use App\Queries\NotificationGroupsQuery; use Illuminate\Container\Attributes\CurrentUser; @@ -14,9 +14,9 @@ /** * The signed-in user, with the unread count the web shows on Notifications. */ - public function show(#[CurrentUser] User $user, NotificationGroupsQuery $notifications): UserResource + public function show(#[CurrentUser] User $user, NotificationGroupsQuery $notifications): CurrentUserResource { - return UserResource::make($user->loadCounts())->additional([ + return CurrentUserResource::make($user->loadCounts())->additional([ 'unread_notifications_count' => $notifications->unreadCount($user), ]); } diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php index 05d5d80db..b3f786582 100644 --- a/app/Http/Controllers/Api/DeviceController.php +++ b/app/Http/Controllers/Api/DeviceController.php @@ -7,7 +7,7 @@ use App\Actions\CreateDevice; use App\Exceptions\ApiException; use App\Http\Requests\CreateApiDeviceRequest; -use App\Http\Resources\UserResource; +use App\Http\Resources\CurrentUserResource; use App\Models\Device; use App\Queries\NotificationGroupsQuery; use Illuminate\Http\JsonResponse; @@ -19,7 +19,7 @@ * Approves the key that signed the request as a device, for the user who approved the app on the web, and answers * as GET /api/v1/me does. A code redeemed by another request in the meantime gets the same answer as a wrong one. */ - public function store(CreateApiDeviceRequest $request, CreateDevice $action, NotificationGroupsQuery $notifications): JsonResponse|UserResource + public function store(CreateApiDeviceRequest $request, CreateDevice $action, NotificationGroupsQuery $notifications): CurrentUserResource|JsonResponse { $device = $action->handle($request->authorizationCode()); @@ -29,7 +29,7 @@ public function store(CreateApiDeviceRequest $request, CreateDevice $action, Not Auth::setUser($device->user); - return UserResource::make($device->user->loadCounts())->additional([ + return CurrentUserResource::make($device->user->loadCounts())->additional([ 'unread_notifications_count' => $notifications->unreadCount($device->user), ]); } diff --git a/app/Http/Controllers/Api/UserLaneController.php b/app/Http/Controllers/Api/UserLaneController.php new file mode 100644 index 000000000..1443047a3 --- /dev/null +++ b/app/Http/Controllers/Api/UserLaneController.php @@ -0,0 +1,27 @@ +handle($user, $request->lane(), $request->color()); + + return CurrentUserResource::make($user->loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($user), + ]); + } +} diff --git a/app/Http/Controllers/Api/UserProfileController.php b/app/Http/Controllers/Api/UserProfileController.php new file mode 100644 index 000000000..97bc03f9f --- /dev/null +++ b/app/Http/Controllers/Api/UserProfileController.php @@ -0,0 +1,31 @@ +handle($user)) { + return ApiException::gitHubSyncFailed()->render(); + } + + return CurrentUserResource::make($user->loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($user), + ]); + } +} diff --git a/app/Http/Requests/UpdateApiUserLaneRequest.php b/app/Http/Requests/UpdateApiUserLaneRequest.php new file mode 100644 index 000000000..1e5cb873d --- /dev/null +++ b/app/Http/Requests/UpdateApiUserLaneRequest.php @@ -0,0 +1,42 @@ +> + */ + public function rules(): array + { + return [ + 'lane' => ['present', 'nullable', 'integer', 'between:0,5'], + 'color' => ['nullable', 'string', 'regex:/^#[0-9a-f]{6}$/', 'prohibits:lane'], + ]; + } + + /** + * The lane the user chose, or null for Automatic. + */ + public function lane(): ?int + { + return $this->filled('lane') ? $this->integer('lane') : null; + } + + /** + * A custom color instead of a lane from the palette, as lowercase six digit hex, or null. + */ + public function color(): ?string + { + return $this->filled('color') ? $this->string('color')->toString() : null; + } +} diff --git a/app/Http/Requests/UpdateApiUserProfileRequest.php b/app/Http/Requests/UpdateApiUserProfileRequest.php new file mode 100644 index 000000000..ee1669fa4 --- /dev/null +++ b/app/Http/Requests/UpdateApiUserProfileRequest.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + ...UserResource::make($this->resource)->toArray($request), + 'preferred_lane' => $this->preferredLane(), + 'automatic_lane' => $this->automaticLane(), + 'sponsors_url' => $this->has_sponsors_listing ? "https://github.com/sponsors/{$this->username}" : null, + 'github_synced_at' => $this->github_synced_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3322d38d7..1d7c98b63 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -70,12 +70,17 @@ private function bootRateLimitingDefaults(): void { RateLimiter::for('commit-reports', fn (Request $request): Limit => Limit::perMinute(5)->by($this->userId($request))); - // Every sync costs four GitHub API requests, out of a shared hourly budget... + // Every sync costs four GitHub API requests, out of a shared hourly budget. The mobile app shares it, and gets the + // same message with a code... RateLimiter::for('profile-syncs', function (Request $request): Limit { $user = $request->user(); assert($user instanceof User); - return Limit::perMinute(1)->by($user->id)->response(function (): RedirectResponse { + return Limit::perMinute(1)->by($user->id)->response(function (Request $request, array $headers): JsonResponse|RedirectResponse { + if ($request->is('api/*')) { + return new ApiException(ApiErrorCode::RateLimited, __('You just synced. Try again in a minute.'), $headers)->render(); + } + Inertia::flash('toast', [ 'type' => 'error', 'message' => 'You just synced. Try again in a minute.', diff --git a/routes/api.php b/routes/api.php index 4721e9d9e..988146cb3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,8 @@ use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserFollowController; +use App\Http\Controllers\Api\UserLaneController; +use App\Http\Controllers\Api\UserProfileController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; use App\Http\Middleware\AuthenticateSignedRequest; @@ -49,6 +51,14 @@ Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); + // User Profile: mirrored from GitHub again, as Sync from GitHub does on the web... + Route::put('me/github', [UserProfileController::class, 'update']) + ->middleware('throttle:profile-syncs') + ->name('api-user-profile.update'); + + // User Lane... + Route::put('me/lane', [UserLaneController::class, 'update'])->name('api-user-lane.update'); + // Log... Route::get('log/{log}', [LogController::class, 'show'])->name('api-log.show'); diff --git a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php index 094cf8f1a..8e8a4619f 100644 --- a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -45,11 +45,49 @@ 'followers_count' => 3, 'following_count' => 2, 'lane' => $user->lane(), + 'preferred_lane' => null, + 'automatic_lane' => $user->automaticLane(), + 'sponsors_url' => null, + 'github_synced_at' => null, ], 'unread_notifications_count' => 1, ]); }); +it('returns what the web shows only the user on their profile settings', function (): void { + $user = User::factory()->create([ + 'id' => '3a788e93-4cf7-4819-9377-94be2f329563', + 'username' => 'octocat', + 'preferred_lane' => 1, + 'has_sponsors_listing' => true, + 'github_synced_at' => '2026-10-01 09:30:00', + ]); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertJsonPath('data.lane', 1) + ->assertJsonPath('data.preferred_lane', 1) + ->assertJsonPath('data.automatic_lane', 4) + ->assertJsonPath('data.sponsors_url', 'https://github.com/sponsors/octocat') + ->assertJsonPath('data.github_synced_at', '2026-10-01T09:30:00Z'); +}); + +it('keeps those to the signed-in user, even on their own profile', function (): void { + $user = User::factory()->create(['preferred_lane' => 1]); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'GET', route('api-user.show', $user)); + + $response->assertOk() + ->assertJsonPath('data.lane', 1) + ->assertJsonMissingPath('data.preferred_lane') + ->assertJsonMissingPath('data.automatic_lane') + ->assertJsonMissingPath('data.sponsors_url') + ->assertJsonMissingPath('data.github_synced_at'); +}); + it('counts unread notifications in groups, like the web', function (): void { $user = User::factory()->create(); $keyPair = apiKeyPairFor($user); diff --git a/tests/Feature/Controllers/Api/DeviceControllerTest.php b/tests/Feature/Controllers/Api/DeviceControllerTest.php index fac0e58a9..99dbb3a44 100644 --- a/tests/Feature/Controllers/Api/DeviceControllerTest.php +++ b/tests/Feature/Controllers/Api/DeviceControllerTest.php @@ -33,6 +33,7 @@ ->assertJsonPath('data.id', $this->user->id) ->assertJsonPath('data.username', 'octocat') ->assertJsonPath('data.lane', $this->user->lane()) + ->assertJsonPath('data.automatic_lane', $this->user->automaticLane()) ->assertJsonPath('data.commits_count', 0) ->assertJsonPath('unread_notifications_count', 0); diff --git a/tests/Feature/Controllers/Api/UserLaneControllerTest.php b/tests/Feature/Controllers/Api/UserLaneControllerTest.php new file mode 100644 index 000000000..3487b6b13 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserLaneControllerTest.php @@ -0,0 +1,104 @@ +user = User::factory()->create(['id' => '3a788e93-4cf7-4819-9377-94be2f329563', 'preferred_lane' => 2]); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may choose a lane, and answers like me', function (int $lane): void { + Notification::factory()->for($this->user, 'recipient')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), ['lane' => $lane]); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonPath('data.lane', $lane) + ->assertJsonPath('data.preferred_lane', $lane) + ->assertJsonPath('data.automatic_lane', 4) + ->assertJsonPath('unread_notifications_count', 1); + + expect($this->user->refresh()->preferred_lane)->toBe($lane); +})->with([0, 1, 3, 4, 5]); + +it('may go back to the automatic lane', function (): void { + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), ['lane' => null]); + + $response->assertOk() + ->assertJsonPath('data.lane', 4) + ->assertJsonPath('data.preferred_lane', null) + ->assertJsonPath('data.automatic_lane', 4); + + expect($this->user->refresh()->preferred_lane)->toBeNull(); +}); + +it('treats an empty lane as automatic', function (): void { + signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), ['lane' => ''])->assertOk(); + + expect($this->user->refresh()->preferred_lane)->toBeNull(); +}); + +it('rejects lanes outside the palette', function (mixed $lane): void { + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), ['lane' => $lane]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('lane'); + + expect($this->user->refresh()->preferred_lane)->toBe(2); +})->with([-1, 6, 1.5, 'teal', '#ff0000', [[1]]]); + +it('may choose a custom color, as the web does', function (): void { + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), ['lane' => null, 'color' => '#1a2b3c']); + + $response->assertOk() + ->assertJsonPath('data.lane', '#1a2b3c') + ->assertJsonPath('data.preferred_lane', '#1a2b3c') + ->assertJsonPath('data.automatic_lane', 4); + + expect($this->user->refresh()->lane_color)->toBe('#1a2b3c') + ->and($this->user->preferred_lane)->toBeNull(); +}); + +it('drops the custom color for a lane from the palette or the automatic one', function (?int $lane): void { + $this->user->update(['lane_color' => '#1a2b3c']); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), ['lane' => $lane]) + ->assertOk() + ->assertJsonPath('data.preferred_lane', $lane); + + expect($this->user->refresh()->lane_color)->toBeNull(); +})->with([1, null]); + +it('rejects colors that are not six digit hex, or that come with a lane', function (array $data): void { + $this->user->update(['lane_color' => '#1a2b3c']); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors('color'); + + expect($this->user->refresh()->lane_color)->toBe('#1a2b3c'); +})->with([ + [['lane' => null, 'color' => '#1A2B3C']], + [['lane' => null, 'color' => '#fff']], + [['lane' => null, 'color' => 'red']], + [['lane' => 1, 'color' => '#1a2b3c']], +]); + +it('requires the lane to be present', function (): void { + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-lane.update'), []); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('lane'); + + expect($this->user->refresh()->preferred_lane)->toBe(2); +}); + +it('rejects requests that are not signed', function (): void { + $this->putJson(route('api-user-lane.update'), ['lane' => 1])->assertUnauthorized(); + + expect($this->user->refresh()->preferred_lane)->toBe(2); +}); diff --git a/tests/Feature/Controllers/Api/UserProfileControllerTest.php b/tests/Feature/Controllers/Api/UserProfileControllerTest.php new file mode 100644 index 000000000..bbd4be9eb --- /dev/null +++ b/tests/Feature/Controllers/Api/UserProfileControllerTest.php @@ -0,0 +1,86 @@ +user = User::factory()->create(['github_id' => '583231', 'username' => 'old-username', 'bio' => 'Old bio']); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('syncs the profile from github, and answers like me', function (): void { + fakeGitHubAccount($this->user, ['login' => 'octocat', 'bio' => 'New bio']); + + Notification::factory()->for($this->user, 'recipient')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-profile.update')); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.name', 'The Octocat') + ->assertJsonPath('data.bio', 'New bio') + ->assertJsonPath('data.company', '@github') + ->assertJsonPath('data.location', 'San Francisco') + ->assertJsonPath('data.website', 'https://github.blog') + ->assertJsonPath('data.github_url', 'https://github.com/octocat') + ->assertJsonPath('data.github_synced_at', now()->toIso8601ZuluString()) + ->assertJsonPath('data.commits_count', 0) + ->assertJsonPath('unread_notifications_count', 1); + + expect($this->user->refresh()) + ->username->toBe('octocat') + ->bio->toBe('New bio') + ->github_synced_at->toDateTimeString()->toBe(now()->toDateTimeString()); +}); + +it('answers with a code when github could not be synced, as the web words it', function (): void { + Http::fake([ + "api.github.com/user/{$this->user->github_id}" => Http::response(['message' => 'Server Error'], 500), + ]); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-profile.update')); + + $response->assertServiceUnavailable() + ->assertExactJson([ + 'message' => 'Could not sync from GitHub. Try again in a minute.', + 'code' => 'github_sync_failed', + ]); + + expect($this->user->refresh()) + ->username->toBe('old-username') + ->github_synced_at->toBeNull(); +}); + +it('allows one sync a minute, shared with the web, with a code', function (): void { + fakeGitHubAccount($this->user); + + $this->actingAs($this->user) + ->fromRoute('user-profile.edit') + ->put(route('user-profile.update')) + ->assertInertiaFlash('toast.type', 'success'); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-profile.update')); + + $response->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertExactJson([ + 'message' => 'You just synced. Try again in a minute.', + 'code' => 'rate_limited', + ]); + + Http::assertSentCount(4); + + $this->travel(1)->minute(); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-profile.update'))->assertOk(); +}); + +it('rejects requests that are not signed', function (): void { + $this->putJson(route('api-user-profile.update'))->assertUnauthorized(); + + Http::assertNothingSent(); +});