From feccac7ef692ebac21fa3d5c0807657f623f65dc Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Tue, 27 Oct 2026 18:33:11 +0000 Subject: [PATCH 1/3] fix: refresh ssh keys while you are signed in Fixes #1228 --- KNOWLEDGE_BASE.md | 4 +- app/Http/Middleware/QueueGitHubSshKeySync.php | 51 +++++++ app/Http/Requests/CreateKeyLookupRequest.php | 2 +- bootstrap/app.php | 2 + .../Cli/KeyLookupControllerTest.php | 6 +- tests/Feature/GitHubSshKeySyncTest.php | 133 ++++++++++++++++++ 6 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 app/Http/Middleware/QueueGitHubSshKeySync.php create mode 100644 tests/Feature/GitHubSshKeySyncTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 97b28096d7..632289f5e8 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -452,11 +452,11 @@ Source: [shortcut reference](resources/js/components/keyboard-shortcuts-dialog.t ### Terminal commits -Settings → Terminal shows how to post using the `/commit` shell script. It signs the submission locally with an Ed25519 or RSA SSH key whose public half is on your GitHub account, as an authentication or a signing key, and /dev/null verifies that signature. No browser login or API token is required for that submission; your private key is not uploaded. The script can create commits, reply with `--reply` using a commit hash or link, and attach up to three images with `--image`, without descriptions. +Settings → Terminal shows how to post using the `/commit` shell script. It signs the submission locally with an Ed25519 or RSA SSH key whose public half is on your GitHub account, as an authentication or a signing key, and /dev/null verifies that signature. Opening the site while you are signed in refreshes the keys on file, so a key added on GitHub after you signed in is recognized without signing out. No browser login or API token is required for that submission; your private key is not uploaded. The script can create commits, reply with `--reply` using a commit hash or link, and attach up to three images with `--image`, without descriptions. Before posting from the terminal or a GitHub Action, accept the [commit rules](#moderation) by making your first submission through the web composer. Terminal posts then follow the same moderation and 10-per-minute submission limit as browser posts, and always need 1–4,000 characters of text, even with images. Their first link supplies the default link card. Review a downloaded script before running it, and never paste a private key into a commit. -Sources: [terminal instructions](resources/js/pages/terminal/show.tsx), [CLI routes](routes/api.php), [signature verification](app/Http/Middleware/AuthenticateSshSignature.php). +Sources: [terminal instructions](resources/js/pages/terminal/show.tsx), [CLI routes](routes/api.php), [signature verification](app/Http/Middleware/AuthenticateSshSignature.php), [key refresh](app/Http/Middleware/QueueGitHubSshKeySync.php). Livestream history: terminal posting was reported working on [day one at 6:32:47](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=23567s). The explanation that follows describes signing with an existing SSH private key and checking the public key on GitHub, with a curl command so users would not need to install a dedicated CLI. This was signed authentication, not anonymous posting, despite the conversation's shorthand about not setting up authentication. diff --git a/app/Http/Middleware/QueueGitHubSshKeySync.php b/app/Http/Middleware/QueueGitHubSshKeySync.php new file mode 100644 index 0000000000..71125597c0 --- /dev/null +++ b/app/Http/Middleware/QueueGitHubSshKeySync.php @@ -0,0 +1,51 @@ +user(); + + $isPageVisit = $request->isMethod('GET') + && ! $request->prefetch() + && ! $request->hasHeader('X-Inertia-Partial-Data') + && ($request->hasHeader('X-Inertia') || ! $request->expectsJson()); + + if ($user instanceof User && $response->isSuccessful() && ! $request->is('api/cli/*') && (! $request->isMethod('GET') || $isPageVisit)) { + rescue(function () use ($user): void { + if (Cache::has("ssh-keys-synced:{$user->id}")) { + return; + } + + if (! Cache::add("ssh-keys-sync-queued:{$user->id}", true, now()->addMinute())) { + return; + } + + dispatch(new SyncGitHubSshKeysJob($user)); + }); + } + + return $response; + } +} diff --git a/app/Http/Requests/CreateKeyLookupRequest.php b/app/Http/Requests/CreateKeyLookupRequest.php index a3f037ae8b..5a58d64179 100644 --- a/app/Http/Requests/CreateKeyLookupRequest.php +++ b/app/Http/Requests/CreateKeyLookupRequest.php @@ -18,7 +18,7 @@ public function authorize(): Response { return $this->publicKey() instanceof SshPublicKey ? Response::allow() - : Response::denyAsNotFound('None of your SSH keys are on the GitHub account of a /dev/null user. Add your key on GitHub (Settings → SSH and GPG keys), then sign in on '.url('/').'.'); + : Response::denyAsNotFound('None of your SSH keys are on the GitHub account of a /dev/null user. Add your key on GitHub (Settings → SSH and GPG keys), open '.url('/').' while you are signed in so the key can sync, then run this again.'); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 121bc7a654..04a0b995f4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\PreventFraming; +use App\Http\Middleware\QueueGitHubSshKeySync; use App\Http\Middleware\RedirectBannedUsers; use App\Http\Middleware\SerializePushSessions; use App\Http\Middleware\StripNullBytes; @@ -58,6 +59,7 @@ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, UpdateLastSeenAt::class, + QueueGitHubSshKeySync::class, RedirectBannedUsers::class, ]); diff --git a/tests/Feature/Controllers/Cli/KeyLookupControllerTest.php b/tests/Feature/Controllers/Cli/KeyLookupControllerTest.php index 2bb5d1d7ca..bdde483a2d 100644 --- a/tests/Feature/Controllers/Cli/KeyLookupControllerTest.php +++ b/tests/Feature/Controllers/Cli/KeyLookupControllerTest.php @@ -29,11 +29,15 @@ }); it('fails when none of the keys are on the github account of a user', function (): void { + Http::fake(); + $response = $this->call('POST', route('cli-key-lookup.store'), content: SshKeyPair::generate()->publicKey->toOpenSsh()); $response->assertNotFound() ->assertHeader('Content-Type', 'text/plain; charset=utf-8') - ->assertSee('None of your SSH keys are on the GitHub account of a /dev/null user.'); + ->assertContent('None of your SSH keys are on the GitHub account of a /dev/null user. Add your key on GitHub (Settings → SSH and GPG keys), open '.url('/')." while you are signed in so the key can sync, then run this again.\n"); + + Http::assertNothingSent(); }); it('only considers the first 20 keys', function (): void { diff --git a/tests/Feature/GitHubSshKeySyncTest.php b/tests/Feature/GitHubSshKeySyncTest.php new file mode 100644 index 0000000000..924fda0723 --- /dev/null +++ b/tests/Feature/GitHubSshKeySyncTest.php @@ -0,0 +1,133 @@ +user = User::factory()->create(); +}); + +it('queues a sync of the github ssh keys when a signed in user opens a page', function (): void { + Queue::fake(); + Http::fake(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + + Queue::assertPushed(SyncGitHubSshKeysJob::class, fn (SyncGitHubSshKeysJob $job): bool => $job->user->is($this->user)); + Http::assertNothingSent(); +}); + +it('does not queue another sync when the keys were just synced', function (): void { + Queue::fake(); + Cache::put("ssh-keys-synced:{$this->user->id}", true, now()->addMinutes(5)); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + +it('does not queue another sync while one is already queued', function (): void { + Queue::fake(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + + Queue::assertPushed(SyncGitHubSshKeysJob::class, 1); +}); + +it('queues the sync again after the short lock expires', function (): void { + Queue::fake(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + + $this->travel(61)->seconds(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + + Queue::assertPushed(SyncGitHubSshKeysJob::class, 2); +}); + +it('does not queue a sync for what a page requests on its own', function (array $headers): void { + Queue::fake(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following), $headers); + + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +})->with([ + 'a partial reload' => [['X-Inertia' => 'true', 'X-Inertia-Partial-Component' => 'log/show', 'X-Inertia-Partial-Data' => 'commits']], + 'a prefetch' => [['Purpose' => 'prefetch']], + 'a json request' => [['Accept' => 'application/json']], +]); + +it('does not queue a sync for a request that fails', function (): void { + Queue::fake(); + + $this->actingAs($this->user)->get(route('operations.show'))->assertNotFound(); + + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + +it('queues a sync when a signed in user does something without opening a page', function (): void { + Queue::fake(); + + $this->actingAs($this->user) + ->postJson(route('commit-star.store', Commit::factory()->create())) + ->assertOk(); + + Queue::assertPushed(SyncGitHubSshKeysJob::class, 1); +}); + +it('does not queue a sync for a guest', function (): void { + Queue::fake(); + + $this->get(route('log.show', Log::Trending))->assertOk(); + + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + +it('does not call github when looking up an unknown key', function (): void { + Queue::fake(); + Http::fake(); + + $this->call('POST', route('cli-key-lookup.store'), content: SshKeyPair::generate()->publicKey->toOpenSsh()) + ->assertNotFound(); + + Http::assertNothingSent(); + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + +it('does not queue a sync for a terminal commit', function (): void { + Queue::fake(); + $keyPair = SshKeyPair::generate(); + SshKey::factory()->for($this->user)->withPublicKey($keyPair->publicKey)->create(); + fakeGitHubSshKeys($this->user, [$keyPair->publicKey]); + $commit = signedCommit($keyPair, 'It was DNS.'); + + $this->call('POST', route('cli-commit.store'), server: [ + 'CONTENT_TYPE' => 'text/plain; charset=utf-8', + 'HTTP_X_SSH_SIGNATURE' => $commit['signature'], + ], content: $commit['payload'])->assertCreated(); + + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + +it('reports a failure to queue the sync without breaking the page', function (): void { + Exceptions::fake(); + Schema::drop('jobs'); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + + Exceptions::assertReported(QueryException::class); +}); From 8759e00b05e59dfa7c010dd106deb4b98e302162 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Wed, 28 Oct 2026 09:00:38 +0000 Subject: [PATCH 2/3] fix: skip ssh key sync on partial and json polls A partial reload that only names props to leave out, and a status poll that returns JSON without asking for it, were counted as page visits. Fixes #1228 --- app/Http/Middleware/QueueGitHubSshKeySync.php | 14 +++++---- tests/Feature/GitHubSshKeySyncTest.php | 29 ++++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/Http/Middleware/QueueGitHubSshKeySync.php b/app/Http/Middleware/QueueGitHubSshKeySync.php index 71125597c0..3e6aae4096 100644 --- a/app/Http/Middleware/QueueGitHubSshKeySync.php +++ b/app/Http/Middleware/QueueGitHubSshKeySync.php @@ -9,16 +9,19 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; final readonly class QueueGitHubSshKeySync { /** * A signed-in visit queues a refresh of that person's GitHub SSH keys, so a key added on GitHub shows up - * without signing out. Polling, partial reloads and prefetches are not visits, or a tab left open would ask - * GitHub forever, and a terminal request is not one either. A visit skips the queue when a sync just finished, - * and otherwise only the first visit in a minute queues one. A failure is reported, never thrown: refreshing - * keys must not break the page. + * without signing out. Polling, partial reloads (including one that only names props to leave out) and prefetches + * are not visits, or a tab left open would ask GitHub forever. A request that asks for JSON, and a JSON response, + * are not visits either, unless the request is a full Inertia visit, and a terminal request is not one. A visit + * skips the queue when a sync just finished, and + * otherwise only the first visit in a minute queues one. A failure is reported, never thrown: refreshing keys + * must not break the page. * * @param Closure(Request): (Response) $next */ @@ -30,7 +33,8 @@ public function handle(Request $request, Closure $next): Response $isPageVisit = $request->isMethod('GET') && ! $request->prefetch() && ! $request->hasHeader('X-Inertia-Partial-Data') - && ($request->hasHeader('X-Inertia') || ! $request->expectsJson()); + && ! $request->hasHeader('X-Inertia-Partial-Except') + && ($request->hasHeader('X-Inertia') || (! $request->expectsJson() && ! $response instanceof JsonResponse)); if ($user instanceof User && $response->isSuccessful() && ! $request->is('api/cli/*') && (! $request->isMethod('GET') || $isPageVisit)) { rescue(function () use ($user): void { diff --git a/tests/Feature/GitHubSshKeySyncTest.php b/tests/Feature/GitHubSshKeySyncTest.php index 924fda0723..7dc1a73713 100644 --- a/tests/Feature/GitHubSshKeySyncTest.php +++ b/tests/Feature/GitHubSshKeySyncTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\Log; +use App\Http\Middleware\HandleInertiaRequests; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\Commit; use App\Models\SshKey; @@ -62,11 +63,16 @@ it('does not queue a sync for what a page requests on its own', function (array $headers): void { Queue::fake(); - $this->actingAs($this->user)->get(route('log.show', Log::Following), $headers); + if (array_key_exists('X-Inertia', $headers)) { + $headers['X-Inertia-Version'] = (string) new HandleInertiaRequests()->version(request()); + } + + $this->actingAs($this->user)->get(route('log.show', Log::Following), $headers)->assertOk(); Queue::assertNotPushed(SyncGitHubSshKeysJob::class); })->with([ 'a partial reload' => [['X-Inertia' => 'true', 'X-Inertia-Partial-Component' => 'log/show', 'X-Inertia-Partial-Data' => 'commits']], + 'an except-only partial reload' => [['X-Inertia' => 'true', 'X-Inertia-Partial-Component' => 'log/show', 'X-Inertia-Partial-Except' => 'commits']], 'a prefetch' => [['Purpose' => 'prefetch']], 'a json request' => [['Accept' => 'application/json']], ]); @@ -79,6 +85,27 @@ Queue::assertNotPushed(SyncGitHubSshKeysJob::class); }); +it('queues a sync for a full inertia visit', function (): void { + Queue::fake(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following), [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), + ])->assertOk(); + + Queue::assertPushed(SyncGitHubSshKeysJob::class, 1); +}); + +it('does not queue a sync for the commit status poll', function (): void { + Queue::fake(); + + $this->actingAs($this->user) + ->get(route('commit-status.show', Commit::factory()->for($this->user)->create())) + ->assertOk(); + + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + it('queues a sync when a signed in user does something without opening a page', function (): void { Queue::fake(); From ffa867e9e6f0ba56c5eea148866837d77843653d Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Fri, 6 Nov 2026 09:29:51 +0000 Subject: [PATCH 3/3] fix: refresh ssh keys at most once an hour A signed-in page load queued a GitHub sync about every five minutes, including stars, and each sync spends three requests on the shared quota. Only a real page visit queues one now, and the next one waits an hour. Sync from GitHub still refreshes the keys immediately. --- KNOWLEDGE_BASE.md | 2 +- app/Http/Middleware/QueueGitHubSshKeySync.php | 18 +++++++++--------- tests/Feature/GitHubSshKeySyncTest.php | 12 +++++++++--- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9b773ed5b3..abefb004ed 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -549,7 +549,7 @@ Source: [shortcut reference](resources/js/components/keyboard-shortcuts-dialog.t ### Terminal commits -Settings → Terminal shows how to post using the `/commit` shell script. It signs the submission locally with an Ed25519 or RSA SSH key whose public half is on your GitHub account, as an authentication or a signing key, and /dev/null verifies that signature. Opening the site while you are signed in refreshes the keys on file, so a key added on GitHub after you signed in is recognized without signing out. No browser login or API token is required for that submission; your private key is not uploaded. The script can create commits, reply with `--reply` using a commit hash or link, and attach up to three images with `--image`, without descriptions. +Settings → Terminal shows how to post using the `/commit` shell script. It signs the submission locally with an Ed25519 or RSA SSH key whose public half is on your GitHub account, as an authentication or a signing key, and /dev/null verifies that signature. Opening the site while you are signed in refreshes the keys on file at most once an hour, so a key added on GitHub after you signed in is recognized without signing out. **Sync from GitHub** on Settings → Profile refreshes them right away. No browser login or API token is required for that submission; your private key is not uploaded. The script can create commits, reply with `--reply` using a commit hash or link, and attach up to three images with `--image`, without descriptions. Before posting from the terminal or a GitHub Action, accept the [commit rules](#moderation) by making your first submission through the web composer. Terminal posts then follow the same moderation and 10-per-minute submission limit as browser posts, and always need 1–4,000 characters of text, even with images. Their first link supplies the default link card. Review a downloaded script before running it, and never paste a private key into a commit. diff --git a/app/Http/Middleware/QueueGitHubSshKeySync.php b/app/Http/Middleware/QueueGitHubSshKeySync.php index 3e6aae4096..dd5f39867a 100644 --- a/app/Http/Middleware/QueueGitHubSshKeySync.php +++ b/app/Http/Middleware/QueueGitHubSshKeySync.php @@ -15,13 +15,13 @@ final readonly class QueueGitHubSshKeySync { /** - * A signed-in visit queues a refresh of that person's GitHub SSH keys, so a key added on GitHub shows up - * without signing out. Polling, partial reloads (including one that only names props to leave out) and prefetches - * are not visits, or a tab left open would ask GitHub forever. A request that asks for JSON, and a JSON response, - * are not visits either, unless the request is a full Inertia visit, and a terminal request is not one. A visit - * skips the queue when a sync just finished, and - * otherwise only the first visit in a minute queues one. A failure is reported, never thrown: refreshing keys - * must not break the page. + * A signed-in page visit queues a refresh of that person's GitHub SSH keys, so a key added on GitHub shows up + * without signing out. One refresh costs three GitHub requests on the shared hourly quota, so only a real page + * load counts. A star or another action does not, nor does polling, a partial reload (including one that only + * names props to leave out), a prefetch, or JSON. A full Inertia visit does, and a terminal request does not. + * A visit skips the queue when a sync just finished, and otherwise only the first visit in an hour queues one. + * Sync from GitHub on the profile still refreshes the keys right away. A failure is reported, never thrown: + * refreshing keys must not break the page. * * @param Closure(Request): (Response) $next */ @@ -36,13 +36,13 @@ public function handle(Request $request, Closure $next): Response && ! $request->hasHeader('X-Inertia-Partial-Except') && ($request->hasHeader('X-Inertia') || (! $request->expectsJson() && ! $response instanceof JsonResponse)); - if ($user instanceof User && $response->isSuccessful() && ! $request->is('api/cli/*') && (! $request->isMethod('GET') || $isPageVisit)) { + if ($user instanceof User && $response->isSuccessful() && $isPageVisit && ! $request->is('api/cli/*')) { rescue(function () use ($user): void { if (Cache::has("ssh-keys-synced:{$user->id}")) { return; } - if (! Cache::add("ssh-keys-sync-queued:{$user->id}", true, now()->addMinute())) { + if (! Cache::add("ssh-keys-sync-queued:{$user->id}", true, now()->addHour())) { return; } diff --git a/tests/Feature/GitHubSshKeySyncTest.php b/tests/Feature/GitHubSshKeySyncTest.php index 7dc1a73713..bea18b0935 100644 --- a/tests/Feature/GitHubSshKeySyncTest.php +++ b/tests/Feature/GitHubSshKeySyncTest.php @@ -48,7 +48,7 @@ Queue::assertPushed(SyncGitHubSshKeysJob::class, 1); }); -it('queues the sync again after the short lock expires', function (): void { +it('queues the sync again after an hour', function (): void { Queue::fake(); $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); @@ -57,6 +57,12 @@ $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + Queue::assertPushed(SyncGitHubSshKeysJob::class, 1); + + $this->travel(1)->hour(); + + $this->actingAs($this->user)->get(route('log.show', Log::Following))->assertOk(); + Queue::assertPushed(SyncGitHubSshKeysJob::class, 2); }); @@ -106,14 +112,14 @@ Queue::assertNotPushed(SyncGitHubSshKeysJob::class); }); -it('queues a sync when a signed in user does something without opening a page', function (): void { +it('does not queue a sync when a signed in user does something without opening a page', function (): void { Queue::fake(); $this->actingAs($this->user) ->postJson(route('commit-star.store', Commit::factory()->create())) ->assertOk(); - Queue::assertPushed(SyncGitHubSshKeysJob::class, 1); + Queue::assertNotPushed(SyncGitHubSshKeysJob::class); }); it('does not queue a sync for a guest', function (): void {