diff --git a/README.md b/README.md index bb6acccba..e71d2d361 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`. + `DELETE /api/v1/me` deletes the signed-in user's account the way Delete account does on the web, with their commits and every device they approved. It answers `204`, and from then on the key gets `device_not_approved`. Each request sends three headers: @@ -126,7 +128,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, 60 stars and unstars a minute, 60 saves and unsaves 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, 60 stars and unstars a minute, 60 saves and unsaves 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 6d30f648e..b6f81dee2 100644 --- a/app/Http/Controllers/Api/CurrentUserController.php +++ b/app/Http/Controllers/Api/CurrentUserController.php @@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api; use App\Actions\DeleteUser; +use App\Http\Resources\CurrentUserResource; use App\Http\Requests\DeleteApiCurrentUserRequest; -use App\Http\Resources\UserResource; use App\Models\User; use App\Queries\NotificationGroupsQuery; use Illuminate\Container\Attributes\CurrentUser; @@ -17,9 +17,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 bedc00d77..6a5aab1f6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,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; @@ -52,6 +54,14 @@ Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); Route::delete('me', [CurrentUserController::class, 'destroy'])->name('api-current-user.destroy'); + // 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 fbceb54a0..49d65f431 100644 --- a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -46,11 +46,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(); +});