Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, and saved commits, and commit, reply, delete, follow, star, and save, 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, and saved commits, and commit, reply, delete, follow, star, save, switch pastéis mode, and give pastéis, 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 `<link rel="alternate">` 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.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,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 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, saved commits, committing, deleting, following, starring and saving 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, saved commits, committing, deleting, following, starring, saving and pastéis 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. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web.

Expand Down Expand Up @@ -128,7 +128,8 @@ An error is JSON with a `message` to show the user. The errors the app has to te
| `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, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. |
| `422` | `pasteis_exhausted` | `POST /api/v1/commits/{id}/pasteis` only: the user gave all 10 of today's pastéis. They come back at 00:00 UTC. |
| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, 30 follows and unfollows a minute, 60 stars and unstars a minute, 60 saves and unsaves a minute, 60 pastéis a minute, one profile sync a minute, or 60 failed signatures a minute from one IP. `Retry-After` says when to try again. |
| `503` | `github_sync_failed` | `PUT /api/v1/me/github` only: GitHub couldn't be reached, or didn't answer as expected. Nothing changed. |

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.
Expand Down
2 changes: 2 additions & 0 deletions app/Enums/ApiErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum ApiErrorCode: string
case InvalidGrant = 'invalid_grant';
case UserBlocked = 'user_blocked';
case UserUnavailable = 'user_unavailable';
case PasteisExhausted = 'pasteis_exhausted';
case RateLimited = 'rate_limited';
case GitHubSyncFailed = 'github_sync_failed';

Expand All @@ -24,6 +25,7 @@ public function status(): int
self::InvalidGrant => 400,
self::InvalidSignature, self::DeviceNotApproved => 401,
self::UserBlocked, self::UserUnavailable => 403,
self::PasteisExhausted => 422,
self::RateLimited => 429,
self::GitHubSyncFailed => 503,
};
Expand Down
8 changes: 8 additions & 0 deletions app/Exceptions/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ 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.'));
}

/**
* The user gave every pastel they get today. The web's own words, which say when they come back.
*/
public static function pasteisExhausted(): self
{
return new self(ApiErrorCode::PasteisExhausted, __('you ran out of pastéis. wait until tomorrow.'));
}

/**
* The profile could not be mirrored from GitHub again, as the web says it.
*/
Expand Down
38 changes: 38 additions & 0 deletions app/Http/Controllers/Api/CommitPastelController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateCommitPastel;
use App\Exceptions\ApiException;
use App\Http\Requests\CreateApiCommitPastelRequest;
use App\Http\Resources\CommitResource;
use App\Models\Commit;
use App\Models\User;
use App\Queries\CommitPageQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class CommitPastelController
{
/**
* Gives the commit one pastel from the user's daily allowance, as the web's pastel button does. It answers with the
* commit as its page shows it, so the app gets the new pasteis_count, with how many pastéis the user has left
* today and how many its author has been given in all. With none left, it is a pasteis_exhausted error.
*/
public function store(CreateApiCommitPastelRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommitPastel $action, CommitPageQuery $page): CommitResource|JsonResponse
{
if (! $action->handle($user, $commit)) {
return ApiException::pasteisExhausted()->render();
}

$commit = $page->get($commit, $user);

return CommitResource::make($commit)->additional([
'pasteis_remaining' => $user->refresh()->pasteisRemaining(),
'pasteis_resets_at' => today()->addDay()->toIso8601ZuluString(),
'author_pasteis_received' => $commit->user?->pasteis_received,
]);
}
}
7 changes: 4 additions & 3 deletions app/Http/Controllers/Api/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
final readonly class UserController
{
/**
* 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.
* A signed request gets the profile as the web shows it to that user, with whether they mute it, its achievements
* and Unix rank, and how many pastéis it was given, which the app only shows in pastéis mode, as the web does. 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, UserAchievementsQuery $achievements, PinnedCommitQuery $pinned, ReplyPreviewsQuery $replyPreviews): UserResource
{
Expand All @@ -36,6 +36,7 @@ public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentU
'is_following' => $currentUser->isFollowing($user),
'follows_you' => $user->isFollowing($currentUser),
'is_muting' => $currentUser->isMuting($user),
'pasteis_received' => $user->pasteis_received,
'activity' => $activity->get($user, $currentUser),
'streaks' => $userStreaks,
'achievements' => $achievements->get($user, $currentUser, $userStreaks),
Expand Down
27 changes: 27 additions & 0 deletions app/Http/Controllers/Api/UserPasteisModeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\UpdateUserPasteisMode;
use App\Http\Requests\UpdateApiUserPasteisModeRequest;
use App\Http\Resources\CurrentUserResource;
use App\Models\User;
use App\Queries\NotificationGroupsQuery;
use Illuminate\Container\Attributes\CurrentUser;

final readonly class UserPasteisModeController
{
/**
* Turns pastéis mode on or off, as the switch in the web's settings does, and answers as GET /api/v1/me does.
*/
public function update(UpdateApiUserPasteisModeRequest $request, #[CurrentUser] User $user, UpdateUserPasteisMode $action, NotificationGroupsQuery $notifications): CurrentUserResource
{
$action->handle($user, $request->boolean('enabled'));

return CurrentUserResource::make($user->loadCounts())->additional([
'unread_notifications_count' => $notifications->unreadCount($user),
]);
}
}
41 changes: 41 additions & 0 deletions app/Http/Requests/CreateApiCommitPastelRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Commit;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class CreateApiCommitPastelRequest extends FormRequest
{
/**
* Like on the web, only a user with pastéis mode on may give pastéis, and never to their own commit. A commit they
* may not see, or whose author is gone, looks like it does not exist.
*/
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$commit = $this->route('commit');
assert($commit instanceof Commit);

return match (true) {
! $user->pasteis_mode => Response::deny(__('Turn on pastéis mode to give pastéis.')),
$commit->user_id === $user->id => Response::deny(__('You cannot award your own commit.')),
$commit->user_id === null, ! $commit->isVisibleTo($user) => Response::denyAsNotFound(),
default => Response::allow(),
};
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
25 changes: 25 additions & 0 deletions app/Http/Requests/UpdateApiUserPasteisModeRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class UpdateApiUserPasteisModeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, list<string>>
*/
public function rules(): array
{
return [
'enabled' => ['required', 'boolean'],
];
}
}
15 changes: 8 additions & 7 deletions app/Http/Resources/CommitResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
*
* 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. 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.
* blocks, where it mentions someone, its status, whether the author pinned it, whether the user starred or saved it,
* and how many pastéis it was given, which the app only shows in pastéis mode, as the web does. 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.
Expand Down Expand Up @@ -75,6 +75,7 @@ public function toArray(Request $request): array
'is_pinned' => $this->user?->pinned_commit_id === $this->id,
'starred' => $this->whenHas('starred'),
'is_saved' => $this->whenHas('is_saved'),
'pasteis_count' => $this->pasteis_count,
'deleted_at' => null,
'parent' => $this->whenLoaded('parent', fn (Commit $parent): self => self::make($parent)),
'quoted_commit_id' => $this->quoted_commit_id,
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Resources/CurrentUserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public function toArray(Request $request): array
{
return [
...UserResource::make($this->resource)->toArray($request),
'pasteis_mode' => $this->pasteis_mode,
'pasteis_remaining' => $this->pasteisRemaining(),
'pasteis_resets_at' => today()->addDay()->toIso8601ZuluString(),
'pasteis_received' => $this->pasteis_received,
'preferred_lane' => $this->preferredLane(),
'automatic_lane' => $this->automaticLane(),
'sponsors_url' => $this->has_sponsors_listing ? "https://github.com/sponsors/{$this->username}" : null,
Expand Down
10 changes: 10 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,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\CommitReplyController;
use App\Http\Controllers\Api\CommitStarController;
use App\Http\Controllers\Api\CurrentUserController;
Expand All @@ -15,6 +16,7 @@
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\UserFollowController;
use App\Http\Controllers\Api\UserLaneController;
use App\Http\Controllers\Api\UserPasteisModeController;
use App\Http\Controllers\Api\UserProfileController;
use App\Http\Controllers\Cli\CommitController;
use App\Http\Controllers\Cli\KeyLookupController;
Expand Down Expand Up @@ -53,6 +55,9 @@
Route::middleware(AuthenticateSignedRequest::class)->group(function (): void {
Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show');
Route::delete('me', [CurrentUserController::class, 'destroy'])->name('api-current-user.destroy');

// Pastéis...
Route::put('me/pasteis-mode', [UserPasteisModeController::class, 'update'])->name('api-user-pasteis-mode.update');

// User Profile: mirrored from GitHub again, as Sync from GitHub does on the web...
Route::put('me/github', [UserProfileController::class, 'update'])
Expand All @@ -76,6 +81,11 @@
->withTrashed()
->name('api-commit-reply.index');

// Commit Pastel...
Route::post('commits/{commit}/pasteis', [CommitPastelController::class, 'store'])
->middleware('throttle:60,1')
->name('api-commit-pastel.store');

// Private saved commits...
Route::get('saved', [BookmarkController::class, 'index'])->name('api-bookmark.index');
Route::put('commits/{commit}/bookmark', [BookmarkController::class, 'store'])
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/Controllers/Api/BookmarkControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
'is_pinned' => false,
'starred' => true,
'is_saved' => true,
'pasteis_count' => 0,
'deleted_at' => null,
'parent' => null,
'quoted_commit_id' => null,
Expand Down
3 changes: 3 additions & 0 deletions tests/Feature/Controllers/Api/CommitControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
$commit = Commit::factory()->for($user)->for($root, 'parent')->create([
'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b',
'body' => 'Ask @nunomaduro about `@Override`',
'pasteis_count' => 4,
]);
$image = CommitImage::factory()->for($commit)->create();
Commit::factory()->for($commit, 'parent')->create();
Expand Down Expand Up @@ -257,6 +258,7 @@
'is_pinned' => false,
'starred' => true,
'is_saved' => true,
'pasteis_count' => 4,
'deleted_at' => null,
'quoted_commit_id' => null,
'quoted_commit' => null,
Expand All @@ -280,6 +282,7 @@
'is_pinned' => false,
'starred' => false,
'is_saved' => false,
'pasteis_count' => 0,
'deleted_at' => null,
'quoted_commit_id' => null,
'quoted_commit' => null,
Expand Down
Loading
Loading