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, quote, delete, follow, star, save, 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 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 `<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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@ 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, saving, reporting and pastéis 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, muting, blocking, reporting 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.

A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank.

`POST` and `DELETE /api/v1/users/{username}/mute` mute and unmute someone, and `POST` and `DELETE /api/v1/users/{username}/block` block and unblock them, as the web does: nobody can mute or block themselves, muting is silent, and blocking ends the follows both ways, which unblocking doesn't bring back. Each answers with the user and the new `is_muting` or `is_blocking`. `GET /api/v1/me/muted` and `GET /api/v1/me/blocked` list the people the user mutes and blocks, last first, as Settings → Safety does, 20 a page.

`GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it says `is_ranked`. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, which is always 0 on Trending, since the web offers none there. An empty Following log comes with a few `suggestions`.

`GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `none`, or a comma-separated list of `replies`, `quotes`, `stars`, `mentions`, `follows` and `moderation`. The response echoes it as `filters.type`. Listing them marks every notification as read, whatever the filter, and `read_at` says when, so the groups read at or after it are the new ones. `GET /api/v1/notifications/{id}/group` lists the notifications of one group, with the same filter, the way the web expands it.
Expand Down Expand Up @@ -130,7 +132,7 @@ An error is JSON with a `message` to show the user. The errors the app has to te
| `400` | `invalid_grant` | `POST /api/v1/devices` only: the code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. |
| `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. |
| `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_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. The error comes with the `user` they blocked, as the web shows them. |
| `403` | `user_unavailable` | The profile isn't available to the user, because it blocked them. |
| `403` | `own_commit` | The user tried to report their own commit. |
| `404` | `commit_unavailable` | The commit to report doesn't exist, was deleted, isn't published, or is across a block. |
Expand Down
14 changes: 9 additions & 5 deletions app/Exceptions/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,34 @@
use App\Enums\ApiErrorCode;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use JsonSerializable;
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
* An error from the signed API. It answers with its message and its code, and the status that goes with the code.
* An error from the signed API. It answers with its message and its code, and the status that goes with the code, and
* anything else the app needs to show it.
*/
final class ApiException extends HttpException
{
/**
* @param array<mixed> $headers
* @param array<string, mixed> $data
*/
public function __construct(public readonly ApiErrorCode $errorCode, string $message, array $headers = [])
public function __construct(public readonly ApiErrorCode $errorCode, string $message, array $headers = [], public readonly array $data = [])
{
parent::__construct($errorCode->status(), $message, headers: $headers);
}

/**
* A profile across a block is unavailable to a signed request, as on the web. Only the one who blocked the other
* is told why.
* is told why, with the user they blocked as the API shows users, for the blocked page the web shows them.
*/
public static function acrossBlock(User $user, bool $blockedByViewer): self
public static function acrossBlock(User $user, bool $blockedByViewer, JsonSerializable $shown): self
{
return $blockedByViewer
? new self(ApiErrorCode::UserBlocked, __("You blocked :name. Neither of you can follow, reply to, or star the other, or see each other's commits.", [
'name' => $user->name,
]))
]), data: ['user' => $shown])
: new self(ApiErrorCode::UserUnavailable, __("This profile isn't available."));
}

Expand Down Expand Up @@ -73,6 +76,7 @@ public function render(): JsonResponse
return response()->json([
'message' => $this->getMessage(),
'code' => $this->errorCode->value,
...$this->data,
], $this->getStatusCode(), $this->getHeaders());
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/CurrentUserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
namespace App\Http\Controllers\Api;

use App\Actions\DeleteUser;
use App\Http\Resources\CurrentUserResource;
use App\Http\Requests\DeleteApiCurrentUserRequest;
use App\Http\Resources\CurrentUserResource;
use App\Models\User;
use App\Queries\NotificationGroupsQuery;
use Illuminate\Container\Attributes\CurrentUser;
Expand Down
46 changes: 46 additions & 0 deletions app/Http/Controllers/Api/UserBlockController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateUserBlock;
use App\Actions\DeleteUserBlock;
use App\Http\Requests\CreateApiUserBlockRequest;
use App\Http\Requests\DeleteApiUserBlockRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final readonly class UserBlockController
{
/**
* The people the user blocks, last blocked first, as Settings → Safety lists them, 20 a page.
*/
public function index(#[CurrentUser] User $user): AnonymousResourceCollection
{
return UserResource::collection($user->blocking()->latest('blocks.created_at')->latest('users.id')->cursorPaginate(20));
}

/**
* Blocks the user, which ends the follows both ways, as on the web, and answers with them and that they are now
* blocked.
*/
public function store(CreateApiUserBlockRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserBlock $action): UserResource
{
$action->handle($currentUser, $user);

return UserResource::make($user)->additional(['is_blocking' => true]);
}

/**
* Unblocking doesn't bring back the follows, as on the web.
*/
public function destroy(DeleteApiUserBlockRequest $request, #[CurrentUser] User $currentUser, User $user, DeleteUserBlock $action): UserResource
{
$action->handle($currentUser, $user);

return UserResource::make($user)->additional(['is_blocking' => false]);
}
}
42 changes: 42 additions & 0 deletions app/Http/Controllers/Api/UserMuteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateUserMute;
use App\Actions\DeleteUserMute;
use App\Http\Requests\CreateApiUserMuteRequest;
use App\Http\Requests\DeleteApiUserMuteRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final readonly class UserMuteController
{
/**
* The people the user mutes, last muted first, as Settings → Safety lists them, 20 a page.
*/
public function index(#[CurrentUser] User $user): AnonymousResourceCollection
{
return UserResource::collection($user->muting()->latest('mutes.created_at')->latest('users.id')->cursorPaginate(20));
}

/**
* Mutes the user silently, as on the web, and answers with them and that they are now muted.
*/
public function store(CreateApiUserMuteRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserMute $action): UserResource
{
$action->handle($currentUser, $user);

return UserResource::make($user)->additional(['is_muting' => true]);
}

public function destroy(DeleteApiUserMuteRequest $request, #[CurrentUser] User $currentUser, User $user, DeleteUserMute $action): UserResource
{
$action->handle($currentUser, $user);

return UserResource::make($user)->additional(['is_muting' => false]);
}
}
33 changes: 33 additions & 0 deletions app/Http/Requests/CreateApiUserBlockRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

final class CreateApiUserBlockRequest extends FormRequest
{
/**
* Anyone but themselves, as on the web.
*/
public function authorize(): bool
{
$user = $this->user();
assert($user instanceof User);

$blocked = $this->route('user');
assert($blocked instanceof User);

return $user->isNot($blocked);
}

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

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

final class CreateApiUserMuteRequest extends FormRequest
{
/**
* Anyone but themselves, as on the web.
*/
public function authorize(): bool
{
$user = $this->user();
assert($user instanceof User);

$muted = $this->route('user');
assert($muted instanceof User);

return $user->isNot($muted);
}

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

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

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

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
3 changes: 2 additions & 1 deletion app/Http/Requests/ListApiUserCommitsRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Http\Requests;

use App\Exceptions\ApiException;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

Expand All @@ -22,7 +23,7 @@ public function authorize(): bool
$viewer = $this->user();
$blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null;

throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id));
throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user)));

return true;
}
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Requests/ShowApiUserRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Http\Requests;

use App\Exceptions\ApiException;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

Expand All @@ -22,7 +23,7 @@ public function authorize(): bool
$viewer = $this->user();
$blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null;

throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id));
throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user)));

return true;
}
Expand Down
14 changes: 13 additions & 1 deletion routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
use App\Http\Controllers\Api\LogController;
use App\Http\Controllers\Api\NotificationController;
use App\Http\Controllers\Api\NotificationGroupController;
use App\Http\Controllers\Api\UserBlockController;
use App\Http\Controllers\Api\UserCommitController;
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\UserFollowController;
use App\Http\Controllers\Api\UserLaneController;
use App\Http\Controllers\Api\UserMuteController;
use App\Http\Controllers\Api\UserPasteisModeController;
use App\Http\Controllers\Api\UserProfileController;
use App\Http\Controllers\Cli\CommitController;
Expand Down Expand Up @@ -58,7 +60,7 @@
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');

Expand Down Expand Up @@ -120,6 +122,16 @@
->middleware('throttle:follows')
->name('api-user-follow.destroy');

// User Mute...
Route::get('me/muted', [UserMuteController::class, 'index'])->name('api-user-mute.index');
Route::post('users/{user:username}/mute', [UserMuteController::class, 'store'])->name('api-user-mute.store');
Route::delete('users/{user:username}/mute', [UserMuteController::class, 'destroy'])->name('api-user-mute.destroy');

// User Block...
Route::get('me/blocked', [UserBlockController::class, 'index'])->name('api-user-block.index');
Route::post('users/{user:username}/block', [UserBlockController::class, 'store'])->name('api-user-block.store');
Route::delete('users/{user:username}/block', [UserBlockController::class, 'destroy'])->name('api-user-block.destroy');

// Notifications...
Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index');
Route::get('notifications/{notification}/group', [NotificationGroupController::class, 'show'])->name('api-notification-group.show');
Expand Down
Loading
Loading