From fb87a6a2732ab02865480d9b752f11698b54aa7b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:09:12 +0100 Subject: [PATCH 1/2] feat: preview a commit from the API The app's composer can show a draft as the commit it would become: its body as it would be kept, its segments and excerpt, and its mentions. Nothing is kept, so previews don't count towards the commit limit. --- PRODUCT.md | 2 +- README.md | 2 + .../Api/CommitPreviewController.php | 30 +++++++++ .../CreateApiCommitPreviewRequest.php | 34 ++++++++++ routes/api.php | 4 ++ .../Api/CommitPreviewControllerTest.php | 66 +++++++++++++++++++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/CommitPreviewController.php create mode 100644 app/Http/Requests/CreateApiCommitPreviewRequest.php create mode 100644 tests/Feature/Controllers/Api/CommitPreviewControllerTest.php diff --git a/PRODUCT.md b/PRODUCT.md index 43b6a160b..ac9cae89a 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 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, notifications, saved commits, mutes and blocks, and can commit, reply, quote, delete, follow, star, save, mute, block switch pastéis mode and give pastéis, and report commits, 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, notifications, saved commits, mutes and blocks, and can preview a draft, commit, reply, quote, delete, follow, star, save, mute, block switch pastéis mode and give pastéis, and report commits, 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. diff --git a/README.md b/README.md index fcc948e5b..f6a1ac281 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ A signed profile also says `is_muting` and has the web's `achievements`, with th `POST /api/v1/commits` takes a `body`, and either a `parent_id` to reply to a commit or a `quoted_commit_id` to quote one, but not both. Returns a `201` on successful commit creation. +`POST /api/v1/commits/preview` shows a draft `body` as the commit it would become, like the web composer's preview, without keeping anything or counting towards the commit limit. It answers with the `body` as it would be kept, its `body_segments` and `preview_segments`, and its `mentions`, as a commit has them. + `POST /api/v1/commits/{id}/reports` reports a commit to the moderators, privately, as the web's Report button does. It takes a `reason`, one of `spam`, `harassment`, `unsafe` or `other`, and optional `details` of up to 500 characters, and answers `204`. Reporting doesn't hide the commit. Only someone else's published commit can be reported: the user's own is a `403` with `own_commit`, and one that is pending, rejected, deleted, missing or across a block is a `404` with `commit_unavailable`. Reporting a commit again also answers `204` and keeps the first report. Each user can send 5 reports a minute. `GET /api/v1/me` also has what only the user sees in the web's profile settings: `preferred_lane` (a lane, a custom `#rrggbb` color, or `null` for Automatic), the `automatic_lane` Automatic picks for them, their `sponsors_url` (or `null`), and `github_synced_at`, when their profile was last synced from GitHub (`null` if it only was when they signed in). `PUT /api/v1/me/github` syncs it from GitHub again, like Sync from GitHub on the web, and shares its limit of one sync a minute. `PUT /api/v1/me/lane` saves their color, with a `lane` from `0` to `5`, or `null` for Automatic, or a custom `color` as lowercase `#rrggbb` with a `null` lane, as the web's color picker does. Both answer like `GET /api/v1/me`. diff --git a/app/Http/Controllers/Api/CommitPreviewController.php b/app/Http/Controllers/Api/CommitPreviewController.php new file mode 100644 index 000000000..bc74dddb4 --- /dev/null +++ b/app/Http/Controllers/Api/CommitPreviewController.php @@ -0,0 +1,30 @@ +replace($request->string('body')->value()); + + return response()->json(['data' => [ + 'body' => $body, + ...$commitBodies->segments($body), + 'mentions' => $mentions->positions($body), + ]]); + } +} diff --git a/app/Http/Requests/CreateApiCommitPreviewRequest.php b/app/Http/Requests/CreateApiCommitPreviewRequest.php new file mode 100644 index 000000000..2c780504c --- /dev/null +++ b/app/Http/Requests/CreateApiCommitPreviewRequest.php @@ -0,0 +1,34 @@ +> + */ + public function rules(): array + { + return [ + 'body' => ['required', 'string', 'max:'.Commit::MAX_BODY_LENGTH], + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 63a0249e2..981560237 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\BookmarkController; use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; +use App\Http\Controllers\Api\CommitPreviewController; use App\Http\Controllers\Api\CommitReplyController; use App\Http\Controllers\Api\CommitReportController; use App\Http\Controllers\Api\CommitStarController; @@ -87,6 +88,9 @@ ->name('api-commit.store'); Route::delete('commits/{commit}', [ApiCommitController::class, 'destroy'])->name('api-commit.destroy'); + // Commit Preview: a draft as the commit it would become, which keeps nothing... + Route::post('commits/preview', [CommitPreviewController::class, 'store'])->name('api-commit-preview.store'); + // Commit Reply... Route::get('commits/{commit}/replies', [CommitReplyController::class, 'index']) ->withTrashed() diff --git a/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php b/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php new file mode 100644 index 000000000..c9988ed86 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php @@ -0,0 +1,66 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('previews a draft the way the commit it becomes is shown', function (string $body): void { + $preview = signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => $body]); + + $commit = signedApiRequest($this->keyPair, 'POST', route('api-commit.store'), ['body' => $body])->json('data'); + + $preview->assertOk() + ->assertExactJson(['data' => [ + 'body' => $commit['body'], + 'body_segments' => $commit['body_segments'], + 'preview_segments' => $commit['preview_segments'], + 'mentions' => $commit['mentions'], + ]]); +})->with([ + 'code, mentions and emoji' => " shipped :rocket: with @nunomaduro\n```php\n\$name = 'devnull';\n```\nrun `composer test` :fire: ", + 'runs of blank lines' => "Shipped it.\n\n\n\nFinally.", + 'too long to show whole in a list' => implode("\n", array_fill(0, 12, 'One more line of the changelog.')), +]); + +it('folds a long draft as a list would, and leaves a short one whole', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => str_repeat('word ', 200)]) + ->assertOk() + ->assertJsonPath('data.preview_segments.0.type', 'text'); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => 'It was DNS.']) + ->assertOk() + ->assertJsonPath('data.body_segments', [['type' => 'text', 'text' => 'It was DNS.']]) + ->assertJsonPath('data.preview_segments', null) + ->assertJsonPath('data.mentions', []); +}); + +it('previews drafts without committing them or spending the commit budget', function (): void { + foreach (range(1, 11) as $draft) { + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => "Draft {$draft}"])->assertOk(); + } + + expect(Commit::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit.store'), ['body' => 'Shipped it.'])->assertCreated(); +}); + +it('requires a body the composer could commit', function (array $data, string $message): void { + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors(['body' => $message]); +})->with([ + 'missing' => [[], 'The body field is required.'], + 'blank' => [['body' => " \n "], 'The body field is required.'], + 'too long' => [['body' => str_repeat('a', Commit::MAX_BODY_LENGTH + 1)], 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.'], +]); + +it('requires a signed request', function (): void { + $this->postJson(route('api-commit-preview.store'), ['body' => 'It was DNS.']) + ->assertUnauthorized(); +}); From 5fbd9ccaeda090be56e0f9210bdd9e6f6729e60a Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 22 Oct 2026 08:10:07 +0200 Subject: [PATCH 2/2] fix: align API preview validation with commits --- KNOWLEDGE_BASE.md | 1 + .../CreateApiCommitPreviewRequest.php | 6 ++--- .../Api/CommitPreviewControllerTest.php | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9a0f5fd9f..385288ced 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -437,6 +437,7 @@ A signed request sees what the web shows you, such as your own pending commits. - read your account, logs, replies, notifications, and saved commits; - commit, reply, and quote, with images, and delete your commits; +- preview commit drafts without publishing them; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; - star or unstar, and save or unsave; - create polls on commits that are neither replies nor quotes, and vote on polls; diff --git a/app/Http/Requests/CreateApiCommitPreviewRequest.php b/app/Http/Requests/CreateApiCommitPreviewRequest.php index 2c780504c..849c11962 100644 --- a/app/Http/Requests/CreateApiCommitPreviewRequest.php +++ b/app/Http/Requests/CreateApiCommitPreviewRequest.php @@ -4,7 +4,7 @@ namespace App\Http\Requests; -use App\Models\Commit; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; final class CreateApiCommitPreviewRequest extends FormRequest @@ -23,12 +23,12 @@ public function prepareForValidation(): void } /** - * @return array> + * @return array> */ public function rules(): array { return [ - 'body' => ['required', 'string', 'max:'.Commit::MAX_BODY_LENGTH], + 'body' => CreateCommitRequest::bodyRules(), ]; } } diff --git a/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php b/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php index c9988ed86..79d38ec4e 100644 --- a/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php @@ -2,8 +2,12 @@ declare(strict_types=1); +use App\Jobs\UnfurlLink; use App\Models\Commit; use App\Models\User; +use App\Rules\CommitBodyLength; +use App\Services\Urls; +use Illuminate\Support\Facades\Queue; beforeEach(function (): void { $this->user = User::factory()->create(); @@ -28,6 +32,22 @@ 'too long to show whole in a list' => implode("\n", array_fill(0, 12, 'One more line of the changelog.')), ]); +it('previews publishable text longer than 4000 raw characters when it contains links', function (): void { + Queue::fake([UnfurlLink::class]); + $body = implode(' ', array_fill(0, 3, mb_str_pad('https://example.com/', 1400, 'a'))); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => $body]) + ->assertOk() + ->assertJsonPath('data.body', $body); + + expect(Commit::query()->exists())->toBeFalse(); + Queue::assertNothingPushed(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit.store'), ['body' => $body]) + ->assertCreated() + ->assertJsonPath('data.body', $body); +}); + it('folds a long draft as a list would, and leaves a short one whole', function (): void { signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => str_repeat('word ', 200)]) ->assertOk() @@ -58,6 +78,8 @@ 'missing' => [[], 'The body field is required.'], 'blank' => [['body' => " \n "], 'The body field is required.'], 'too long' => [['body' => str_repeat('a', Commit::MAX_BODY_LENGTH + 1)], 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.'], + 'oversized link' => [['body' => mb_str_pad('https://example.com/', Urls::MAX_LENGTH + 1, 'a')], 'Each link must not be greater than '.Urls::MAX_LENGTH.' characters.'], + 'raw body too long' => [['body' => implode(' ', array_fill(0, 20, mb_str_pad('https://example.com/', Urls::MAX_LENGTH, 'a')))], 'The body field must not be greater than '.CommitBodyLength::MAX_RAW_LENGTH.' characters.'], ]); it('requires a signed request', function (): void {