Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions app/Enums/ApiErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -24,6 +25,7 @@ public function status(): int
self::InvalidSignature, self::DeviceNotApproved => 401,
self::UserBlocked, self::UserUnavailable => 403,
self::RateLimited => 429,
self::GitHubSyncFailed => 503,
};
}
}
8 changes: 8 additions & 0 deletions app/Exceptions/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/Api/CurrentUserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
]);
}
Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/Api/DeviceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());

Expand All @@ -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),
]);
}
Expand Down
27 changes: 27 additions & 0 deletions app/Http/Controllers/Api/UserLaneController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\UpdateUserLane;
use App\Http\Requests\UpdateApiUserLaneRequest;
use App\Http\Resources\CurrentUserResource;
use App\Models\User;
use App\Queries\NotificationGroupsQuery;
use Illuminate\Container\Attributes\CurrentUser;

final readonly class UserLaneController
{
/**
* Saves the user's color, as Save color does on the web, and answers as GET /api/v1/me does.
*/
public function update(UpdateApiUserLaneRequest $request, #[CurrentUser] User $user, UpdateUserLane $action, NotificationGroupsQuery $notifications): CurrentUserResource
{
$action->handle($user, $request->lane(), $request->color());

return CurrentUserResource::make($user->loadCounts())->additional([
'unread_notifications_count' => $notifications->unreadCount($user),
]);
}
}
31 changes: 31 additions & 0 deletions app/Http/Controllers/Api/UserProfileController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\UpdateUserProfile;
use App\Exceptions\ApiException;
use App\Http\Requests\UpdateApiUserProfileRequest;
use App\Http\Resources\CurrentUserResource;
use App\Models\User;
use App\Queries\NotificationGroupsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class UserProfileController
{
/**
* Mirrors the user's GitHub profile again, as Sync from GitHub does on the web, and answers as GET /api/v1/me does.
*/
public function update(UpdateApiUserProfileRequest $request, #[CurrentUser] User $user, UpdateUserProfile $action, NotificationGroupsQuery $notifications): CurrentUserResource|JsonResponse
{
if (! $action->handle($user)) {
return ApiException::gitHubSyncFailed()->render();
}

return CurrentUserResource::make($user->loadCounts())->additional([
'unread_notifications_count' => $notifications->unreadCount($user),
]);
}
}
42 changes: 42 additions & 0 deletions app/Http/Requests/UpdateApiUserLaneRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class UpdateApiUserLaneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, array<int, string>>
*/
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;
}
}
23 changes: 23 additions & 0 deletions app/Http/Requests/UpdateApiUserProfileRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class UpdateApiUserProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
33 changes: 33 additions & 0 deletions app/Http/Resources/CurrentUserResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* The signed-in user: their public profile, plus what only they see on the web's profile settings. That is the lane
* or custom color they chose (`null` for Automatic) and the one Automatic gives them, their GitHub Sponsors page, and when their profile
* was last mirrored from GitHub.
*
* @mixin User
*/
final class CurrentUserResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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(),
];
}
}
9 changes: 7 additions & 2 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
10 changes: 10 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand Down
38 changes: 38 additions & 0 deletions tests/Feature/Controllers/Api/CurrentUserControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/Controllers/Api/DeviceControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading