From 8e07a112e214a3f3c212351e7021c25a391c25ea Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 15 Oct 2026 19:08:43 +0100 Subject: [PATCH 01/15] refactor: share the SSH signature checks The mobile API signs requests the same way the commit script signs commits, so it needs the same checks: a valid signature, a recent timestamp, a key on a user's GitHub account and a nonce used once. SignedCommits did all of that inline. A new SshSignatures service holds those checks now. verify() checks the signature and returns the key. authenticate() checks the timestamp and nonce, finds the key's owner and spends the nonce once it finds them. SignedCommits calls both, in the same order as before and with the same messages, so the script sees no difference. The nonce cache key is now ssh-signature-nonce instead of commit-nonce, since API requests will use it too. --- app/Services/SignedCommits.php | 48 +------- app/Services/SshSignatures.php | 86 +++++++++++++++ tests/Unit/Services/SshSignaturesTest.php | 127 ++++++++++++++++++++++ 3 files changed, 217 insertions(+), 44 deletions(-) create mode 100644 app/Services/SshSignatures.php create mode 100644 tests/Unit/Services/SshSignaturesTest.php diff --git a/app/Services/SignedCommits.php b/app/Services/SignedCommits.php index e86cebe27..d484bb323 100644 --- a/app/Services/SignedCommits.php +++ b/app/Services/SignedCommits.php @@ -4,11 +4,7 @@ namespace App\Services; -use App\Actions\UpdateUserSshKeys; -use App\Dto\SshPublicKey; -use App\Dto\SshSignature; use App\Models\User; -use Illuminate\Support\Facades\Cache; use InvalidArgumentException; /** @@ -20,9 +16,7 @@ { public const string Namespace = 'devnull-commit'; - private const int MaxClockSkewSeconds = 300; - - public function __construct(private UpdateUserSshKeys $updateUserSshKeys) + public function __construct(private SshSignatures $sshSignatures) { // } @@ -32,9 +26,7 @@ public function __construct(private UpdateUserSshKeys $updateUserSshKeys) */ public function verify(string $payload, string $signature): array { - $sshSignature = SshSignature::parse($signature); - - throw_unless($sshSignature->verify($payload, self::Namespace), InvalidArgumentException::class, 'Invalid signature.'); + $publicKey = $this->sshSignatures->verify($payload, $signature, self::Namespace); $parts = explode("\n", $payload, 4); @@ -42,27 +34,12 @@ public function verify(string $payload, string $signature): array [$timestamp, $nonce, $replyTo, $body] = $parts; - throw_unless( - ctype_digit($timestamp) && abs(now()->getTimestamp() - (int) $timestamp) <= self::MaxClockSkewSeconds, - InvalidArgumentException::class, - 'Signature expired. Check that your clock is right.', - ); - - throw_unless(preg_match('/^[0-9a-f]{32}$/', $nonce) === 1, InvalidArgumentException::class, 'Malformed payload.'); - - $user = $this->findKeyOwner($sshSignature->publicKey); + $user = $this->sshSignatures->authenticate($publicKey, $timestamp, $nonce); throw_if( ! $user instanceof User, InvalidArgumentException::class, - "This SSH key ({$sshSignature->publicKey->fingerprint()}) is not on the GitHub account of a /dev/null user.", - ); - - throw_unless( - // Remembered a bit longer than a signature can be valid (from -skew to +skew around its timestamp)... - Cache::add("commit-nonce:{$nonce}", true, self::MaxClockSkewSeconds * 2 + 60), - InvalidArgumentException::class, - 'This signature was already used.', + "This SSH key ({$publicKey->fingerprint()}) is not on the GitHub account of a /dev/null user.", ); return [ @@ -71,21 +48,4 @@ public function verify(string $payload, string $signature): array 'body' => mb_trim($body), ]; } - - /** - * Finds the user whose GitHub account has the key, re-checking GitHub every few minutes so that - * a key removed from GitHub stops working shortly after. - */ - private function findKeyOwner(SshPublicKey $publicKey): ?User - { - $user = User::query()->whereRelation('sshKeys', 'fingerprint', $publicKey->fingerprint())->first(); - - if ($user === null) { - return null; - } - - $this->updateUserSshKeys->handle($user, unlessRecentlySynced: true); - - return $user->sshKeys()->where('fingerprint', $publicKey->fingerprint())->exists() ? $user : null; - } } diff --git a/app/Services/SshSignatures.php b/app/Services/SshSignatures.php new file mode 100644 index 000000000..6be84f03d --- /dev/null +++ b/app/Services/SshSignatures.php @@ -0,0 +1,86 @@ +verify($message, $namespace), InvalidArgumentException::class, 'Invalid signature.'); + + return $sshSignature->publicKey; + } + + /** + * Finds the devnull user who signed with the key, from the timestamp and nonce signed along with it. + * + * A signature expires a few minutes after its timestamp, and is only good once: its nonce is spent when it finds + * the user. Returns null when the key is not on the GitHub account of a devnull user. + */ + public function authenticate(SshPublicKey $publicKey, string $timestamp, string $nonce): ?User + { + throw_unless( + ctype_digit($timestamp) && abs(now()->getTimestamp() - (int) $timestamp) <= self::MaxClockSkewSeconds, + InvalidArgumentException::class, + 'Signature expired. Check that your clock is right.', + ); + + throw_unless(preg_match('/^[0-9a-f]{32}$/', $nonce) === 1, InvalidArgumentException::class, 'Malformed payload.'); + + $user = $this->findKeyOwner($publicKey); + + if (! $user instanceof User) { + return null; + } + + throw_unless( + // Remembered a bit longer than a signature can be valid (from -skew to +skew around its timestamp)... + Cache::add("ssh-signature-nonce:{$nonce}", true, self::MaxClockSkewSeconds * 2 + 60), + InvalidArgumentException::class, + 'This signature was already used.', + ); + + return $user; + } + + /** + * Finds the user whose GitHub account has the key, re-checking GitHub every few minutes so that + * a key removed from GitHub stops working shortly after. + */ + private function findKeyOwner(SshPublicKey $publicKey): ?User + { + $user = User::query()->whereRelation('sshKeys', 'fingerprint', $publicKey->fingerprint())->first(); + + if ($user === null) { + return null; + } + + $this->updateUserSshKeys->handle($user, unlessRecentlySynced: true); + + return $user->sshKeys()->where('fingerprint', $publicKey->fingerprint())->exists() ? $user : null; + } +} diff --git a/tests/Unit/Services/SshSignaturesTest.php b/tests/Unit/Services/SshSignaturesTest.php new file mode 100644 index 000000000..df5ee30b5 --- /dev/null +++ b/tests/Unit/Services/SshSignaturesTest.php @@ -0,0 +1,127 @@ +keyPair = SshKeyPair::generate(); + $this->user = User::factory()->create(); + + SshKey::factory()->for($this->user)->withPublicKey($this->keyPair->publicKey)->create(); + + fakeGitHubSshKeys($this->user, [$this->keyPair->publicKey]); +}); + +it('returns the key that signed the message', function (): void { + $keyPair = SshKeyPair::generate(); + + $publicKey = resolve(SshSignatures::class)->verify('It was DNS.', $keyPair->sign('It was DNS.', 'devnull-api'), 'devnull-api'); + + expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()); +}); + +it('verifies signatures made by ssh-keygen', function (): void { + $publicKey = resolve(SshSignatures::class)->verify(sshFixture('message'), sshFixture('ed25519.sig'), 'devnull-commit'); + + expect($publicKey->fingerprint())->toBe(SshPublicKey::fromOpenSsh(sshFixture('ed25519.pub'))->fingerprint()); +}); + +it('rejects a signature over another message', function (): void { + $signature = SshKeyPair::generate()->sign('It was DNS.', 'devnull-api'); + + expect(fn (): SshPublicKey => resolve(SshSignatures::class)->verify('It was not DNS.', $signature, 'devnull-api')) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +}); + +it('rejects a signature for another namespace', function (): void { + $signature = SshKeyPair::generate()->sign('It was DNS.', 'devnull-commit'); + + expect(fn (): SshPublicKey => resolve(SshSignatures::class)->verify('It was DNS.', $signature, 'devnull-api')) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +}); + +it('rejects malformed signatures', function (): void { + expect(fn (): SshPublicKey => resolve(SshSignatures::class)->verify('It was DNS.', 'not a signature', 'devnull-api')) + ->toThrow(InvalidArgumentException::class, 'Malformed SSH signature.'); +}); + +it('finds the user whose github account has the key', function (): void { + $user = resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))); + + expect($user?->is($this->user))->toBeTrue(); +}); + +it('accepts small clock differences', function (int $offset): void { + $user = resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, (string) (now()->getTimestamp() + $offset), bin2hex(random_bytes(16))); + + expect($user?->is($this->user))->toBeTrue(); +})->with([-300, 300]); + +it('rejects old, future or non-numeric timestamps', function (string $timestamp): void { + expect(fn (): ?User => resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, $timestamp, bin2hex(random_bytes(16)))) + ->toThrow(InvalidArgumentException::class, 'Signature expired. Check that your clock is right.'); +})->with([ + 'old' => fn (): string => (string) (now()->getTimestamp() - 301), + 'future' => fn (): string => (string) (now()->getTimestamp() + 301), + 'non-numeric' => 'soon', + 'missing' => '', +]); + +it('rejects malformed nonces', function (string $nonce): void { + expect(fn (): ?User => resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), $nonce)) + ->toThrow(InvalidArgumentException::class, 'Malformed payload.'); +})->with([ + 'too short' => 'abc123', + 'uppercase' => mb_strtoupper(str_repeat('ab', 16)), + 'missing' => '', +]); + +it('spends the nonce once it finds the user', function (): void { + $nonce = bin2hex(random_bytes(16)); + $sshSignatures = resolve(SshSignatures::class); + + $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), $nonce); + + expect(fn (): ?User => $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), $nonce)) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); +}); + +it('returns null for keys that are not on the github account of a user, without spending the nonce', function (): void { + $keyPair = SshKeyPair::generate(); + $nonce = bin2hex(random_bytes(16)); + $sshSignatures = resolve(SshSignatures::class); + + expect($sshSignatures->authenticate($keyPair->publicKey, (string) now()->getTimestamp(), $nonce))->toBeNull(); + + $user = User::factory()->create(); + SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + fakeGitHubSshKeys($user, [$keyPair->publicKey]); + + expect($sshSignatures->authenticate($keyPair->publicKey, (string) now()->getTimestamp(), $nonce)?->is($user))->toBeTrue(); +}); + +it('returns null for keys removed from github since the last sync', function (): void { + $keyPair = SshKeyPair::generate(); + $user = User::factory()->create(); + $sshKey = SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + fakeGitHubSshKeys($user); + + expect(resolve(SshSignatures::class)->authenticate($keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))))->toBeNull() + ->and($sshKey->fresh())->toBeNull(); +}); + +it('checks github at most every 5 minutes', function (): void { + $sshSignatures = resolve(SshSignatures::class); + + $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))); + $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))); + + Http::assertSentCount(3); +}); From 7b84aaca7ab67801930a75a32ce5d6705ac035dd Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 11:06:24 +0100 Subject: [PATCH 02/15] feat: sign requests to the API with the user's SSH key The mobile app keeps an ed25519 key on the device, and the user adds it to their GitHub account, usually as a signing key. The app signs each request to /api/v1 with that key, the way the commit script signs commits. There are no passwords or tokens. The app sends X-Devnull-Timestamp, X-Devnull-Nonce and X-SSH-Signature. The signature covers the timestamp, nonce, method, path with query and raw body, joined by newlines, in the devnull-api namespace. SignedRequests rebuilds that message and runs the shared checks. AuthenticateSignedRequest turns a failure into a 401. The public endpoints answer requests without a signature exactly as before. A signed request to the same URLs gets what the web shows that user: their own pending commits, whether they follow the profile, its activity, streaks and pinned commit, and a commit's ancestors. The resources put these on top of the public fields, so every name the public API uses stays the same. A request with any of the signature headers has to be signed right, so a broken signature is a 401 and never the public view. The profile and commit page queries move into UserCommitsQuery, PinnedCommitQuery and CommitPageQuery, so the web and the API load them the same way. They load what upstream's controllers load now: the card data from CommitBuilder::withCardDataFor(), and the reply previews on a profile. Visibility is the web's, blocks included. A commit across a block is a 404, like on the web, and so are its ancestors. The web shows neither of two people across a block the other's profile, so a signed request for one is a 403, with user_blocked for the one who blocked and user_unavailable for the other. ShowApiUserRequest and ListApiUserCommitsRequest make that check. A request without a signature always gets the public view, even when a user is signed in some other way. API routes have no session, so that only happens in tests, like upstream's own tests that sign in on the web and then read the public API. Signed requests get 120 requests a minute per user instead of 60 per IP. GET /api/v1/me is the first endpoint only signed requests can use. If GitHub can't be reached to check a key, the API answers 503 with the message the script prints. Errors from the signed API have a code next to the message, so the app can tell them apart without reading the message. A bad, expired, reused or malformed signature is invalid_signature and a key that isn't on anyone's GitHub account is key_not_on_github, both 401s. GitHub being down is github_unreachable, and going over the 120 a minute is rate_limited. ApiException carries the code and renders the JSON, and ApiErrorCode lists the codes with their statuses. Unsigned requests to the public endpoints get the same 404s and 429s as before, without a code. --- app/Enums/ApiErrorCode.php | 29 +++ app/Exceptions/ApiException.php | 45 ++++ app/Http/Controllers/Api/CommitController.php | 17 +- .../Controllers/Api/CurrentUserController.php | 23 ++ .../Controllers/Api/UserCommitController.php | 13 +- app/Http/Controllers/Api/UserController.php | 24 +- app/Http/Controllers/CommitController.php | 8 +- app/Http/Controllers/UserController.php | 32 +-- .../Middleware/AuthenticateSignedRequest.php | 83 +++++++ .../Requests/ListApiUserCommitsRequest.php | 14 ++ app/Http/Requests/ShowApiCommitRequest.php | 6 +- app/Http/Requests/ShowApiUserRequest.php | 37 +++ app/Http/Resources/CommitResource.php | 53 ++++- app/Http/Resources/UserResource.php | 4 + app/Providers/AppServiceProvider.php | 14 +- app/Queries/CommitPageQuery.php | 23 ++ app/Queries/PinnedCommitQuery.php | 30 +++ app/Queries/UserCommitsQuery.php | 39 +++ app/Services/Mentions.php | 39 ++- app/Services/SignedRequests.php | 57 +++++ bootstrap/app.php | 10 + routes/api.php | 22 +- tests/Feature/ApiSignatureTest.php | 225 ++++++++++++++++++ .../Controllers/Api/CommitControllerTest.php | 175 ++++++++++++++ .../Api/CurrentUserControllerTest.php | 68 ++++++ .../Api/UserCommitControllerTest.php | 145 +++++++++++ .../Controllers/Api/UserControllerTest.php | 154 ++++++++++++ tests/Pest.php | 50 ++++ .../Unit/Providers/AppServiceProviderTest.php | 18 ++ tests/Unit/Queries/CommitPageQueryTest.php | 62 +++++ tests/Unit/Queries/PinnedCommitQueryTest.php | 30 +++ tests/Unit/Queries/UserCommitsQueryTest.php | 60 +++++ tests/Unit/Services/MentionsTest.php | 34 +++ tests/Unit/Services/SignedRequestsTest.php | 101 ++++++++ 34 files changed, 1689 insertions(+), 55 deletions(-) create mode 100644 app/Enums/ApiErrorCode.php create mode 100644 app/Exceptions/ApiException.php create mode 100644 app/Http/Controllers/Api/CurrentUserController.php create mode 100644 app/Http/Middleware/AuthenticateSignedRequest.php create mode 100644 app/Http/Requests/ShowApiUserRequest.php create mode 100644 app/Queries/CommitPageQuery.php create mode 100644 app/Queries/PinnedCommitQuery.php create mode 100644 app/Queries/UserCommitsQuery.php create mode 100644 app/Services/SignedRequests.php create mode 100644 tests/Feature/ApiSignatureTest.php create mode 100644 tests/Feature/Controllers/Api/CurrentUserControllerTest.php create mode 100644 tests/Unit/Queries/CommitPageQueryTest.php create mode 100644 tests/Unit/Queries/PinnedCommitQueryTest.php create mode 100644 tests/Unit/Queries/UserCommitsQueryTest.php create mode 100644 tests/Unit/Services/SignedRequestsTest.php diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php new file mode 100644 index 000000000..7c02f5b6c --- /dev/null +++ b/app/Enums/ApiErrorCode.php @@ -0,0 +1,29 @@ + 401, + self::UserBlocked, self::UserUnavailable => 403, + self::RateLimited => 429, + self::GitHubUnreachable => 503, + }; + } +} diff --git a/app/Exceptions/ApiException.php b/app/Exceptions/ApiException.php new file mode 100644 index 000000000..831ec6b7c --- /dev/null +++ b/app/Exceptions/ApiException.php @@ -0,0 +1,45 @@ + $headers + */ + public function __construct(public readonly ApiErrorCode $errorCode, string $message, array $headers = []) + { + parent::__construct($errorCode->status(), $message, headers: $headers); + } + + /** + * A profile across a block is unavailable to a signed request, as on the web. Only the one who blocked the other + * is told why. + */ + public static function acrossBlock(User $user, bool $blockedByViewer): self + { + return $blockedByViewer + ? new self(ApiErrorCode::UserBlocked, __("You blocked :name. Neither of you can follow, reply to, or star the other, or see each other's commits.", [ + 'name' => $user->name, + ])) + : new self(ApiErrorCode::UserUnavailable, __("This profile isn't available.")); + } + + public function render(): JsonResponse + { + return response()->json([ + 'message' => $this->getMessage(), + 'code' => $this->errorCode->value, + ], $this->getStatusCode(), $this->getHeaders()); + } +} diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index f0adc46d3..353fd18ca 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -7,11 +7,26 @@ use App\Http\Requests\ShowApiCommitRequest; use App\Http\Resources\CommitResource; use App\Models\Commit; +use App\Models\User; +use App\Queries\CommitAncestorsQuery; +use App\Queries\CommitPageQuery; +use Illuminate\Container\Attributes\CurrentUser; final readonly class CommitController { - public function show(ShowApiCommitRequest $request, Commit $commit): CommitResource + /** + * A signed request gets the commit as its page shows it to that user, with the commits it replies to above it. + */ + public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $currentUser, Commit $commit, CommitPageQuery $page, CommitAncestorsQuery $ancestors): CommitResource { + if ($currentUser instanceof User) { + $commit = $page->get($commit, $currentUser); + + return CommitResource::make($commit)->additional([ + 'ancestors' => CommitResource::collection($ancestors->get($commit, $currentUser)), + ]); + } + return CommitResource::make(Commit::query() ->withPublicPreview() ->whereKey($commit->id) diff --git a/app/Http/Controllers/Api/CurrentUserController.php b/app/Http/Controllers/Api/CurrentUserController.php new file mode 100644 index 000000000..beaae6724 --- /dev/null +++ b/app/Http/Controllers/Api/CurrentUserController.php @@ -0,0 +1,23 @@ +loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($user), + ]); + } +} diff --git a/app/Http/Controllers/Api/UserCommitController.php b/app/Http/Controllers/Api/UserCommitController.php index b53a94209..ac98a3d8f 100644 --- a/app/Http/Controllers/Api/UserCommitController.php +++ b/app/Http/Controllers/Api/UserCommitController.php @@ -8,12 +8,23 @@ use App\Http\Resources\CommitResource; use App\Models\User; use App\Queries\CommitBuilder; +use App\Queries\UserCommitsQuery; +use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; final readonly class UserCommitController { - public function index(ListApiUserCommitsRequest $request, User $user): AnonymousResourceCollection + /** + * A signed request gets the profile's Commits or Replies tab as the web shows it to that user. + */ + public function index(ListApiUserCommitsRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserCommitsQuery $commits): AnonymousResourceCollection { + if ($currentUser instanceof User) { + return CommitResource::collection($commits->builder($user, $currentUser, $request->replies()) + ->cursorPaginate(20) + ->appends($request->only('replies'))); + } + return CommitResource::collection($user->commits() ->approved() ->when( diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 7d6374154..6644e6c24 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -4,13 +4,35 @@ namespace App\Http\Controllers\Api; +use App\Http\Requests\ShowApiUserRequest; +use App\Http\Resources\CommitResource; use App\Http\Resources\UserResource; +use App\Models\Commit; use App\Models\User; +use App\Queries\PinnedCommitQuery; +use App\Queries\UserActivityQuery; +use App\Queries\UserStreakQuery; +use Illuminate\Container\Attributes\CurrentUser; final readonly class UserController { - public function show(User $user): UserResource + /** + * A signed request gets the profile as the web shows it to that user. The pinned commit is left out of the + * commits list, so it comes with the profile, as on the web. + */ + public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, PinnedCommitQuery $pinned): UserResource { + if ($currentUser instanceof User) { + $pinnedCommit = $pinned->get($user, $currentUser); + + return UserResource::make($user->loadCounts($currentUser))->additional([ + 'is_following' => $currentUser->isFollowing($user), + 'activity' => $activity->get($user, $currentUser), + 'streaks' => $streaks->get($user, $currentUser), + 'pinned_commit' => $pinnedCommit instanceof Commit ? CommitResource::make($pinnedCommit) : null, + ]); + } + return UserResource::make($user->loadPublicCounts()); } } diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index fcac4cefa..5fde18581 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -13,6 +13,7 @@ use App\Models\Commit; use App\Models\User; use App\Queries\CommitAncestorsQuery; +use App\Queries\CommitPageQuery; use App\Queries\CommitRepliesQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; @@ -26,7 +27,7 @@ /** * Commits are public, so guests and crawlers read the same page, with the commit's link preview and the replies shown under it as structured data. */ - public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, CommitAncestorsQuery $ancestors, CommitRepliesQuery $replies, PageMetadata $metadata): Response + public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, CommitPageQuery $page, CommitAncestorsQuery $ancestors, CommitRepliesQuery $replies, PageMetadata $metadata): Response { $threadReplies = $replies->paginate($commit, $currentUser); @@ -35,10 +36,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs $threadReplies->items(), ); - $commit = Commit::query() - ->withVisibleReplyCount($currentUser) - ->withCardDataFor($currentUser) - ->findOrFail($commit->id); + $commit = $page->get($commit, $currentUser); return Inertia::render('commit/show', [ 'commit' => $commit, diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index f96885742..69501ffac 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -9,13 +9,13 @@ use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; -use App\Queries\CommitBuilder; +use App\Queries\PinnedCommitQuery; use App\Queries\UserAchievementsQuery; use App\Queries\UserActivityQuery; +use App\Queries\UserCommitsQuery; use App\Queries\UserStreakQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; @@ -26,7 +26,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, UserCommitsQuery $commits, PinnedCommitQuery $pinned, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -51,30 +51,8 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'streaks' => $getStreaks, 'achievements' => fn (): array => $achievements->get($user, $currentUser, $getStreaks()), 'tab' => $request->tab(...), - 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'replies' || $user->pinned_commit_id === null ? null : $user->commits() - ->visibleTo($currentUser) - ->topLevel() - ->withVisibleReplyCount($currentUser) - ->withReplyPreviews($currentUser) - ->withCardDataFor($currentUser) - ->find($user->pinned_commit_id), - 'commits' => Inertia::scroll(fn () => $user->commits() - ->visibleTo($currentUser) - ->when( - $request->tab() === 'replies', - fn (CommitBuilder $query): CommitBuilder => $query - ->whereNotNull('parent_id') - ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query - ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($currentUser)) - ->with('user')]), - fn (CommitBuilder $query): CommitBuilder => $query->topLevel() - ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), - ) - ->withVisibleReplyCount($currentUser) - ->withReplyPreviews($currentUser) - ->withCardDataFor($currentUser) - ->latest() - ->latest('id') + 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'replies' ? null : $pinned->get($user, $currentUser), + 'commits' => Inertia::scroll(fn () => $commits->builder($user, $currentUser, $request->tab() === 'replies') ->cursorPaginate(20) ->appends(['tab' => $request->tab()])), ])->withViewData(['preview' => $currentUser instanceof User ? null : $metadata->user($user->loadPublicCounts())]); diff --git a/app/Http/Middleware/AuthenticateSignedRequest.php b/app/Http/Middleware/AuthenticateSignedRequest.php new file mode 100644 index 000000000..b695cbb6a --- /dev/null +++ b/app/Http/Middleware/AuthenticateSignedRequest.php @@ -0,0 +1,83 @@ +signedRequests->isSigned($request)) { + return $this->asGuest($request, $next); + } + + try { + ['publicKey' => $publicKey, 'user' => $user] = $this->signedRequests->verify($request); + } catch (InvalidArgumentException $invalidArgumentException) { + throw new ApiException(ApiErrorCode::InvalidSignature, $invalidArgumentException->getMessage()); + } + + if (! $user instanceof User) { + throw new ApiException(ApiErrorCode::KeyNotOnGitHub, __('This SSH key (:fingerprint) is not on the GitHub account of a devnull user.', [ + 'fingerprint' => $publicKey->fingerprint(), + ])); + } + + Auth::setUser($user); + + return $next($request); + } + + /** + * Only a signature signs a request in. A request without one gets what a guest gets, even when a user is already + * signed in some other way, such as on the web in a test, and that user is signed in again afterwards. + * + * @param Closure(Request): (Response) $next + */ + private function asGuest(Request $request, Closure $next): Response + { + $user = Auth::user(); + + if (! $user instanceof User) { + return $next($request); + } + + Auth::forgetUser(); + + try { + return $next($request); + } finally { + Auth::setUser($user); + } + } +} diff --git a/app/Http/Requests/ListApiUserCommitsRequest.php b/app/Http/Requests/ListApiUserCommitsRequest.php index 59299d179..149d7958a 100644 --- a/app/Http/Requests/ListApiUserCommitsRequest.php +++ b/app/Http/Requests/ListApiUserCommitsRequest.php @@ -4,12 +4,26 @@ namespace App\Http\Requests; +use App\Exceptions\ApiException; +use App\Models\User; use Illuminate\Foundation\Http\FormRequest; final class ListApiUserCommitsRequest extends FormRequest { + /** + * Anyone may list the commits of a profile, but the web shows neither of two people across a block the other's + * commits, so a signed request across one gets a code instead. + */ public function authorize(): bool { + $user = $this->route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id)); + return true; } diff --git a/app/Http/Requests/ShowApiCommitRequest.php b/app/Http/Requests/ShowApiCommitRequest.php index 6329f6af2..0e3d46916 100644 --- a/app/Http/Requests/ShowApiCommitRequest.php +++ b/app/Http/Requests/ShowApiCommitRequest.php @@ -4,19 +4,21 @@ namespace App\Http\Requests; -use App\Enums\CommitStatus; use App\Models\Commit; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class ShowApiCommitRequest extends FormRequest { + /** + * Anyone may see an approved commit. A signed request may also see its user's own pending or rejected commits. + */ public function authorize(): Response { $commit = $this->route('commit'); assert($commit instanceof Commit); - return $commit->status === CommitStatus::Approved ? Response::allow() : Response::denyAsNotFound(); + return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/ShowApiUserRequest.php b/app/Http/Requests/ShowApiUserRequest.php new file mode 100644 index 000000000..d5a92b2bf --- /dev/null +++ b/app/Http/Requests/ShowApiUserRequest.php @@ -0,0 +1,37 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id)); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index de7df67ab..95959e0c7 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -5,10 +5,24 @@ namespace App\Http\Resources; use App\Models\Commit; +use App\Models\User; +use App\Services\CodeSpans; +use App\Services\Mentions; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** + * A commit and its author, as anyone may see them. + * + * A signed request gets the commit as the web shows it to that user instead: parent_id is the parent the commit + * replies to, whoever may see it, and on top come the body split into prose, inline code and highlighted code + * blocks, where it mentions someone, its status, whether the author pinned it, and whether the user starred or saved + * it. As on the web, the counts, link_preview, images, starred, is_saved and parent are + * only there when they were loaded, so ancestors and the commits of notifications come without some of them. + * + * A deleted commit that still has replies stays in its thread as a tombstone: its id, parent_id, deleted_at and + * replies_count, but never its author or body. Only signed requests are ever sent one. + * * @mixin Commit */ final class CommitResource extends JsonResource @@ -18,18 +32,45 @@ final class CommitResource extends JsonResource */ public function toArray(Request $request): array { - return [ + if ($this->trashed()) { + return [ + 'id' => $this->id, + 'parent_id' => $this->parent_id, + 'deleted_at' => $this->deleted_at?->toIso8601ZuluString(), + 'replies_count' => $this->whenCounted('replies'), + ]; + } + + $isSigned = $request->user() instanceof User; + + $commit = [ 'id' => $this->id, 'short_hash' => mb_substr(str_replace('-', '', $this->id), -7), 'body' => $this->body, - 'parent_id' => $this->parent?->id, + 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, 'html_url' => route('commit.show', [$this->user, $this->resource]), 'created_at' => $this->created_at->toIso8601ZuluString(), - 'stars_count' => $this->stars_count, - 'replies_count' => $this->replies_count, - 'link_preview' => $this->linkPreview, - 'images' => $this->images, + 'stars_count' => $this->whenCounted('stars'), + 'replies_count' => $this->whenCounted('replies'), + 'link_preview' => $this->whenLoaded('linkPreview'), + 'images' => $this->whenLoaded('images'), 'author' => UserResource::make($this->user), ]; + + if (! $isSigned) { + return $commit; + } + + return [ + ...$commit, + 'body_segments' => resolve(CodeSpans::class)->highlight($this->body), + 'mentions' => resolve(Mentions::class)->positions($this->body), + 'status' => $this->status, + 'is_pinned' => $this->user?->pinned_commit_id === $this->id, + 'starred' => $this->whenHas('starred'), + 'is_saved' => $this->whenHas('is_saved'), + 'deleted_at' => null, + 'parent' => $this->whenLoaded('parent', fn (Commit $parent): self => self::make($parent)), + ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 2ff28693b..1b4e97fd8 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -9,6 +9,9 @@ use Illuminate\Http\Resources\Json\JsonResource; /** + * A user's public profile, with their counts when they were loaded. A signed request also gets the lane, the color + * the web draws them in. + * * @mixin User */ final class UserResource extends JsonResource @@ -34,6 +37,7 @@ public function toArray(Request $request): array 'commits_count' => $this->whenCounted('commits'), 'followers_count' => $this->whenCounted('followers'), 'following_count' => $this->whenCounted('following'), + 'lane' => $this->when($request->user() instanceof User, fn (): int => $this->lane()), ]; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 85b4dc3e9..24c82f839 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,8 @@ namespace App\Providers; use App\Contracts\Services\DnsResolver; +use App\Enums\ApiErrorCode; +use App\Exceptions\ApiException; use App\Models\User; use App\Services\DnsResolvers\SystemDnsResolver; use Illuminate\Cache\RateLimiting\Limit; @@ -80,7 +82,17 @@ private function bootRateLimitingDefaults(): void }); }); - RateLimiter::for('api', fn (Request $request): Limit => Limit::perMinute(60)->by((string) $request->ip())); + // The mobile app's 429 comes with a code. The public API's stays as it is... + $rateLimited = fn (Request $request, array $headers): JsonResponse => new ApiException(ApiErrorCode::RateLimited, 'Too Many Attempts.', $headers)->render(); + + RateLimiter::for('api', function (Request $request) use ($rateLimited): Limit { + $user = $request->user(); + + // Signed requests from the mobile app share one budget per user, whichever network the phone is on... + return $user instanceof User + ? Limit::perMinute(120)->by($user->id)->response($rateLimited) + : Limit::perMinute(60)->by((string) $request->ip()); + }); // GitHub fetches every README image through a few proxy addresses, so each graph gets its own budget... RateLimiter::for('activity-graph', fn (Request $request): Limit => Limit::perMinute(60)->by("{$request->ip()}|{$request->path()}")); diff --git a/app/Queries/CommitPageQuery.php b/app/Queries/CommitPageQuery.php new file mode 100644 index 000000000..c2f654a44 --- /dev/null +++ b/app/Queries/CommitPageQuery.php @@ -0,0 +1,23 @@ +withVisibleReplyCount($viewer) + ->withCardDataFor($viewer) + ->findOrFail($commit->id); + } +} diff --git a/app/Queries/PinnedCommitQuery.php b/app/Queries/PinnedCommitQuery.php new file mode 100644 index 000000000..ee66f9a98 --- /dev/null +++ b/app/Queries/PinnedCommitQuery.php @@ -0,0 +1,30 @@ +pinned_commit_id === null) { + return null; + } + + return Commit::query() + ->whereBelongsTo($user) + ->visibleTo($viewer) + ->topLevel() + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->find($user->pinned_commit_id); + } +} diff --git a/app/Queries/UserCommitsQuery.php b/app/Queries/UserCommitsQuery.php new file mode 100644 index 000000000..6d0284520 --- /dev/null +++ b/app/Queries/UserCommitsQuery.php @@ -0,0 +1,39 @@ +whereBelongsTo($user) + ->visibleTo($viewer) + ->when( + $replies, + fn (CommitBuilder $query): CommitBuilder => $query + ->whereNotNull('parent_id') + ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) + ->with('user')]), + fn (CommitBuilder $query): CommitBuilder => $query->topLevel() + ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), + ) + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->latest() + ->latest('id'); + } +} diff --git a/app/Services/Mentions.php b/app/Services/Mentions.php index 352c1c31c..cae3c974e 100644 --- a/app/Services/Mentions.php +++ b/app/Services/Mentions.php @@ -6,6 +6,8 @@ final readonly class Mentions { + private const string Pattern = '/(?codeSpans->withoutCode($text), - $matches, - ); + preg_match_all(self::Pattern, $this->codeSpans->withoutCode($text), $matches); return array_values(array_unique(array_map(strtolower(...), $matches[1]))); } + + /** + * Finds where each "@username" is in the text, by the same rules and also skipping code, so a client can link it + * to the profile like the web does. The username keeps its case, and the offset and length count characters in + * the whole text and include the "@". + * + * @return list + */ + public function positions(string $text): array + { + $positions = []; + $offset = 0; + + foreach ($this->codeSpans->split($text) as $index => $part) { + if ($index % 2 === 0) { + preg_match_all(self::Pattern, $part, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + foreach ($matches as $match) { + $positions[] = [ + 'username' => $match[1][0], + 'offset' => $offset + mb_strlen(mb_strcut($part, 0, $match[0][1])), + 'length' => mb_strlen($match[0][0]), + ]; + } + } + + $offset += mb_strlen($part); + } + + return $positions; + } } diff --git a/app/Services/SignedRequests.php b/app/Services/SignedRequests.php new file mode 100644 index 000000000..46f9ef3a4 --- /dev/null +++ b/app/Services/SignedRequests.php @@ -0,0 +1,57 @@ +\n\n\n\n". The timestamp and nonce + * are also sent in the X-Devnull-Timestamp and X-Devnull-Nonce headers, and the signature in X-SSH-Signature. + */ +final readonly class SignedRequests +{ + public const string Namespace = 'devnull-api'; + + private const array Headers = ['X-Devnull-Timestamp', 'X-Devnull-Nonce', 'X-SSH-Signature']; + + public function __construct(private SshSignatures $sshSignatures) + { + // + } + + /** + * Whether the request comes with any of the signature headers. It still has to pass verify() to be trusted. + */ + public function isSigned(Request $request): bool + { + return array_any(self::Headers, fn (string $header): bool => $request->headers->has($header)); + } + + /** + * The user is null when the key is not on the GitHub account of a devnull user. + * + * @return array{publicKey: SshPublicKey, user: User|null} + */ + public function verify(Request $request): array + { + $timestamp = $request->headers->get('X-Devnull-Timestamp', '') ?? ''; + $nonce = $request->headers->get('X-Devnull-Nonce', '') ?? ''; + + $publicKey = $this->sshSignatures->verify( + implode("\n", [$timestamp, $nonce, $request->getMethod(), $request->getRequestUri(), $request->getContent()]), + $request->headers->get('X-SSH-Signature', '') ?? '', + self::Namespace, + ); + + return [ + 'publicKey' => $publicKey, + 'user' => $this->sshSignatures->authenticate($publicKey, $timestamp, $nonce), + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 7c0d1327e..44f3175bc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,7 +2,10 @@ declare(strict_types=1); +use App\Enums\ApiErrorCode; use App\Enums\Log; +use App\Exceptions\ApiException; +use App\Http\Middleware\AuthenticateSignedRequest; use App\Http\Middleware\AuthenticateSshSignature; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; @@ -13,6 +16,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -37,6 +41,7 @@ // Rate limits apply to the user authenticated by their SSH signature... $middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSshSignature::class); + $middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSignedRequest::class); $middleware->web(append: [ HandleAppearance::class, @@ -67,4 +72,9 @@ return response(($message ?: Response::$statusTexts[$status] ?? 'Error.')."\n", $status, $headers) ->header('Content-Type', 'text/plain; charset=utf-8'); }); + + // Signed requests from the mobile app also check keys against GitHub... + $exceptions->render(fn (ConnectionException|RequestException $exception, Request $request): ?JsonResponse => $request->is('api/v1/*') + ? new ApiException(ApiErrorCode::GitHubUnreachable, 'Could not reach GitHub to check your SSH key. Try again in a moment.')->render() + : null); })->create(); diff --git a/routes/api.php b/routes/api.php index f60b4e668..36edbf995 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,10 +3,12 @@ declare(strict_types=1); use App\Http\Controllers\Api\CommitController as ApiCommitController; +use App\Http\Controllers\Api\CurrentUserController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; +use App\Http\Middleware\AuthenticateSignedRequest; use App\Http\Middleware\AuthenticateSshSignature; use App\Http\Middleware\ThrottleCommits; use Illuminate\Support\Facades\Route; @@ -21,10 +23,18 @@ }); Route::prefix('v1')->middleware('throttle:api')->group(function (): void { - Route::get('users/{user:username}', [UserController::class, 'show']) - ->name('api-user.show'); - Route::get('users/{user:username}/commits', [UserCommitController::class, 'index']) - ->name('api-user-commit.index'); - Route::get('commits/{commit}', [ApiCommitController::class, 'show']) - ->name('api-commit.show'); + // Public, or as the web shows them to the user when signed by the mobile app... + Route::middleware(AuthenticateSignedRequest::optional())->group(function (): void { + Route::get('users/{user:username}', [UserController::class, 'show']) + ->name('api-user.show'); + Route::get('users/{user:username}/commits', [UserCommitController::class, 'index']) + ->name('api-user-commit.index'); + Route::get('commits/{commit}', [ApiCommitController::class, 'show']) + ->name('api-commit.show'); + }); + + // Signed by the mobile app with an SSH key that is on the user's GitHub account... + Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { + Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); + }); }); diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php new file mode 100644 index 000000000..281e43004 --- /dev/null +++ b/tests/Feature/ApiSignatureTest.php @@ -0,0 +1,225 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('authenticates a request signed with a key on the github account of the user', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertCookieMissing(config('session.cookie')); + + $this->assertAuthenticatedAs($this->user); +}); + +it('rejects a request that differs from the one that was signed', function (): void { + $server = signApiRequest($this->keyPair, 'GET', route('api-current-user.show')); + + $response = $this->call('GET', route('api-current-user.show', ['tampered' => 1]), server: $server); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Invalid signature.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a signature for another namespace', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), namespace: 'devnull-commit'); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Invalid signature.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a request without a signature', function (): void { + $response = $this->getJson(route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Malformed SSH signature.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects an expired or future timestamp', function (int $offset): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), timestamp: now()->getTimestamp() + $offset); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Signature expired. Check that your clock is right.') + ->assertJsonPath('code', 'invalid_signature'); +})->with([-301, 301]); + +it('rejects a malformed nonce', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), nonce: 'nonce'); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Malformed payload.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a reused nonce', function (): void { + $nonce = bin2hex(random_bytes(16)); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), nonce: $nonce)->assertOk(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), nonce: $nonce); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'This signature was already used.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects keys that are not on the github account of a user', function (): void { + $keyPair = SshKeyPair::generate(); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertJsonPath('message', "This SSH key ({$keyPair->publicKey->fingerprint()}) is not on the GitHub account of a devnull user.") + ->assertJsonPath('code', 'key_not_on_github'); + + Http::assertNothingSent(); +}); + +it('rejects keys removed from github since the last sync', function (): void { + $keyPair = SshKeyPair::generate(); + $user = User::factory()->create(); + $sshKey = SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + fakeGitHubSshKeys($user); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertJsonPath('message', "This SSH key ({$keyPair->publicKey->fingerprint()}) is not on the GitHub account of a devnull user.") + ->assertJsonPath('code', 'key_not_on_github'); + + expect($sshKey->fresh())->toBeNull(); +}); + +it('fails when github cannot be reached', function (): void { + $keyPair = SshKeyPair::generate(); + $user = User::factory()->create(); + + SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + Http::fake([ + "api.github.com/user/{$user->github_id}" => Http::response(['message' => 'Server Error'], 500), + ]); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertServiceUnavailable() + ->assertJsonPath('message', 'Could not reach GitHub to check your SSH key. Try again in a moment.') + ->assertJsonPath('code', 'github_unreachable'); +}); + +it('never answers a badly signed request to a public endpoint with the public view', function (Closure $uri, Closure $server, string $message): void { + $uri = $uri(Commit::factory()->create()); + + $response = $this->call('GET', $uri, server: $server($this->keyPair, $uri)); + + $response->assertUnauthorized() + ->assertJsonPath('message', $message) + ->assertJsonPath('code', 'invalid_signature') + ->assertJsonMissingPath('data'); + + $this->assertGuest(); +})->with([ + 'a profile' => fn (Commit $commit): string => route('api-user.show', $commit->user), + 'a list of commits' => fn (Commit $commit): string => route('api-user-commit.index', $commit->user), + 'a commit' => fn (Commit $commit): string => route('api-commit.show', $commit), + 'a commit that does not exist' => fn (): string => 'api/v1/commits/01a13f6e-ebdd-72c7-bf9d-be2a9b2315a3', +])->with([ + 'a wrong signature' => [ + fn (SshKeyPair $keyPair, string $uri): array => [...signApiRequest($keyPair, 'GET', $uri), 'HTTP_X_DEVNULL_NONCE' => str_repeat('0', 32)], + 'Invalid signature.', + ], + 'an empty signature' => [ + fn (SshKeyPair $keyPair, string $uri): array => [...signApiRequest($keyPair, 'GET', $uri), 'HTTP_X_SSH_SIGNATURE' => ''], + 'Malformed SSH signature.', + ], + 'an expired signature' => [ + fn (SshKeyPair $keyPair, string $uri): array => signApiRequest($keyPair, 'GET', $uri, timestamp: now()->getTimestamp() - 301), + 'Signature expired. Check that your clock is right.', + ], +]); + +it('answers a request without a signature with the public view, whoever is signed in on the web', function (Closure $uri): void { + $commit = Commit::factory()->for($this->user)->pending()->create(); + $public = Commit::factory()->for($this->user)->create(); + + $this->actingAs($this->user) + ->getJson($uri($commit)) + ->assertJsonMissingPath('data.status') + ->assertJsonMissing(['id' => $commit->id]); + + $this->assertAuthenticatedAs($this->user); + $this->getJson(route('api-commit.show', $public))->assertOk()->assertJsonMissingPath('data.status'); +})->with([ + 'a profile' => fn (Commit $commit): string => route('api-user.show', $commit->user), + 'a list of commits' => fn (Commit $commit): string => route('api-user-commit.index', $commit->user), + 'a commit' => fn (Commit $commit): string => route('api-commit.show', $commit), +]); + +it('treats any one of the signature headers as a signed request', function (string $header): void { + $commit = Commit::factory()->create(); + + $response = $this->getJson(route('api-commit.show', $commit), [$header => '1']); + + $response->assertUnauthorized(); +})->with(['X-Devnull-Timestamp', 'X-Devnull-Nonce', 'X-SSH-Signature']); + +it('limits each signed-in user to 120 requests a minute, wherever they come from', function (): void { + $sign = fn (string $address): array => [...signApiRequest($this->keyPair, 'GET', route('api-current-user.show')), 'REMOTE_ADDR' => $address]; + + foreach (range(1, 120) as $attempt) { + $this->call('GET', route('api-current-user.show'), server: $sign('10.0.0.1'))->assertOk(); + } + + $this->call('GET', route('api-current-user.show'), server: $sign('10.0.0.1')) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertJsonPath('message', 'Too Many Attempts.') + ->assertJsonPath('code', 'rate_limited'); + + $this->call('GET', route('api-current-user.show'), server: $sign('10.0.0.2'))->assertTooManyRequests(); + + $this->call('GET', route('api-current-user.show'), server: [...signApiRequest(apiKeyPairFor(User::factory()->create()), 'GET', route('api-current-user.show')), 'REMOTE_ADDR' => '10.0.0.1']) + ->assertOk(); + + Auth::forgetGuards(); + + $this->getJson(route('api-user.show', $this->user), ['REMOTE_ADDR' => '10.0.0.1']) + ->assertOk() + ->assertJsonMissingPath('data.lane'); +}); + +it("leaves the public api's 429 without a code", function (): void { + $commit = Commit::factory()->create(); + + foreach (range(1, 60) as $attempt) { + $this->getJson(route('api-commit.show', $commit))->assertOk(); + } + + $this->getJson(route('api-commit.show', $commit)) + ->assertTooManyRequests() + ->assertJsonPath('message', 'Too Many Attempts.') + ->assertJsonMissingPath('code'); +}); + +it('answers unknown api routes in json', function (): void { + $response = $this->get('api/v1/unknown'); + + $response->assertNotFound() + ->assertHeader('Content-Type', 'application/json'); +}); diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index bfc2093ba..e18417a35 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\CommitStatus; +use App\Models\Bookmark; use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; @@ -189,3 +190,177 @@ $this->getJson(route('api-commit.show', $commit), ['REMOTE_ADDR' => '10.0.0.2'])->assertOk(); }); + +it('shows a signed request the commit as its page shows it to its user', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + $keyPair = apiKeyPairFor($user); + $root = Commit::factory()->create(['body' => 'Who broke prod?', 'created_at' => now()->subMinute()]); + $commit = Commit::factory()->for($user)->for($root, 'parent')->create([ + 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', + 'body' => 'Ask @nunomaduro about `@Override`', + ]); + $image = CommitImage::factory()->for($commit)->create(); + Commit::factory()->for($commit, 'parent')->create(); + Commit::factory()->for($commit, 'parent')->pending()->create(); + $commit->stars()->attach($user); + Bookmark::factory()->create(['user_id' => $user->id, 'commit_id' => $commit->id]); + + $response = signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)); + + $author = fn (User $author): array => [ + 'id' => $author->id, + 'username' => $author->username, + 'name' => $author->name, + 'avatar_url' => $author->avatar_url, + 'bio' => $author->bio, + 'company' => $author->company, + 'location' => $author->location, + 'website' => $author->website, + 'github_url' => "https://github.com/{$author->username}", + 'html_url' => route('user.show', $author), + 'feed_url' => route('user-feed.show', $author), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $author->lane(), + ]; + + $response->assertOk() + ->assertExactJson([ + 'data' => [ + 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', + 'short_hash' => 'e3f4a5b', + 'body' => 'Ask @nunomaduro about `@Override`', + 'parent_id' => $root->id, + 'html_url' => route('commit.show', [$user, $commit]), + 'created_at' => now()->toIso8601ZuluString(), + 'stars_count' => 1, + 'replies_count' => 1, + 'link_preview' => null, + 'images' => [ + ['id' => $image->id, 'width' => 1200, 'height' => 800, 'description' => null, 'url' => route('commit-image.show', $image)], + ], + 'author' => $author($user), + 'body_segments' => [ + ['type' => 'text', 'text' => 'Ask @nunomaduro about '], + ['type' => 'code', 'code' => '@Override'], + ], + 'mentions' => [['username' => 'nunomaduro', 'offset' => 4, 'length' => 11]], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => true, + 'is_saved' => true, + 'deleted_at' => null, + ], + 'ancestors' => [[ + 'id' => $root->id, + 'short_hash' => mb_substr(str_replace('-', '', $root->id), -7), + 'body' => 'Who broke prod?', + 'parent_id' => null, + 'html_url' => route('commit.show', [$root->user, $root]), + 'created_at' => now()->subMinute()->toIso8601ZuluString(), + 'stars_count' => 0, + 'link_preview' => null, + 'images' => [], + 'author' => $author($root->user), + 'body_segments' => [['type' => 'text', 'text' => 'Who broke prod?']], + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => false, + 'is_saved' => false, + 'deleted_at' => null, + ]], + ]); +}); + +it('shows a signed-in user their own pending commits, and the parents they may see', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $parent = Commit::factory()->for($user)->pending()->create(); + $commit = Commit::factory()->for($user)->for($parent, 'parent')->pending()->create(); + $image = CommitImage::factory()->for($commit)->create(); + + $this->getJson(route('api-commit.show', $commit))->assertNotFound(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.parent_id', $parent->id) + ->assertJsonPath('data.images.0.id', $image->id) + ->assertJsonPath('ancestors.0.id', $parent->id) + ->assertJsonPath('ancestors.0.status', 'pending'); +}); + +it('hides from a signed request the commits its user may not see', function (Closure $factory): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $factory()))->assertNotFound(); +})->with([ + 'pending' => [fn (): Commit => Commit::factory()->pending()->create()], + 'rejected' => [fn (): Commit => Commit::factory()->rejected()->create()], + 'deleted' => [fn (): Commit => tap(Commit::factory()->create())->delete()], +]); + +it('hides from a signed request the commits and ancestors across a block, as the web does', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $blocker = User::factory()->create(); + $blocked = Commit::factory()->for($blocker)->create(); + $reply = Commit::factory()->for($blocked, 'parent')->create(); + + $blocker->blocking()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $blocked))->assertNotFound(); + signedApiRequest($keyPair, 'GET', route('api-commit.show', $reply)) + ->assertOk() + ->assertJsonPath('data.parent_id', $blocked->id) + ->assertJsonCount(0, 'ancestors'); + $this->getJson(route('api-commit.show', $blocked))->assertOk(); +}); + +it('hides from a signed request the ancestors its user may not see', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $hidden = Commit::factory()->for(Commit::factory(), 'parent')->pending()->create(); + $commit = Commit::factory()->for($user)->for($hidden, 'parent')->create(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.parent_id', $hidden->id) + ->assertJsonCount(0, 'ancestors'); +}); + +it('shows deleted ancestors to a signed request as tombstones', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $root = Commit::factory()->create(['created_at' => now()->subMinutes(2)]); + $deleted = Commit::factory()->for($root, 'parent')->create(['body' => 'Gone', 'created_at' => now()->subMinute()]); + $commit = Commit::factory()->for($deleted, 'parent')->create(); + $deleted->delete(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonCount(2, 'ancestors') + ->assertJsonPath('ancestors.0.id', $root->id) + ->assertJsonPath('ancestors.1', [ + 'id' => $deleted->id, + 'parent_id' => $root->id, + 'deleted_at' => now()->toIso8601ZuluString(), + ]) + ->assertDontSee('Gone'); +}); + +it('links the topmost ancestor to its parent when a thread is too deep to show it all', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $parent = Commit::factory()->create(); + + foreach (range(1, 21) as $depth) { + $parent = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinutes(30 - $depth)]); + } + + $response = signedApiRequest($keyPair, 'GET', route('api-commit.show', $parent)); + + $response->assertOk() + ->assertJsonCount(20, 'ancestors') + ->assertJsonPath('ancestors.0.parent.id', $response->json('ancestors.0.parent_id')) + ->assertJsonMissingPath('ancestors.1.parent'); +}); + diff --git a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php new file mode 100644 index 000000000..094cf8f1a --- /dev/null +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -0,0 +1,68 @@ +create([ + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + ]); + $keyPair = apiKeyPairFor($user); + + $user->following()->attach(User::factory()->count(2)->create()); + $user->followers()->attach(User::factory()->create()); + Commit::factory()->for($user)->pending()->create(); + Notification::factory()->for($user, 'recipient')->create(); + Notification::factory()->for($user, 'recipient')->read()->create(); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertExactJson([ + 'data' => [ + 'id' => $user->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $user), + 'feed_url' => route('user-feed.show', $user), + 'created_at' => now()->toIso8601ZuluString(), + 'commits_count' => 1, + 'followers_count' => 3, + 'following_count' => 2, + 'lane' => $user->lane(), + ], + 'unread_notifications_count' => 1, + ]); +}); + +it('counts unread notifications in groups, like the web', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->for($user)->create(); + + Notification::factory()->for($user, 'recipient')->starred($commit)->count(3)->create(); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertJsonPath('unread_notifications_count', 1); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-current-user.show'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/UserCommitControllerTest.php b/tests/Feature/Controllers/Api/UserCommitControllerTest.php index 20af79143..cb2478cbd 100644 --- a/tests/Feature/Controllers/Api/UserCommitControllerTest.php +++ b/tests/Feature/Controllers/Api/UserCommitControllerTest.php @@ -104,3 +104,148 @@ expect($queries())->toBe($withOne); })->with(['commits' => '0', 'replies' => '1']); + +it('lists for a signed request the commits of a profile as the web shows them to its user', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(['username' => 'octocat']); + + $older = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $newer = Commit::factory()->for($user)->create(); + Commit::factory()->for($newer, 'parent')->create(); + Commit::factory()->for($newer, 'parent')->pending()->create(); + Commit::factory()->for($user)->for($older, 'parent')->create(); + Commit::factory()->for($user)->pending()->create(); + $newer->stars()->attach($viewer); + + signedApiRequest($keyPair, 'GET', '/api/v1/users/octocat/commits') + ->assertOk() + ->assertJsonPath('data.*.id', [$newer->id, $older->id]) + ->assertJsonPath('data.0.replies_count', 1) + ->assertJsonPath('data.0.stars_count', 1) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.is_saved', false) + ->assertJsonPath('data.0.status', 'approved') + ->assertJsonPath('data.0.author.lane', $user->lane()) + ->assertJsonMissingPath('data.0.parent') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists the pending commits of a signed-in user on their own profile', function (Closure $commit, string $replies): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $pending = $commit($user); + + $this->getJson(route('api-user-commit.index', [$user, 'replies' => $replies])) + ->assertOk() + ->assertJsonCount(0, 'data'); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', [$user, 'replies' => $replies])) + ->assertOk() + ->assertJsonPath('data.*.id', [$pending->id]) + ->assertJsonPath('data.0.status', 'pending'); +})->with([ + 'commits' => [fn (User $user): Commit => Commit::factory()->for($user)->pending()->create(), '0'], + 'replies' => [fn (User $user): Commit => Commit::factory()->for($user)->for(Commit::factory()->pending(), 'parent')->pending()->create(), '1'], +]); + +it('leaves the pinned commit out of the commits a signed request lists', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(); + $other = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$other->id]); +}); + +it('lists for a signed request the replies of a user with the commits they reply to', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + $reply = Commit::factory()->for($user)->for($parent, 'parent')->create(); + $hidden = Commit::factory()->for($user)->for(Commit::factory()->pending(), 'parent')->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($user)->create(); + Commit::factory()->for($user)->for($parent, 'parent')->pending()->create(); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', [$user, 'replies' => 1])) + ->assertOk() + ->assertJsonPath('data.*.id', [$reply->id, $hidden->id]) + ->assertJsonPath('data.0.parent.id', $parent->id) + ->assertJsonPath('data.0.parent.author.id', $parent->user_id) + ->assertJsonPath('data.1.parent_id', $hidden->parent_id) + ->assertJsonPath('data.1.parent', null) + ->assertJsonMissingPath('data.2'); +}); + +it('keeps the replies filter in the link to the next page of a signed request', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + Commit::factory()->for($user)->for($parent, 'parent')->count(21)->sequence( + fn ($sequence): array => ['created_at' => now()->subMinutes($sequence->index)], + )->create(); + + $first = signedApiRequest($keyPair, 'GET', route('api-user-commit.index', [$user, 'replies' => 1])) + ->assertOk() + ->assertJsonCount(20, 'data'); + + expect($first->json('links.next'))->toContain('replies=1'); + + signedApiRequest($keyPair, 'GET', $first->json('links.next')) + ->assertOk() + ->assertJsonCount(1, 'data'); +}); + +it('loads a page of commits for a signed request without a query per commit', function (string $replies): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(['username' => 'octocat']); + + $create = fn (int $count) => Commit::factory()->for($user)->count($count) + ->for(LinkPreview::factory()->fetched()) + ->has(CommitImage::factory()->count(2)->sequence(['position' => 0], ['position' => 1]), 'images') + ->state(fn (): array => ['parent_id' => $replies === '1' ? Commit::factory()->create()->id : null]) + ->create(); + + $queries = function () use ($keyPair, $replies): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($keyPair, 'GET', "/api/v1/users/octocat/commits?replies={$replies}")->assertOk(); + + return count(DB::getQueryLog()); + }; + + // The first signed request also syncs the user's keys with GitHub... + $queries(); + + $create(1); + $withOne = $queries(); + + $create(5); + + expect($queries())->toBe($withOne); +})->with(['commits' => '0', 'replies' => '1']); + +it('lists no commits of a profile across a block for a signed request', function (Closure $block, string $code): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + Commit::factory()->for($user)->create(); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code); + + $this->getJson(route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonCount(1, 'data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked'], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable'], +]); diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index 6a1a3ead2..c2ee5757e 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use App\Models\Commit; +use App\Models\CommitImage; use App\Models\User; +use App\Queries\UserActivityQuery; it('shows a public profile with public counts', function (): void { $user = User::factory()->create([ @@ -68,3 +70,155 @@ it('returns not found for unknown usernames', function (): void { $this->getJson('/api/v1/users/missing')->assertNotFound(); }); + +it('shows a signed request the profile as the web shows it to its user', function (): void { + $this->travelTo(now()->setDate(2026, 10, 15)->setTime(12, 0)); + + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create([ + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + ]); + + Commit::factory()->for($user)->count(2)->create(); + Commit::factory()->for($user)->create(['created_at' => now()->subDays(3)]); + Commit::factory()->for($user)->pending()->create(); + + $user->followers()->attach($viewer); + $user->following()->attach(User::factory()->count(2)->create()); + + $response = signedApiRequest($keyPair, 'GET', route('api-user.show', $user)); + + $response->assertOk() + ->assertExactJson([ + 'data' => [ + 'id' => $user->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $user), + 'feed_url' => route('user-feed.show', $user), + 'created_at' => now()->toIso8601ZuluString(), + 'commits_count' => 3, + 'followers_count' => 1, + 'following_count' => 2, + 'lane' => $user->lane(), + ], + 'is_following' => true, + 'activity' => resolve(UserActivityQuery::class)->get($user, $viewer), + 'streaks' => ['longest' => 1, 'current' => 1], + 'pinned_commit' => null, + ]) + ->assertJsonPath('activity.scale', 'week') + ->assertJsonCount(7, 'activity.buckets') + ->assertJsonPath('activity.buckets.3', ['date' => '2026-10-12', 'count' => 1]) + ->assertJsonPath('activity.buckets.6', ['date' => '2026-10-15', 'count' => 2]); +}); + +it('counts the pending commits of a signed-in user on their own profile', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + Commit::factory()->for($user)->pending()->create(); + + $this->getJson(route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('data.commits_count', 0) + ->assertJsonMissingPath('is_following'); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('data.commits_count', 1) + ->assertJsonPath('is_following', false); +}); + +it('shows a signed request the pinned commit of the profile', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(['body' => 'Pinned']); + Commit::factory()->for($pinned, 'parent')->create(); + Commit::factory()->for($pinned, 'parent')->pending()->create(); + CommitImage::factory()->for($pinned)->create(); + $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); + + $response = signedApiRequest($keyPair, 'GET', route('api-user.show', $user)); + + $response->assertOk() + ->assertJsonPath('pinned_commit.id', $pinned->id) + ->assertJsonPath('pinned_commit.body', 'Pinned') + ->assertJsonPath('pinned_commit.is_pinned', true) + ->assertJsonPath('pinned_commit.replies_count', 1) + ->assertJsonCount(1, 'pinned_commit.images') + ->assertJsonPath('pinned_commit.author.id', $user->id); +}); + +it('hides a pinned commit the signed-in user may not see', function (Closure $pinned): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $user->forceFill(['pinned_commit_id' => $pinned($user)->id])->save(); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('pinned_commit', null); +})->with([ + 'pending' => fn (User $user): Commit => Commit::factory()->for($user)->pending()->create(), + 'deleted' => fn (User $user): Commit => tap(Commit::factory()->for($user)->create())->delete(), +]); + +it('shows a signed-in user their own pending pinned commit', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $pinned = Commit::factory()->for($user)->pending()->create(); + $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('pinned_commit.id', $pinned->id) + ->assertJsonPath('pinned_commit.status', 'pending'); +}); + +it('tells a signed request it blocked the profile, as the web does', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(['name' => 'The Octocat']); + + $viewer->blocking()->attach($user); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertForbidden() + ->assertExactJson([ + 'message' => "You blocked The Octocat. Neither of you can follow, reply to, or star the other, or see each other's commits.", + 'code' => 'user_blocked', + ]); + + $this->getJson(route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('data.id', $user->id); +}); + +it('shows a signed request a profile that blocked its user as unavailable, without saying why', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + + $user->blocking()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertForbidden() + ->assertExactJson([ + 'message' => "This profile isn't available.", + 'code' => 'user_unavailable', + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 3a7529d2c..5a1475e73 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,6 +4,7 @@ use App\Dto\SshPublicKey; use App\Jobs\UnfurlLink; +use App\Models\SshKey; use App\Models\User; use App\Services\OpenAI; use Carbon\CarbonInterface; @@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Process; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use Illuminate\Testing\TestResponse; use Tests\Support\SshKeyPair; use Tests\TestCase; @@ -227,3 +229,51 @@ function queuedJob(string $queue, CarbonInterface $createdAt): void 'created_at' => $createdAt->getTimestamp(), ]); } + +/** + * Signs an API request the way the mobile app does, and returns its headers as server variables. + * + * @return array + */ +function signApiRequest(SshKeyPair $keyPair, string $method, string $uri, string $body = '', ?int $timestamp = null, ?string $nonce = null, string $namespace = 'devnull-api'): array +{ + $timestamp ??= now()->getTimestamp(); + $nonce ??= bin2hex(random_bytes(16)); + + $query = parse_url(url($uri), PHP_URL_QUERY); + $target = parse_url(url($uri), PHP_URL_PATH).(is_string($query) ? "?{$query}" : ''); + + return [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DEVNULL_TIMESTAMP' => (string) $timestamp, + 'HTTP_X_DEVNULL_NONCE' => $nonce, + 'HTTP_X_SSH_SIGNATURE' => $keyPair->sign(implode("\n", [$timestamp, $nonce, mb_strtoupper($method), $target, $body]), $namespace), + ]; +} + +/** + * Sends an API request signed the way the mobile app does, with the data as its JSON body. + * + * @param array|null $data + */ +function signedApiRequest(SshKeyPair $keyPair, string $method, string $uri, ?array $data = null, ?int $timestamp = null, ?string $nonce = null, string $namespace = 'devnull-api'): TestResponse +{ + $body = $data === null ? '' : json_encode($data, JSON_THROW_ON_ERROR); + + return test()->call($method, $uri, server: signApiRequest($keyPair, $method, $uri, $body, $timestamp, $nonce, $namespace), content: $body); +} + +/** + * Gives the user a key on their GitHub account that the mobile app could sign API requests with. + */ +function apiKeyPairFor(User $user): SshKeyPair +{ + $keyPair = SshKeyPair::generate(); + + SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + fakeGitHubSshKeys($user, [$keyPair->publicKey]); + + return $keyPair; +} diff --git a/tests/Unit/Providers/AppServiceProviderTest.php b/tests/Unit/Providers/AppServiceProviderTest.php index 9ec33afff..bf764d213 100644 --- a/tests/Unit/Providers/AppServiceProviderTest.php +++ b/tests/Unit/Providers/AppServiceProviderTest.php @@ -66,3 +66,21 @@ ->and($limit->maxAttempts)->toBe(60) ->and($limit->decaySeconds)->toBe(60); }); + +it('limits each signed-in user to 120 api requests a minute, whatever their address', function (): void { + $user = User::factory()->create(); + + $request = Request::create('/api/v1/users/octocat', server: ['REMOTE_ADDR' => '203.0.113.7']); + $request->setUserResolver(fn (): User => $user); + + $limiter = RateLimiter::limiter('api'); + + assert($limiter instanceof Closure); + + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class) + ->and($limit->key)->toBe($user->id) + ->and($limit->maxAttempts)->toBe(120) + ->and($limit->decaySeconds)->toBe(60); +}); diff --git a/tests/Unit/Queries/CommitPageQueryTest.php b/tests/Unit/Queries/CommitPageQueryTest.php new file mode 100644 index 000000000..ee625f5d8 --- /dev/null +++ b/tests/Unit/Queries/CommitPageQueryTest.php @@ -0,0 +1,62 @@ +create(); + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + Commit::factory()->for($commit, 'parent')->pending()->create(); + $commit->stars()->attach([$viewer->id, User::factory()->create()->id]); + Bookmark::factory()->create(['user_id' => $viewer->id, 'commit_id' => $commit->id]); + + $loaded = new CommitPageQuery()->get($commit, $viewer); + + expect($loaded->replies_count)->toBe(1) + ->and($loaded->stars_count)->toBe(2) + ->and($loaded->starred)->toBeTrue() + ->and($loaded->is_saved)->toBeTrue() + ->and($loaded->relationLoaded('user'))->toBeTrue() + ->and($loaded->relationLoaded('linkPreview'))->toBeTrue() + ->and($loaded->relationLoaded('images'))->toBeTrue(); +}); + +it('only counts the stars and saves of the viewer as theirs', function (): void { + $commit = Commit::factory()->create(); + $commit->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $commit->id]); + + $loaded = new CommitPageQuery()->get($commit, User::factory()->create()); + + expect($loaded->stars_count)->toBe(1) + ->and($loaded->starred)->toBeFalse() + ->and($loaded->is_saved)->toBeFalse(); +}); + +it('loads the commit for a guest, who starred and saved nothing', function (): void { + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + Commit::factory()->for($commit, 'parent')->pending()->create(); + $commit->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $commit->id]); + + $loaded = new CommitPageQuery()->get($commit, null); + + expect($loaded->replies_count)->toBe(1) + ->and($loaded->stars_count)->toBe(1) + ->and($loaded->starred)->toBeFalse() + ->and($loaded->is_saved)->toBeFalse(); +}); + +it('does not find a deleted commit', function (): void { + $commit = Commit::factory()->create(); + $commit->delete(); + + new CommitPageQuery()->get($commit, User::factory()->create()); +})->throws(ModelNotFoundException::class); diff --git a/tests/Unit/Queries/PinnedCommitQueryTest.php b/tests/Unit/Queries/PinnedCommitQueryTest.php new file mode 100644 index 000000000..deaddc349 --- /dev/null +++ b/tests/Unit/Queries/PinnedCommitQueryTest.php @@ -0,0 +1,30 @@ +create(); + $pinned = Commit::factory()->for($user)->pending()->create(); + $user->update(['pinned_commit_id' => $pinned->id]); + + expect(resolve(PinnedCommitQuery::class)->get($user, $user)?->id)->toBe($pinned->id) + ->and(resolve(PinnedCommitQuery::class)->get($user, User::factory()->create()))->toBeNull() + ->and(resolve(PinnedCommitQuery::class)->get(User::factory()->create(), $user))->toBeNull(); +}); + +it('only finds an approved pinned commit for a guest', function (): void { + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(); + $user->update(['pinned_commit_id' => $pinned->id]); + + $author = User::factory()->create(); + $pendingPinned = Commit::factory()->for($author)->pending()->create(); + $author->update(['pinned_commit_id' => $pendingPinned->id]); + + expect(resolve(PinnedCommitQuery::class)->get($user, null)?->id)->toBe($pinned->id) + ->and(resolve(PinnedCommitQuery::class)->get($author, null))->toBeNull(); +}); diff --git a/tests/Unit/Queries/UserCommitsQueryTest.php b/tests/Unit/Queries/UserCommitsQueryTest.php new file mode 100644 index 000000000..ef584cff5 --- /dev/null +++ b/tests/Unit/Queries/UserCommitsQueryTest.php @@ -0,0 +1,60 @@ +create(); + + $older = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $newer = Commit::factory()->for($user)->create(); + Commit::factory()->for($user)->for($older, 'parent')->create(); + Commit::factory()->create(); + + $commits = resolve(UserCommitsQuery::class)->builder($user, User::factory()->create())->get(); + + expect($commits->modelKeys())->toBe([$newer->id, $older->id]) + ->and($commits->first()->relationLoaded('user'))->toBeTrue() + ->and($commits->last()->replies_count)->toBe(1); +}); + +it('only includes the commits the viewer may see', function (): void { + $user = User::factory()->create(); + + $approved = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $pending = Commit::factory()->for($user)->pending()->create(); + Commit::factory()->for($user)->rejected()->create(); + + expect(resolve(UserCommitsQuery::class)->builder($user, User::factory()->create())->get()->modelKeys())->toBe([$approved->id]) + ->and(resolve(UserCommitsQuery::class)->builder($user, $user)->get()->modelKeys())->toHaveCount(3)->toContain($pending->id) + ->and(resolve(UserCommitsQuery::class)->builder($user, null)->get()->modelKeys())->toBe([$approved->id]); +}); + +it('leaves the pinned commit out of the commits tab', function (): void { + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(); + $other = Commit::factory()->for($user)->create(); + $user->update(['pinned_commit_id' => $pinned->id]); + + expect(resolve(UserCommitsQuery::class)->builder($user, $user)->get()->modelKeys())->toBe([$other->id]); +}); + +it('includes the replies of the user with the parents the viewer may see', function (): void { + $user = User::factory()->create(); + $viewer = User::factory()->create(); + $parent = Commit::factory()->create(); + $hiddenParent = Commit::factory()->pending()->create(); + $reply = Commit::factory()->for($user)->for($parent, 'parent')->create(['created_at' => now()->subMinute()]); + $replyToHidden = Commit::factory()->for($user)->for($hiddenParent, 'parent')->create(); + Commit::factory()->for($user)->create(); + + $replies = resolve(UserCommitsQuery::class)->builder($user, $viewer, replies: true)->get(); + + expect($replies->modelKeys())->toBe([$replyToHidden->id, $reply->id]) + ->and($replies->first()->parent)->toBeNull() + ->and($replies->last()->parent?->id)->toBe($parent->id) + ->and($replies->last()->parent?->relationLoaded('user'))->toBeTrue(); +}); diff --git a/tests/Unit/Services/MentionsTest.php b/tests/Unit/Services/MentionsTest.php index b74202092..f9648dd51 100644 --- a/tests/Unit/Services/MentionsTest.php +++ b/tests/Unit/Services/MentionsTest.php @@ -52,3 +52,37 @@ expect($mentions->usernames("thanks @taylorotwell for `@Override`\n```java\n@Deprecated\nvoid run() {}\n```")) ->toBe(['taylorotwell']); }); + +it('finds where every mention is, keeping its case', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions('@NunoMaduro and @taylorotwell, thanks! cc @nunomaduro'))->toBe([ + ['username' => 'NunoMaduro', 'offset' => 0, 'length' => 11], + ['username' => 'taylorotwell', 'offset' => 16, 'length' => 13], + ['username' => 'nunomaduro', 'offset' => 42, 'length' => 11], + ]); +}); + +it('counts offsets in characters, not bytes', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions('Café 🎉 with @nuno.'))->toBe([ + ['username' => 'nuno', 'offset' => 12, 'length' => 5], + ]); +}); + +it('finds no positions for what only looks like a mention', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions('Mail nuno@laravel.com, npm i @inertiajs/react, try @vue.js'))->toBe([]); +}); + +it('skips mentions in inline code and code blocks when finding where they are', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions("thanks @taylorotwell for `@Override`, @nuno\n```java\n@Deprecated\nvoid run() {}\n```\ncc @jess-archer"))->toBe([ + ['username' => 'taylorotwell', 'offset' => 7, 'length' => 13], + ['username' => 'nuno', 'offset' => 38, 'length' => 5], + ['username' => 'jess-archer', 'offset' => 85, 'length' => 12], + ]); +}); diff --git a/tests/Unit/Services/SignedRequestsTest.php b/tests/Unit/Services/SignedRequestsTest.php new file mode 100644 index 000000000..7a9590676 --- /dev/null +++ b/tests/Unit/Services/SignedRequestsTest.php @@ -0,0 +1,101 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('tells a request with any of the signature headers from one without', function (array $headers, bool $isSigned): void { + $request = Request::create('https://devnull.test/api/v1/users/octocat', server: $headers); + + expect(resolve(SignedRequests::class)->isSigned($request))->toBe($isSigned); +})->with([ + 'none' => [[], false], + 'other headers' => [['HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer token'], false], + 'a timestamp' => [['HTTP_X_DEVNULL_TIMESTAMP' => '1791000000'], true], + 'a nonce' => [['HTTP_X_DEVNULL_NONCE' => str_repeat('0', 32)], true], + 'a signature' => [['HTTP_X_SSH_SIGNATURE' => ''], true], +]); + +it('verifies a signed request', function (string $method, string $uri, string $body): void { + $request = Request::create($uri, $method, server: signApiRequest($this->keyPair, $method, $uri, $body), content: $body); + + ['publicKey' => $publicKey, 'user' => $user] = resolve(SignedRequests::class)->verify($request); + + expect($publicKey->fingerprint())->toBe($this->keyPair->publicKey->fingerprint()) + ->and($user?->is($this->user))->toBeTrue(); +})->with([ + 'without a body' => ['GET', 'https://devnull.test/api/v1/log/latest', ''], + 'with a query' => ['GET', 'https://devnull.test/api/v1/log/latest?cursor=abc&x=%20y', ''], + 'with a body' => ['POST', 'https://devnull.test/api/v1/commits', '{"body":"It was DNS.","parent_id":null}'], + 'with a multi-line body' => ['POST', 'https://devnull.test/api/v1/commits', "{\"body\":\"It was DNS.\"}\n\n"], +]); + +it('returns no user for keys that are not on the github account of a user', function (): void { + $keyPair = SshKeyPair::generate(); + $uri = 'https://devnull.test/api/v1/me'; + + $request = Request::create($uri, server: signApiRequest($keyPair, 'GET', $uri)); + + ['publicKey' => $publicKey, 'user' => $user] = resolve(SignedRequests::class)->verify($request); + + expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()) + ->and($user)->toBeNull(); +}); + +it('rejects a request that differs from the one that was signed', function (string $method, string $uri, string $body, array $headers): void { + $server = signApiRequest($this->keyPair, 'POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}'); + + $request = Request::create($uri, $method, server: [...$server, ...$headers], content: $body); + + expect(fn (): array => resolve(SignedRequests::class)->verify($request)) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +})->with([ + 'method' => ['PUT', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}', []], + 'path' => ['POST', 'https://devnull.test/api/v1/commitz?a=1', '{"body":"It was DNS."}', []], + 'query' => ['POST', 'https://devnull.test/api/v1/commits?a=2', '{"body":"It was DNS."}', []], + 'missing query' => ['POST', 'https://devnull.test/api/v1/commits', '{"body":"It was DNS."}', []], + 'body' => ['POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was not DNS."}', []], + 'timestamp' => ['POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}', ['HTTP_X_DEVNULL_TIMESTAMP' => '1']], + 'nonce' => ['POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}', ['HTTP_X_DEVNULL_NONCE' => str_repeat('0', 32)]], +]); + +it('rejects a signature for another namespace', function (): void { + $uri = 'https://devnull.test/api/v1/me'; + + $request = Request::create($uri, server: signApiRequest($this->keyPair, 'GET', $uri, namespace: 'devnull-commit')); + + expect(fn (): array => resolve(SignedRequests::class)->verify($request)) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +}); + +it('rejects a request without a signature', function (): void { + expect(fn (): array => resolve(SignedRequests::class)->verify(Request::create('https://devnull.test/api/v1/me'))) + ->toThrow(InvalidArgumentException::class, 'Malformed SSH signature.'); +}); + +it('rejects an expired signature', function (): void { + $uri = 'https://devnull.test/api/v1/me'; + + $request = Request::create($uri, server: signApiRequest($this->keyPair, 'GET', $uri, timestamp: now()->getTimestamp() - 301)); + + expect(fn (): array => resolve(SignedRequests::class)->verify($request)) + ->toThrow(InvalidArgumentException::class, 'Signature expired. Check that your clock is right.'); +}); + +it('rejects a replayed request', function (): void { + $uri = 'https://devnull.test/api/v1/me'; + $server = signApiRequest($this->keyPair, 'GET', $uri); + + resolve(SignedRequests::class)->verify(Request::create($uri, server: $server)); + + expect(fn (): array => resolve(SignedRequests::class)->verify(Request::create($uri, server: $server))) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); +}); From 4b9acc23ec58267fa3275bd9e330315cfde8473a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 11:08:09 +0100 Subject: [PATCH 03/15] feat: read the log, replies and notifications from the API These endpoints are only for signed requests: - GET /api/v1/log/{log} is one of the web's logs, 20 commits a page. - GET /api/v1/commits/{commit}/replies is the replies under a commit, 50 a page, with quotes of it among them and deleted replies that still have replies as tombstones, as on the web. It works for a deleted commit too, as its replies stay in the thread. - GET /api/v1/notifications is grouped the way the web groups them, with the first few actors, how many there are and how many are unread. Like the web, listing them marks every notification as read, and read_at says when, so the ones read since then are the new ones. They reuse the web's queries, so the visibility rules are the same, mutes and blocks included. A notification's commit comes with its author and the commit it replies to, when the user may see that one. --- .../Controllers/Api/CommitReplyController.php | 21 ++ app/Http/Controllers/Api/LogController.php | 19 ++ .../Api/NotificationController.php | 45 ++++ .../Requests/ListApiCommitRepliesRequest.php | 35 +++ app/Http/Resources/NotificationResource.php | 39 +++ routes/api.php | 14 + .../Api/CommitReplyControllerTest.php | 209 +++++++++++++++ .../Controllers/Api/LogControllerTest.php | 208 +++++++++++++++ .../Api/NotificationControllerTest.php | 243 ++++++++++++++++++ 9 files changed, 833 insertions(+) create mode 100644 app/Http/Controllers/Api/CommitReplyController.php create mode 100644 app/Http/Controllers/Api/LogController.php create mode 100644 app/Http/Controllers/Api/NotificationController.php create mode 100644 app/Http/Requests/ListApiCommitRepliesRequest.php create mode 100644 app/Http/Resources/NotificationResource.php create mode 100644 tests/Feature/Controllers/Api/CommitReplyControllerTest.php create mode 100644 tests/Feature/Controllers/Api/LogControllerTest.php create mode 100644 tests/Feature/Controllers/Api/NotificationControllerTest.php diff --git a/app/Http/Controllers/Api/CommitReplyController.php b/app/Http/Controllers/Api/CommitReplyController.php new file mode 100644 index 000000000..851a3ed86 --- /dev/null +++ b/app/Http/Controllers/Api/CommitReplyController.php @@ -0,0 +1,21 @@ +paginate($commit, $user)); + } +} diff --git a/app/Http/Controllers/Api/LogController.php b/app/Http/Controllers/Api/LogController.php new file mode 100644 index 000000000..cef3042fa --- /dev/null +++ b/app/Http/Controllers/Api/LogController.php @@ -0,0 +1,19 @@ +query()->builder($user)->cursorPaginate(20)); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 000000000..e29f991f6 --- /dev/null +++ b/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,45 @@ +handle($user); + + $notifications = $query->paginate($user); + + /** @var array $items */ + $items = $notifications->items(); + + new Collection($items)->loadMissing([ + 'commit.user', + 'commit.parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)) + ->with('user'), + ]); + + return NotificationResource::collection($notifications)->additional([ + 'read_at' => $readAt->toIso8601ZuluString(), + ]); + } +} diff --git a/app/Http/Requests/ListApiCommitRepliesRequest.php b/app/Http/Requests/ListApiCommitRepliesRequest.php new file mode 100644 index 000000000..39b39105d --- /dev/null +++ b/app/Http/Requests/ListApiCommitRepliesRequest.php @@ -0,0 +1,35 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return $commit->isVisibleTo($user) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php new file mode 100644 index 000000000..c0bb9c757 --- /dev/null +++ b/app/Http/Resources/NotificationResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'subject_id' => $this->subject_id, + 'reason' => $this->reason, + 'read_at' => $this->read_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at->toIso8601ZuluString(), + 'actor' => UserResource::make($this->actor), + 'actors' => UserResource::collection($this->actors), + 'actors_count' => $this->actors_count, + 'unread_count' => $this->unread_count, + 'commit' => $this->commit instanceof Commit ? CommitResource::make($this->commit) : null, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 36edbf995..1d2b94ab0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,7 +3,10 @@ declare(strict_types=1); use App\Http\Controllers\Api\CommitController as ApiCommitController; +use App\Http\Controllers\Api\CommitReplyController; use App\Http\Controllers\Api\CurrentUserController; +use App\Http\Controllers\Api\LogController; +use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Cli\CommitController; @@ -36,5 +39,16 @@ // Signed by the mobile app with an SSH key that is on the user's GitHub account... Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); + + // Log... + Route::get('log/{log}', [LogController::class, 'show'])->name('api-log.show'); + + // Commit Reply... + Route::get('commits/{commit}/replies', [CommitReplyController::class, 'index']) + ->withTrashed() + ->name('api-commit-reply.index'); + + // Notifications... + Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index'); }); }); diff --git a/tests/Feature/Controllers/Api/CommitReplyControllerTest.php b/tests/Feature/Controllers/Api/CommitReplyControllerTest.php new file mode 100644 index 000000000..17ef42134 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitReplyControllerTest.php @@ -0,0 +1,209 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lists visible replies oldest first with their own reply counts', function (): void { + $parent = Commit::factory()->create(); + + $second = Commit::factory()->for($parent, 'parent')->create(['body' => 'Second', 'created_at' => now()->subMinute()]); + Commit::factory()->for($parent, 'parent')->create(['body' => 'First', 'created_at' => now()->subMinutes(2)]); + Commit::factory()->for($parent, 'parent')->for($this->user)->pending()->create(['body' => 'Mine, pending']); + Commit::factory()->for($parent, 'parent')->pending()->create(['body' => 'Hidden']); + Commit::factory()->for($second, 'parent')->count(2)->create(); + Commit::factory()->for($second, 'parent')->pending()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.body', 'First') + ->assertJsonPath('data.0.parent_id', $parent->id) + ->assertJsonPath('data.1.body', 'Second') + ->assertJsonPath('data.1.replies_count', 2) + ->assertJsonPath('data.2.body', 'Mine, pending') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('shows the stars and saves of each reply as the user sees them', function (): void { + $parent = Commit::factory()->create(); + $starred = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinutes(2)]); + $starred->stars()->attach([$this->user->id, User::factory()->create()->id]); + Bookmark::factory()->create(['user_id' => $this->user->id, 'commit_id' => $starred->id]); + $starredByOthers = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinute()]); + $starredByOthers->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $starredByOthers->id]); + Commit::factory()->for($parent, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonPath('data.0.stars_count', 2) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.is_saved', true) + ->assertJsonPath('data.1.stars_count', 1) + ->assertJsonPath('data.1.starred', false) + ->assertJsonPath('data.1.is_saved', false) + ->assertJsonPath('data.2.stars_count', 0) + ->assertJsonPath('data.2.starred', false) + ->assertJsonPath('data.2.is_saved', false); +}); + +it('does not query each reply, its author or its stars', function (): void { + $parent = Commit::factory()->create(); + + $queries = function () use ($parent): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent))->assertOk(); + + return count(DB::getQueryLog()); + }; + + $create = function (int $count) use ($parent): void { + foreach (Commit::factory()->for($parent, 'parent')->count($count)->create() as $reply) { + $reply->stars()->attach([$this->user->id, User::factory()->create()->id]); + } + }; + + // The first signed request also syncs the user's keys with GitHub... + $queries(); + + $create(2); + $few = $queries(); + + $create(8); + + expect($queries())->toBe($few); +}); + +it('lists replies 50 at a time with a cursor to the next page', function (): void { + $parent = Commit::factory()->create(); + + Commit::factory()->for($parent, 'parent')->count(52) + ->state(new Sequence(fn (Sequence $sequence): array => [ + 'body' => "Reply {$sequence->index}", + 'created_at' => now()->subMinutes(100 - $sequence->index), + ])) + ->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $first->assertOk() + ->assertJsonCount(50, 'data') + ->assertJsonPath('data.49.body', 'Reply 49'); + + $second = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', [$parent, 'cursor' => $first->json('meta.next_cursor')])); + + $second->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.body', 'Reply 50'); +}); + +it('hides the replies of commits the user may not see', function (): void { + $parent = Commit::factory()->pending()->create(); + Commit::factory()->for($parent, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertNotFound(); +}); + +it('lists deleted replies that still have replies as tombstones', function (): void { + $parent = Commit::factory()->create(); + + $tombstone = Commit::factory()->for($parent, 'parent')->create(['body' => 'Gone', 'created_at' => now()->subMinutes(2)]); + Commit::factory()->for($tombstone, 'parent')->create(); + $tombstone->delete(); + Commit::factory()->for($parent, 'parent')->create(['body' => 'Also gone', 'created_at' => now()->subMinute()])->delete(); + $pendingTombstone = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($pendingTombstone, 'parent')->pending()->create(); + $pendingTombstone->delete(); + $kept = Commit::factory()->for($parent, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0', [ + 'id' => $tombstone->id, + 'parent_id' => $parent->id, + 'deleted_at' => now()->toIso8601ZuluString(), + 'replies_count' => 1, + ]) + ->assertJsonPath('data.1.id', $kept->id) + ->assertDontSee('Gone'); +}); + +it('lists the replies of a deleted commit', function (): void { + $parent = Commit::factory()->create(); + $reply = Commit::factory()->for($parent, 'parent')->create(); + $parent->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $reply->id); +}); + +it('hides the replies of deleted commits the user may not see', function (): void { + $parent = Commit::factory()->pending()->create(); + Commit::factory()->for($parent, 'parent')->create(); + $parent->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertNotFound(); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-commit-reply.index', Commit::factory()->create()))->assertUnauthorized(); +}); + +it('leaves out replies by muted people and across blocks, as the web does', function (): void { + $muted = User::factory()->create(); + $blocker = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->user->muting()->attach($muted); + $blocker->blocking()->attach($this->user); + + $reply = Commit::factory()->for($parent, 'parent')->create(); + Commit::factory()->for($parent, 'parent')->for($muted)->create(); + Commit::factory()->for($parent, 'parent')->for($blocker)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)) + ->assertOk() + ->assertJsonPath('data.*.id', [$reply->id]); +}); + +it('returns not found for the replies to a commit across a block', function (): void { + $parent = Commit::factory()->create(); + Commit::factory()->for($parent, 'parent')->create(); + + $this->user->blocking()->attach($parent->user); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent))->assertNotFound(); +}); + +it('lists quotes of the commit with its replies, as the web does', function (): void { + $commit = Commit::factory()->create(); + $reply = Commit::factory()->for($commit, 'parent')->create(['created_at' => now()->subMinute()]); + $quote = Commit::factory()->create(['quoted_commit_id' => $commit->id]); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $commit)) + ->assertOk() + ->assertJsonPath('data.*.id', [$reply->id, $quote->id]); +}); diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php new file mode 100644 index 000000000..c18657f67 --- /dev/null +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -0,0 +1,208 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lists the latest commits of everyone, newest first', function (): void { + $author = User::factory()->create([ + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + ]); + $older = Commit::factory()->for($author)->create(['body' => 'It was DNS.', 'created_at' => now()->subMinute()]); + $newer = Commit::factory()->for($this->user)->pending()->create(['body' => 'Mine, pending']); + Commit::factory()->for($older, 'parent')->count(2)->create(); + Commit::factory()->for($older, 'parent')->pending()->create(); + Commit::factory()->pending()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $newer->id) + ->assertJsonPath('data.0.status', 'pending') + ->assertJsonPath('data.0.replies_count', 0) + ->assertJsonPath('data.1', [ + 'id' => $older->id, + 'short_hash' => mb_substr(str_replace('-', '', $older->id), -7), + 'body' => 'It was DNS.', + 'parent_id' => null, + 'html_url' => route('commit.show', [$author, $older]), + 'created_at' => now()->subMinute()->toIso8601ZuluString(), + 'stars_count' => 0, + 'replies_count' => 2, + 'link_preview' => null, + 'images' => [], + 'author' => [ + 'id' => $author->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => $author->bio, + 'company' => $author->company, + 'location' => $author->location, + 'website' => $author->website, + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $author), + 'feed_url' => route('user-feed.show', $author), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $author->lane(), + ], + 'body_segments' => [['type' => 'text', 'text' => 'It was DNS.']], + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => false, + 'is_saved' => false, + 'deleted_at' => null, + ]) + ->assertJsonPath('links.next', null) + ->assertJsonPath('meta.next_cursor', null) + ->assertJsonPath('meta.per_page', 20); +}); + +it('shows what the web shows on a commit', function (): void { + $author = User::factory()->create(['username' => 'octocat', 'preferred_lane' => 4]); + $linkPreview = LinkPreview::factory()->fetched()->create(['url' => 'https://laravel.com']); + $commit = Commit::factory()->for($author)->for($linkPreview)->create(['body' => 'Ask @NunoMaduro about https://laravel.com']); + $image = CommitImage::factory()->for($commit)->create(['width' => 640, 'height' => 480]); + $commit->stars()->attach([$this->user->id, User::factory()->create()->id]); + Bookmark::factory()->create(['user_id' => $this->user->id, 'commit_id' => $commit->id]); + $author->forceFill(['pinned_commit_id' => $commit->id])->save(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonPath('data.0.stars_count', 2) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.is_saved', true) + ->assertJsonPath('data.0.is_pinned', true) + ->assertJsonPath('data.0.mentions', [['username' => 'NunoMaduro', 'offset' => 4, 'length' => 11]]) + ->assertJsonPath('data.0.link_preview', [ + 'url' => 'https://laravel.com', + 'title' => $linkPreview->title, + 'description' => $linkPreview->description, + 'image_url' => $linkPreview->image_url, + 'site_name' => $linkPreview->site_name, + 'embed' => null, + ]) + ->assertJsonPath('data.0.images', [[ + 'id' => $image->id, + 'width' => 640, + 'height' => 480, + 'description' => null, + 'url' => route('commit-image.show', $image), + ]]) + ->assertJsonPath('data.0.author.lane', 4); +}); + +it('only shows the saves and stars of the user', function (): void { + $commit = Commit::factory()->create(); + $commit->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $commit->id]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonPath('data.0.id', $commit->id) + ->assertJsonPath('data.0.stars_count', 1) + ->assertJsonPath('data.0.starred', false) + ->assertJsonPath('data.0.is_saved', false); +}); + +it('lists the commits of the users followed and the user', function (): void { + $followed = User::factory()->create(); + $this->user->following()->attach($followed); + + $mine = Commit::factory()->for($this->user)->create(['created_at' => now()->subMinute()]); + $theirs = Commit::factory()->for($followed)->create(); + Commit::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'following')); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $theirs->id) + ->assertJsonPath('data.1.id', $mine->id); +}); + +it('leaves deleted commits out of the log', function (): void { + $kept = Commit::factory()->create(); + Commit::factory()->for($this->user)->create()->delete(); + + $deleted = Commit::factory()->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($deleted, 'parent')->create(); + $deleted->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $kept->id); +}); + +it('lists commits 20 at a time with a cursor to the next page', function (): void { + Commit::factory()->count(22) + ->state(new Sequence(fn (Sequence $sequence): array => [ + 'body' => "Commit {$sequence->index}", + 'created_at' => now()->subMinutes($sequence->index), + ])) + ->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $first->assertOk() + ->assertJsonCount(20, 'data') + ->assertJsonPath('data.0.body', 'Commit 0') + ->assertJsonPath('data.19.body', 'Commit 19'); + + $second = signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['latest', 'cursor' => $first->json('meta.next_cursor')])); + + $second->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.body', 'Commit 20') + ->assertJsonPath('data.1.body', 'Commit 21') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('does not query each commit or author', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest'))->assertOk(); + + $queries = function (): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest'))->assertOk(); + + return count(DB::getQueryLog()); + }; + + Commit::factory()->count(2)->create(); + $few = $queries(); + + Commit::factory()->count(8)->create(); + + expect($queries())->toBe($few); +}); + +it('returns not found for unknown logs', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', 'api/v1/log/unknown'); + + $response->assertNotFound(); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-log.show', 'latest'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php new file mode 100644 index 000000000..e79a2c65c --- /dev/null +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -0,0 +1,243 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lists the notifications of the user, newest first', function (): void { + $followed = Notification::factory()->for($this->user, 'recipient')->create(['created_at' => now()->subMinute()]); + $replied = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + Notification::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $reply = $replied->commit; + + $user = fn (User $user): array => [ + 'id' => $user->id, + 'username' => $user->username, + 'name' => $user->name, + 'avatar_url' => $user->avatar_url, + 'bio' => $user->bio, + 'company' => $user->company, + 'location' => $user->location, + 'website' => $user->website, + 'github_url' => "https://github.com/{$user->username}", + 'html_url' => route('user.show', $user), + 'feed_url' => route('user-feed.show', $user), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $user->lane(), + ]; + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0', [ + 'id' => $replied->id, + 'type' => 'replied', + 'subject_id' => $reply->parent_id, + 'reason' => null, + 'read_at' => now()->toIso8601ZuluString(), + 'created_at' => now()->toIso8601ZuluString(), + 'actor' => $user($replied->actor), + 'actors' => [$user($replied->actor)], + 'actors_count' => 1, + 'unread_count' => 0, + 'commit' => [ + 'id' => $reply->id, + 'short_hash' => mb_substr(str_replace('-', '', $reply->id), -7), + 'body' => $reply->body, + 'parent_id' => $reply->parent_id, + 'html_url' => route('commit.show', [$replied->actor, $reply]), + 'created_at' => now()->toIso8601ZuluString(), + 'author' => $user($replied->actor), + 'body_segments' => [['type' => 'text', 'text' => $reply->body]], + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'deleted_at' => null, + 'parent' => [ + 'id' => $reply->parent->id, + 'short_hash' => mb_substr(str_replace('-', '', $reply->parent->id), -7), + 'body' => $reply->parent->body, + 'parent_id' => null, + 'html_url' => route('commit.show', [$this->user, $reply->parent]), + 'created_at' => now()->toIso8601ZuluString(), + 'author' => $user($this->user), + 'body_segments' => [['type' => 'text', 'text' => $reply->parent->body]], + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'deleted_at' => null, + ], + ], + ]) + ->assertJsonPath('data.1.id', $followed->id) + ->assertJsonPath('data.1.type', 'followed') + ->assertJsonPath('data.1.commit', null) + ->assertJsonMissingPath('data.0.key') + ->assertJsonPath('read_at', now()->toIso8601ZuluString()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('hides notifications that no longer hold', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->create(); + + resolve(DeleteUserFollow::class)->handle($notification->actor, $this->user); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('shows moderation updates with the reason a commit was rejected', function (): void { + $commit = Commit::factory()->for($this->user)->rejected()->create(); + Notification::factory()->for($this->user, 'recipient')->create([ + 'type' => NotificationType::Rejected, + 'commit_id' => $commit->id, + 'reason' => 'Spam.', + ]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonPath('data.0.type', 'rejected') + ->assertJsonPath('data.0.reason', 'Spam.') + ->assertJsonPath('data.0.commit.id', $commit->id) + ->assertJsonPath('data.0.commit.status', 'rejected'); +}); + +it('hides moderation updates that no longer hold', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + Notification::factory()->for($this->user, 'recipient')->create([ + 'type' => NotificationType::Rejected, + 'commit_id' => $commit->id, + ]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('hides mentions in commits that are not approved yet', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->mentioned()->create(); + $notification->commit?->update(['status' => CommitStatus::Pending]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('hides notifications about deleted commits', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + + $notification->commit?->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('leaves out the commit a mention replies to when the user may not see it', function (Closure $parent): void { + $parent = $parent(); + $actor = User::factory()->create(); + $mention = Commit::factory()->for($actor)->for($parent, 'parent')->create(['body' => "Ask @{$this->user->username}"]); + Notification::factory()->for($this->user, 'recipient')->for($actor, 'actor')->create([ + 'type' => NotificationType::Mentioned, + 'commit_id' => $mention->id, + ]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonPath('data.0.type', 'mentioned') + ->assertJsonPath('data.0.commit.id', $mention->id) + ->assertJsonPath('data.0.commit.parent_id', $parent->id) + ->assertJsonPath('data.0.commit.parent', null); +})->with([ + 'pending' => fn (): Commit => Commit::factory()->pending()->create(), + 'deleted' => fn (): Commit => tap(Commit::factory()->create())->delete(), +]); + +it('marks every notification as read like the web, and says when, so the new ones read since then', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertJsonPath('read_at', now()->toIso8601ZuluString()) + ->assertJsonPath('data.0.read_at', now()->toIso8601ZuluString()); + + expect($notification->refresh()->read_at?->toIso8601ZuluString())->toBe(now()->toIso8601ZuluString()); + + $readAt = now()->toIso8601ZuluString(); + $this->travel(1)->minute(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertJsonPath('read_at', now()->toIso8601ZuluString()) + ->assertJsonPath('data.0.read_at', $readAt); +}); + +it('does not query each notification', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-notification.index'))->assertOk(); + + $queries = function (): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index'))->assertOk(); + + return count(DB::getQueryLog()); + }; + + // Two followers make a group with stacked actors, which takes the same queries however many there are... + Notification::factory()->for($this->user, 'recipient')->count(2)->create(); + Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $few = $queries(); + + Notification::factory()->for($this->user, 'recipient')->count(3)->create(); + Notification::factory()->for($this->user, 'recipient')->replied()->count(3)->create(); + + expect($queries())->toBe($few); +}); + +it('groups stars per commit, newest stargazer first, like the web', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $stargazers = User::factory()->count(4)->create(); + + foreach ($stargazers as $index => $stargazer) { + $this->travelTo(now()->subMinutes(4 - $index), fn () => resolve(CreateCommitStar::class)->handle($stargazer, $commit)); + } + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'starred') + ->assertJsonPath('data.0.subject_id', $commit->id) + ->assertJsonPath('data.0.actor.id', $stargazers[3]->id) + ->assertJsonPath('data.0.commit.id', $commit->id) + ->assertJsonPath('data.0.commit.author.id', $this->user->id) + ->assertJsonPath('data.0.actors_count', 4) + ->assertJsonPath('data.0.actors.*.id', [$stargazers[3]->id, $stargazers[2]->id, $stargazers[1]->id]) + ->assertJsonMissingPath('data.0.actors.0.email'); + + expect($this->user->notifications()->unread()->exists())->toBeFalse(); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-notification.index'))->assertUnauthorized(); +}); From 20a8a4e7bb7c7fa94a4c0dfd8b77adca2941ffad Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 11:09:53 +0100 Subject: [PATCH 04/15] feat: commit, delete and follow from the API Signed requests can now write: - POST /api/v1/commits makes a commit, or a reply with parent_id. It answers 201 with the commit as its page shows it. It has the same validation, moderation and limit of 10 commits a minute as the web: the body rules are the web's own, a commit across a block can't be replied to, and a pending commit goes to the same AI review. When the limit is hit, ThrottleCommits answers in JSON for the API, with the rate_limited code. - DELETE /api/v1/commits/{commit} deletes one of the user's commits. - POST and DELETE /api/v1/users/{username}/follow follow and unfollow. Following yourself, or anyone across a block, is a 403, like on the web. Both share the web's limit of 30 a minute. Going over it, or any other limit the web puts on social actions, answers the app with rate_limited. The signature tests now include two messages signed with ssh-keygen itself, so the documented format is checked against the real tool. --- app/Http/Controllers/Api/CommitController.php | 25 +++ .../Controllers/Api/UserFollowController.php | 30 +++ app/Http/Middleware/ThrottleCommits.php | 7 + app/Http/Requests/CreateApiCommitRequest.php | 54 ++++++ .../Requests/CreateApiUserFollowRequest.php | 30 +++ app/Http/Requests/DeleteApiCommitRequest.php | 31 ++++ .../Requests/DeleteApiUserFollowRequest.php | 23 +++ bootstrap/app.php | 7 + routes/api.php | 15 ++ tests/Feature/ApiSignatureTest.php | 38 ++++ .../Controllers/Api/CommitControllerTest.php | 171 ++++++++++++++++++ .../Api/UserFollowControllerTest.php | 82 +++++++++ 12 files changed, 513 insertions(+) create mode 100644 app/Http/Controllers/Api/UserFollowController.php create mode 100644 app/Http/Requests/CreateApiCommitRequest.php create mode 100644 app/Http/Requests/CreateApiUserFollowRequest.php create mode 100644 app/Http/Requests/DeleteApiCommitRequest.php create mode 100644 app/Http/Requests/DeleteApiUserFollowRequest.php create mode 100644 tests/Feature/Controllers/Api/UserFollowControllerTest.php diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 353fd18ca..0960e4ba7 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -4,6 +4,10 @@ namespace App\Http\Controllers\Api; +use App\Actions\CreateCommit; +use App\Actions\DeleteCommit; +use App\Http\Requests\CreateApiCommitRequest; +use App\Http\Requests\DeleteApiCommitRequest; use App\Http\Requests\ShowApiCommitRequest; use App\Http\Resources\CommitResource; use App\Models\Commit; @@ -11,6 +15,8 @@ use App\Queries\CommitAncestorsQuery; use App\Queries\CommitPageQuery; use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Response; final readonly class CommitController { @@ -32,4 +38,23 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren ->whereKey($commit->id) ->sole()); } + + /** + * Answers with the new commit as its page shows it, pending review unless the user is an admin. + */ + public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse + { + $commit = $action->handle($user, $request->string('body')->value(), $request->parent()); + + return CommitResource::make($page->get($commit, $user)) + ->response() + ->setStatusCode(201); + } + + public function destroy(DeleteApiCommitRequest $request, Commit $commit, DeleteCommit $action): Response + { + $action->handle($commit); + + return response()->noContent(); + } } diff --git a/app/Http/Controllers/Api/UserFollowController.php b/app/Http/Controllers/Api/UserFollowController.php new file mode 100644 index 000000000..246365ed3 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowController.php @@ -0,0 +1,30 @@ +handle($follower, $user); + + return response()->noContent(); + } + + public function destroy(DeleteApiUserFollowRequest $request, #[CurrentUser] User $follower, User $user, DeleteUserFollow $action): Response + { + $action->handle($follower, $user); + + return response()->noContent(); + } +} diff --git a/app/Http/Middleware/ThrottleCommits.php b/app/Http/Middleware/ThrottleCommits.php index a3e7162ba..b29b05f23 100644 --- a/app/Http/Middleware/ThrottleCommits.php +++ b/app/Http/Middleware/ThrottleCommits.php @@ -4,6 +4,8 @@ namespace App\Http\Middleware; +use App\Enums\ApiErrorCode; +use App\Exceptions\ApiException; use App\Models\User; use Closure; use Illuminate\Http\Request; @@ -46,6 +48,11 @@ private function slowDown(Request $request, int $retryAfter): Response ->header('Content-Type', 'text/plain; charset=utf-8'); } + // The mobile app reads JSON with a code... + if ($request->routeIs('api-commit.store')) { + return new ApiException(ApiErrorCode::RateLimited, $message, ['Retry-After' => $retryAfter])->render(); + } + if ($request->expectsJson()) { return response()->json(['message' => $message], 429, ['Retry-After' => $retryAfter]); } diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php new file mode 100644 index 000000000..9514e604a --- /dev/null +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -0,0 +1,54 @@ +> + */ + public function rules(): array + { + $user = $this->user(); + assert($user instanceof User); + + return [ + 'body' => CreateCommitRequest::BODY_RULES, + 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], + ]; + } + + public function parent(): ?Commit + { + $user = $this->user(); + assert($user instanceof User); + + return $this->filled('parent_id') + ? Commit::query()->visibleTo($user)->find($this->string('parent_id')->value()) + : null; + } +} diff --git a/app/Http/Requests/CreateApiUserFollowRequest.php b/app/Http/Requests/CreateApiUserFollowRequest.php new file mode 100644 index 000000000..af05fcf3e --- /dev/null +++ b/app/Http/Requests/CreateApiUserFollowRequest.php @@ -0,0 +1,30 @@ +user(); + assert($follower instanceof User); + + $user = $this->route('user'); + assert($user instanceof User); + + return $follower->isNot($user) && ! $follower->hasBlockWith($user); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeleteApiCommitRequest.php b/app/Http/Requests/DeleteApiCommitRequest.php new file mode 100644 index 000000000..3522e2e9e --- /dev/null +++ b/app/Http/Requests/DeleteApiCommitRequest.php @@ -0,0 +1,31 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return $user->id === $commit->user_id; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeleteApiUserFollowRequest.php b/app/Http/Requests/DeleteApiUserFollowRequest.php new file mode 100644 index 000000000..61e8ac702 --- /dev/null +++ b/app/Http/Requests/DeleteApiUserFollowRequest.php @@ -0,0 +1,23 @@ +header('Content-Type', 'text/plain; charset=utf-8'); }); + // The limits the web puts on social actions apply to the mobile app too, and answer it with a code... + $exceptions->render(fn (ThrottleRequestsException $exception, Request $request): ?JsonResponse => $request->is('api/v1/*') && $request->user() instanceof User + ? new ApiException(ApiErrorCode::RateLimited, $exception->getMessage(), $exception->getHeaders())->render() + : null); + // Signed requests from the mobile app also check keys against GitHub... $exceptions->render(fn (ConnectionException|RequestException $exception, Request $request): ?JsonResponse => $request->is('api/v1/*') ? new ApiException(ApiErrorCode::GitHubUnreachable, 'Could not reach GitHub to check your SSH key. Try again in a moment.')->render() diff --git a/routes/api.php b/routes/api.php index 1d2b94ab0..dd03a7d7d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; +use App\Http\Controllers\Api\UserFollowController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; use App\Http\Middleware\AuthenticateSignedRequest; @@ -43,11 +44,25 @@ // Log... Route::get('log/{log}', [LogController::class, 'show'])->name('api-log.show'); + // Commit... + Route::post('commits', [ApiCommitController::class, 'store']) + ->middleware(ThrottleCommits::class) + ->name('api-commit.store'); + Route::delete('commits/{commit}', [ApiCommitController::class, 'destroy'])->name('api-commit.destroy'); + // Commit Reply... Route::get('commits/{commit}/replies', [CommitReplyController::class, 'index']) ->withTrashed() ->name('api-commit-reply.index'); + // User Follow... + Route::post('users/{user:username}/follow', [UserFollowController::class, 'store']) + ->middleware('throttle:follows') + ->name('api-user-follow.store'); + Route::delete('users/{user:username}/follow', [UserFollowController::class, 'destroy']) + ->middleware('throttle:follows') + ->name('api-user-follow.destroy'); + // Notifications... Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index'); }); diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php index 281e43004..6ad083451 100644 --- a/tests/Feature/ApiSignatureTest.php +++ b/tests/Feature/ApiSignatureTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Dto\SshPublicKey; use App\Models\Commit; use App\Models\SshKey; use App\Models\User; +use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Http; use Tests\Support\SshKeyPair; @@ -24,6 +26,42 @@ $this->assertAuthenticatedAs($this->user); }); +it('accepts requests signed by ssh-keygen over the documented message', function (string $message, string $signature, int $status): void { + $this->travelTo(CarbonImmutable::createFromTimestamp(1791000000)); + + // Made with `ssh-keygen -t ed25519`, then `ssh-keygen -Y sign -n devnull-api` over each message... + $publicKey = SshPublicKey::fromOpenSsh('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIrhcHdLGpoKRYqI6eBWDeFu3VITYyTHDGiQ7QhYXCPa vector'); + $user = User::factory()->create(); + + SshKey::factory()->for($user)->withPublicKey($publicKey)->create(); + + fakeGitHubSshKeys($user, [$publicKey]); + + [$timestamp, $nonce, $method, $target, $body] = explode("\n", $message, 5); + + $response = $this->call($method, $target, server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_DEVNULL_TIMESTAMP' => $timestamp, + 'HTTP_X_DEVNULL_NONCE' => $nonce, + 'HTTP_X_SSH_SIGNATURE' => $signature, + ], content: $body); + + $response->assertStatus($status); + + $this->assertAuthenticatedAs($user); +})->with([ + 'with a query and no body' => [ + "1791000000\n4f3c2b1a09e8d7c6b5a4938271605f4e\nGET\n/api/v1/log/latest?cursor=abc\n", + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgiuFwd0samgpFiojp4FYN4W7dUhNjJMcMaJDtCFhcI9oAAAALZGV2bnVsbC1hcGkAAAAAAAAABnNoYTUxMgAAAFMAAAALc3NoLWVkMjU1MTkAAABAKZ8la0GdO1nis8Bj7T2TApm89jTsN0of83/YJILbEsR6k/1q08FOHkSl3eZRIPoRVIjJTkXvqWhKlGMe31QwBw==', + 200, + ], + 'with the raw body, spaces and trailing newline included' => [ + "1791000000\n0123456789abcdef0123456789abcdef\nPOST\n/api/v1/commits\n{\"body\": \"It was DNS.\", \"parent_id\": null}\n", + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgiuFwd0samgpFiojp4FYN4W7dUhNjJMcMaJDtCFhcI9oAAAALZGV2bnVsbC1hcGkAAAAAAAAABnNoYTUxMgAAAFMAAAALc3NoLWVkMjU1MTkAAABAZrHU0K+nsKCLlC5abo8rZbyiRE0aBSBDvXD2285XW8VE/L1Zx6VBP+Hx+XltHDV9TfGNJPDW+lmrHvsIEjU8BA==', + 201, + ], +]); + it('rejects a request that differs from the one that was signed', function (): void { $server = signApiRequest($this->keyPair, 'GET', route('api-current-user.show')); diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index e18417a35..d36b3c406 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use App\Enums\CommitStatus; +use App\Jobs\ModerateCommit; use App\Models\Bookmark; use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; use App\Models\User; +use Illuminate\Support\Facades\Queue; it('shows an approved commit to anyone', function (): void { $user = User::factory()->create([ @@ -364,3 +366,172 @@ ->assertJsonMissingPath('ancestors.1.parent'); }); +it('creates a commit from a signed request, pending review', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => ' It was DNS :tada: ']); + + $commit = $user->commits()->sole(); + + $response->assertCreated() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.body', 'It was DNS 🎉') + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.parent_id', null) + ->assertJsonPath('data.replies_count', 0) + ->assertJsonPath('data.stars_count', 0) + ->assertJsonPath('data.starred', false) + ->assertJsonPath('data.is_saved', false) + ->assertJsonPath('data.images', []) + ->assertJsonPath('data.author.id', $user->id) + ->assertJsonMissingPath('ancestors'); + + expect($commit->status)->toBe(CommitStatus::Pending); +}); + +it('publishes the commits of admins right away', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + config(['app.admins' => [$user->username]]); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.']) + ->assertCreated() + ->assertJsonPath('data.status', 'approved'); +}); + +it('replies to a commit from a signed request', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $parent = Commit::factory()->create(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.', 'parent_id' => $parent->id]) + ->assertCreated() + ->assertJsonPath('data.parent_id', $parent->id); + + expect($user->commits()->sole()->parent_id)->toBe($parent->id); +}); + +it('validates a commit from a signed request', function (Closure $data, string $field, string $message): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), $data()) + ->assertUnprocessable() + ->assertJsonValidationErrors([$field => $message]); + + expect($user->commits()->exists())->toBeFalse(); +})->with([ + 'missing body' => [fn (): array => [], 'body', 'The body field is required.'], + 'too short' => [fn (): array => ['body' => ' ab '], 'body', 'The body field must be at least 3 characters.'], + 'too long' => [fn (): array => ['body' => str_repeat('a', Commit::MAX_BODY_LENGTH + 1)], 'body', 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.'], + 'parent that is not a uuid' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => 'abc'], 'parent_id', 'The parent id field must be a valid UUID.'], + 'unknown parent' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => '0199e3a4-0000-7000-8000-000000000000'], 'parent_id', 'That commit is no longer available to reply to.'], + 'hidden parent' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => Commit::factory()->pending()->create()->id], 'parent_id', 'That commit is no longer available to reply to.'], + 'deleted parent' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => tap(Commit::factory()->create())->delete()->id], 'parent_id', 'That commit is no longer available to reply to.'], +]); + +it('may not reply to a commit across a block, as on the web', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $parent = Commit::factory()->create(); + + $parent->user->blocking()->attach($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.', 'parent_id' => $parent->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['parent_id' => 'That commit is no longer available to reply to.']); + + expect($user->commits()->exists())->toBeFalse(); +}); + +it('sends a commit from a signed request to the ai moderation the web uses', function (): void { + config(['services.openai.key' => 'sk-test']); + Queue::fake(); + + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.']); + + $response->assertCreated(); + + Queue::assertPushed(ModerateCommit::class, fn (ModerateCommit $job): bool => $job->commit->id === $response->json('data.id')); +}); + +it('collapses blank lines before measuring a body, like the web does', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => "It was DNS.\r\n\n\n\n\nIt always is."]) + ->assertCreated() + ->assertJsonPath('data.body', "It was DNS.\n\nIt always is."); +}); + +it('counts each emoji as one character, like the web does', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => str_repeat('🚀', Commit::MAX_BODY_LENGTH)]) + ->assertCreated(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => str_repeat('🚀', Commit::MAX_BODY_LENGTH + 1)]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['body' => 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.']); + + expect($user->commits()->sole()->body)->toBe(str_repeat('🚀', Commit::MAX_BODY_LENGTH)); +}); + +it('limits a signed-in user to 10 commits a minute, in json', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + foreach (range(1, 10) as $attempt) { + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => "Commit {$attempt}"])->assertCreated(); + } + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'One too many']) + ->assertTooManyRequests() + ->assertExactJson(['message' => 'Slow down: 10 commits a minute. Even CI takes breaks.', 'code' => 'rate_limited']) + ->assertHeader('Retry-After', '60'); + + expect($user->commits()->count())->toBe(10); +}); + +it('deletes a commit of the signed-in user', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->for($user)->create(); + + signedApiRequest($keyPair, 'DELETE', route('api-commit.destroy', $commit))->assertNoContent(); + + $this->assertSoftDeleted($commit); +}); + +it('may not delete a commit that is already deleted', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = tap(Commit::factory()->for($user)->create())->delete(); + + signedApiRequest($keyPair, 'DELETE', route('api-commit.destroy', $commit))->assertNotFound(); +}); + +it('may not delete the commits of others', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $commit = Commit::factory()->create(); + + signedApiRequest($keyPair, 'DELETE', route('api-commit.destroy', $commit))->assertForbidden(); + + $this->assertNotSoftDeleted($commit); +}); + +it('rejects commits and deletes that are not signed', function (): void { + $commit = Commit::factory()->create(); + + $this->postJson(route('api-commit.store'), ['body' => 'It was DNS.'])->assertUnauthorized(); + $this->deleteJson(route('api-commit.destroy', $commit))->assertUnauthorized(); + + expect(Commit::query()->count())->toBe(1); + $this->assertNotSoftDeleted($commit); +}); diff --git a/tests/Feature/Controllers/Api/UserFollowControllerTest.php b/tests/Feature/Controllers/Api/UserFollowControllerTest.php new file mode 100644 index 000000000..9cb9ac5f3 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowControllerTest.php @@ -0,0 +1,82 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may follow a user', function (): void { + $user = User::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $user)); + + $response->assertNoContent(); + + expect($this->user->isFollowing($user))->toBeTrue() + ->and($user->notifications()->sole()->type)->toBe(NotificationType::Followed); +}); + +it('may not follow themselves', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $this->user)); + + $response->assertForbidden(); + + expect($this->user->following()->count())->toBe(0); +}); + +it('may not follow anyone across a block, as on the web', function (Closure $block): void { + $user = User::factory()->create(); + + $block($this->user, $user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $user))->assertForbidden(); + + expect($this->user->isFollowing($user))->toBeFalse(); +})->with([ + 'blocking' => fn (User $follower, User $user) => $follower->blocking()->attach($user), + 'blocked' => fn (User $follower, User $user) => $user->blocking()->attach($follower), +]); + +it('may unfollow a user', function (): void { + $user = User::factory()->create(); + + $this->user->following()->attach($user); + + $response = signedApiRequest($this->keyPair, 'DELETE', route('api-user-follow.destroy', $user)); + + $response->assertNoContent(); + + expect($this->user->isFollowing($user))->toBeFalse(); +}); + +it('returns not found for unknown usernames', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', 'api/v1/users/missing/follow'); + + $response->assertNotFound(); +}); + +it('rejects requests that are not signed', function (string $method): void { + $user = User::factory()->create(); + + $this->json($method, route('api-user-follow.store', $user))->assertUnauthorized(); + + expect($user->followers()->count())->toBe(0); +})->with(['POST', 'DELETE']); + +it('limits follows and unfollows to 30 a minute, like the web, with a code', function (): void { + $user = User::factory()->create(); + + foreach (range(1, 30) as $attempt) { + signedApiRequest($this->keyPair, $attempt % 2 === 0 ? 'DELETE' : 'POST', route('api-user-follow.store', $user))->assertNoContent(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $user)) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertExactJson(['message' => 'Too Many Attempts.', 'code' => 'rate_limited']); +}); From 6eae99d1ef963eb7db1ced76029d3d5069f5cd65 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 11:11:24 +0100 Subject: [PATCH 05/15] docs: describe signed requests next to the public API PRODUCT.md listed a write API among the things devnull leaves out, which this branch adds. It now describes the signed API next to the public one, and the exclusion is down to API tokens, which there still aren't any of. The README gets a short section after Public API with the headers, the signed message and the error codes, including the ones for a profile across a block. --- PRODUCT.md | 3 ++- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/PRODUCT.md b/PRODUCT.md index 47ce0eb86..9d86a7c67 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,6 +23,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Sign-in is GitHub OAuth only. Profile changes happen on GitHub ("Edit on GitHub"), then sync at next login or with "Sync from GitHub" (once a minute). - Everything except the landing page, the public read API, and profile feeds requires login. - Public read API: read-only JSON under `/api/v1`, no authentication, for public profiles (with counts), a user's commits or replies, and single commits. It returns exactly what a guest's link preview may show: approved, non-deleted commits only (with their images), approved reply counts, and a parent only while it is approved. Never emails or other hidden profile fields. 60 requests a minute per IP. +- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key that is on the user's GitHub account (no passwords or API tokens; the user must have signed in on the web once). A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, and notifications, and commit, reply, delete, and follow, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. - Profile feeds: every profile has an Atom feed of its latest 50 approved top-level commits at `/@{username}/feed.atom`, their images as enclosures, advertised with `` on the profile's guest preview. - First sign-in leads to a short setup at `/onboarding`: confirm the mirrored profile, pick a color, follow a few suggested developers, learn the composer, `?` shortcuts and terminal commits, then land on the log (or the page the user was headed to). Skippable at any step (Skip setup or Esc); finishing or skipping ends it for good. Accounts that existed before it never see it. - Profile links (`/@{username}`) and links to approved commits (`/@{username}/commits/{id}`) unfurl with a link preview (the author and their bio or counts, or the commit body and reply count) for guests and crawlers; a guest opening one sees the landing page, and the profile or commit itself stays behind login. Pending and rejected commits are a 404 for guests. @@ -51,7 +52,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - People search: find developers by name or GitHub username (⌘K). Commits are not searchable. - Rate limits, per user: stars and saves 60/min each, pins 20/min, follows 30/min, people lookups (search, mention suggestions, hover cards) 120/min combined, exports 3/hour. Throttled actions keep the page and show a toast; throttled pages show the 429 page. - Public read API and Atom feeds: read-only, so nothing can be created, starred, or followed through them, and there are no API tokens. Lists are cursor-paginated, 20 per page. -- Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, a write API or API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist. +- Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist. - GitHub username renames: signing in takes the current GitHub username. If a stale account still holds it because its owner renamed on GitHub, that account moves to a `_renamed-` placeholder until its owner signs in again. Accounts are never merged. ## Brand Commitments diff --git a/README.md b/README.md index cfc9af051..685037323 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,49 @@ curl -s https://dev-null.sh/api/v1/users/nunomaduro/commits | jq '.data[] | {sho Every profile also has an Atom feed of its latest 50 top-level commits at `/@{username}/feed.atom`, with their images as enclosures, cached for five minutes. +## Signed requests + +The mobile app signs its requests to `/api/v1` with an SSH key that is on the user's GitHub account, the way `devnull commit` signs commits. A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting and following only take signed requests. Signed requests are limited to 120 a minute per user. + +Each request sends three headers: + +| Header | Value | +|---|---| +| `X-Devnull-Timestamp` | Unix time in seconds, within 5 minutes of the server's clock | +| `X-Devnull-Nonce` | 32 random hex characters, used once | +| `X-SSH-Signature` | The SSHSIG signature, base64 on one line | + +The signature is made with `ssh-keygen -Y sign -n devnull-api` over these lines, joined by `\n`: the timestamp, the nonce, the method, the path with its query string exactly as sent, and the raw body. + +```text +1760534400 +3f9c2a7e5b1d4c8f9a0b2c3d4e5f6a7b +POST +/api/v1/commits +{"body":"Hello from my phone"} +``` + +A request with any of these headers has to be signed right. A bad or expired signature is a `401`, never the public view. A request without them always gets the public view, and across a block a signed request gets neither profile: it is a `403`, `user_blocked` for the one who blocked and `user_unavailable` for the other, and their commits are hidden as on the web. + +### Errors + +An error is JSON with a `message` to show the user. The errors the app has to tell apart also have a `code`, which stays the same when the message changes: + +```json +{"message": "Signature expired. Check that your clock is right.", "code": "invalid_signature"} +``` + +| Status | `code` | When | +|---|---|---| +| `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. | +| `401` | `key_not_on_github` | The key isn't on the GitHub account of a devnull user. | +| `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, or 30 follows and unfollows a minute. `Retry-After` says when to try again. | +| `503` | `github_unreachable` | GitHub couldn't be reached to check the key. | + +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. + ## Available Tooling ### Development From 491e10937dfcca71b252cb1f96cf8909e32479f9 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 12:01:42 +0100 Subject: [PATCH 06/15] feat: say whether a profile follows you in the signed API The web's profile now shows a "follows you" badge (#149) from its followsYou prop. A signed GET /api/v1/users/{username} sends the same thing as follows_you, next to is_following, so the mobile app can show the badge too. Unsigned requests don't get it, like is_following. --- app/Http/Controllers/Api/UserController.php | 1 + .../Controllers/Api/UserControllerTest.php | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 6644e6c24..37514b96e 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -27,6 +27,7 @@ public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentU return UserResource::make($user->loadCounts($currentUser))->additional([ 'is_following' => $currentUser->isFollowing($user), + 'follows_you' => $user->isFollowing($currentUser), 'activity' => $activity->get($user, $currentUser), 'streaks' => $streaks->get($user, $currentUser), 'pinned_commit' => $pinnedCommit instanceof Commit ? CommitResource::make($pinnedCommit) : null, diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index c2ee5757e..d8b7828c5 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -116,6 +116,7 @@ 'lane' => $user->lane(), ], 'is_following' => true, + 'follows_you' => false, 'activity' => resolve(UserActivityQuery::class)->get($user, $viewer), 'streaks' => ['longest' => 1, 'current' => 1], 'pinned_commit' => null, @@ -135,7 +136,8 @@ $this->getJson(route('api-user.show', $user)) ->assertOk() ->assertJsonPath('data.commits_count', 0) - ->assertJsonMissingPath('is_following'); + ->assertJsonMissingPath('is_following') + ->assertJsonMissingPath('follows_you'); signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) ->assertOk() @@ -143,6 +145,19 @@ ->assertJsonPath('is_following', false); }); +it('tells a signed request whether the profile follows its user', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + + $user->following()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('is_following', false) + ->assertJsonPath('follows_you', true); +}); + it('shows a signed request the pinned commit of the profile', function (): void { $viewer = User::factory()->create(); $keyPair = apiKeyPairFor($viewer); From 4220d25809897b630215c1f1e7ac0255b96f8772 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 12:42:28 +0100 Subject: [PATCH 07/15] feat: add a devices table for keys approved for the mobile app A device is an SSH key that its owner approved for the /dev/null app on the web. It has the key's fingerprint, a name like "devnull on iPhone", when it was approved and when it was last used. A fingerprint is one device, so approving a key again moves it to whoever approved it last. It lives apart from ssh_keys on purpose. UpdateUserSshKeys rewrites ssh_keys from GitHub on every sync, while an approval should stay until the user revokes it. Devices go when their user is deleted. Nothing reads it yet. --- app/Models/Device.php | 67 +++++++++++++++++++ app/Models/User.php | 9 +++ database/factories/DeviceFactory.php | 36 ++++++++++ ...2026_10_16_170000_create_devices_table.php | 25 +++++++ tests/Unit/Models/DeviceTest.php | 34 ++++++++++ tests/Unit/Models/UserTest.php | 9 +++ 6 files changed, 180 insertions(+) create mode 100644 app/Models/Device.php create mode 100644 database/factories/DeviceFactory.php create mode 100644 database/migrations/2026_10_16_170000_create_devices_table.php create mode 100644 tests/Unit/Models/DeviceTest.php diff --git a/app/Models/Device.php b/app/Models/Device.php new file mode 100644 index 000000000..9eb7aa35a --- /dev/null +++ b/app/Models/Device.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + use HasUuids; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'user_id' => 'string', + 'fingerprint' => 'string', + 'name' => 'string', + 'approved_at' => 'datetime', + 'last_used_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 10ed1ea24..53a674238 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -50,6 +50,7 @@ * @property-read Collection $notifications * @property-read Collection $sshKeys * @property-read Collection $pollVotes + * @property-read Collection $devices * @property-read int $commits_count * @property-read int $following_count * @property-read int $followers_count @@ -132,6 +133,14 @@ public function sshKeys(): HasMany return $this->hasMany(SshKey::class); } + /** + * @return HasMany + */ + public function devices(): HasMany + { + return $this->hasMany(Device::class); + } + /** * @return BelongsToMany */ diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php new file mode 100644 index 000000000..3a5ca31a8 --- /dev/null +++ b/database/factories/DeviceFactory.php @@ -0,0 +1,36 @@ + + */ +final class DeviceFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'fingerprint' => 'SHA256:'.mb_rtrim(base64_encode(random_bytes(32)), '='), + 'name' => 'devnull on iPhone', + 'approved_at' => now(), + ]; + } + + public function withPublicKey(SshPublicKey $publicKey): self + { + return $this->state(fn (array $attributes): array => [ + 'fingerprint' => $publicKey->fingerprint(), + ]); + } +} diff --git a/database/migrations/2026_10_16_170000_create_devices_table.php b/database/migrations/2026_10_16_170000_create_devices_table.php new file mode 100644 index 000000000..fb6d6baa6 --- /dev/null +++ b/database/migrations/2026_10_16_170000_create_devices_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('fingerprint')->unique(); + $table->string('name'); + $table->timestamp('approved_at'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } +}; diff --git a/tests/Unit/Models/DeviceTest.php b/tests/Unit/Models/DeviceTest.php new file mode 100644 index 000000000..d85678405 --- /dev/null +++ b/tests/Unit/Models/DeviceTest.php @@ -0,0 +1,34 @@ +create()->refresh(); + + expect(array_keys($device->toArray())) + ->toBe([ + 'id', + 'fingerprint', + 'name', + 'approved_at', + 'last_used_at', + ]); +}); + +it('belongs to a user', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->for($user)->create(); + + expect($device->user->is($user))->toBeTrue(); +}); + +it('is deleted with its user', function (): void { + $device = Device::factory()->create(); + + $device->user->delete(); + + expect($device->fresh())->toBeNull(); +}); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 279797756..629956d89 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Commit; +use App\Models\Device; use App\Models\SshKey; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -49,6 +50,14 @@ expect($user->sshKeys)->toHaveCount(2); }); +it('has devices', function (): void { + $user = User::factory()->create(); + Device::factory()->for($user)->count(2)->create(); + Device::factory()->create(); + + expect($user->devices)->toHaveCount(2); +}); + it('has following and followers', function (): void { $user = User::factory()->create(); $followed = User::factory()->create(); From 7af9b2b2abe42c398ea256972ed79d351568f9fb Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 17:22:25 +0100 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20list=20and=20revoke=20devices=20u?= =?UTF-8?q?nder=20Settings=20=E2=86=92=20Devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new settings page at /settings/devices, next to Safety, lists the user's approved devices with their name, fingerprint, when they were approved and when they were last used, newest first. Revoke deletes the device after an inline confirm that stays in the row's slot, then the row collapses like a deleted commit, and focus moves to the next row. Someone else's device is a 404. It follows the usual shape: DeviceController with index and destroy, DeleteDeviceRequest and the DeleteDevice action. --- app/Actions/DeleteDevice.php | 18 ++ app/Http/Controllers/DeviceController.php | 34 +++ app/Http/Requests/DeleteDeviceRequest.php | 32 +++ resources/js/layouts/settings/layout.tsx | 6 + resources/js/pages/device/index.tsx | 238 ++++++++++++++++++ resources/js/types/device.ts | 7 + resources/js/types/index.ts | 1 + routes/web.php | 5 + .../Controllers/DeviceControllerTest.php | 79 ++++++ tests/Unit/Actions/DeleteDeviceTest.php | 16 ++ 10 files changed, 436 insertions(+) create mode 100644 app/Actions/DeleteDevice.php create mode 100644 app/Http/Controllers/DeviceController.php create mode 100644 app/Http/Requests/DeleteDeviceRequest.php create mode 100644 resources/js/pages/device/index.tsx create mode 100644 resources/js/types/device.ts create mode 100644 tests/Feature/Controllers/DeviceControllerTest.php create mode 100644 tests/Unit/Actions/DeleteDeviceTest.php diff --git a/app/Actions/DeleteDevice.php b/app/Actions/DeleteDevice.php new file mode 100644 index 000000000..2d278fd9d --- /dev/null +++ b/app/Actions/DeleteDevice.php @@ -0,0 +1,18 @@ +delete(); + } +} diff --git a/app/Http/Controllers/DeviceController.php b/app/Http/Controllers/DeviceController.php new file mode 100644 index 000000000..7009fdea1 --- /dev/null +++ b/app/Http/Controllers/DeviceController.php @@ -0,0 +1,34 @@ + $user->devices()->latest('approved_at')->get(), + ]); + } + + public function destroy(DeleteDeviceRequest $request, Device $device, DeleteDevice $action): RedirectResponse + { + $action->handle($device); + + return back(); + } +} diff --git a/app/Http/Requests/DeleteDeviceRequest.php b/app/Http/Requests/DeleteDeviceRequest.php new file mode 100644 index 000000000..f36970886 --- /dev/null +++ b/app/Http/Requests/DeleteDeviceRequest.php @@ -0,0 +1,32 @@ +user(); + assert($user instanceof User); + + $device = $this->route('device'); + assert($device instanceof Device); + + return $device->user_id === $user->id ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index e479ef938..cfd9871cd 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'; import { PageTabs } from '@/components/page-tabs'; import { useCurrentUrl } from '@/hooks/use-current-url'; import { edit as editAppearance } from '@/routes/appearance'; +import { index as devices } from '@/routes/device'; import { edit as editSafety } from '@/routes/safety'; import { show as showTerminal } from '@/routes/terminal'; import { edit } from '@/routes/user-profile'; @@ -38,6 +39,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) { href: editSafety(), isActive: isCurrentUrl(editSafety()), }, + { + title: 'Devices', + href: devices(), + isActive: isCurrentUrl(devices()), + }, ]} /> diff --git a/resources/js/pages/device/index.tsx b/resources/js/pages/device/index.tsx new file mode 100644 index 000000000..aac49eaeb --- /dev/null +++ b/resources/js/pages/device/index.tsx @@ -0,0 +1,238 @@ +import { Head, router } from '@inertiajs/react'; +import { useRef, useState } from 'react'; +import DeviceController from '@/actions/App/Http/Controllers/DeviceController'; +import { Button } from '@/components/ui/button'; +import { useNow } from '@/hooks/use-now'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { cn, formatRelativeTime } from '@/lib/utils'; +import type { Device } from '@/types'; + +export default function Index({ devices }: { devices: Device[] }) { + const headingRef = useRef(null); + + // A revoked row unmounts with the focus inside it, so it moves to the next row, or the heading... + const focusAfter = (id: string) => { + const index = devices.findIndex((device) => device.id === id); + const next = devices[index + 1] ?? devices[index - 1]; + + const target = next + ? document.querySelector( + `[data-device="${next.id}"] [data-revoke]`, + ) + : null; + + (target ?? headingRef.current)?.focus({ preventScroll: true }); + }; + + return ( + <> + + +
+
+

+ Approved devices +

+

+ Each one can use /dev/null as you through the app, since + you approved it here on the web. Revoke any you don't + use or don't recognize. +

+
+ + {devices.length === 0 ? ( +
+

+ fatal: no devices approved yet +

+

+ Sign in from the /dev/null app and approve it here + to add one. +

+
+ ) : ( +
    + {devices.map((device) => ( + focusAfter(device.id)} + /> + ))} +
+ )} +
+ + ); +} + +function DeviceRow({ + device, + onRevoked, +}: { + device: Device; + onRevoked: () => void; +}) { + const now = useNow(); + const [revocation, setRevocation] = useState< + 'idle' | 'confirming' | 'revoking' + >('idle'); + const [hasCancelled, setHasCancelled] = useState(false); + + const cancel = () => { + setRevocation('idle'); + setHasCancelled(true); + }; + + const revoke = () => { + setRevocation('revoking'); + + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + + window.setTimeout( + () => + router.visit(DeviceController.destroy(device), { + preserveScroll: true, + onSuccess: onRevoked, + onError: () => setRevocation('idle'), + }), + prefersReducedMotion ? 0 : 700, + ); + }; + + return ( +
  • +
    +
    +
    +

    + {device.name} +

    +

    + {device.fingerprint} +

    + + {revocation === 'idle' && ( +

    + Approved{' '} +

    + )} + + {revocation === 'confirming' && ( +

    + Revoke this device? The app on it signs out. +

    + )} + + {revocation === 'revoking' && ( +

    + rm {device.fingerprint} → /dev/null +

    + )} +
    + + {revocation === 'idle' && ( + + )} + + {revocation === 'confirming' && ( +
    { + if (event.key === 'Escape') { + cancel(); + } + }} + className="flex shrink-0 gap-2" + > + + +
    + )} +
    +
    +
  • + ); +} + +/** + * "just now", "3m ago", or a date, in mono like every time devnull prints. + */ +function Time({ date, now }: { date: string; now: Date }) { + const relative = formatRelativeTime(date, now); + + return ( + + ); +} + +Index.layout = [AppLayout, SettingsLayout]; diff --git a/resources/js/types/device.ts b/resources/js/types/device.ts new file mode 100644 index 000000000..8d8d39fcc --- /dev/null +++ b/resources/js/types/device.ts @@ -0,0 +1,7 @@ +export type Device = { + id: string; + name: string; + fingerprint: string; + approved_at: string; + last_used_at: string | null; +}; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 7745bef78..7bc9f010e 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -2,6 +2,7 @@ export type * from './achievement'; export type * from './activity'; export type * from './auth'; export type * from './commit'; +export type * from './device'; export type * from './log'; export type * from './navigation'; export type * from './notification'; diff --git a/routes/web.php b/routes/web.php index 987aa0a47..b3e7e43c1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,7 @@ use App\Http\Controllers\CommitSitemapController; use App\Http\Controllers\CommitStarController; use App\Http\Controllers\CommitStarCountController; +use App\Http\Controllers\DeviceController; use App\Http\Controllers\ExportController; use App\Http\Controllers\FailedJobController; use App\Http\Controllers\FailedJobRetryController; @@ -257,6 +258,10 @@ // Safety... Route::get('settings/safety', [SafetyController::class, 'edit'])->name('safety.edit'); + // Devices... + Route::get('settings/devices', [DeviceController::class, 'index'])->name('device.index'); + Route::delete('settings/devices/{device}', [DeviceController::class, 'destroy'])->name('device.destroy'); + // User... Route::delete('user', [UserController::class, 'destroy'])->name('user.destroy'); diff --git a/tests/Feature/Controllers/DeviceControllerTest.php b/tests/Feature/Controllers/DeviceControllerTest.php new file mode 100644 index 000000000..866e7f444 --- /dev/null +++ b/tests/Feature/Controllers/DeviceControllerTest.php @@ -0,0 +1,79 @@ +create(); + + $older = Device::factory()->for($user)->create([ + 'name' => 'devnull on iPad', + 'approved_at' => now()->subWeek(), + 'last_used_at' => now()->subDay(), + ]); + $newer = Device::factory()->for($user)->create(['name' => 'devnull on iPhone']); + + Device::factory()->create(); + + $response = $this->actingAs($user)->get(route('device.index')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('device/index') + ->has('devices', 2) + ->where('devices.0', [ + 'id' => $newer->id, + 'fingerprint' => $newer->fingerprint, + 'name' => 'devnull on iPhone', + 'approved_at' => now()->startOfSecond()->toJSON(), + 'last_used_at' => null, + ]) + ->where('devices.1.id', $older->id) + ->where('devices.1.last_used_at', now()->subDay()->startOfSecond()->toJSON())); +}); + +it('lists no devices', function (): void { + $response = $this->actingAs(User::factory()->create())->get(route('device.index')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('device/index') + ->has('devices', 0)); +}); + +it('revokes a device', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->for($user)->create(); + + $response = $this->actingAs($user) + ->fromRoute('device.index') + ->delete(route('device.destroy', $device)); + + $response->assertRedirectToRoute('device.index'); + + expect($device->fresh())->toBeNull(); +}); + +it("never revokes someone else's device", function (): void { + $device = Device::factory()->create(); + + $response = $this->actingAs(User::factory()->create()) + ->delete(route('device.destroy', $device)); + + $response->assertNotFound(); + + expect($device->fresh())->not->toBeNull(); +}); + +it('requires sign in', function (string $method, Closure $uri): void { + $device = Device::factory()->create(); + + $this->{$method}($uri($device))->assertRedirectToRoute('home'); + + expect($device->fresh())->not->toBeNull(); +})->with([ + 'the list' => ['get', fn (Device $device): string => route('device.index')], + 'revoking' => ['delete', fn (Device $device): string => route('device.destroy', $device)], +]); diff --git a/tests/Unit/Actions/DeleteDeviceTest.php b/tests/Unit/Actions/DeleteDeviceTest.php new file mode 100644 index 000000000..557934008 --- /dev/null +++ b/tests/Unit/Actions/DeleteDeviceTest.php @@ -0,0 +1,16 @@ +create(); + $other = Device::factory()->for($device->user)->create(); + + resolve(DeleteDevice::class)->handle($device); + + expect($device->fresh())->toBeNull() + ->and($other->fresh())->not->toBeNull(); +}); From 60957c4c943094cd180e42fcf39bc6720b29fc19 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 17:23:29 +0100 Subject: [PATCH 09/15] feat: sign in the mobile app on dev-null.sh with OAuth and PKCE The app signs in the way an OAuth app does, with devnull as the provider. It makes a key and a PKCE verifier, then opens /mobile/authorize in a browser session with the key's fingerprint, a device name, the S256 challenge, a state and a devnull:// redirect_uri. A guest signs in with GitHub first and comes back to the page. It asks "Approve devnull on iPhone?" and shows the fingerprint and the account it signs in as. Approving is a plain form post, so the browser follows the 303 back into the app with a one-time code and the state. Cancel is a plain link to the redirect with error=access_denied. The code is random, kept only as a hash, good for 5 minutes and once, and bound to the user, the key, the device name, the challenge and the redirect. Each user can approve 10 times a minute. A redirect_uri that doesn't start with one of the prefixes in config/mobile.php, or any other bad parameter, gets a plain error page and never a redirect. The app redeems the code with POST /api/v1/devices and {"code", "code_verifier"}, signed with the key. Only the signature, its timestamp and its nonce are checked, as the key isn't approved yet. The code has to be unexpired and unused, handed out for that key, and match base64url(sha256(code_verifier)). Anything else is a 400 with invalid_grant. CreateDevice redeems it in a transaction with the code locked, so two requests with one code can't both get a device. The answer is the user, like GET /api/v1/me. Each key gets 12 tries a minute and each address 60, counted before signatures are checked. From then on a signed request is the user who approved its key. The key no longer has to be on their GitHub account, and the API never asks GitHub about it. A key nobody approved, or one that was revoked, is a 401 with device_not_approved, so a key made for the GitHub Action still can't use the API, as the Terminal page promises. key_not_on_github and github_unreachable go, since the API doesn't check GitHub any more. The commit script and the Action go through SignedCommits and keep checking GitHub as before. SshSignatures gains checkFreshness() for the API's half. --- app/Actions/CreateAuthorizationCode.php | 32 +++ app/Actions/CreateDevice.php | 43 ++++ app/Actions/UpdateDeviceLastUsedAt.php | 22 +++ app/Dto/MobileAuthorization.php | 49 +++++ app/Enums/ApiErrorCode.php | 10 +- app/Exceptions/ApiException.php | 8 + app/Http/Controllers/Api/DeviceController.php | 36 ++++ .../MobileAuthorizationController.php | 44 +++++ .../Middleware/AuthenticateSignedRequest.php | 13 +- app/Http/Requests/CreateApiDeviceRequest.php | 91 +++++++++ .../CreateMobileAuthorizationRequest.php | 44 +++++ .../ShowMobileAuthorizationRequest.php | 76 ++++++++ app/Models/AuthorizationCode.php | 86 ++++++++ app/Models/User.php | 9 + app/Providers/AppServiceProvider.php | 7 + app/Services/SignedRequests.php | 37 +++- app/Services/SshSignatures.php | 47 +++-- bootstrap/app.php | 5 - config/mobile.php | 13 ++ .../factories/AuthorizationCodeFactory.php | 66 +++++++ ...70001_create_authorization_codes_table.php | 27 +++ .../js/pages/mobile-authorization/show.tsx | 184 ++++++++++++++++++ routes/api.php | 9 +- routes/web.php | 7 + tests/Feature/ApiSignatureTest.php | 101 +++++++--- .../Controllers/Api/DeviceControllerTest.php | 174 +++++++++++++++++ .../MobileAuthorizationControllerTest.php | 144 ++++++++++++++ tests/Pest.php | 9 +- .../Actions/CreateAuthorizationCodeTest.php | 38 ++++ tests/Unit/Actions/CreateDeviceTest.php | 49 +++++ .../Actions/UpdateDeviceLastUsedAtTest.php | 32 +++ tests/Unit/Models/AuthorizationCodeTest.php | 41 ++++ .../Unit/Providers/AppServiceProviderTest.php | 33 ++++ tests/Unit/Services/SignedRequestsTest.php | 32 ++- tests/Unit/Services/SshSignaturesTest.php | 20 ++ 35 files changed, 1563 insertions(+), 75 deletions(-) create mode 100644 app/Actions/CreateAuthorizationCode.php create mode 100644 app/Actions/CreateDevice.php create mode 100644 app/Actions/UpdateDeviceLastUsedAt.php create mode 100644 app/Dto/MobileAuthorization.php create mode 100644 app/Http/Controllers/Api/DeviceController.php create mode 100644 app/Http/Controllers/MobileAuthorizationController.php create mode 100644 app/Http/Requests/CreateApiDeviceRequest.php create mode 100644 app/Http/Requests/CreateMobileAuthorizationRequest.php create mode 100644 app/Http/Requests/ShowMobileAuthorizationRequest.php create mode 100644 app/Models/AuthorizationCode.php create mode 100644 config/mobile.php create mode 100644 database/factories/AuthorizationCodeFactory.php create mode 100644 database/migrations/2026_10_16_170001_create_authorization_codes_table.php create mode 100644 resources/js/pages/mobile-authorization/show.tsx create mode 100644 tests/Feature/Controllers/Api/DeviceControllerTest.php create mode 100644 tests/Feature/Controllers/MobileAuthorizationControllerTest.php create mode 100644 tests/Unit/Actions/CreateAuthorizationCodeTest.php create mode 100644 tests/Unit/Actions/CreateDeviceTest.php create mode 100644 tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php create mode 100644 tests/Unit/Models/AuthorizationCodeTest.php diff --git a/app/Actions/CreateAuthorizationCode.php b/app/Actions/CreateAuthorizationCode.php new file mode 100644 index 000000000..8cf4d771e --- /dev/null +++ b/app/Actions/CreateAuthorizationCode.php @@ -0,0 +1,32 @@ +authorizationCodes()->create([ + 'code_hash' => AuthorizationCode::hash($code), + 'fingerprint' => $authorization->fingerprint, + 'device_name' => $authorization->deviceName, + 'code_challenge' => $authorization->codeChallenge, + 'redirect_uri' => $authorization->redirectUri, + 'expires_at' => now()->addMinutes(AuthorizationCode::EXPIRES_AFTER_MINUTES), + ]); + + return $code; + } +} diff --git a/app/Actions/CreateDevice.php b/app/Actions/CreateDevice.php new file mode 100644 index 000000000..bd3e20531 --- /dev/null +++ b/app/Actions/CreateDevice.php @@ -0,0 +1,43 @@ +whereKey($authorizationCode->id) + ->where('expires_at', '>', now()) + ->lockForUpdate() + ->first(); + + if (! $code instanceof AuthorizationCode) { + return null; + } + + $code->delete(); + + return Device::query()->updateOrCreate(['fingerprint' => $code->fingerprint], [ + 'user_id' => $code->user_id, + 'name' => $code->device_name, + 'approved_at' => now(), + 'last_used_at' => null, + ]); + }); + } +} diff --git a/app/Actions/UpdateDeviceLastUsedAt.php b/app/Actions/UpdateDeviceLastUsedAt.php new file mode 100644 index 000000000..77020de78 --- /dev/null +++ b/app/Actions/UpdateDeviceLastUsedAt.php @@ -0,0 +1,22 @@ +last_used_at?->isAfter(now()->subMinute()) === true) { + return; + } + + $device->update(['last_used_at' => now()]); + } +} diff --git a/app/Dto/MobileAuthorization.php b/app/Dto/MobileAuthorization.php new file mode 100644 index 000000000..340d783d8 --- /dev/null +++ b/app/Dto/MobileAuthorization.php @@ -0,0 +1,49 @@ + $parameters + */ + public function redirectTo(array $parameters): string + { + return $this->redirectUri.(str_contains($this->redirectUri, '?') ? '&' : '?').http_build_query([...$parameters, 'state' => $this->state]); + } + + /** + * The parameters as the app sent them, for the approval form to send back. + * + * @return array{fingerprint: string, device_name: string, code_challenge: string, code_challenge_method: string, state: string, redirect_uri: string} + */ + public function toParameters(): array + { + return [ + 'fingerprint' => $this->fingerprint, + 'device_name' => $this->deviceName, + 'code_challenge' => $this->codeChallenge, + 'code_challenge_method' => 'S256', + 'state' => $this->state, + 'redirect_uri' => $this->redirectUri, + ]; + } +} diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php index 7c02f5b6c..05650da54 100644 --- a/app/Enums/ApiErrorCode.php +++ b/app/Enums/ApiErrorCode.php @@ -11,19 +11,19 @@ enum ApiErrorCode: string { case InvalidSignature = 'invalid_signature'; - case KeyNotOnGitHub = 'key_not_on_github'; - case RateLimited = 'rate_limited'; - case GitHubUnreachable = 'github_unreachable'; + case DeviceNotApproved = 'device_not_approved'; + case InvalidGrant = 'invalid_grant'; case UserBlocked = 'user_blocked'; case UserUnavailable = 'user_unavailable'; + case RateLimited = 'rate_limited'; public function status(): int { return match ($this) { - self::InvalidSignature, self::KeyNotOnGitHub => 401, + self::InvalidGrant => 400, + self::InvalidSignature, self::DeviceNotApproved => 401, self::UserBlocked, self::UserUnavailable => 403, self::RateLimited => 429, - self::GitHubUnreachable => 503, }; } } diff --git a/app/Exceptions/ApiException.php b/app/Exceptions/ApiException.php index 831ec6b7c..1b146089c 100644 --- a/app/Exceptions/ApiException.php +++ b/app/Exceptions/ApiException.php @@ -35,6 +35,14 @@ public static function acrossBlock(User $user, bool $blockedByViewer): self : new self(ApiErrorCode::UserUnavailable, __("This profile isn't available.")); } + /** + * The one answer to a code the app can't redeem, whatever is wrong with it, as OAuth answers it. + */ + 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.')); + } + public function render(): JsonResponse { return response()->json([ diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php new file mode 100644 index 000000000..05d5d80db --- /dev/null +++ b/app/Http/Controllers/Api/DeviceController.php @@ -0,0 +1,36 @@ +handle($request->authorizationCode()); + + if (! $device instanceof Device) { + return ApiException::invalidGrant()->render(); + } + + Auth::setUser($device->user); + + return UserResource::make($device->user->loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($device->user), + ]); + } +} diff --git a/app/Http/Controllers/MobileAuthorizationController.php b/app/Http/Controllers/MobileAuthorizationController.php new file mode 100644 index 000000000..2c8057d37 --- /dev/null +++ b/app/Http/Controllers/MobileAuthorizationController.php @@ -0,0 +1,44 @@ +authorization(); + + return Inertia::render('mobile-authorization/show', [ + 'authorization' => $authorization->toParameters(), + 'cancelUrl' => $authorization->redirectTo(['error' => 'access_denied']), + 'csrfToken' => csrf_token(), + ]); + } + + /** + * Sends the user back to the app with a one-time code, which the app redeems for a device with its PKCE verifier. + */ + public function store(CreateMobileAuthorizationRequest $request, #[CurrentUser] User $user, CreateAuthorizationCode $action): RedirectResponse + { + $authorization = $request->authorization(); + + $code = $action->handle($user, $authorization); + + return redirect()->away($authorization->redirectTo(['code' => $code]), 303); + } +} diff --git a/app/Http/Middleware/AuthenticateSignedRequest.php b/app/Http/Middleware/AuthenticateSignedRequest.php index b695cbb6a..916fb7c58 100644 --- a/app/Http/Middleware/AuthenticateSignedRequest.php +++ b/app/Http/Middleware/AuthenticateSignedRequest.php @@ -6,6 +6,7 @@ use App\Enums\ApiErrorCode; use App\Exceptions\ApiException; +use App\Models\Device; use App\Models\User; use App\Services\SignedRequests; use Closure; @@ -15,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response; /** - * Authenticates a request to the API signed by the mobile app with an SSH key that is on the user's GitHub account. + * Authenticates a request to the API signed by the mobile app with an SSH key that the user approved as a device. * * The public read endpoints use it through optional(): a request without any signature header is read as a guest, * but one that is signed must be signed right, so a broken client never quietly gets the public view instead. @@ -42,18 +43,16 @@ public function handle(Request $request, Closure $next, ?string $mode = null): R } try { - ['publicKey' => $publicKey, 'user' => $user] = $this->signedRequests->verify($request); + ['device' => $device] = $this->signedRequests->verify($request); } catch (InvalidArgumentException $invalidArgumentException) { throw new ApiException(ApiErrorCode::InvalidSignature, $invalidArgumentException->getMessage()); } - if (! $user instanceof User) { - throw new ApiException(ApiErrorCode::KeyNotOnGitHub, __('This SSH key (:fingerprint) is not on the GitHub account of a devnull user.', [ - 'fingerprint' => $publicKey->fingerprint(), - ])); + if (! $device instanceof Device) { + throw new ApiException(ApiErrorCode::DeviceNotApproved, __("This key isn't approved for the /dev/null app. Sign in again in the app.")); } - Auth::setUser($user); + Auth::setUser($device->user); return $next($request); } diff --git a/app/Http/Requests/CreateApiDeviceRequest.php b/app/Http/Requests/CreateApiDeviceRequest.php new file mode 100644 index 000000000..63b29d59a --- /dev/null +++ b/app/Http/Requests/CreateApiDeviceRequest.php @@ -0,0 +1,91 @@ +fingerprint = $signedRequests->verifySignature($this)->fingerprint(); + } catch (InvalidArgumentException $invalidArgumentException) { + throw new ApiException(ApiErrorCode::InvalidSignature, $invalidArgumentException->getMessage()); + } + + $key = "devices:{$this->fingerprint}"; + + throw_if(RateLimiter::tooManyAttempts($key, self::RedemptionsPerKeyPerMinute), fn (): ApiException => new ApiException(ApiErrorCode::RateLimited, __('Too Many Attempts.'), [ + 'Retry-After' => RateLimiter::availableIn($key), + ])); + + RateLimiter::hit($key); + + return true; + } + + /** + * The verifier is the one the app made its PKCE challenge from (RFC 7636). + * + * @return array> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:255'], + 'code_verifier' => ['required', 'string', 'regex:/^[A-Za-z0-9._~-]{43,128}$/'], + ]; + } + + public function authorizationCode(): AuthorizationCode + { + assert($this->authorizationCode instanceof AuthorizationCode); + + return $this->authorizationCode; + } + + /** + * Whatever is wrong with the code, it gets the same answer: one that is unknown, expired or used, handed out for + * another key, or whose challenge the verifier doesn't match. + */ + public function passedValidation(): void + { + $code = AuthorizationCode::query() + ->where('code_hash', AuthorizationCode::hash($this->string('code')->value())) + ->where('expires_at', '>', now()) + ->first(); + + throw_unless( + $code instanceof AuthorizationCode + && hash_equals($code->fingerprint, $this->fingerprint) + && hash_equals($code->code_challenge, AuthorizationCode::challengeFor($this->string('code_verifier')->value())), + ApiException::invalidGrant(), + ); + + $this->authorizationCode = $code; + } +} diff --git a/app/Http/Requests/CreateMobileAuthorizationRequest.php b/app/Http/Requests/CreateMobileAuthorizationRequest.php new file mode 100644 index 000000000..0e0692530 --- /dev/null +++ b/app/Http/Requests/CreateMobileAuthorizationRequest.php @@ -0,0 +1,44 @@ +> + */ + public function rules(): array + { + return ShowMobileAuthorizationRequest::parameterRules(); + } + + public function authorization(): MobileAuthorization + { + return new MobileAuthorization( + $this->string('fingerprint')->value(), + $this->string('device_name')->value(), + $this->string('code_challenge')->value(), + $this->string('state')->value(), + $this->string('redirect_uri')->value(), + ); + } + + public function failedValidation(Validator $validator): never + { + throw ShowMobileAuthorizationRequest::invalid($this, $validator); + } +} diff --git a/app/Http/Requests/ShowMobileAuthorizationRequest.php b/app/Http/Requests/ShowMobileAuthorizationRequest.php new file mode 100644 index 000000000..872c7b0fe --- /dev/null +++ b/app/Http/Requests/ShowMobileAuthorizationRequest.php @@ -0,0 +1,76 @@ +> + */ + public static function parameterRules(): array + { + /** @var list $prefixes */ + $prefixes = config('mobile.redirect_uri_prefixes'); + + return [ + 'fingerprint' => ['required', 'string', 'regex:/^SHA256:[A-Za-z0-9+\/]{43}$/'], + 'device_name' => ['required', 'string', 'max:100'], + 'code_challenge' => ['required', 'string', 'regex:/^[A-Za-z0-9_-]{43}$/'], + 'code_challenge_method' => ['required', 'string', 'in:S256'], + 'state' => ['required', 'string', 'max:255'], + 'redirect_uri' => ['required', 'string', 'max:255', 'starts_with:'.implode(',', $prefixes), 'not_regex:/#/'], + ]; + } + + /** + * A request the app didn't make right gets a plain error page, and never a redirect, so nothing is sent anywhere. + */ + public static function invalid(FormRequest $request, Validator $validator): HttpResponseException + { + return new HttpResponseException(Inertia::render('mobile-authorization/show', [ + 'error' => $validator->errors()->first(), + ])->toResponse($request)->setStatusCode(400)); + } + + public function authorize(): bool + { + return true; + } + + /** + * @return array> + */ + public function rules(): array + { + return self::parameterRules(); + } + + public function authorization(): MobileAuthorization + { + return new MobileAuthorization( + $this->string('fingerprint')->value(), + $this->string('device_name')->value(), + $this->string('code_challenge')->value(), + $this->string('state')->value(), + $this->string('redirect_uri')->value(), + ); + } + + public function failedValidation(Validator $validator): never + { + throw self::invalid($this, $validator); + } +} diff --git a/app/Models/AuthorizationCode.php b/app/Models/AuthorizationCode.php new file mode 100644 index 000000000..9a96e0666 --- /dev/null +++ b/app/Models/AuthorizationCode.php @@ -0,0 +1,86 @@ + */ + use HasFactory; + + use HasUuids; + + public const int EXPIRES_AFTER_MINUTES = 5; + + /** + * The hash a code is stored and found by. Codes are long and random, so a fast hash is enough. + */ + public static function hash(string $code): string + { + return hash('sha256', $code); + } + + /** + * The PKCE S256 challenge for a verifier: base64url(sha256(verifier)), without padding. + */ + public static function challengeFor(string $verifier): string + { + return mb_rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + } + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'user_id' => 'string', + 'code_hash' => 'string', + 'fingerprint' => 'string', + 'device_name' => 'string', + 'code_challenge' => 'string', + 'redirect_uri' => 'string', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 53a674238..6ae6f0fc7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -51,6 +51,7 @@ * @property-read Collection $sshKeys * @property-read Collection $pollVotes * @property-read Collection $devices + * @property-read Collection $authorizationCodes * @property-read int $commits_count * @property-read int $following_count * @property-read int $followers_count @@ -141,6 +142,14 @@ public function devices(): HasMany return $this->hasMany(Device::class); } + /** + * @return HasMany + */ + public function authorizationCodes(): HasMany + { + return $this->hasMany(AuthorizationCode::class); + } + /** * @return BelongsToMany */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 24c82f839..39de96052 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -94,6 +94,13 @@ private function bootRateLimitingDefaults(): void : Limit::perMinute(60)->by((string) $request->ip()); }); + // Redeeming a code for a device is limited per key once the signature checks out. Before that, each address gets + // a ceiling, so unsigned or badly signed requests can't force signature checks... + RateLimiter::for('devices', fn (Request $request): Limit => Limit::perMinute(60)->by((string) $request->ip())->response($rateLimited)); + + // Approving the devnull app on the web hands out a code each time... + RateLimiter::for('mobile-authorizations', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request))); + // GitHub fetches every README image through a few proxy addresses, so each graph gets its own budget... RateLimiter::for('activity-graph', fn (Request $request): Limit => Limit::perMinute(60)->by("{$request->ip()}|{$request->path()}")); diff --git a/app/Services/SignedRequests.php b/app/Services/SignedRequests.php index 46f9ef3a4..86bcc528f 100644 --- a/app/Services/SignedRequests.php +++ b/app/Services/SignedRequests.php @@ -4,8 +4,9 @@ namespace App\Services; +use App\Actions\UpdateDeviceLastUsedAt; use App\Dto\SshPublicKey; -use App\Models\User; +use App\Models\Device; use Illuminate\Http\Request; /** @@ -13,6 +14,9 @@ * * The signed message is "\n\n\n\n". The timestamp and nonce * are also sent in the X-Devnull-Timestamp and X-Devnull-Nonce headers, and the signature in X-SSH-Signature. + * + * A key signs in as a user once that user approved it as a device on the web. The key doesn't have to be on their + * GitHub account: the approval is the proof. So a key made for something else, like committing from CI, can't sign in. */ final readonly class SignedRequests { @@ -20,7 +24,7 @@ private const array Headers = ['X-Devnull-Timestamp', 'X-Devnull-Nonce', 'X-SSH-Signature']; - public function __construct(private SshSignatures $sshSignatures) + public function __construct(private SshSignatures $sshSignatures, private UpdateDeviceLastUsedAt $updateDeviceLastUsedAt) { // } @@ -34,11 +38,10 @@ public function isSigned(Request $request): bool } /** - * The user is null when the key is not on the GitHub account of a devnull user. - * - * @return array{publicKey: SshPublicKey, user: User|null} + * Checks the signature, its timestamp and its nonce, which it spends, and returns the key that signed the request, + * whether or not it is approved as a device yet. */ - public function verify(Request $request): array + public function verifySignature(Request $request): SshPublicKey { $timestamp = $request->headers->get('X-Devnull-Timestamp', '') ?? ''; $nonce = $request->headers->get('X-Devnull-Nonce', '') ?? ''; @@ -49,9 +52,29 @@ public function verify(Request $request): array self::Namespace, ); + $this->sshSignatures->checkFreshness($timestamp, $nonce); + + return $publicKey; + } + + /** + * The device is null when nobody approved the key that signed the request. Using a device counts as its last use. + * + * @return array{publicKey: SshPublicKey, device: Device|null} + */ + public function verify(Request $request): array + { + $publicKey = $this->verifySignature($request); + + $device = Device::query()->with('user')->where('fingerprint', $publicKey->fingerprint())->first(); + + if ($device instanceof Device) { + $this->updateDeviceLastUsedAt->handle($device); + } + return [ 'publicKey' => $publicKey, - 'user' => $this->sshSignatures->authenticate($publicKey, $timestamp, $nonce), + 'device' => $device, ]; } } diff --git a/app/Services/SshSignatures.php b/app/Services/SshSignatures.php index 6be84f03d..143c84434 100644 --- a/app/Services/SshSignatures.php +++ b/app/Services/SshSignatures.php @@ -43,13 +43,7 @@ public function verify(string $message, string $signature, string $namespace): S */ public function authenticate(SshPublicKey $publicKey, string $timestamp, string $nonce): ?User { - throw_unless( - ctype_digit($timestamp) && abs(now()->getTimestamp() - (int) $timestamp) <= self::MaxClockSkewSeconds, - InvalidArgumentException::class, - 'Signature expired. Check that your clock is right.', - ); - - throw_unless(preg_match('/^[0-9a-f]{32}$/', $nonce) === 1, InvalidArgumentException::class, 'Malformed payload.'); + $this->ensureRecent($timestamp, $nonce); $user = $this->findKeyOwner($publicKey); @@ -57,16 +51,22 @@ public function authenticate(SshPublicKey $publicKey, string $timestamp, string return null; } - throw_unless( - // Remembered a bit longer than a signature can be valid (from -skew to +skew around its timestamp)... - Cache::add("ssh-signature-nonce:{$nonce}", true, self::MaxClockSkewSeconds * 2 + 60), - InvalidArgumentException::class, - 'This signature was already used.', - ); + $this->spendNonce($nonce); return $user; } + /** + * Checks the timestamp and nonce signed along with a message and spends the nonce, whoever the key belongs to: a + * signature expires a few minutes after its timestamp, and is only good once. + */ + public function checkFreshness(string $timestamp, string $nonce): void + { + $this->ensureRecent($timestamp, $nonce); + + $this->spendNonce($nonce); + } + /** * Finds the user whose GitHub account has the key, re-checking GitHub every few minutes so that * a key removed from GitHub stops working shortly after. @@ -83,4 +83,25 @@ private function findKeyOwner(SshPublicKey $publicKey): ?User return $user->sshKeys()->where('fingerprint', $publicKey->fingerprint())->exists() ? $user : null; } + + private function ensureRecent(string $timestamp, string $nonce): void + { + throw_unless( + ctype_digit($timestamp) && abs(now()->getTimestamp() - (int) $timestamp) <= self::MaxClockSkewSeconds, + InvalidArgumentException::class, + 'Signature expired. Check that your clock is right.', + ); + + throw_unless(preg_match('/^[0-9a-f]{32}$/', $nonce) === 1, InvalidArgumentException::class, 'Malformed payload.'); + } + + private function spendNonce(string $nonce): void + { + throw_unless( + // Remembered a bit longer than a signature can be valid (from -skew to +skew around its timestamp)... + Cache::add("ssh-signature-nonce:{$nonce}", true, self::MaxClockSkewSeconds * 2 + 60), + InvalidArgumentException::class, + 'This signature was already used.', + ); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 1f3518ba0..f97929b0e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -79,9 +79,4 @@ $exceptions->render(fn (ThrottleRequestsException $exception, Request $request): ?JsonResponse => $request->is('api/v1/*') && $request->user() instanceof User ? new ApiException(ApiErrorCode::RateLimited, $exception->getMessage(), $exception->getHeaders())->render() : null); - - // Signed requests from the mobile app also check keys against GitHub... - $exceptions->render(fn (ConnectionException|RequestException $exception, Request $request): ?JsonResponse => $request->is('api/v1/*') - ? new ApiException(ApiErrorCode::GitHubUnreachable, 'Could not reach GitHub to check your SSH key. Try again in a moment.')->render() - : null); })->create(); diff --git a/config/mobile.php b/config/mobile.php new file mode 100644 index 000000000..c0a953c58 --- /dev/null +++ b/config/mobile.php @@ -0,0 +1,13 @@ + ['devnull://'], + +]; diff --git a/database/factories/AuthorizationCodeFactory.php b/database/factories/AuthorizationCodeFactory.php new file mode 100644 index 000000000..52d34fc6b --- /dev/null +++ b/database/factories/AuthorizationCodeFactory.php @@ -0,0 +1,66 @@ + + */ +final class AuthorizationCodeFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'code_hash' => AuthorizationCode::hash(bin2hex(random_bytes(32))), + 'fingerprint' => 'SHA256:'.mb_rtrim(base64_encode(random_bytes(32)), '='), + 'device_name' => 'devnull on iPhone', + 'code_challenge' => AuthorizationCode::challengeFor(bin2hex(random_bytes(32))), + 'redirect_uri' => 'devnull://auth/callback', + 'expires_at' => now()->addMinutes(AuthorizationCode::EXPIRES_AFTER_MINUTES), + ]; + } + + /** + * The code the app was handed, whose hash is stored. + */ + public function withCode(string $code): self + { + return $this->state(fn (array $attributes): array => [ + 'code_hash' => AuthorizationCode::hash($code), + ]); + } + + /** + * The PKCE challenge for the verifier the app keeps. + */ + public function withVerifier(string $verifier): self + { + return $this->state(fn (array $attributes): array => [ + 'code_challenge' => AuthorizationCode::challengeFor($verifier), + ]); + } + + public function withPublicKey(SshPublicKey $publicKey): self + { + return $this->state(fn (array $attributes): array => [ + 'fingerprint' => $publicKey->fingerprint(), + ]); + } + + public function expired(): self + { + return $this->state(fn (array $attributes): array => [ + 'expires_at' => now()->subSecond(), + ]); + } +} diff --git a/database/migrations/2026_10_16_170001_create_authorization_codes_table.php b/database/migrations/2026_10_16_170001_create_authorization_codes_table.php new file mode 100644 index 000000000..2642afa3b --- /dev/null +++ b/database/migrations/2026_10_16_170001_create_authorization_codes_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('code_hash')->unique(); + $table->string('fingerprint'); + $table->string('device_name'); + $table->string('code_challenge'); + $table->string('redirect_uri'); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->index('user_id'); + }); + } +}; diff --git a/resources/js/pages/mobile-authorization/show.tsx b/resources/js/pages/mobile-authorization/show.tsx new file mode 100644 index 000000000..5739e5cc6 --- /dev/null +++ b/resources/js/pages/mobile-authorization/show.tsx @@ -0,0 +1,184 @@ +import { Head } from '@inertiajs/react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useState } from 'react'; +import MobileAuthorizationController from '@/actions/App/Http/Controllers/MobileAuthorizationController'; +import AppLogo from '@/components/app-logo'; +import { Button } from '@/components/ui/button'; +import { UserAvatar } from '@/components/user-avatar'; +import { useUser } from '@/hooks/use-user'; +import { laneColor } from '@/lib/utils'; + +type Authorization = { + fingerprint: string; + device_name: string; + code_challenge: string; + code_challenge_method: string; + state: string; + redirect_uri: string; +}; + +type Props = + | { error: string } + | { authorization: Authorization; cancelUrl: string; csrfToken: string }; + +export default function Show(props: Props) { + return 'error' in props ? ( + + ) : ( + + ); +} + +function Approve({ + authorization, + cancelUrl, + csrfToken, +}: { + authorization: Authorization; + cancelUrl: string; + csrfToken: string; +}) { + const { user } = useUser(); + const [isApproving, setIsApproving] = useState(false); + + return ( + +

    + Approve {authorization.device_name}? +

    + +
    +
    +
    + Signs in as +
    +
    + + + + {user.name} + + + @{user.username} + + +
    +
    +
    +
    With key
    +
    + {authorization.fingerprint} +
    +
    +
    + +

    + The app will be able to use /dev/null as you. You can revoke it + any time under Settings → Devices. +

    + + {/* A plain form, so the browser follows the redirect back into the app... */} +
    setIsApproving(true)} + className="mt-8 flex flex-wrap gap-2" + > + + {Object.entries(authorization).map(([name, value]) => ( + + ))} + + +
    +
    + ); +} + +function Invalid({ error }: { error: string }) { + return ( + +

    + This sign-in link isn't valid. +

    +

    + {error} Start signing in again from the /dev/null app. +

    +
    + ); +} + +function Page({ + title, + lane, + command, + children, +}: { + title: string; + lane: string; + command: string; + children: ReactNode; +}) { + return ( + <> + + +
    +
      +
    1. +
      + + +
      +
      + +
      +
    2. + +
    3. +
      + +
      +
      +

      + {command} +

      + {children} +
      +
    4. +
    +
    + + ); +} diff --git a/routes/api.php b/routes/api.php index dd03a7d7d..ad6d690c8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitReplyController; use App\Http\Controllers\Api\CurrentUserController; +use App\Http\Controllers\Api\DeviceController; use App\Http\Controllers\Api\LogController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\UserCommitController; @@ -37,7 +38,13 @@ ->name('api-commit.show'); }); - // Signed by the mobile app with an SSH key that is on the user's GitHub account... + // Signing in: the app redeems the code it got on the web, signed with a key that isn't approved yet... + Route::post('devices', [DeviceController::class, 'store']) + ->withoutMiddleware('throttle:api') + ->middleware('throttle:devices') + ->name('api-device.store'); + + // Signed by the mobile app with an SSH key that the user approved as a device... Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); diff --git a/routes/web.php b/routes/web.php index b3e7e43c1..f7b0cb0dd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,7 @@ use App\Http\Controllers\FailedJobRetryController; use App\Http\Controllers\LogController; use App\Http\Controllers\MentionSuggestionController; +use App\Http\Controllers\MobileAuthorizationController; use App\Http\Controllers\ModerationController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationGroupController; @@ -262,6 +263,12 @@ Route::get('settings/devices', [DeviceController::class, 'index'])->name('device.index'); Route::delete('settings/devices/{device}', [DeviceController::class, 'destroy'])->name('device.destroy'); + // Mobile Authorization... + Route::get('mobile/authorize', [MobileAuthorizationController::class, 'show'])->name('mobile-authorization.show'); + Route::post('mobile/authorize', [MobileAuthorizationController::class, 'store']) + ->middleware('throttle:mobile-authorizations') + ->name('mobile-authorization.store'); + // User... Route::delete('user', [UserController::class, 'destroy'])->name('user.destroy'); diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php index 6ad083451..4de031fb0 100644 --- a/tests/Feature/ApiSignatureTest.php +++ b/tests/Feature/ApiSignatureTest.php @@ -4,6 +4,7 @@ use App\Dto\SshPublicKey; use App\Models\Commit; +use App\Models\Device; use App\Models\SshKey; use App\Models\User; use Carbon\CarbonImmutable; @@ -16,7 +17,7 @@ $this->keyPair = apiKeyPairFor($this->user); }); -it('authenticates a request signed with a key on the github account of the user', function (): void { +it('authenticates a request signed with a key the user approved as a device', function (): void { $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')); $response->assertOk() @@ -33,9 +34,7 @@ $publicKey = SshPublicKey::fromOpenSsh('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIrhcHdLGpoKRYqI6eBWDeFu3VITYyTHDGiQ7QhYXCPa vector'); $user = User::factory()->create(); - SshKey::factory()->for($user)->withPublicKey($publicKey)->create(); - - fakeGitHubSshKeys($user, [$publicKey]); + Device::factory()->for($user)->withPublicKey($publicKey)->create(); [$timestamp, $nonce, $method, $target, $body] = explode("\n", $message, 5); @@ -116,49 +115,97 @@ ->assertJsonPath('code', 'invalid_signature'); }); -it('rejects keys that are not on the github account of a user', function (): void { +it('rejects a key nobody approved as a device, without asking github', function (): void { $keyPair = SshKeyPair::generate(); + $user = User::factory()->create(); + + // Even on the user's GitHub account, a key needs approving on the web... + SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); $response->assertUnauthorized() - ->assertJsonPath('message', "This SSH key ({$keyPair->publicKey->fingerprint()}) is not on the GitHub account of a devnull user.") - ->assertJsonPath('code', 'key_not_on_github'); + ->assertExactJson([ + 'message' => "This key isn't approved for the /dev/null app. Sign in again in the app.", + 'code' => 'device_not_approved', + ]); + $this->assertGuest(); Http::assertNothingSent(); }); -it('rejects keys removed from github since the last sync', function (): void { +it('rejects a key that is not approved as a device on the public endpoints too', function (Closure $uri): void { $keyPair = SshKeyPair::generate(); - $user = User::factory()->create(); - $sshKey = SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); - - fakeGitHubSshKeys($user); + $commit = Commit::factory()->create(); - $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + $response = signedApiRequest($keyPair, 'GET', $uri($commit)); $response->assertUnauthorized() - ->assertJsonPath('message', "This SSH key ({$keyPair->publicKey->fingerprint()}) is not on the GitHub account of a devnull user.") - ->assertJsonPath('code', 'key_not_on_github'); + ->assertJsonPath('code', 'device_not_approved') + ->assertJsonMissingPath('data'); - expect($sshKey->fresh())->toBeNull(); + $this->assertGuest(); +})->with([ + 'a profile' => fn (Commit $commit): string => route('api-user.show', $commit->user), + 'a list of commits' => fn (Commit $commit): string => route('api-user-commit.index', $commit->user), + 'a commit' => fn (Commit $commit): string => route('api-commit.show', $commit), +]); + +it("signs in with an approved device whose key is on nobody's github account, without asking github", function (): void { + expect(SshKey::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + Http::assertNothingSent(); }); -it('fails when github cannot be reached', function (): void { - $keyPair = SshKeyPair::generate(); - $user = User::factory()->create(); +it('signs in as the user who approved the device, whoever has the key on github', function (): void { + $other = User::factory()->create(); - SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + SshKey::factory()->for($other)->withPublicKey($this->keyPair->publicKey)->create(); - Http::fake([ - "api.github.com/user/{$user->github_id}" => Http::response(['message' => 'Server Error'], 500), - ]); + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); - $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + $this->assertAuthenticatedAs($this->user); +}); + +it('rejects a revoked device', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + $this->user->devices()->sole()->delete(); + Auth::forgetGuards(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertJsonPath('code', 'device_not_approved'); +}); + +it('records when a device was last used, at most once a minute', function (): void { + $device = $this->user->devices()->sole(); + + expect($device->last_used_at)->toBeNull(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); + + $usedAt = now(); + $this->travel(59)->seconds(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe($usedAt->toDateTimeString()); + + $this->travel(1)->seconds(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); - $response->assertServiceUnavailable() - ->assertJsonPath('message', 'Could not reach GitHub to check your SSH key. Try again in a moment.') - ->assertJsonPath('code', 'github_unreachable'); + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); }); it('never answers a badly signed request to a public endpoint with the public view', function (Closure $uri, Closure $server, string $message): void { diff --git a/tests/Feature/Controllers/Api/DeviceControllerTest.php b/tests/Feature/Controllers/Api/DeviceControllerTest.php new file mode 100644 index 000000000..13d402b96 --- /dev/null +++ b/tests/Feature/Controllers/Api/DeviceControllerTest.php @@ -0,0 +1,174 @@ +user = User::factory()->create(['username' => 'octocat']); + $this->keyPair = SshKeyPair::generate(); + $this->verifier = str_repeat('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', 2); + + AuthorizationCode::factory() + ->for($this->user) + ->withCode('the-code') + ->withVerifier($this->verifier) + ->withPublicKey($this->keyPair->publicKey) + ->create(['device_name' => 'devnull on iPhone']); +}); + +it('approves the key that redeems the code as a device, and answers like me', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.lane', $this->user->lane()) + ->assertJsonPath('data.commits_count', 0) + ->assertJsonPath('unread_notifications_count', 0); + + $device = Device::query()->sole(); + + expect($device->user_id)->toBe($this->user->id) + ->and($device->fingerprint)->toBe($this->keyPair->publicKey->fingerprint()) + ->and($device->name)->toBe('devnull on iPhone') + ->and($device->approved_at->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and(AuthorizationCode::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + Http::assertNothingSent(); +}); + +it('never needs the key to be on github', function (): void { + expect(SshKey::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertOk(); +}); + +it('refuses a code it cannot redeem, the same way whatever is wrong with it', function (Closure $request): void { + $response = $request($this); + + $response->assertBadRequest() + ->assertExactJson([ + 'message' => 'This sign-in has expired or was already used. Sign in again in the app.', + 'code' => 'invalid_grant', + ]); + + expect(Device::query()->exists())->toBeFalse(); +})->with([ + 'a wrong verifier' => fn ($test): TestResponse => signedApiRequest($test->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => str_repeat('a', 43)]), + 'an unknown code' => fn ($test): TestResponse => signedApiRequest($test->keyPair, 'POST', route('api-device.store'), ['code' => 'another-code', 'code_verifier' => $test->verifier]), + 'another key' => fn ($test): TestResponse => signedApiRequest(SshKeyPair::generate(), 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $test->verifier]), + 'an expired code' => function ($test): TestResponse { + $test->travel(AuthorizationCode::EXPIRES_AFTER_MINUTES)->minutes(); + + return signedApiRequest($test->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $test->verifier]); + }, +]); + +it('refuses a code handed out for another key, and leaves it for that key', function (): void { + $other = SshKeyPair::generate(); + + AuthorizationCode::factory()->withCode('code-for-other')->withVerifier($this->verifier)->withPublicKey($other->publicKey)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'code-for-other', 'code_verifier' => $this->verifier]) + ->assertBadRequest() + ->assertJsonPath('code', 'invalid_grant'); + + signedApiRequest($other, 'POST', route('api-device.store'), ['code' => 'code-for-other', 'code_verifier' => $this->verifier]) + ->assertOk(); +}); + +it('redeems a code only once', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier])->assertOk(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertBadRequest() + ->assertJsonPath('code', 'invalid_grant'); +}); + +it('approves again a key approved for someone else, for the user who approved it now', function (): void { + $previous = Device::factory()->withPublicKey($this->keyPair->publicKey)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + expect($previous->refresh()->user_id)->toBe($this->user->id); +}); + +it('refuses a code that was redeemed by another request in the meantime', function (): void { + // The code is checked, then used up by another request before this one approves its device... + AuthorizationCode::retrieved(function (AuthorizationCode $code): void { + AuthorizationCode::query()->whereKey($code->id)->delete(); + }); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertBadRequest() + ->assertJsonPath('code', 'invalid_grant'); + + expect(Device::query()->exists())->toBeFalse(); +}); + +it('validates the code and the verifier', function (array $data, string $field): void { + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'no code' => [['code_verifier' => str_repeat('a', 43)], 'code'], + 'no verifier' => [['code' => 'the-code'], 'code_verifier'], + 'a short verifier' => [['code' => 'the-code', 'code_verifier' => str_repeat('a', 42)], 'code_verifier'], + 'a long verifier' => [['code' => 'the-code', 'code_verifier' => str_repeat('a', 129)], 'code_verifier'], + 'a verifier with other characters' => [['code' => 'the-code', 'code_verifier' => str_repeat('a', 42).'!'], 'code_verifier'], +]); + +it('rejects a request that is not signed right', function (Closure $server, string $message): void { + $body = json_encode(['code' => 'the-code', 'code_verifier' => $this->verifier], JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api-device.store'), server: $server($this->keyPair, $body), content: $body); + + $response->assertUnauthorized() + ->assertExactJson(['message' => $message, 'code' => 'invalid_signature']); + + expect(Device::query()->exists())->toBeFalse(); +})->with([ + 'no signature' => [fn (): array => ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], 'Malformed SSH signature.'], + 'a signature over another body' => [fn (SshKeyPair $keyPair): array => signApiRequest($keyPair, 'POST', route('api-device.store'), '{}'), 'Invalid signature.'], + 'an expired signature' => [fn (SshKeyPair $keyPair, string $body): array => signApiRequest($keyPair, 'POST', route('api-device.store'), $body, timestamp: now()->getTimestamp() - 301), 'Signature expired. Check that your clock is right.'], +]); + +it('limits each key to 12 tries a minute, wherever they come from', function (): void { + foreach (range(1, 12) as $attempt) { + $server = [...signApiRequest($this->keyPair, 'POST', route('api-device.store'), '{"code":"wrong","code_verifier":"'.str_repeat('a', 43).'"}'), 'REMOTE_ADDR' => "203.0.113.{$attempt}"]; + + $this->call('POST', route('api-device.store'), server: $server, content: '{"code":"wrong","code_verifier":"'.str_repeat('a', 43).'"}')->assertBadRequest(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertExactJson(['message' => 'Too Many Attempts.', 'code' => 'rate_limited']); + + signedApiRequest(SshKeyPair::generate(), 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertBadRequest(); +}); + +it('limits each address to 60 tries a minute, before checking signatures', function (): void { + foreach (range(1, 60) as $attempt) { + $this->postJson(route('api-device.store'))->assertUnauthorized(); + } + + $this->postJson(route('api-device.store')) + ->assertTooManyRequests() + ->assertJsonPath('code', 'rate_limited'); +}); diff --git a/tests/Feature/Controllers/MobileAuthorizationControllerTest.php b/tests/Feature/Controllers/MobileAuthorizationControllerTest.php new file mode 100644 index 000000000..55b91ab28 --- /dev/null +++ b/tests/Feature/Controllers/MobileAuthorizationControllerTest.php @@ -0,0 +1,144 @@ +keyPair = SshKeyPair::generate(); + $this->parameters = [ + 'fingerprint' => $this->keyPair->publicKey->fingerprint(), + 'device_name' => 'devnull on iPhone', + 'code_challenge' => AuthorizationCode::challengeFor(str_repeat('v', 43)), + 'code_challenge_method' => 'S256', + 'state' => 'af0ifjsldkj', + 'redirect_uri' => 'devnull://auth/callback', + ]; +}); + +it('asks the signed-in user to approve the app on the device', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('mobile-authorization.show', $this->parameters)); + + $response->assertOk() + ->assertInertia(fn (Assert $page): Assert => $page + ->component('mobile-authorization/show') + ->where('authorization', $this->parameters) + ->where('cancelUrl', 'devnull://auth/callback?error=access_denied&state=af0ifjsldkj') + ->where('csrfToken', session()->token()) + ->where('auth.user.id', $user->id)); +}); + +it('sends a guest through the github sign-in, then back to approve the app', function (): void { + Queue::fake(); + fakeGitHubSponsorsListing(); + + $this->get(route('mobile-authorization.show', $this->parameters))->assertRedirectToRoute('home'); + + $user = User::factory()->create(['github_id' => '583231']); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => $user->username, + ])); + + $location = (string) $this->get(route('login.store'))->assertRedirect()->headers->get('Location'); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + expect(strtok($location, '?'))->toBe(route('mobile-authorization.show')) + ->and(collect($query)->sortKeys()->all())->toBe(collect($this->parameters)->sortKeys()->all()); + + $this->assertAuthenticatedAs($user); +}); + +it('answers a request the app did not make right with a plain error page, and never a redirect', function (array $parameters, string $error): void { + $response = $this->actingAs(User::factory()->create()) + ->get(route('mobile-authorization.show', [...$this->parameters, ...$parameters])); + + $response->assertBadRequest() + ->assertInertia(fn (Assert $page): Assert => $page + ->component('mobile-authorization/show') + ->where('error', $error) + ->missing('authorization') + ->missing('cancelUrl')); +})->with([ + 'no fingerprint' => [['fingerprint' => null], 'The fingerprint field is required.'], + 'a malformed fingerprint' => [['fingerprint' => 'MD5:aa:bb'], 'The fingerprint field format is invalid.'], + 'no device name' => [['device_name' => null], 'The device name field is required.'], + 'a long device name' => [['device_name' => str_repeat('a', 101)], 'The device name field must not be greater than 100 characters.'], + 'no challenge' => [['code_challenge' => null], 'The code challenge field is required.'], + 'a malformed challenge' => [['code_challenge' => 'short'], 'The code challenge field format is invalid.'], + 'a plain challenge' => [['code_challenge_method' => 'plain'], 'The selected code challenge method is invalid.'], + 'no state' => [['state' => null], 'The state field is required.'], + 'no redirect' => [['redirect_uri' => null], 'The redirect uri field is required.'], + 'a redirect to a website' => [['redirect_uri' => 'https://evil.example/callback'], 'The redirect uri field must start with one of the following: devnull://.'], + 'a redirect to another app' => [['redirect_uri' => 'otherapp://auth/callback'], 'The redirect uri field must start with one of the following: devnull://.'], + 'a redirect with a fragment' => [['redirect_uri' => 'devnull://auth/callback#x'], 'The redirect uri field format is invalid.'], +]); + +it('sends the app back with a one-time code and its state once the user approves it', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('mobile-authorization.store'), $this->parameters); + + $response->assertStatus(303); + + $location = (string) $response->headers->get('Location'); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + expect($location)->toStartWith('devnull://auth/callback?code=') + ->and($query['state'])->toBe('af0ifjsldkj') + ->and($query['code'])->toBeString(); + + $code = AuthorizationCode::query()->sole(); + + expect($code->code_hash)->toBe(AuthorizationCode::hash($query['code'])) + ->and($code->user_id)->toBe($user->id) + ->and($code->fingerprint)->toBe($this->keyPair->publicKey->fingerprint()) + ->and($code->device_name)->toBe('devnull on iPhone') + ->and($code->code_challenge)->toBe($this->parameters['code_challenge']) + ->and($code->redirect_uri)->toBe('devnull://auth/callback'); +}); + +it('keeps the query a redirect already has', function (): void { + $response = $this->actingAs(User::factory()->create()) + ->post(route('mobile-authorization.store'), [...$this->parameters, 'redirect_uri' => 'devnull://auth/callback?app=1']); + + expect((string) $response->headers->get('Location'))->toStartWith('devnull://auth/callback?app=1&code=') + ->toEndWith('&state=af0ifjsldkj'); +}); + +it('approves nothing the app did not ask for right', function (): void { + $response = $this->actingAs(User::factory()->create()) + ->post(route('mobile-authorization.store'), [...$this->parameters, 'redirect_uri' => 'https://evil.example/callback']); + + $response->assertBadRequest() + ->assertInertia(fn (Assert $page): Assert => $page->where('error', 'The redirect uri field must start with one of the following: devnull://.')); + + expect(AuthorizationCode::query()->exists())->toBeFalse(); +}); + +it('approves only for a signed-in user, with a csrf token', function (): void { + $this->post(route('mobile-authorization.store'), $this->parameters)->assertRedirectToRoute('home'); + + expect(AuthorizationCode::query()->exists())->toBeFalse(); +}); + +it('lets each user approve the app 10 times a minute', function (): void { + $user = User::factory()->create(); + + foreach (range(1, 10) as $attempt) { + $this->actingAs($user)->post(route('mobile-authorization.store'), $this->parameters)->assertStatus(303); + } + + $this->actingAs($user)->post(route('mobile-authorization.store'), $this->parameters)->assertTooManyRequests(); + + expect(AuthorizationCode::query()->count())->toBe(10); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 5a1475e73..7a3ba43bb 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,7 +4,7 @@ use App\Dto\SshPublicKey; use App\Jobs\UnfurlLink; -use App\Models\SshKey; +use App\Models\Device; use App\Models\User; use App\Services\OpenAI; use Carbon\CarbonInterface; @@ -265,15 +265,14 @@ function signedApiRequest(SshKeyPair $keyPair, string $method, string $uri, ?arr } /** - * Gives the user a key on their GitHub account that the mobile app could sign API requests with. + * Gives the user a key approved as a device, that the mobile app signs API requests with. It isn't on their GitHub + * account, as the API doesn't need it to be. */ function apiKeyPairFor(User $user): SshKeyPair { $keyPair = SshKeyPair::generate(); - SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); - - fakeGitHubSshKeys($user, [$keyPair->publicKey]); + Device::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); return $keyPair; } diff --git a/tests/Unit/Actions/CreateAuthorizationCodeTest.php b/tests/Unit/Actions/CreateAuthorizationCodeTest.php new file mode 100644 index 000000000..7fc6e452b --- /dev/null +++ b/tests/Unit/Actions/CreateAuthorizationCodeTest.php @@ -0,0 +1,38 @@ +create(); + $authorization = new MobileAuthorization('SHA256:key', 'devnull on iPhone', 'challenge', 'state', 'devnull://auth/callback'); + + $code = resolve(CreateAuthorizationCode::class)->handle($user, $authorization); + + $stored = AuthorizationCode::query()->sole(); + + expect($code)->toMatch('/^[0-9a-f]{64}$/') + ->and($stored->code_hash)->toBe(AuthorizationCode::hash($code)) + ->and($stored->code_hash)->not->toBe($code) + ->and($stored->user_id)->toBe($user->id) + ->and($stored->fingerprint)->toBe('SHA256:key') + ->and($stored->device_name)->toBe('devnull on iPhone') + ->and($stored->code_challenge)->toBe('challenge') + ->and($stored->redirect_uri)->toBe('devnull://auth/callback') + ->and($stored->expires_at->toDateTimeString())->toBe(now()->addMinutes(5)->toDateTimeString()); +}); + +it('hands out a different code every time', function (): void { + $user = User::factory()->create(); + $authorization = new MobileAuthorization('SHA256:key', 'devnull on iPhone', 'challenge', 'state', 'devnull://auth/callback'); + + $first = resolve(CreateAuthorizationCode::class)->handle($user, $authorization); + $second = resolve(CreateAuthorizationCode::class)->handle($user, $authorization); + + expect($first)->not->toBe($second) + ->and(AuthorizationCode::query()->count())->toBe(2); +}); diff --git a/tests/Unit/Actions/CreateDeviceTest.php b/tests/Unit/Actions/CreateDeviceTest.php new file mode 100644 index 000000000..59557e756 --- /dev/null +++ b/tests/Unit/Actions/CreateDeviceTest.php @@ -0,0 +1,49 @@ +create(); + $code = AuthorizationCode::factory()->for($user)->create(['fingerprint' => 'SHA256:key', 'device_name' => 'devnull on iPhone']); + + $device = resolve(CreateDevice::class)->handle($code); + + expect($device?->refresh()->user_id)->toBe($user->id) + ->and($device?->fingerprint)->toBe('SHA256:key') + ->and($device?->name)->toBe('devnull on iPhone') + ->and($device?->approved_at->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and($device?->last_used_at)->toBeNull() + ->and($code->fresh())->toBeNull(); +}); + +it('moves a key approved by someone else to the user who approved it now', function (): void { + $user = User::factory()->create(); + $previous = Device::factory()->create(['fingerprint' => 'SHA256:key', 'last_used_at' => now()->subDay(), 'approved_at' => now()->subWeek()]); + $code = AuthorizationCode::factory()->for($user)->create(['fingerprint' => 'SHA256:key', 'device_name' => 'devnull on Pixel']); + + $device = resolve(CreateDevice::class)->handle($code); + + expect($device?->is($previous))->toBeTrue() + ->and($device?->refresh()->user_id)->toBe($user->id) + ->and($device?->name)->toBe('devnull on Pixel') + ->and($device?->approved_at->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and($device?->last_used_at)->toBeNull() + ->and(Device::query()->count())->toBe(1); +}); + +it('approves nothing for a code that was used or expired since it was checked', function (Closure $change): void { + $code = AuthorizationCode::factory()->create(); + + $change($code); + + expect(resolve(CreateDevice::class)->handle($code))->toBeNull() + ->and(Device::query()->exists())->toBeFalse(); +})->with([ + 'used' => fn (AuthorizationCode $code) => $code->delete(), + 'expired' => fn () => test()->travel(AuthorizationCode::EXPIRES_AFTER_MINUTES)->minutes(), +]); diff --git a/tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php b/tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php new file mode 100644 index 000000000..dbb40b22c --- /dev/null +++ b/tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php @@ -0,0 +1,32 @@ +create(); + $action = resolve(UpdateDeviceLastUsedAt::class); + + $action->handle($device); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); + + $usedAt = now(); + $this->travel(59)->seconds(); + + DB::enableQueryLog(); + + $action->handle($device); + + expect(DB::getQueryLog())->toBeEmpty() + ->and($device->refresh()->last_used_at?->toDateTimeString())->toBe($usedAt->toDateTimeString()); + + $this->travel(1)->second(); + + $action->handle($device); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); +}); diff --git a/tests/Unit/Models/AuthorizationCodeTest.php b/tests/Unit/Models/AuthorizationCodeTest.php new file mode 100644 index 000000000..76a26ad4a --- /dev/null +++ b/tests/Unit/Models/AuthorizationCodeTest.php @@ -0,0 +1,41 @@ +create()->refresh(); + + expect(array_keys($code->toArray())) + ->toBe([ + 'id', + 'user_id', + 'fingerprint', + 'device_name', + 'code_challenge', + 'redirect_uri', + 'expires_at', + 'created_at', + 'updated_at', + ]); +}); + +it('belongs to a user, and is deleted with them', function (): void { + $user = User::factory()->create(); + $code = AuthorizationCode::factory()->for($user)->create(); + + expect($code->user->is($user))->toBeTrue() + ->and($user->authorizationCodes()->sole()->is($code))->toBeTrue(); + + $user->delete(); + + expect($code->fresh())->toBeNull(); +}); + +it('makes the pkce s256 challenge of a verifier', function (): void { + // The example in RFC 7636, appendix B... + expect(AuthorizationCode::challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')) + ->toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); +}); diff --git a/tests/Unit/Providers/AppServiceProviderTest.php b/tests/Unit/Providers/AppServiceProviderTest.php index bf764d213..5ec13c390 100644 --- a/tests/Unit/Providers/AppServiceProviderTest.php +++ b/tests/Unit/Providers/AppServiceProviderTest.php @@ -84,3 +84,36 @@ ->and($limit->maxAttempts)->toBe(120) ->and($limit->decaySeconds)->toBe(60); }); + +it('limits each address to 60 device redemptions a minute, before their signatures are checked', function (): void { + $request = Request::create('/api/v1/devices', 'POST', server: ['REMOTE_ADDR' => '203.0.113.7']); + + $limiter = RateLimiter::limiter('devices'); + + assert($limiter instanceof Closure); + + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class) + ->and($limit->key)->toBe('203.0.113.7') + ->and($limit->maxAttempts)->toBe(60) + ->and($limit->decaySeconds)->toBe(60); +}); + +it('limits each user to 10 approvals of the devnull app a minute', function (): void { + $user = User::factory()->create(); + + $request = Request::create('/mobile/authorize', 'POST'); + $request->setUserResolver(fn (): User => $user); + + $limiter = RateLimiter::limiter('mobile-authorizations'); + + assert($limiter instanceof Closure); + + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class) + ->and($limit->key)->toBe($user->id) + ->and($limit->maxAttempts)->toBe(10) + ->and($limit->decaySeconds)->toBe(60); +}); diff --git a/tests/Unit/Services/SignedRequestsTest.php b/tests/Unit/Services/SignedRequestsTest.php index 7a9590676..5f30545b4 100644 --- a/tests/Unit/Services/SignedRequestsTest.php +++ b/tests/Unit/Services/SignedRequestsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Models\SshKey; use App\Models\User; use App\Services\SignedRequests; use Illuminate\Http\Request; @@ -27,10 +28,12 @@ it('verifies a signed request', function (string $method, string $uri, string $body): void { $request = Request::create($uri, $method, server: signApiRequest($this->keyPair, $method, $uri, $body), content: $body); - ['publicKey' => $publicKey, 'user' => $user] = resolve(SignedRequests::class)->verify($request); + ['publicKey' => $publicKey, 'device' => $device] = resolve(SignedRequests::class)->verify($request); expect($publicKey->fingerprint())->toBe($this->keyPair->publicKey->fingerprint()) - ->and($user?->is($this->user))->toBeTrue(); + ->and($device?->is($this->user->devices()->sole()))->toBeTrue() + ->and($device?->user->is($this->user))->toBeTrue() + ->and($device?->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); })->with([ 'without a body' => ['GET', 'https://devnull.test/api/v1/log/latest', ''], 'with a query' => ['GET', 'https://devnull.test/api/v1/log/latest?cursor=abc&x=%20y', ''], @@ -38,16 +41,35 @@ 'with a multi-line body' => ['POST', 'https://devnull.test/api/v1/commits', "{\"body\":\"It was DNS.\"}\n\n"], ]); -it('returns no user for keys that are not on the github account of a user', function (): void { +it('returns no device for a key nobody approved, on github or not', function (bool $isOnGitHub): void { $keyPair = SshKeyPair::generate(); $uri = 'https://devnull.test/api/v1/me'; + if ($isOnGitHub) { + SshKey::factory()->withPublicKey($keyPair->publicKey)->create(); + } + $request = Request::create($uri, server: signApiRequest($keyPair, 'GET', $uri)); - ['publicKey' => $publicKey, 'user' => $user] = resolve(SignedRequests::class)->verify($request); + ['publicKey' => $publicKey, 'device' => $device] = resolve(SignedRequests::class)->verify($request); expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()) - ->and($user)->toBeNull(); + ->and($device)->toBeNull(); +})->with([ + "on nobody's github account" => false, + "on a user's github account" => true, +]); + +it('verifies the signature of a key nobody approved yet, and spends its nonce', function (): void { + $keyPair = SshKeyPair::generate(); + $uri = 'https://devnull.test/api/v1/devices'; + $server = signApiRequest($keyPair, 'POST', $uri, '{"code":"abc"}'); + + $publicKey = resolve(SignedRequests::class)->verifySignature(Request::create($uri, 'POST', server: $server, content: '{"code":"abc"}')); + + expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()) + ->and(fn (): mixed => resolve(SignedRequests::class)->verifySignature(Request::create($uri, 'POST', server: $server, content: '{"code":"abc"}'))) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); }); it('rejects a request that differs from the one that was signed', function (string $method, string $uri, string $body, array $headers): void { diff --git a/tests/Unit/Services/SshSignaturesTest.php b/tests/Unit/Services/SshSignaturesTest.php index df5ee30b5..39cd8e470 100644 --- a/tests/Unit/Services/SshSignaturesTest.php +++ b/tests/Unit/Services/SshSignaturesTest.php @@ -125,3 +125,23 @@ Http::assertSentCount(3); }); + +it('checks the freshness of a signature whoever signed it, and spends its nonce', function (): void { + $nonce = bin2hex(random_bytes(16)); + $sshSignatures = resolve(SshSignatures::class); + + $sshSignatures->checkFreshness((string) now()->getTimestamp(), $nonce); + + expect(fn () => $sshSignatures->checkFreshness((string) now()->getTimestamp(), $nonce)) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); + + Http::assertNothingSent(); +}); + +it('rejects a stale timestamp or a malformed nonce when checking freshness', function (string $timestamp, string $nonce, string $message): void { + expect(fn () => resolve(SshSignatures::class)->checkFreshness($timestamp, $nonce)) + ->toThrow(InvalidArgumentException::class, $message); +})->with([ + 'an old timestamp' => [fn (): string => (string) (now()->getTimestamp() - 301), str_repeat('a', 32), 'Signature expired. Check that your clock is right.'], + 'a malformed nonce' => [fn (): string => (string) now()->getTimestamp(), 'nonce', 'Malformed payload.'], +]); From 1078a430e812592ddcf0baea916d83953f549dac Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 17:23:41 +0100 Subject: [PATCH 10/15] docs: describe signing in to the signed API The README's signed requests section now says a key signs in once its user approved it as a device, and that it doesn't need to be on their GitHub account. A new "Signing in" part walks through the flow with example URLs: the authorize page and its parameters, the approve and cancel redirects, and POST /api/v1/devices with each status and code it answers with. The error codes table gains invalid_grant and device_not_approved, and loses key_not_on_github and github_unreachable. PRODUCT.md's signed API bullet says the same in a few sentences, and Settings now lists Devices. The Terminal page says a CI key "can sign devnull commits, but never push code or log in". That holds: a key on GitHub alone can't use the API, as only a device approved on the web can, so it stays as it is. --- PRODUCT.md | 4 ++-- README.md | 44 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index 9d86a7c67..50b3e1ca4 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,7 +23,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Sign-in is GitHub OAuth only. Profile changes happen on GitHub ("Edit on GitHub"), then sync at next login or with "Sync from GitHub" (once a minute). - Everything except the landing page, the public read API, and profile feeds requires login. - Public read API: read-only JSON under `/api/v1`, no authentication, for public profiles (with counts), a user's commits or replies, and single commits. It returns exactly what a guest's link preview may show: approved, non-deleted commits only (with their images), approved reply counts, and a parent only while it is approved. Never emails or other hidden profile fields. 60 requests a minute per IP. -- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key that is on the user's GitHub account (no passwords or API tokens; the user must have signed in on the web once). A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, and notifications, and commit, reply, delete, and follow, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. +- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, and notifications, and commit, reply, delete, and follow, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. - Profile feeds: every profile has an Atom feed of its latest 50 approved top-level commits at `/@{username}/feed.atom`, their images as enclosures, advertised with `` on the profile's guest preview. - First sign-in leads to a short setup at `/onboarding`: confirm the mirrored profile, pick a color, follow a few suggested developers, learn the composer, `?` shortcuts and terminal commits, then land on the log (or the page the user was headed to). Skippable at any step (Skip setup or Esc); finishing or skipping ends it for good. Accounts that existed before it never see it. - Profile links (`/@{username}`) and links to approved commits (`/@{username}/commits/{id}`) unfurl with a link preview (the author and their bio or counts, or the commit body and reply count) for guests and crawlers; a guest opening one sees the landing page, and the profile or commit itself stays behind login. Pending and rejected commits are a 404 for guests. @@ -32,7 +32,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Profiles live at `/@{username}` with commit, following, and follower counts, a commit activity graph with longest and current streaks, and Commits and Replies tabs. Commits is the default and lists the user's top-level commits; `?tab=replies` lists their authored replies with visible parent context. Both feeds use infinite scroll. - Operations (`/operations`, admins only; a 404 for everyone else): database and cache connectivity, the GitHub API rate limit when GitHub credentials are configured, the database queue backlog per queue with the age of its oldest job, the most recent failed jobs (each can be retried or forgotten), pending moderation and link previews, link previews that came back without metadata in the last 24 hours, and signups, commits, and replies over the last 24 hours and 7 days. It refreshes every 15 seconds. - Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), appearance (light/dark/system), delete account. -- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), delete account. +- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), devices (the mobile app on each device the user approved, when it was last used, and revoking it), delete account. ## Capabilities and Constraints diff --git a/README.md b/README.md index 685037323..d7156a5d4 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Every profile also has an Atom feed of its latest 50 top-level commits at `/@{us ## Signed requests -The mobile app signs its requests to `/api/v1` with an SSH key that is on the user's GitHub account, the way `devnull commit` signs commits. A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting and following only take signed requests. Signed requests are limited to 120 a minute per user. +The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting and following only take signed requests. Signed requests are limited to 120 a minute per user. Each request sends three headers: @@ -100,25 +100,61 @@ POST A request with any of these headers has to be signed right. A bad or expired signature is a `401`, never the public view. A request without them always gets the public view, and across a block a signed request gets neither profile: it is a `403`, `user_blocked` for the one who blocked and `user_unavailable` for the other, and their commits are hidden as on the web. +A key signs in once its user approved it as a device. It doesn't have to be on their GitHub account, and being on it isn't enough, so a key made for the GitHub Action can't use the API. Users see their devices, when each was last used, and revoke them under Settings → Devices (`/settings/devices`). A key that isn't approved, or was revoked, gets a `401` with `device_not_approved`, and the app signs in again. + ### Errors An error is JSON with a `message` to show the user. The errors the app has to tell apart also have a `code`, which stays the same when the message changes: ```json -{"message": "Signature expired. Check that your clock is right.", "code": "invalid_signature"} +{"message": "This key isn't approved for the /dev/null app. Sign in again in the app.", "code": "device_not_approved"} ``` | Status | `code` | When | |---|---|---| +| `400` | `invalid_grant` | `POST /api/v1/devices` only: the code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. | | `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. | -| `401` | `key_not_on_github` | The key isn't on the GitHub account of a devnull user. | +| `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, or 30 follows and unfollows a minute. `Retry-After` says when to try again. | -| `503` | `github_unreachable` | GitHub couldn't be reached to check the key. | 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. +### Signing in + +The app signs in the way an OAuth app does, with the authorization code flow and PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)), and /dev/null as the provider. The code it gets is bound to its key, so only the app that holds the key can redeem it. + +1. The app makes an ed25519 key and a random `code_verifier`, and opens this page in a browser session (`ASWebAuthenticationSession` on iOS): + + ```text + https://dev-null.sh/mobile/authorize + ?fingerprint=SHA256:hPEdU4FVZ1lfjzbPyBhqTWG2qxUu3ZbCnD8mZw9t2Vk + &device_name=devnull%20on%20iPhone + &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + &code_challenge_method=S256 + &state=af0ifjsldkj + &redirect_uri=devnull://auth/callback + ``` + + `code_challenge` is `base64url(sha256(code_verifier))` without padding. `redirect_uri` has to start with `devnull://`. A guest signs in with GitHub first and comes back to the page. A request with a missing or bad parameter gets an error page and is never redirected. +2. The page asks "Approve devnull on iPhone?", with the key's fingerprint and the account it signs in as. **Approve** redirects (`303`) to `devnull://auth/callback?code=…&state=af0ifjsldkj`. **Cancel** goes to `devnull://auth/callback?error=access_denied&state=af0ifjsldkj`. The app checks that `state` is the one it sent. A code is good for 5 minutes and only once, and each user can approve 10 times a minute. +3. The app redeems the code with `POST /api/v1/devices`, signed with the key like any other request, though the key isn't approved yet: + + ```json + {"code": "…", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"} + ``` + + The key becomes an approved device of the user who approved it, and the answer is `200` with the user, like `GET /api/v1/me`. From then on every request it signs is theirs, until they revoke the device. + +| Status | `code` | Means | +|---|---|---| +| `200` | | The device is approved. The body is the user, with `unread_notifications_count`. | +| `400` | `invalid_grant` | The code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. Sign in again. | +| `401` | `invalid_signature` | The signature is bad, expired or used already. | +| `422` | | `code` or `code_verifier` is missing, or the verifier isn't 43 to 128 characters of `A-Z a-z 0-9 - . _ ~`. | +| `429` | `rate_limited` | Over 12 tries a minute for one key, or 60 from one IP, counted before signatures are checked. `Retry-After` says when to try again. | + ## Available Tooling ### Development From 55dd9981ee78803dcbbe3c0c6648bb93bf7a0a3c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 16:38:21 +0100 Subject: [PATCH 11/15] feat: serve the following, latest and trending logs like the web Upstream replaced the Everyone log with Latest and Trending. GET /api/v1/log/{log} now takes following, latest and trending, the same values as the web, and everyone is a 404. Each log uses the web's own query, so the app gets the same ranking, the same 20 a page, and the same mutes and blocks. Trending isn't newest first, so the response says is_ranked and which commit was newest when it was loaded. With ?since= the app gets what the web shows above a ranked log: the newer commits other than the user's own as latest_commits, and new_commits_count, which every log returns. ?since= goes through the web's ShowLogRequest. An empty Following log comes with suggestions, like the web. The API doesn't change the log the web remembers for the user. The app keeps its own tabs. --- README.md | 2 + app/Http/Controllers/Api/LogController.php | 28 ++++- .../Controllers/Api/LogControllerTest.php | 113 +++++++++++++++++- 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d7156a5d4..7e17cf4e4 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Every profile also has an Atom feed of its latest 50 top-level commits at `/@{us The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting and following only take signed requests. Signed requests are limited to 120 a minute per user. +`GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`. + Each request sends three headers: | Header | Value | diff --git a/app/Http/Controllers/Api/LogController.php b/app/Http/Controllers/Api/LogController.php index cef3042fa..72cd7c78d 100644 --- a/app/Http/Controllers/Api/LogController.php +++ b/app/Http/Controllers/Api/LogController.php @@ -5,15 +5,39 @@ namespace App\Http\Controllers\Api; use App\Enums\Log; +use App\Http\Requests\ShowLogRequest; use App\Http\Resources\CommitResource; +use App\Models\Commit; use App\Models\User; +use App\Queries\LatestLogCommitsQuery; +use App\Queries\SuggestedCommitsQuery; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Collection; final readonly class LogController { - public function show(#[CurrentUser] User $user, Log $log): AnonymousResourceCollection + /** + * Following, Latest or Trending, as the web shows it, 20 commits a page. Trending is ranked rather than newest + * first, so it also says which commit was newest when it was loaded, and with ?since= it offers the commits newer + * than that one, other than the user's own, the way the web offers them above it. Every log counts the commits + * newer than ?since=. An empty Following log suggests a few commits, as on the web. + */ + public function show(ShowLogRequest $request, #[CurrentUser] User $user, Log $log, LatestLogCommitsQuery $latest, SuggestedCommitsQuery $suggested): AnonymousResourceCollection { - return CommitResource::collection($log->query()->builder($user)->cursorPaginate(20)); + $since = $request->since(); + + return CommitResource::collection($log->query()->builder($user)->cursorPaginate(20))->additional([ + 'log' => $log, + 'is_ranked' => $log->isRanked(), + 'newest_commit_id' => $log->isRanked() ? $latest->newestId($log, $user) : null, + ...$since instanceof Commit ? [ + 'latest_commits' => CommitResource::collection($latest->get($log, $user, $since, null)), + 'new_commits_count' => $latest->count($log, $user, $since), + ] : [], + 'suggestions' => CommitResource::collection($log === Log::Following && ! $user->following()->exists() + ? $suggested->get($user) + : new Collection()), + ]); } } diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index c18657f67..544cce705 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Actions\UpdateCommitHotScores; +use App\Enums\Log; use App\Models\Bookmark; use App\Models\Commit; use App\Models\CommitImage; @@ -197,10 +199,117 @@ expect($queries())->toBe($few); }); -it('returns not found for unknown logs', function (): void { - $response = signedApiRequest($this->keyPair, 'GET', 'api/v1/log/unknown'); +it('returns not found for unknown logs', function (string $log): void { + $response = signedApiRequest($this->keyPair, 'GET', "api/v1/log/{$log}"); $response->assertNotFound(); +})->with(['unknown', 'everyone']); + +it('ranks the trending log like the web, and says which commit was newest', function (): void { + $starred = Commit::factory()->create(['body' => 'Starred', 'created_at' => now()->subHour()]); + $starred->stars()->attach(User::factory()->create()); + resolve(UpdateCommitHotScores::class)->handle([$starred->id]); + $newer = Commit::factory()->create(['body' => 'Newer']); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'trending')) + ->assertOk() + ->assertJsonPath('data.*.id', [$starred->id, $newer->id]) + ->assertJsonPath('log', 'trending') + ->assertJsonPath('is_ranked', true) + ->assertJsonPath('newest_commit_id', $newer->id) + ->assertJsonMissingPath('latest_commits') + ->assertJsonMissingPath('new_commits_count'); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')) + ->assertOk() + ->assertJsonPath('data.*.id', [$newer->id, $starred->id]) + ->assertJsonPath('log', 'latest') + ->assertJsonPath('is_ranked', false) + ->assertJsonPath('newest_commit_id', null); +}); + +it('pages through the trending log in its ranking', function (): void { + $commits = Commit::factory()->count(22) + ->sequence(fn (Sequence $sequence): array => ['hot_score' => 100 - $sequence->index]) + ->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'trending')); + + $first->assertOk()->assertJsonPath('data.*.id', $commits->take(20)->pluck('id')->all()); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['trending', 'cursor' => $first->json('meta.next_cursor')])) + ->assertOk() + ->assertJsonPath('data.*.id', $commits->slice(20)->pluck('id')->values()->all()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('offers the commits newer than the one seen above the trending log, like the web', function (): void { + $seen = Commit::factory()->create(['created_at' => now()->subMinutes(3), 'hot_score' => 10]); + $first = Commit::factory()->create(['created_at' => now()->subMinutes(2)]); + $second = Commit::factory()->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['trending', 'since' => $seen->id])) + ->assertOk() + ->assertJsonPath('latest_commits.*.id', [$second->id, $first->id]) + ->assertJsonPath('latest_commits.0.author.id', $second->user_id) + ->assertJsonPath('new_commits_count', 2); +}); + +it('counts the commits newer than the one seen on a log in time order, without offering them', function (string $log): void { + $this->user->following()->attach($followed = User::factory()->create()); + $seen = Commit::factory()->for($followed)->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($followed)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', [$log, 'since' => $seen->id])) + ->assertOk() + ->assertJsonPath('latest_commits', []) + ->assertJsonPath('new_commits_count', 1); +})->with(['following', 'latest']); + +it('validates the commit the user has seen', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['trending', 'since' => 'abc'])) + ->assertUnprocessable() + ->assertJsonValidationErrors('since'); +}); + +it('leaves out muted people and people across a block on every log, as the web does', function (Log $log): void { + $muted = User::factory()->create(); + $blocked = User::factory()->create(); + $blocker = User::factory()->create(); + $this->user->following()->attach([$muted->id, $blocked->id, $blocker->id]); + $this->user->muting()->attach($muted); + $this->user->blocking()->attach($blocked); + $blocker->blocking()->attach($this->user); + + $mine = Commit::factory()->for($this->user)->create(); + Commit::factory()->for($muted)->create(); + Commit::factory()->for($blocked)->create(); + Commit::factory()->for($blocker)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', $log)) + ->assertOk() + ->assertJsonPath('data.*.id', [$mine->id]); +})->with(Log::cases()); + +it('suggests commits on an empty following log, as the web does', function (): void { + $suggested = Commit::factory()->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'following')) + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonPath('suggestions.*.id', [$suggested->id]) + ->assertJsonPath('suggestions.0.author.id', $suggested->user_id); + + $this->user->following()->attach($suggested->user); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'following')) + ->assertOk() + ->assertJsonPath('suggestions', []); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')) + ->assertOk() + ->assertJsonPath('suggestions', []); }); it('rejects requests that are not signed', function (): void { From eaf14c165a1bf23b867f215ccb4f3a82699fdea7 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 16:40:05 +0100 Subject: [PATCH 12/15] feat: filter and expand notifications in the API like the web Upstream regrouped the notification inbox and added filters. GET /api/v1/notifications now goes through the web's ListNotificationsRequest, so it takes the same ?type= values (all, replies, quotes, stars, mentions, follows, moderation, with all as the default) and the same ?through= the web uses to refresh every group it has loaded. The response echoes the filter as filters.type, like the web's prop. Each notification now says which group it leads (group_id) and how many events the group has (event_count), and the actor says whether the user follows them (is_followed), so the app can offer a follow back. These are the web's names. GET /api/v1/notifications/{notification}/group lists the notifications of one group, filtered the same way, through the web's ShowNotificationGroupRequest and query. Like the web, it marks nothing as read. The web shows no unread counts per filter, so the API doesn't either. --- README.md | 2 + .../Api/NotificationController.php | 12 +- .../Api/NotificationGroupController.php | 37 +++++ app/Http/Resources/NotificationResource.php | 9 +- app/Http/Resources/UserResource.php | 1 + routes/api.php | 2 + .../Api/NotificationControllerTest.php | 129 +++++++++++++++++- 7 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/Api/NotificationGroupController.php diff --git a/README.md b/README.md index 7e17cf4e4..928dfa3da 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the d `GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`. +`GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `replies`, `quotes`, `stars`, `mentions`, `follows` or `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. + Each request sends three headers: | Header | Value | diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index e29f991f6..1e88f77ac 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Actions\UpdateNotificationsReadAt; +use App\Http\Requests\ListNotificationsRequest; use App\Http\Resources\NotificationResource; use App\Models\Notification; use App\Models\User; @@ -18,15 +19,15 @@ final readonly class NotificationController { /** - * The notifications grouped like the web groups them. As on the web, listing them marks every notification as - * read, and read_at says when, so the ones read since then are the new ones. A commit comes with its author and - * the commit it replies to, when the user may see it. + * The notifications grouped and filtered by ?type= like the web does it. As on the web, listing them marks every + * notification as read, whatever the filter, and read_at says when, so the ones read since then are the new ones. + * A commit comes with its author and the commit it replies to, when the user may see it. */ - public function index(#[CurrentUser] User $user, NotificationGroupsQuery $query, UpdateNotificationsReadAt $action): AnonymousResourceCollection + public function index(ListNotificationsRequest $request, #[CurrentUser] User $user, NotificationGroupsQuery $query, UpdateNotificationsReadAt $action): AnonymousResourceCollection { $readAt = $action->handle($user); - $notifications = $query->paginate($user); + $notifications = $query->paginate($user, $request->type(), $request->through()); /** @var array $items */ $items = $notifications->items(); @@ -39,6 +40,7 @@ public function index(#[CurrentUser] User $user, NotificationGroupsQuery $query, ]); return NotificationResource::collection($notifications)->additional([ + 'filters' => ['type' => $request->type()], 'read_at' => $readAt->toIso8601ZuluString(), ]); } diff --git a/app/Http/Controllers/Api/NotificationGroupController.php b/app/Http/Controllers/Api/NotificationGroupController.php new file mode 100644 index 000000000..bb28b7216 --- /dev/null +++ b/app/Http/Controllers/Api/NotificationGroupController.php @@ -0,0 +1,37 @@ +members($user, $notification, $request->type()) + ->with([ + 'commit.user', + 'commit.parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)) + ->with('user'), + ]) + ->latest() + ->latest('id') + ->cursorPaginate(20) + ->withQueryString()); + } +} diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php index c0bb9c757..43df53126 100644 --- a/app/Http/Resources/NotificationResource.php +++ b/app/Http/Resources/NotificationResource.php @@ -10,8 +10,9 @@ use Illuminate\Http\Resources\Json\JsonResource; /** - * A group of notifications, as the web shows it: the newest one, the first few actors, how many actors there are - * and how many of them are unread. A rejected commit comes with the reason the moderator gave. + * A group of notifications, as the web shows it: the newest one, the group it leads, how many events and actors the + * group has and how many events are unread, and in a list the first few actors. The actor says whether the user + * follows them. A rejected commit comes with the reason the moderator gave. * * @mixin Notification */ @@ -30,7 +31,9 @@ public function toArray(Request $request): array 'read_at' => $this->read_at?->toIso8601ZuluString(), 'created_at' => $this->created_at->toIso8601ZuluString(), 'actor' => UserResource::make($this->actor), - 'actors' => UserResource::collection($this->actors), + 'actors' => UserResource::collection($this->whenLoaded('actors')), + 'group_id' => $this->group_id, + 'event_count' => $this->event_count, 'actors_count' => $this->actors_count, 'unread_count' => $this->unread_count, 'commit' => $this->commit instanceof Commit ? CommitResource::make($this->commit) : null, diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 1b4e97fd8..266206258 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -38,6 +38,7 @@ public function toArray(Request $request): array 'followers_count' => $this->whenCounted('followers'), 'following_count' => $this->whenCounted('following'), 'lane' => $this->when($request->user() instanceof User, fn (): int => $this->lane()), + 'is_followed' => $this->whenHas('is_followed'), ]; } } diff --git a/routes/api.php b/routes/api.php index ad6d690c8..4721e9d9e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\DeviceController; use App\Http\Controllers\Api\LogController; use App\Http\Controllers\Api\NotificationController; +use App\Http\Controllers\Api\NotificationGroupController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserFollowController; @@ -72,5 +73,6 @@ // Notifications... Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index'); + Route::get('notifications/{notification}/group', [NotificationGroupController::class, 'show'])->name('api-notification-group.show'); }); }); diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php index e79a2c65c..b891816c7 100644 --- a/tests/Feature/Controllers/Api/NotificationControllerTest.php +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -50,8 +50,10 @@ 'reason' => null, 'read_at' => now()->toIso8601ZuluString(), 'created_at' => now()->toIso8601ZuluString(), - 'actor' => $user($replied->actor), - 'actors' => [$user($replied->actor)], + 'actor' => [...$user($replied->actor), 'is_followed' => false], + 'actors' => [[...$user($replied->actor), 'is_followed' => false]], + 'group_id' => $replied->id, + 'event_count' => 1, 'actors_count' => 1, 'unread_count' => 0, 'commit' => [ @@ -238,6 +240,129 @@ expect($this->user->notifications()->unread()->exists())->toBeFalse(); }); +it('filters notifications by type like the web, and echoes the filter', function (?string $type, array $types): void { + $approved = Commit::factory()->for($this->user)->create(); + $rejected = Commit::factory()->for($this->user)->rejected()->create(); + + Notification::factory()->for($this->user, 'recipient')->create(['created_at' => now()->subMinutes(7)]); + Notification::factory()->for($this->user, 'recipient')->replied()->create(['created_at' => now()->subMinutes(6)]); + Notification::factory()->for($this->user, 'recipient')->quoted()->create(['created_at' => now()->subMinutes(5)]); + Notification::factory()->for($this->user, 'recipient')->starred()->create(['created_at' => now()->subMinutes(4)]); + Notification::factory()->for($this->user, 'recipient')->mentioned()->create(['created_at' => now()->subMinutes(3)]); + Notification::factory()->for($this->user, 'recipient')->for($approved, 'commit')->create(['type' => NotificationType::Approved, 'created_at' => now()->subMinutes(2)]); + Notification::factory()->for($this->user, 'recipient')->for($rejected, 'commit')->create(['type' => NotificationType::Rejected, 'reason' => 'Please remove the token.', 'created_at' => now()->subMinute()]); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', $type === null ? [] : ['type' => $type])) + ->assertOk() + ->assertJsonPath('data.*.type', $types) + ->assertJsonPath('filters', ['type' => $type ?? 'all']); +})->with([ + 'no filter' => [null, ['rejected', 'approved', 'mentioned', 'starred', 'quoted', 'replied', 'followed']], + 'all' => ['all', ['rejected', 'approved', 'mentioned', 'starred', 'quoted', 'replied', 'followed']], + 'replies' => ['replies', ['replied']], + 'quotes' => ['quotes', ['quoted']], + 'stars' => ['stars', ['starred']], + 'mentions' => ['mentions', ['mentioned']], + 'follows' => ['follows', ['followed']], + 'moderation' => ['moderation', ['rejected', 'approved']], +]); + +it('marks every notification as read whatever the filter, as the web does', function (): void { + $followed = Notification::factory()->for($this->user, 'recipient')->create(); + $replied = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', ['type' => 'follows'])) + ->assertOk() + ->assertJsonPath('data.*.id', [$followed->id]); + + expect($replied->refresh()->read_at)->not->toBeNull(); +}); + +it('validates the filter before marking anything as read', function (array $query, string $field): void { + $notification = Notification::factory()->for($this->user, 'recipient')->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', $query)) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + + expect($notification->refresh()->read_at)->toBeNull(); +})->with([ + 'an unknown type' => [['type' => 'secret'], 'type'], + 'a date that is not one' => [['through' => 'yesterday-ish'], 'through'], +]); + +it('lists every group through a date when asked, like the web refreshing its inbox', function (): void { + $notifications = Notification::factory()->for($this->user, 'recipient')->replied()->count(22) + ->sequence(fn ($sequence): array => ['created_at' => now()->subHours($sequence->index * 2)]) + ->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertJsonCount(20, 'data'); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', ['through' => $notifications->last()->created_at->toJSON()])) + ->assertJsonCount(22, 'data'); +}); + +it('says which group a notification leads, how many events it has, and whether the user follows the actor', function (): void { + $first = Notification::factory()->for($this->user, 'recipient')->create(['created_at' => now()->subMinutes(2)]); + $latest = Notification::factory()->for($this->user, 'recipient')->create(); + $this->user->following()->attach($latest->actor); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $latest->id) + ->assertJsonPath('data.0.group_id', $first->id) + ->assertJsonPath('data.0.event_count', 2) + ->assertJsonPath('data.0.actors_count', 2) + ->assertJsonPath('data.0.actor.is_followed', true) + ->assertJsonPath('data.0.actors.*.id', [$latest->actor_id, $first->actor_id]) + ->assertJsonMissingPath('data.0.actors.0.is_followed'); +}); + +it('lists the notifications of a group like the web expands it', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $old = Notification::factory()->for($this->user, 'recipient')->starred($commit)->create(['created_at' => now()->subDays(2)]); + Notification::factory()->for($this->user, 'recipient')->replied($commit)->create(['created_at' => now()->subMinute()]); + $latest = Notification::factory()->for($this->user, 'recipient')->starred($commit)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification-group.show', [$latest, 'type' => 'stars'])) + ->assertOk() + ->assertJsonPath('data.*.id', [$latest->id, $old->id]) + ->assertJsonPath('data.0.group_id', $old->id) + ->assertJsonPath('data.0.commit.id', $commit->id) + ->assertJsonPath('data.0.commit.author.id', $this->user->id) + ->assertJsonMissingPath('data.0.actors') + ->assertJsonPath('meta.next_cursor', null); + + expect($latest->refresh()->read_at)->toBeNull(); +}); + +it('returns not found for the group of a notification the user may not see', function (): void { + $other = Notification::factory()->create(); + $hidden = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $hidden->commit->update(['status' => CommitStatus::Rejected]); + + foreach ([$other, $hidden] as $notification) { + signedApiRequest($this->keyPair, 'GET', route('api-notification-group.show', $notification))->assertNotFound(); + } +}); + +it('leaves out what muted people and people across a block did, as the web does', function (): void { + $kept = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $muted = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $blocked = Notification::factory()->for($this->user, 'recipient')->mentioned()->create(); + $this->user->muting()->attach($muted->actor); + $blocked->actor->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$kept->id]); + + expect($muted->refresh()->read_at)->toBeNull(); +}); + it('rejects requests that are not signed', function (): void { $this->getJson(route('api-notification.index'))->assertUnauthorized(); + $this->getJson(route('api-notification-group.show', Notification::factory()->create()))->assertUnauthorized(); }); From 124132d6231fc1da54b2519fbf054480be0d61af Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 16:44:13 +0100 Subject: [PATCH 13/15] feat: send polls, quotes and reply previews with signed commits The web's commit cards now show an excerpt of a long commit, a poll, the commit a quote quotes, and under each commit in a list its newest reply or quote. Signed responses carry the same, with the web's names, so the app can draw them: - body_segments and preview_segments come from the web's CommitBodies, so a long commit has the excerpt a list shows, or null when it fits. - poll is the web's own poll array: closes_at, is_closed, has_voted, total_votes, and options with votes_count and voted. PollResource adds each option's percentage, rounded like the web rounds it. Counts and percentages stay null until the user voted, wrote the poll, or it closed, as on the web. - quoted_commit_id says the commit quotes another, and quoted_commit embeds it with its author and poll. It is a tombstone once deleted, and null when the user may no longer see it, such as across a block. - reply_preview is the newest visible reply or quote under a commit in the log, a profile and the pinned commit, with the web's next_cursor. CommitBuilder hands these over as arrays, so ReplyPreviewsQuery loads them again as commits in one query per page, and they come in the same shape as every other commit. The link card needs nothing new. link_preview is already the card the author chose, and upstream gave it the web's embed for every request. Unsigned requests get none of the fields above. --- README.md | 2 + app/Http/Controllers/Api/LogController.php | 13 +- .../Controllers/Api/UserCommitController.php | 11 +- app/Http/Controllers/Api/UserController.php | 5 +- app/Http/Resources/CommitResource.php | 21 ++- app/Http/Resources/PollResource.php | 41 ++++++ app/Queries/ReplyPreviewsQuery.php | 57 ++++++++ .../Controllers/Api/CommitControllerTest.php | 124 ++++++++++++++++++ .../Controllers/Api/LogControllerTest.php | 100 +++++++++----- .../Api/NotificationControllerTest.php | 4 + .../Api/UserCommitControllerTest.php | 20 +++ .../Controllers/Api/UserControllerTest.php | 5 +- tests/Unit/Queries/ReplyPreviewsQueryTest.php | 59 +++++++++ 13 files changed, 412 insertions(+), 50 deletions(-) create mode 100644 app/Http/Resources/PollResource.php create mode 100644 app/Queries/ReplyPreviewsQuery.php create mode 100644 tests/Unit/Queries/ReplyPreviewsQueryTest.php diff --git a/README.md b/README.md index 928dfa3da..058d03137 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Every profile also has an Atom feed of its latest 50 top-level commits at `/@{us The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting and following only take signed requests. Signed requests are limited to 120 a minute per user. +Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. + `GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`. `GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `replies`, `quotes`, `stars`, `mentions`, `follows` or `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. diff --git a/app/Http/Controllers/Api/LogController.php b/app/Http/Controllers/Api/LogController.php index 72cd7c78d..d91c3763c 100644 --- a/app/Http/Controllers/Api/LogController.php +++ b/app/Http/Controllers/Api/LogController.php @@ -10,10 +10,11 @@ use App\Models\Commit; use App\Models\User; use App\Queries\LatestLogCommitsQuery; +use App\Queries\ReplyPreviewsQuery; use App\Queries\SuggestedCommitsQuery; use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; -use Illuminate\Support\Collection; final readonly class LogController { @@ -23,16 +24,20 @@ * than that one, other than the user's own, the way the web offers them above it. Every log counts the commits * newer than ?since=. An empty Following log suggests a few commits, as on the web. */ - public function show(ShowLogRequest $request, #[CurrentUser] User $user, Log $log, LatestLogCommitsQuery $latest, SuggestedCommitsQuery $suggested): AnonymousResourceCollection + public function show(ShowLogRequest $request, #[CurrentUser] User $user, Log $log, LatestLogCommitsQuery $latest, SuggestedCommitsQuery $suggested, ReplyPreviewsQuery $replyPreviews): AnonymousResourceCollection { $since = $request->since(); + $commits = $log->query()->builder($user)->cursorPaginate(20); + $latestCommits = $since instanceof Commit ? $latest->get($log, $user, $since, null) : new Collection(); - return CommitResource::collection($log->query()->builder($user)->cursorPaginate(20))->additional([ + $replyPreviews->load([...$commits->items(), ...$latestCommits], $user); + + return CommitResource::collection($commits)->additional([ 'log' => $log, 'is_ranked' => $log->isRanked(), 'newest_commit_id' => $log->isRanked() ? $latest->newestId($log, $user) : null, ...$since instanceof Commit ? [ - 'latest_commits' => CommitResource::collection($latest->get($log, $user, $since, null)), + 'latest_commits' => CommitResource::collection($latestCommits), 'new_commits_count' => $latest->count($log, $user, $since), ] : [], 'suggestions' => CommitResource::collection($log === Log::Following && ! $user->following()->exists() diff --git a/app/Http/Controllers/Api/UserCommitController.php b/app/Http/Controllers/Api/UserCommitController.php index ac98a3d8f..fcbdd549b 100644 --- a/app/Http/Controllers/Api/UserCommitController.php +++ b/app/Http/Controllers/Api/UserCommitController.php @@ -8,6 +8,7 @@ use App\Http\Resources\CommitResource; use App\Models\User; use App\Queries\CommitBuilder; +use App\Queries\ReplyPreviewsQuery; use App\Queries\UserCommitsQuery; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -17,12 +18,16 @@ /** * A signed request gets the profile's Commits or Replies tab as the web shows it to that user. */ - public function index(ListApiUserCommitsRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserCommitsQuery $commits): AnonymousResourceCollection + public function index(ListApiUserCommitsRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserCommitsQuery $commits, ReplyPreviewsQuery $replyPreviews): AnonymousResourceCollection { if ($currentUser instanceof User) { - return CommitResource::collection($commits->builder($user, $currentUser, $request->replies()) + $signedCommits = $commits->builder($user, $currentUser, $request->replies()) ->cursorPaginate(20) - ->appends($request->only('replies'))); + ->appends($request->only('replies')); + + $replyPreviews->load($signedCommits->items(), $currentUser); + + return CommitResource::collection($signedCommits); } return CommitResource::collection($user->commits() diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 37514b96e..d81d63188 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -10,6 +10,7 @@ use App\Models\Commit; use App\Models\User; use App\Queries\PinnedCommitQuery; +use App\Queries\ReplyPreviewsQuery; use App\Queries\UserActivityQuery; use App\Queries\UserStreakQuery; use Illuminate\Container\Attributes\CurrentUser; @@ -20,11 +21,13 @@ * A signed request gets the profile as the web shows it to that user. The pinned commit is left out of the * commits list, so it comes with the profile, as on the web. */ - public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, PinnedCommitQuery $pinned): UserResource + public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, PinnedCommitQuery $pinned, ReplyPreviewsQuery $replyPreviews): UserResource { if ($currentUser instanceof User) { $pinnedCommit = $pinned->get($user, $currentUser); + $replyPreviews->load(array_filter([$pinnedCommit]), $currentUser); + return UserResource::make($user->loadCounts($currentUser))->additional([ 'is_following' => $currentUser->isFollowing($user), 'follows_you' => $user->isFollowing($currentUser), diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 95959e0c7..d5b0dfa1f 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -5,9 +5,11 @@ namespace App\Http\Resources; use App\Models\Commit; +use App\Models\Poll; use App\Models\User; -use App\Services\CodeSpans; +use App\Services\CommitBodies; use App\Services\Mentions; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -17,8 +19,12 @@ * A signed request gets the commit as the web shows it to that user instead: parent_id is the parent the commit * replies to, whoever may see it, and on top come the body split into prose, inline code and highlighted code * blocks, where it mentions someone, its status, whether the author pinned it, and whether the user starred or saved - * it. As on the web, the counts, link_preview, images, starred, is_saved and parent are - * only there when they were loaded, so ancestors and the commits of notifications come without some of them. + * it. Like the web's cards, it also has preview_segments, the excerpt a list shows of a long commit, the poll, with + * what the user may see of its votes, and the commit it quotes, or null once the user may no longer see that one, + * while quoted_commit_id still says it quoted something. In a list, reply_preview is the newest reply or quote the + * web shows under it. As on the web, the counts, link_preview, images, starred, is_saved, parent, poll, + * quoted_commit and reply_preview are only there when they were loaded, so ancestors and the commits of + * notifications come without some of them. * * A deleted commit that still has replies stays in its thread as a tombstone: its id, parent_id, deleted_at and * replies_count, but never its author or body. Only signed requests are ever sent one. @@ -63,7 +69,7 @@ public function toArray(Request $request): array return [ ...$commit, - 'body_segments' => resolve(CodeSpans::class)->highlight($this->body), + ...resolve(CommitBodies::class)->segments($this->body), 'mentions' => resolve(Mentions::class)->positions($this->body), 'status' => $this->status, 'is_pinned' => $this->user?->pinned_commit_id === $this->id, @@ -71,6 +77,13 @@ public function toArray(Request $request): array 'is_saved' => $this->whenHas('is_saved'), 'deleted_at' => null, 'parent' => $this->whenLoaded('parent', fn (Commit $parent): self => self::make($parent)), + 'quoted_commit_id' => $this->quoted_commit_id, + 'quoted_commit' => $this->whenLoaded('quotedCommit', fn (Commit $quoted): self => self::make($quoted)), + 'poll' => $this->whenLoaded('poll', fn (Poll $poll): PollResource => PollResource::make($poll)), + 'reply_preview' => $this->whenLoaded('replyPreview', fn (Collection $replies): array => [ + 'data' => self::collection($replies), + 'next_cursor' => data_get($this->getAttribute('reply_preview'), 'next_cursor'), + ]), ]; } } diff --git a/app/Http/Resources/PollResource.php b/app/Http/Resources/PollResource.php new file mode 100644 index 000000000..82bf901a4 --- /dev/null +++ b/app/Http/Resources/PollResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + assert($this->resource instanceof Poll); + + $poll = $this->resource->toArray(); + $total = $poll['total_votes']; + + return [ + ...$poll, + 'options' => array_map(fn (array $option): array => [ + ...$option, + 'percentage' => match (true) { + $total === null => null, + $total === 0 => 0, + default => (int) round(($option['votes_count'] ?? 0) / $total * 100), + }, + ], $poll['options']), + ]; + } +} diff --git a/app/Queries/ReplyPreviewsQuery.php b/app/Queries/ReplyPreviewsQuery.php new file mode 100644 index 000000000..3fffdcfab --- /dev/null +++ b/app/Queries/ReplyPreviewsQuery.php @@ -0,0 +1,57 @@ + $commits + */ + public function load(iterable $commits, ?User $viewer): void + { + $commits = new Collection($commits) + ->whereInstanceOf(Commit::class) + ->filter(fn (Commit $commit): bool => $commit->hasAttribute('reply_preview')); + + $ids = $commits->flatMap(fn (Commit $commit): array => array_column($this->preview($commit)['data'], 'id')); + + $replies = $ids->isEmpty() ? new Collection() : Commit::query() + ->withTrashed() + ->whereKey($ids->all()) + ->withVisibleReplyCount($viewer) + ->withCardDataFor($viewer) + ->without('quotedCommit') + ->get() + ->keyBy('id'); + + foreach ($commits as $commit) { + $commit->setRelation('replyPreview', new Collection(array_values(array_filter(array_map( + fn (array $reply): ?Commit => $replies->get($reply['id']), + $this->preview($commit)['data'], + ))))); + } + } + + /** + * @return array{data: list, next_cursor: string|null} + */ + private function preview(Commit $commit): array + { + /** @var array{data: list, next_cursor: string|null} $preview */ + $preview = $commit->getAttribute('reply_preview'); + + return $preview; + } +} diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index d36b3c406..35b82a909 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -8,6 +8,9 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; +use App\Models\Poll; +use App\Models\PollOption; +use App\Models\PollVote; use App\Models\User; use Illuminate\Support\Facades\Queue; @@ -245,12 +248,16 @@ ['type' => 'text', 'text' => 'Ask @nunomaduro about '], ['type' => 'code', 'code' => '@Override'], ], + 'preview_segments' => null, 'mentions' => [['username' => 'nunomaduro', 'offset' => 4, 'length' => 11]], 'status' => 'approved', 'is_pinned' => false, 'starred' => true, 'is_saved' => true, 'deleted_at' => null, + 'quoted_commit_id' => null, + 'quoted_commit' => null, + 'poll' => null, ], 'ancestors' => [[ 'id' => $root->id, @@ -264,16 +271,133 @@ 'images' => [], 'author' => $author($root->user), 'body_segments' => [['type' => 'text', 'text' => 'Who broke prod?']], + 'preview_segments' => null, 'mentions' => [], 'status' => 'approved', 'is_pinned' => false, 'starred' => false, 'is_saved' => false, 'deleted_at' => null, + 'quoted_commit_id' => null, + 'quoted_commit' => null, + 'poll' => null, ]], ]); }); +it('shows a signed request the poll of a commit as the web does, with each share once the votes show', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->create(['body' => 'Tabs or spaces?']); + $poll = Poll::factory()->for($commit)->withOptions('Tabs', 'Spaces', 'Both')->create(['opened_at' => now()->subHour()]); + [$tabs, $spaces, $both] = $poll->options->all(); + PollVote::factory()->for($tabs, 'option')->count(2)->create(); + + $option = fn (PollOption $option, ?int $votes, bool $voted, ?int $percentage): array => [ + 'id' => $option->id, + 'label' => $option->label, + 'votes_count' => $votes, + 'voted' => $voted, + 'percentage' => $percentage, + ]; + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.poll', [ + 'id' => $poll->id, + 'closes_at' => $poll->closesAt()?->toJSON(), + 'is_closed' => false, + 'has_voted' => false, + 'total_votes' => null, + 'options' => [ + $option($tabs, null, false, null), + $option($spaces, null, false, null), + $option($both, null, false, null), + ], + ]); + + PollVote::factory()->for($both, 'option')->for($user)->create(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.poll.has_voted', true) + ->assertJsonPath('data.poll.total_votes', 3) + ->assertJsonPath('data.poll.options', [ + $option($tabs, 2, false, 67), + $option($spaces, 0, false, 0), + $option($both, 1, true, 33), + ]); + + $this->getJson(route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonMissingPath('data.poll'); +}); + +it('shows a signed request the shares of a closed poll nobody voted on', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->closed()->create(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $poll->commit)) + ->assertOk() + ->assertJsonPath('data.poll.is_closed', true) + ->assertJsonPath('data.poll.total_votes', 0) + ->assertJsonPath('data.poll.options.*.percentage', [0, 0]); +}); + +it('embeds the commit a commit quotes for a signed request, with its poll', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $quoted = Commit::factory()->create(['body' => 'Tabs or spaces?']); + Poll::factory()->for($quoted)->withOptions('Tabs', 'Spaces')->create(); + $quote = Commit::factory()->for($quoted, 'quotedCommit')->create(['body' => 'Neither.']); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonPath('data.quoted_commit_id', $quoted->id) + ->assertJsonPath('data.quoted_commit.id', $quoted->id) + ->assertJsonPath('data.quoted_commit.body', 'Tabs or spaces?') + ->assertJsonPath('data.quoted_commit.author.id', $quoted->user_id) + ->assertJsonPath('data.quoted_commit.poll.options.*.label', ['Tabs', 'Spaces']) + ->assertJsonMissingPath('data.quoted_commit.quoted_commit'); + + $this->getJson(route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonMissingPath('data.quoted_commit_id') + ->assertJsonMissingPath('data.quoted_commit'); +}); + +it('embeds a deleted quoted commit as a tombstone for a signed request', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $quoted = Commit::factory()->create(); + $quote = Commit::factory()->for($quoted, 'quotedCommit')->create(); + $quoted->delete(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonPath('data.quoted_commit', [ + 'id' => $quoted->id, + 'parent_id' => null, + 'deleted_at' => now()->toIso8601ZuluString(), + ]); +}); + +it('leaves out a quoted commit the signed-in user may not see, but still says one was quoted', function (Closure $hide): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $quoted = Commit::factory()->create(); + $quote = Commit::factory()->for($quoted, 'quotedCommit')->create(); + + $hide($quoted, $viewer); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonPath('data.quoted_commit_id', $quoted->id) + ->assertJsonPath('data.quoted_commit', null); +})->with([ + 'pending' => fn (Commit $quoted) => $quoted->update(['status' => CommitStatus::Pending]), + 'rejected' => fn (Commit $quoted) => $quoted->update(['status' => CommitStatus::Rejected]), + 'across a block' => fn (Commit $quoted, User $viewer) => $quoted->user?->blocking()->attach($viewer), +]); + it('shows a signed-in user their own pending commits, and the parents they may see', function (): void { $user = User::factory()->create(); $keyPair = apiKeyPairFor($user); diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index 544cce705..d8fa2962a 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -9,6 +9,7 @@ use App\Models\CommitImage; use App\Models\LinkPreview; use App\Models\User; +use App\Services\CommitBodies; use Illuminate\Database\Eloquent\Factories\Sequence; use Illuminate\Support\Facades\DB; @@ -25,7 +26,8 @@ ]); $older = Commit::factory()->for($author)->create(['body' => 'It was DNS.', 'created_at' => now()->subMinute()]); $newer = Commit::factory()->for($this->user)->pending()->create(['body' => 'Mine, pending']); - Commit::factory()->for($older, 'parent')->count(2)->create(); + Commit::factory()->for($older, 'parent')->create(['created_at' => now()->subSeconds(30)]); + $reply = Commit::factory()->for($older, 'parent')->create(['created_at' => now()->subSeconds(20)]); Commit::factory()->for($older, 'parent')->pending()->create(); Commit::factory()->pending()->create(); @@ -36,45 +38,71 @@ ->assertJsonPath('data.0.id', $newer->id) ->assertJsonPath('data.0.status', 'pending') ->assertJsonPath('data.0.replies_count', 0) - ->assertJsonPath('data.1', [ - 'id' => $older->id, - 'short_hash' => mb_substr(str_replace('-', '', $older->id), -7), - 'body' => 'It was DNS.', - 'parent_id' => null, - 'html_url' => route('commit.show', [$author, $older]), - 'created_at' => now()->subMinute()->toIso8601ZuluString(), - 'stars_count' => 0, - 'replies_count' => 2, - 'link_preview' => null, - 'images' => [], - 'author' => [ - 'id' => $author->id, - 'username' => 'octocat', - 'name' => 'The Octocat', - 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', - 'bio' => $author->bio, - 'company' => $author->company, - 'location' => $author->location, - 'website' => $author->website, - 'github_url' => 'https://github.com/octocat', - 'html_url' => route('user.show', $author), - 'feed_url' => route('user-feed.show', $author), - 'created_at' => now()->toIso8601ZuluString(), - 'lane' => $author->lane(), - ], - 'body_segments' => [['type' => 'text', 'text' => 'It was DNS.']], - 'mentions' => [], - 'status' => 'approved', - 'is_pinned' => false, - 'starred' => false, - 'is_saved' => false, - 'deleted_at' => null, - ]) - ->assertJsonPath('links.next', null) + ->assertJsonPath('data.0.reply_preview', ['data' => [], 'next_cursor' => null]) + ->assertJsonPath('data.1.reply_preview.data.*.id', [$reply->id]) + ->assertJsonPath('data.1.reply_preview.data.0.author.id', $reply->user_id) + ->assertJsonPath('data.1.reply_preview.data.0.parent_id', $older->id); + + expect($response->json('data.1.reply_preview.next_cursor'))->toBeString(); + + expect(collect($response->json('data.1'))->except('reply_preview')->all())->toBe([ + 'id' => $older->id, + 'short_hash' => mb_substr(str_replace('-', '', $older->id), -7), + 'body' => 'It was DNS.', + 'parent_id' => null, + 'html_url' => route('commit.show', [$author, $older]), + 'created_at' => now()->subMinute()->toIso8601ZuluString(), + 'stars_count' => 0, + 'replies_count' => 2, + 'link_preview' => null, + 'images' => [], + 'author' => [ + 'id' => $author->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => $author->bio, + 'company' => $author->company, + 'location' => $author->location, + 'website' => $author->website, + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $author), + 'feed_url' => route('user-feed.show', $author), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $author->lane(), + ], + 'body_segments' => [['type' => 'text', 'text' => 'It was DNS.']], + 'preview_segments' => null, + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => false, + 'is_saved' => false, + 'deleted_at' => null, + 'quoted_commit_id' => null, + 'quoted_commit' => null, + 'poll' => null, + ]); + + $response->assertJsonPath('links.next', null) ->assertJsonPath('meta.next_cursor', null) ->assertJsonPath('meta.per_page', 20); }); +it('sends the excerpt the web shows of a long commit in a list', function (): void { + $long = Commit::factory()->create(['body' => str_repeat('It was DNS. ', 100)]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonPath('data.0.id', $long->id) + ->assertJsonPath('data.0.body', $long->body) + ->assertJsonPath('data.0.body_segments', resolve(CommitBodies::class)->segments($long->body)['body_segments']) + ->assertJsonPath('data.0.preview_segments', resolve(CommitBodies::class)->segments($long->body)['preview_segments']); + + expect($response->json('data.0.preview_segments'))->not->toBeNull(); +}); + it('shows what the web shows on a commit', function (): void { $author = User::factory()->create(['username' => 'octocat', 'preferred_lane' => 4]); $linkPreview = LinkPreview::factory()->fetched()->create(['url' => 'https://laravel.com']); diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php index b891816c7..228dfeb58 100644 --- a/tests/Feature/Controllers/Api/NotificationControllerTest.php +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -65,6 +65,7 @@ 'created_at' => now()->toIso8601ZuluString(), 'author' => $user($replied->actor), 'body_segments' => [['type' => 'text', 'text' => $reply->body]], + 'preview_segments' => null, 'mentions' => [], 'status' => 'approved', 'is_pinned' => false, @@ -78,11 +79,14 @@ 'created_at' => now()->toIso8601ZuluString(), 'author' => $user($this->user), 'body_segments' => [['type' => 'text', 'text' => $reply->parent->body]], + 'preview_segments' => null, 'mentions' => [], 'status' => 'approved', 'is_pinned' => false, 'deleted_at' => null, + 'quoted_commit_id' => null, ], + 'quoted_commit_id' => null, ], ]) ->assertJsonPath('data.1.id', $followed->id) diff --git a/tests/Feature/Controllers/Api/UserCommitControllerTest.php b/tests/Feature/Controllers/Api/UserCommitControllerTest.php index cb2478cbd..0c510d871 100644 --- a/tests/Feature/Controllers/Api/UserCommitControllerTest.php +++ b/tests/Feature/Controllers/Api/UserCommitControllerTest.php @@ -249,3 +249,23 @@ 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked'], 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable'], ]); + +it('shows a signed request the newest reply or quote under each commit, as the web does', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create(['created_at' => now()->subMinutes(3)]); + Commit::factory()->for($commit, 'parent')->create(['created_at' => now()->subMinutes(2)]); + $quote = Commit::factory()->for($commit, 'quotedCommit')->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($user)->create(['created_at' => now()->subMinutes(4)]); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonPath('data.0.reply_preview.data.*.id', [$quote->id]) + ->assertJsonPath('data.0.reply_preview.data.0.quoted_commit_id', $commit->id) + ->assertJsonMissingPath('data.0.reply_preview.data.0.quoted_commit') + ->assertJsonPath('data.1.reply_preview', ['data' => [], 'next_cursor' => null]); + + $this->getJson(route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonMissingPath('data.0.reply_preview'); +}); diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index d8b7828c5..b06b04cb4 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -163,7 +163,7 @@ $keyPair = apiKeyPairFor($viewer); $user = User::factory()->create(); $pinned = Commit::factory()->for($user)->create(['body' => 'Pinned']); - Commit::factory()->for($pinned, 'parent')->create(); + $reply = Commit::factory()->for($pinned, 'parent')->create(); Commit::factory()->for($pinned, 'parent')->pending()->create(); CommitImage::factory()->for($pinned)->create(); $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); @@ -176,7 +176,8 @@ ->assertJsonPath('pinned_commit.is_pinned', true) ->assertJsonPath('pinned_commit.replies_count', 1) ->assertJsonCount(1, 'pinned_commit.images') - ->assertJsonPath('pinned_commit.author.id', $user->id); + ->assertJsonPath('pinned_commit.author.id', $user->id) + ->assertJsonPath('pinned_commit.reply_preview.data.*.id', [$reply->id]); }); it('hides a pinned commit the signed-in user may not see', function (Closure $pinned): void { diff --git a/tests/Unit/Queries/ReplyPreviewsQueryTest.php b/tests/Unit/Queries/ReplyPreviewsQueryTest.php new file mode 100644 index 000000000..4b9583862 --- /dev/null +++ b/tests/Unit/Queries/ReplyPreviewsQueryTest.php @@ -0,0 +1,59 @@ +create(); + $commit = Commit::factory()->create(['created_at' => now()->subMinutes(2)]); + $reply = Commit::factory()->for($commit, 'parent')->create(['created_at' => now()->subMinute()]); + $reply->stars()->attach($viewer); + $quiet = Commit::factory()->create(); + + $commits = Commit::query()->whereKey([$commit->id, $quiet->id])->withVisibleReplyCount($viewer)->withReplyPreviews($viewer)->latest()->get(); + + resolve(ReplyPreviewsQuery::class)->load($commits, $viewer); + + expect($commits[0]->getRelation('replyPreview'))->toHaveCount(0) + ->and($commits[1]->getRelation('replyPreview')->modelKeys())->toBe([$reply->id]) + ->and($commits[1]->getRelation('replyPreview')->sole()->starred)->toBeTrue() + ->and($commits[1]->getRelation('replyPreview')->sole()->relationLoaded('user'))->toBeTrue(); +}); + +it('leaves commits without a reply preview alone', function (): void { + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + + $commits = Commit::query()->whereKey($commit->id)->get(); + + resolve(ReplyPreviewsQuery::class)->load($commits, null); + + expect($commits->sole()->relationLoaded('replyPreview'))->toBeFalse(); +}); + +it('loads every reply preview in one query, however many commits there are', function (): void { + $queries = function (): int { + $commits = Commit::query()->topLevel()->withVisibleReplyCount(null)->withReplyPreviews(null)->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + resolve(ReplyPreviewsQuery::class)->load($commits, null); + + return count(DB::getQueryLog()); + }; + + $parents = Commit::factory()->count(2)->create(); + $parents->each(fn (Commit $parent) => Commit::factory()->for($parent, 'parent')->create()); + + $few = $queries(); + + $parents = Commit::factory()->count(6)->create(); + $parents->each(fn (Commit $parent) => Commit::factory()->for($parent, 'parent')->create()); + + expect($queries())->toBe($few); +}); From 32b16a6cccb0717d6116ab072b4452e584d884a4 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 16:45:03 +0100 Subject: [PATCH 14/15] feat: send mutes and achievements with signed profiles The web's profile now says whether you mute the person and shows their achievements and Unix rank. A signed GET /api/v1/users/{username} adds is_muting and achievements, the web's names, from the web's own UserAchievementsQuery, sharing the streaks it already computes the way the web does. Muting doesn't hide a profile or its commits, on the web or here. It only quiets the logs, replies and notifications. --- README.md | 2 + app/Http/Controllers/Api/UserController.php | 14 +++++-- .../Controllers/Api/UserControllerTest.php | 40 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 058d03137..6ec97ce39 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the d Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. +A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. + `GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`. `GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `replies`, `quotes`, `stars`, `mentions`, `follows` or `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. diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index d81d63188..67ebbd8ab 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -11,6 +11,7 @@ use App\Models\User; use App\Queries\PinnedCommitQuery; use App\Queries\ReplyPreviewsQuery; +use App\Queries\UserAchievementsQuery; use App\Queries\UserActivityQuery; use App\Queries\UserStreakQuery; use Illuminate\Container\Attributes\CurrentUser; @@ -18,21 +19,26 @@ final readonly class UserController { /** - * A signed request gets the profile as the web shows it to that user. The pinned commit is left out of the - * commits list, so it comes with the profile, as on the web. + * A signed request gets the profile as the web shows it to that user, with whether they mute it, and its + * achievements and Unix rank. The pinned commit is left out of the commits list, so it comes with the profile, as + * on the web. */ - public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, PinnedCommitQuery $pinned, ReplyPreviewsQuery $replyPreviews): UserResource + public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PinnedCommitQuery $pinned, ReplyPreviewsQuery $replyPreviews): UserResource { if ($currentUser instanceof User) { $pinnedCommit = $pinned->get($user, $currentUser); $replyPreviews->load(array_filter([$pinnedCommit]), $currentUser); + $userStreaks = $streaks->get($user, $currentUser); + return UserResource::make($user->loadCounts($currentUser))->additional([ 'is_following' => $currentUser->isFollowing($user), 'follows_you' => $user->isFollowing($currentUser), + 'is_muting' => $currentUser->isMuting($user), 'activity' => $activity->get($user, $currentUser), - 'streaks' => $streaks->get($user, $currentUser), + 'streaks' => $userStreaks, + 'achievements' => $achievements->get($user, $currentUser, $userStreaks), 'pinned_commit' => $pinnedCommit instanceof Commit ? CommitResource::make($pinnedCommit) : null, ]); } diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index b06b04cb4..738d6f76a 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -5,6 +5,7 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Models\User; +use App\Queries\UserAchievementsQuery; use App\Queries\UserActivityQuery; it('shows a public profile with public counts', function (): void { @@ -117,10 +118,14 @@ ], 'is_following' => true, 'follows_you' => false, + 'is_muting' => false, 'activity' => resolve(UserActivityQuery::class)->get($user, $viewer), 'streaks' => ['longest' => 1, 'current' => 1], + 'achievements' => resolve(UserAchievementsQuery::class)->get($user, $viewer), 'pinned_commit' => null, ]) + ->assertJsonPath('achievements.rank', 'user') + ->assertJsonPath('achievements.level', 1) ->assertJsonPath('activity.scale', 'week') ->assertJsonCount(7, 'activity.buckets') ->assertJsonPath('activity.buckets.3', ['date' => '2026-10-12', 'count' => 1]) @@ -158,6 +163,41 @@ ->assertJsonPath('follows_you', true); }); +it('tells a signed request whether its user mutes the profile, which still shows', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create(); + + $viewer->muting()->attach($user); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('is_muting', true); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$commit->id]); +}); + +it('shows a signed request the achievements and unix rank of the profile', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + Commit::factory()->for($user)->count(60)->create(); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('achievements', resolve(UserAchievementsQuery::class)->get($user, $viewer)) + ->assertJsonPath('achievements.rank', 'maintainer') + ->assertJsonPath('achievements.level', 4) + ->assertJsonCount(6, 'achievements.ranks'); + + $this->getJson(route('api-user.show', $user)) + ->assertOk() + ->assertJsonMissingPath('achievements'); +}); + it('shows a signed request the pinned commit of the profile', function (): void { $viewer = User::factory()->create(); $keyPair = apiKeyPairFor($viewer); From b51fe41a7fad00348afab9edc56c1d29efe1e957 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:05:37 +0100 Subject: [PATCH 15/15] feat: list a profile's followers and following in the API GET /api/v1/users/{username}/followers and /following list people the way the web's connections pages do: newest follow first, 30 a page. A signed request also gets each person's lane, whether its user follows them (is_followed) and whether they follow its user (follows_you). Across a block a signed request gets user_blocked or user_unavailable, as the profile does. --- README.md | 4 +- .../Api/UserFollowersController.php | 24 ++++ .../Api/UserFollowingController.php | 24 ++++ .../Requests/ListApiUserFollowersRequest.php | 37 +++++ .../Requests/ListApiUserFollowingRequest.php | 37 +++++ app/Http/Resources/UserResource.php | 3 +- app/Queries/UserConnectionsQuery.php | 33 +++++ routes/api.php | 6 + .../Api/UserFollowersControllerTest.php | 130 ++++++++++++++++++ .../Api/UserFollowingControllerTest.php | 84 +++++++++++ .../Unit/Queries/UserConnectionsQueryTest.php | 43 ++++++ 11 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Api/UserFollowersController.php create mode 100644 app/Http/Controllers/Api/UserFollowingController.php create mode 100644 app/Http/Requests/ListApiUserFollowersRequest.php create mode 100644 app/Http/Requests/ListApiUserFollowingRequest.php create mode 100644 app/Queries/UserConnectionsQuery.php create mode 100644 tests/Feature/Controllers/Api/UserFollowersControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserFollowingControllerTest.php create mode 100644 tests/Unit/Queries/UserConnectionsQueryTest.php diff --git a/README.md b/README.md index 6ec97ce39..498dccb41 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ Read-only, no authentication, JSON, versioned under `/api/v1`. Only approved com |---|---| | `GET /api/v1/users/{username}` | A public profile with commit, follower, and following counts | | `GET /api/v1/users/{username}/commits` | The user's top-level commits, newest first, 20 per page (`?replies=1` for their replies instead) | +| `GET /api/v1/users/{username}/followers` | The people following the user, newest follow first, 30 per page | +| `GET /api/v1/users/{username}/following` | The people the user follows, newest follow first, 30 per page | | `GET /api/v1/commits/{id}` | One commit with its author, public parent id, star and reply counts, link preview (with its `embed` player, if any), and images (`id`, `url`, `width`, `height`) | Lists are cursor-paginated: follow `links.next` (or pass `meta.next_cursor` as `?cursor=`) until it is `null`. @@ -82,7 +84,7 @@ The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the d Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. -A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. +A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. In a signed list of followers or following, each person says `is_followed`, whether the user follows them, and `follows_you`. `GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`. diff --git a/app/Http/Controllers/Api/UserFollowersController.php b/app/Http/Controllers/Api/UserFollowersController.php new file mode 100644 index 000000000..8e24dc7f0 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowersController.php @@ -0,0 +1,24 @@ +builder($user->followers(), $currentUser)->cursorPaginate(30)); + } +} diff --git a/app/Http/Controllers/Api/UserFollowingController.php b/app/Http/Controllers/Api/UserFollowingController.php new file mode 100644 index 000000000..4ffa7ace3 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowingController.php @@ -0,0 +1,24 @@ +builder($user->following(), $currentUser)->cursorPaginate(30)); + } +} diff --git a/app/Http/Requests/ListApiUserFollowersRequest.php b/app/Http/Requests/ListApiUserFollowersRequest.php new file mode 100644 index 000000000..8ee45a60c --- /dev/null +++ b/app/Http/Requests/ListApiUserFollowersRequest.php @@ -0,0 +1,37 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id)); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListApiUserFollowingRequest.php b/app/Http/Requests/ListApiUserFollowingRequest.php new file mode 100644 index 000000000..3d8b51960 --- /dev/null +++ b/app/Http/Requests/ListApiUserFollowingRequest.php @@ -0,0 +1,37 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id)); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 266206258..78ab541aa 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -10,7 +10,7 @@ /** * A user's public profile, with their counts when they were loaded. A signed request also gets the lane, the color - * the web draws them in. + * the web draws them in, and in a list of people, whether its user follows each one and whether each one follows them. * * @mixin User */ @@ -39,6 +39,7 @@ public function toArray(Request $request): array 'following_count' => $this->whenCounted('following'), 'lane' => $this->when($request->user() instanceof User, fn (): int => $this->lane()), 'is_followed' => $this->whenHas('is_followed'), + 'follows_you' => $this->whenHas('follows_you'), ]; } } diff --git a/app/Queries/UserConnectionsQuery.php b/app/Queries/UserConnectionsQuery.php new file mode 100644 index 000000000..ee9560b1b --- /dev/null +++ b/app/Queries/UserConnectionsQuery.php @@ -0,0 +1,33 @@ + $connections + * @return BelongsToMany + */ + public function builder(BelongsToMany $connections, ?User $viewer): BelongsToMany + { + if ($viewer instanceof User) { + $connections->withExists([ + 'followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($viewer->id), + 'following as follows_you' => fn (Builder $query): Builder => $query->whereKey($viewer->id), + ]); + } + + return $connections + ->latest('follows.created_at') + ->latest('users.id'); + } +} diff --git a/routes/api.php b/routes/api.php index 4721e9d9e..d6c6fb892 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\UserFollowersController; +use App\Http\Controllers\Api\UserFollowingController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; use App\Http\Middleware\AuthenticateSignedRequest; @@ -35,6 +37,10 @@ ->name('api-user.show'); Route::get('users/{user:username}/commits', [UserCommitController::class, 'index']) ->name('api-user-commit.index'); + Route::get('users/{user:username}/followers', [UserFollowersController::class, 'index']) + ->name('api-user-followers.index'); + Route::get('users/{user:username}/following', [UserFollowingController::class, 'index']) + ->name('api-user-following.index'); Route::get('commits/{commit}', [ApiCommitController::class, 'show']) ->name('api-commit.show'); }); diff --git a/tests/Feature/Controllers/Api/UserFollowersControllerTest.php b/tests/Feature/Controllers/Api/UserFollowersControllerTest.php new file mode 100644 index 000000000..983c8aef4 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowersControllerTest.php @@ -0,0 +1,130 @@ +create(['username' => 'octocat']); + $earlier = User::factory()->create(['bio' => 'Ships on Fridays.']); + $later = User::factory()->create(); + $user->followers()->attach($earlier, ['created_at' => now()->subMinute()]); + $user->followers()->attach($later); + $user->following()->attach(User::factory()->create()); + + $this->getJson('/api/v1/users/octocat/followers') + ->assertOk() + ->assertJsonPath('data.*.id', [$later->id, $earlier->id]) + ->assertJsonPath('data.1.username', $earlier->username) + ->assertJsonPath('data.1.bio', 'Ships on Fridays.') + ->assertJsonMissingPath('data.0.lane') + ->assertJsonMissingPath('data.0.is_followed') + ->assertJsonMissingPath('data.0.follows_you') + ->assertJsonMissingPath('data.0.email') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('says for a signed request whether its user follows each follower, and whether each follows them', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $followed = User::factory()->create(); + $stranger = User::factory()->create(); + $user->followers()->attach($stranger, ['created_at' => now()->subMinutes(2)]); + $user->followers()->attach($followed, ['created_at' => now()->subMinute()]); + $user->followers()->attach($mutual); + $viewer->following()->attach([$followed->id, $mutual->id]); + $viewer->followers()->attach($mutual); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$mutual->id, $followed->id, $stranger->id]) + ->assertJsonPath('data.*.is_followed', [true, true, false]) + ->assertJsonPath('data.*.follows_you', [true, false, false]) + ->assertJsonPath('data.0.lane', $mutual->lane()); +}); + +it('lists the signed-in user among the followers, following nobody', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->followers()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$viewer->id]) + ->assertJsonPath('data.0.is_followed', false) + ->assertJsonPath('data.0.follows_you', false); +}); + +it('pages through followers 30 at a time with a cursor', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $followers = User::factory()->count(31)->create(); + + foreach ($followers as $index => $follower) { + $user->followers()->attach($follower, ['created_at' => now()->subMinutes($index)]); + } + + $first = signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonCount(30, 'data') + ->assertJsonPath('meta.per_page', 30); + + expect($first->json('meta.next_cursor'))->toBeString(); + + signedApiRequest($keyPair, 'GET', $first->json('links.next')) + ->assertOk() + ->assertJsonPath('data.*.id', [$followers->last()->id]) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('loads a page of followers without a query per follower', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + + $queries = function () use ($keyPair, $user): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user))->assertOk(); + + return count(DB::getQueryLog()); + }; + + // The first signed request also syncs the user's keys with GitHub... + $queries(); + + $user->followers()->attach(User::factory()->create()); + $withOne = $queries(); + + $user->followers()->attach(User::factory()->count(5)->create()); + + expect($queries())->toBe($withOne); +}); + +it('lists no followers of a profile across a block for a signed request', function (Closure $block, string $code): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->followers()->attach(User::factory()->create()); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code); + + $this->getJson(route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonCount(1, 'data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked'], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable'], +]); + +it('returns not found for unknown usernames', function (): void { + $this->getJson('/api/v1/users/missing/followers')->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/Api/UserFollowingControllerTest.php b/tests/Feature/Controllers/Api/UserFollowingControllerTest.php new file mode 100644 index 000000000..b5b2cc0f4 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowingControllerTest.php @@ -0,0 +1,84 @@ +create(['username' => 'octocat']); + $earlier = User::factory()->create(); + $later = User::factory()->create(); + $user->following()->attach($earlier, ['created_at' => now()->subMinute()]); + $user->following()->attach($later); + $user->followers()->attach(User::factory()->create()); + + $this->getJson('/api/v1/users/octocat/following') + ->assertOk() + ->assertJsonPath('data.*.id', [$later->id, $earlier->id]) + ->assertJsonMissingPath('data.0.lane') + ->assertJsonMissingPath('data.0.is_followed') + ->assertJsonMissingPath('data.0.follows_you') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('says for a signed request whether its user follows each one, and whether each follows them', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $followed = User::factory()->create(); + $stranger = User::factory()->create(); + $user->following()->attach($stranger, ['created_at' => now()->subMinutes(2)]); + $user->following()->attach($followed, ['created_at' => now()->subMinute()]); + $user->following()->attach($mutual); + $viewer->following()->attach([$followed->id, $mutual->id]); + $viewer->followers()->attach($mutual); + + signedApiRequest($keyPair, 'GET', route('api-user-following.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$mutual->id, $followed->id, $stranger->id]) + ->assertJsonPath('data.*.is_followed', [true, true, false]) + ->assertJsonPath('data.*.follows_you', [true, false, false]) + ->assertJsonPath('data.0.lane', $mutual->lane()); +}); + +it('pages through the people a user follows 30 at a time', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->count(31)->create(); + + foreach ($followed as $index => $person) { + $user->following()->attach($person, ['created_at' => now()->subMinutes($index)]); + } + + $first = $this->getJson(route('api-user-following.index', $user)) + ->assertOk() + ->assertJsonCount(30, 'data'); + + $this->getJson($first->json('links.next')) + ->assertOk() + ->assertJsonPath('data.*.id', [$followed->last()->id]); +}); + +it('lists no one a profile follows across a block for a signed request', function (Closure $block, string $code): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->following()->attach(User::factory()->create()); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-following.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code); + + $this->getJson(route('api-user-following.index', $user)) + ->assertOk() + ->assertJsonCount(1, 'data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked'], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable'], +]); + +it('returns not found for unknown usernames', function (): void { + $this->getJson('/api/v1/users/missing/following')->assertNotFound(); +}); diff --git a/tests/Unit/Queries/UserConnectionsQueryTest.php b/tests/Unit/Queries/UserConnectionsQueryTest.php new file mode 100644 index 000000000..98bc52224 --- /dev/null +++ b/tests/Unit/Queries/UserConnectionsQueryTest.php @@ -0,0 +1,43 @@ +create(); + $earlier = User::factory()->create(); + $later = User::factory()->create(); + $user->followers()->attach($later); + $user->followers()->attach($earlier, ['created_at' => now()->subMinute()]); + + expect(resolve(UserConnectionsQuery::class)->builder($user->followers(), null)->get()->modelKeys()) + ->toBe([$later->id, $earlier->id]); +}); + +it('says whether the viewer follows each one, and whether each follows the viewer', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $stranger = User::factory()->create(); + $user->following()->attach($stranger, ['created_at' => now()->subMinute()]); + $user->following()->attach($mutual); + $viewer->following()->attach($mutual); + $mutual->following()->attach($viewer); + + $connections = resolve(UserConnectionsQuery::class)->builder($user->following(), $viewer)->get(); + + expect($connections->modelKeys())->toBe([$mutual->id, $stranger->id]) + ->and($connections->pluck('is_followed')->all())->toBe([true, false]) + ->and($connections->pluck('follows_you')->all())->toBe([true, false]); +}); + +it('says neither to a guest', function (): void { + $user = User::factory()->create(); + $user->following()->attach(User::factory()->create()); + + $connection = resolve(UserConnectionsQuery::class)->builder($user->following(), null)->sole(); + + expect($connection->getAttributes())->not->toHaveKeys(['is_followed', 'follows_you']); +});