From b6142a798c67368fa2b21b335fc3761b973de626 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:00:22 +0000 Subject: [PATCH] feat: pin and unpin a commit from the signed API --- KNOWLEDGE_BASE.md | 3 +- .../Controllers/Api/CommitPinController.php | 35 +++++ routes/api.php | 9 ++ .../Api/CommitPinControllerTest.php | 126 ++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/CommitPinController.php create mode 100644 tests/Feature/Controllers/Api/CommitPinControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 5c2685f8b..a01aaf0ad 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -445,12 +445,13 @@ A signed request sees what the web shows you, such as your own pending commits. - create polls on commits that are neither replies nor quotes, and vote on polls; - change Remotes settings, read and send messages with images in conversations and orgs, mark them pulled, react to messages, find people, and start orgs; - stash or reopen conversations and orgs, merge or drop message requests, and send typing indicators; +- pin or unpin one of your commits on your profile; - switch Pastéis mode and give pastéis; - report commits; - sync from GitHub and save your lane color; - delete your account. -Signed request bodies are JSON, or multipart when they carry images, so app commits can have images or a poll but no link-card choice. Voice notes, pinning, scheduling, search, and translations remain web-only. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. +Signed request bodies are JSON, or multipart when they carry images, so app commits can have images or a poll but no link-card choice. Voice notes, scheduling, search, and translations remain web-only. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. Sources: [signed requests](app/Services/SignedRequests.php), [sign-in page](app/Http/Controllers/MobileAuthorizationController.php), [devices settings](resources/js/pages/device/index.tsx), [API routes](routes/api.php). diff --git a/app/Http/Controllers/Api/CommitPinController.php b/app/Http/Controllers/Api/CommitPinController.php new file mode 100644 index 000000000..c1e18b2a1 --- /dev/null +++ b/app/Http/Controllers/Api/CommitPinController.php @@ -0,0 +1,35 @@ +handle($user, $commit); + + return CommitResource::make($page->get($commit, $user)); + } + + public function destroy(DeleteCommitPinRequest $request, #[CurrentUser] User $user, Commit $commit, DeleteCommitPin $action, CommitPageQuery $page): CommitResource + { + $action->handle($user, $commit); + + return CommitResource::make($page->get($commit, $user)); + } +} diff --git a/routes/api.php b/routes/api.php index 915587c51..b356e63a7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Exceptions\ApiException; use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; +use App\Http\Controllers\Api\CommitPinController; use App\Http\Controllers\Api\CommitPollVoteController; use App\Http\Controllers\Api\CommitPreviewController; use App\Http\Controllers\Api\CommitReplyController; @@ -155,6 +156,14 @@ ->middleware('throttle:stars') ->name('api-commit-star.destroy'); + // Commit Pin... + Route::put('commits/{commit}/pin', [CommitPinController::class, 'update']) + ->middleware('throttle:pins') + ->name('api-commit-pin.update'); + Route::delete('commits/{commit}/pin', [CommitPinController::class, 'destroy']) + ->middleware('throttle:pins') + ->name('api-commit-pin.destroy'); + // User Follow... Route::post('users/{user:username}/follow', [UserFollowController::class, 'store']) ->middleware('throttle:follows') diff --git a/tests/Feature/Controllers/Api/CommitPinControllerTest.php b/tests/Feature/Controllers/Api/CommitPinControllerTest.php new file mode 100644 index 000000000..b530afab8 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPinControllerTest.php @@ -0,0 +1,126 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('pins an own commit and answers with it pinned', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.is_pinned', true); + + expect($this->user->fresh()->pinned_commit_id)->toBe($commit->id); +}); + +it('answers with the commit as the commit endpoint shows it', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + Commit::factory()->for($commit, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)); + + $response->assertOk() + ->assertExactJson(['data' => signedApiRequest($this->keyPair, 'GET', route('api-commit.show', $commit))->json('data')]); +}); + +it('replaces the pin, as on the web', function (): void { + $first = Commit::factory()->for($this->user)->create(); + $replacement = Commit::factory()->for($this->user)->pending()->create(); + + signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $first))->assertOk(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $replacement)); + + $response->assertOk()->assertJsonPath('data.is_pinned', true); + + expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id); +}); + +it('unpins an own commit and answers with it unpinned', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $this->user->forceFill(['pinned_commit_id' => $commit->id])->save(); + + $response = signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.is_pinned', false); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +}); + +it('does not clear a replacement on a stale unpin', function (): void { + $first = Commit::factory()->for($this->user)->create(); + $replacement = Commit::factory()->for($this->user)->create(); + $this->user->forceFill(['pinned_commit_id' => $replacement->id])->save(); + + $response = signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $first)); + + $response->assertOk()->assertJsonPath('data.is_pinned', false); + + expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id); +}); + +it('may not pin or unpin the commit of someone else', function (string $method): void { + $commit = Commit::factory()->create(); + $commit->user->forceFill(['pinned_commit_id' => $commit->id])->save(); + + signedApiRequest($this->keyPair, $method, "api/v1/commits/{$commit->id}/pin")->assertForbidden(); + + expect($commit->user->fresh()->pinned_commit_id)->toBe($commit->id) + ->and($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('may not pin or unpin a reply', function (string $method): void { + $reply = Commit::factory()->for($this->user)->for(Commit::factory(), 'parent')->create(); + + signedApiRequest($this->keyPair, $method, "api/v1/commits/{$reply->id}/pin")->assertForbidden(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('may not pin or unpin a deleted commit', function (string $method): void { + $commit = Commit::factory()->for($this->user)->create(); + $commit->delete(); + + signedApiRequest($this->keyPair, $method, "api/v1/commits/{$commit->id}/pin")->assertNotFound(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('returns not found for unknown commits', function (string $method): void { + $response = signedApiRequest($this->keyPair, $method, 'api/v1/commits/0199e3a4-0000-7000-8000-000000000000/pin'); + + $response->assertNotFound(); +})->with(['PUT', 'DELETE']); + +it('requires a signed request', function (string $method): void { + $commit = Commit::factory()->for($this->user)->create(); + + $response = $this->json($method, "api/v1/commits/{$commit->id}/pin"); + + $response->assertUnauthorized(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('limits pins and unpins to 20 a minute, like the web, with a code', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + + foreach (range(1, 20) as $attempt) { + signedApiRequest($this->keyPair, $attempt % 2 === 0 ? 'DELETE' : 'PUT', route('api-commit-pin.update', $commit))->assertOk(); + } + + signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)) + ->assertTooManyRequests() + ->assertExactJson(['message' => 'Too Many Attempts.', 'code' => 'rate_limited']); +});