diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 4f6391d9f7..fd659d9bd2 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -549,11 +549,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 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. -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..dd5f39867a --- /dev/null +++ b/app/Http/Middleware/QueueGitHubSshKeySync.php @@ -0,0 +1,55 @@ +user(); + + $isPageVisit = $request->isMethod('GET') + && ! $request->prefetch() + && ! $request->hasHeader('X-Inertia-Partial-Data') + && ! $request->hasHeader('X-Inertia-Partial-Except') + && ($request->hasHeader('X-Inertia') || (! $request->expectsJson() && ! $response instanceof JsonResponse)); + + 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()->addHour())) { + return; + } + + dispatch(new SyncGitHubSshKeysJob($user)); + }); + } + + return $response; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index bdc4945dfc..ab9de56a10 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; @@ -64,6 +65,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 93c03a312e..6a41f20615 100644 --- a/tests/Feature/Controllers/Cli/KeyLookupControllerTest.php +++ b/tests/Feature/Controllers/Cli/KeyLookupControllerTest.php @@ -31,12 +31,16 @@ }); 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.') ->assertSee('then click Sync from GitHub on '.route('user-profile.edit').'.'); + + Http::assertNothingSent(); }); it('finds a key added on github after signing in once the user syncs from github', function (): void { diff --git a/tests/Feature/GitHubSshKeySyncTest.php b/tests/Feature/GitHubSshKeySyncTest.php new file mode 100644 index 0000000000..bea18b0935 --- /dev/null +++ b/tests/Feature/GitHubSshKeySyncTest.php @@ -0,0 +1,166 @@ +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 an hour', 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, 1); + + $this->travel(1)->hour(); + + $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(); + + 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']], +]); + +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 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('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::assertNotPushed(SyncGitHubSshKeysJob::class); +}); + +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); +});