From 7c7f825430b650a719e468bcb3fa4c2576ceeb46 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Mon, 2 Nov 2026 23:53:51 +0100 Subject: [PATCH 1/3] feat: give anyone on GitHub a page, looked up when a member first opens it --- KNOWLEDGE_BASE.md | 15 +- app/Actions/CreateGitHubProfile.php | 74 ++++ app/Actions/CreateProfileLookup.php | 24 ++ ...ge.php => CreateProfileOpenGraphImage.php} | 33 +- app/Actions/DeleteUser.php | 4 +- app/Actions/UpdateMentionedOrganizations.php | 22 +- app/Actions/UpdateOrCreateUser.php | 6 + app/Actions/UpdateStaleHandles.php | 10 +- .../Controllers/ProfileLookupController.php | 38 +++ .../ProfileOpenGraphImageController.php | 14 +- .../UnclaimedProfileController.php | 39 +++ .../Requests/CreateApiOrgMemberRequest.php | 5 +- app/Http/Requests/CreateApiOrgRequest.php | 5 +- app/Http/Requests/CreateOrgMemberRequest.php | 5 +- app/Http/Requests/CreateOrgRequest.php | 5 +- .../Requests/CreateProfileLookupRequest.php | 40 +++ .../Requests/ListApiSuggestedUsersRequest.php | 5 +- .../Requests/ShowProfileLookupRequest.php | 45 +++ .../Requests/ShowUnclaimedProfileRequest.php | 38 +++ app/Jobs/LookUpGitHubProfile.php | 55 +++ app/Models/Profile.php | 19 +- app/Providers/AppServiceProvider.php | 7 + app/Queries/BioProfilesQuery.php | 2 +- app/Queries/GitHubLookupPauseQuery.php | 47 +++ app/Queries/ProfileLookupQuery.php | 37 ++ app/Services/OpenGraphCanvas.php | 7 +- app/Services/PageMetadata.php | 27 ++ ...0_create_deleted_github_accounts_table.php | 21 ++ resources/js/pages/organization/show.tsx | 45 ++- resources/js/pages/privacy/show.tsx | 15 + resources/js/pages/profile/lookup.tsx | 321 ++++++++++++++++++ resources/js/pages/profile/unclaimed.tsx | 110 ++++++ resources/js/types/global.d.ts | 1 + routes/web.php | 20 +- .../Controllers/Api/OrgControllerTest.php | 2 + .../Api/OrgMemberControllerTest.php | 4 + .../Api/SuggestedUserControllerTest.php | 7 + .../Feature/Controllers/OrgControllerTest.php | 10 + .../Controllers/OrgMemberControllerTest.php | 3 + .../ProfileLookupControllerTest.php | 118 +++++++ .../ProfileOpenGraphImageControllerTest.php | 16 + .../UnclaimedProfileControllerTest.php | 58 ++++ .../Controllers/UserControllerTest.php | 7 - tests/Feature/ErrorPageTest.php | 4 +- ...hp => CreateProfileOpenGraphImageTest.php} | 21 +- tests/Unit/Actions/DeleteUserTest.php | 1 + tests/Unit/Actions/UpdateOrCreateUserTest.php | 19 ++ tests/Unit/Jobs/LookUpGitHubProfileTest.php | 170 ++++++++++ 48 files changed, 1516 insertions(+), 85 deletions(-) create mode 100644 app/Actions/CreateGitHubProfile.php create mode 100644 app/Actions/CreateProfileLookup.php rename app/Actions/{CreateOrganizationOpenGraphImage.php => CreateProfileOpenGraphImage.php} (51%) create mode 100644 app/Http/Controllers/ProfileLookupController.php create mode 100644 app/Http/Controllers/UnclaimedProfileController.php create mode 100644 app/Http/Requests/CreateProfileLookupRequest.php create mode 100644 app/Http/Requests/ShowProfileLookupRequest.php create mode 100644 app/Http/Requests/ShowUnclaimedProfileRequest.php create mode 100644 app/Jobs/LookUpGitHubProfile.php create mode 100644 app/Queries/GitHubLookupPauseQuery.php create mode 100644 app/Queries/ProfileLookupQuery.php create mode 100644 database/migrations/2026_11_02_120000_create_deleted_github_accounts_table.php create mode 100644 resources/js/pages/profile/lookup.tsx create mode 100644 resources/js/pages/profile/unclaimed.tsx create mode 100644 tests/Feature/Controllers/ProfileLookupControllerTest.php create mode 100644 tests/Feature/Controllers/UnclaimedProfileControllerTest.php rename tests/Unit/Actions/{CreateOrganizationOpenGraphImageTest.php => CreateProfileOpenGraphImageTest.php} (70%) create mode 100644 tests/Unit/Jobs/LookUpGitHubProfileTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de570fe32..59c1b4d69 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -12,6 +12,7 @@ This file documents the platform's features, stack, and stories. Feature entries - [First-time setup](#first-time-setup) - [Profiles and GitHub sync](#profiles-and-github-sync) - [Organization pages](#organization-pages) + - [Developers not on /dev/null yet](#developers-not-on-devnull-yet) - [Going live](#going-live) - [Achievement badges and XP](#achievement-badges-and-xp) - [Following developers and suggestions](#following-developers-and-suggestions) @@ -112,11 +113,11 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re ### Organization pages -GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization: its members post for it. Everything on its page comes from GitHub, except its color and its commits. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile. +GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, or once a signed-in member opens its address, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization: its members post for it. Everything on its page comes from GitHub, except its color and its commits. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile. The **Commits** tab lists the organization's own commits, newest first. The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, its members also see its pending and rejected ones, and commits across a block or by people you muted are left out. -A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks for no extra GitHub permission: it checks that GitHub lists you among the organization's public members. GitHub keeps membership private by default, so if yours is, the page links to the organization's People page on GitHub, where you make your membership public, then claim again. You can make it private again once you're the admin. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. +While nobody has claimed an organization's page, it says so to signed-in visitors, with a **Claim** button. A member of the organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks for no extra GitHub permission: it checks that GitHub lists you among the organization's public members. GitHub keeps membership private by default, so if yours is, the page links to the organization's People page on GitHub, where you make your membership public, then claim again. You can make it private again once you're the admin. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. @@ -128,6 +129,14 @@ Signed in, you can **Follow** an organization from its page, which shows how man Sources: [organization page](resources/js/pages/organization/show.tsx), [commits](app/Queries/OrganizationCommitsQuery.php), [posting as an organization](app/Rules/OrganizationToPostAs.php), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php). +### Developers not on /dev/null yet + +Signed in, you can open the address of anyone on GitHub, like `/@octocat`, even if they never signed up. The first time, the page looks the handle up on GitHub while you wait, usually a few seconds, then shows what it found: a developer's page, or an organization's. Each member can look up to 30 handles an hour, since lookups share GitHub's limit with everyone; when that limit runs low, the page says when lookups resume. A handle that is no GitHub account says so, and isn't asked about again for a day. Guests and search engines can't look anyone up: they're asked to sign in. + +A developer who isn't here yet gets a page with their GitHub avatar, name, handle, bio and a link to GitHub, marked **Not on /dev/null yet**, but never their location or website. Anyone with the link can open it, and it shares as a card, but search engines are asked to leave it out. Signing in with that GitHub account makes the page yours: guests see an **I am @handle** button for that. If you delete your account, your page doesn't come back as one of these, until you sign in again. + +Sources: [looking handles up](app/Http/Controllers/ProfileLookupController.php), [lookup page](resources/js/pages/profile/lookup.tsx), [developer page](resources/js/pages/profile/unclaimed.tsx), [GitHub lookups](app/Actions/CreateGitHubProfile.php). + ### Going live Share a stream you are running from the **…** menu on your own profile: **Go live** takes a link to a YouTube or Twitch stream (on YouTube: a `/live/` link, a watch link, an embed, or one with a start time all work; on Twitch: your channel link) and how long to show it, from **1 to 12 hours** (two by default). YouTube and Twitch are supported. While you are live, your avatar gets a red ring and a **Live** marker on your profile, in logs, and in hover cards. Hovering it (or tapping it on a touch screen) shows the stream's live thumbnail with a **Watch** button; the stream itself plays on the provider in a new tab, never embedded. On your profile, the avatar links straight to the stream. @@ -634,7 +643,7 @@ Settings → Profile → **Delete account** opens a confirmation for permanent d You can sign in with GitHub again later, but start from a new account rather than restoring the deleted one. Export your data first if you want a copy. Deleting from /dev/null cannot retract copies of public content held elsewhere. -As the privacy policy explains, timeout and ban records retain your GitHub account id, reason, and dates after account deletion, so deleting and recreating an account cannot evade those restrictions. Deleted content may also remain in backups and logs for a period. +As the privacy policy explains, timeout and ban records retain your GitHub account id, reason, and dates after account deletion, so deleting and recreating an account cannot evade those restrictions. Your GitHub account id is also kept until you sign in again, only so that your page doesn't come back as one of a developer not on /dev/null yet. Deleted content may also remain in backups and logs for a period. Sources: [account deletion confirmation](resources/js/components/delete-user.tsx), [account deletion](app/Actions/DeleteUser.php), [privacy policy](resources/js/pages/privacy/show.tsx). diff --git a/app/Actions/CreateGitHubProfile.php b/app/Actions/CreateGitHubProfile.php new file mode 100644 index 000000000..9f0d3ddc0 --- /dev/null +++ b/app/Actions/CreateGitHubProfile.php @@ -0,0 +1,74 @@ +github->accountByLogin($handle) ?? []; + $type = Arr::get($account, 'type'); + + if ($type === 'Organization') { + $this->updateOrCreateOrganization->handle($account); + + return; + } + + $githubId = (string) Arr::integer($account, 'id', 0); + + if ($type !== 'User' || DB::table('deleted_github_accounts')->where('github_id', $githubId)->exists()) { + Cache::put("github-profiles:not-found:{$handle}", $type === 'User' ? 'deleted' : 'missing', now()->addDay()); + + return; + } + + $profile = GitHubProfile::fromAccount($account); + + DB::transaction(function () use ($profile, $githubId): void { + $this->updateStaleHandles->handle($profile->handle, $githubId); + + $existing = Profile::query()->where('type', ProfileType::User)->firstWhere('github_id', $githubId); + + // A user who renamed on GitHub only moves to their new handle: their next sign-in syncs the rest... + if ($existing?->user instanceof User) { + $existing->update(['handle' => $profile->handle]); + + return; + } + + Profile::query()->updateOrCreate(['github_id' => $githubId, 'type' => ProfileType::User], [ + 'handle' => $profile->handle, + 'name' => $profile->name, + 'avatar_url' => $profile->avatarUrl, + 'bio' => $profile->bio, + 'github_synced_at' => now(), + ]); + }); + } +} diff --git a/app/Actions/CreateProfileLookup.php b/app/Actions/CreateProfileLookup.php new file mode 100644 index 000000000..37eadd2c1 --- /dev/null +++ b/app/Actions/CreateProfileLookup.php @@ -0,0 +1,24 @@ +id}:{$organization->openGraphImageVersion()}", + "open-graph-image:profile:{$owner->id}:{$owner->openGraphImageVersion()}", now()->addHour(), - fn (): string => $this->render($organization), + fn (): string => $this->render($owner), ); } /** - * Drawn like a profile's card, in the organization's lane, /dev/null's blue until its admins choose one. + * Drawn like a user's card, in the organization's lane, /dev/null's blue until its admins choose one. An unclaimed + * developer's is grey, with no lane of their own yet, and says they aren't here, where a user's shows their stats. */ - private function render(Organization $organization): string + private function render(Organization|Profile $owner): string { $canvas = new OpenGraphCanvas($this->github); - $lane = $organization->lane() === null ? OpenGraphCanvas::PRIMARY : $canvas->lane($organization->lane()); + $lane = match (true) { + $owner instanceof Profile => OpenGraphCanvas::INK_MUTED, + $owner->lane() === null => OpenGraphCanvas::PRIMARY, + default => $canvas->lane($owner->lane()), + }; $width = OpenGraphCanvas::CONTENT_RIGHT - OpenGraphCanvas::CONTENT_X; $bio = $canvas->clamp( - $canvas->wrap($canvas->clean((string) $organization->bio), 'Geist-Regular', self::BIO_SIZE, $width), + $canvas->wrap($canvas->clean((string) $owner->bio), 'Geist-Regular', self::BIO_SIZE, $width), self::BIO_MAX_LINES, 'Geist-Regular', self::BIO_SIZE, $width, ); @@ -51,13 +60,13 @@ private function render(Organization $organization): string $top = max(56, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $blockHeight, 2)); $canvas->rail($top + intdiv(self::AVATAR_SIZE, 2), $lane); - $canvas->avatar($organization, OpenGraphCanvas::CONTENT_X + 10, $top, self::AVATAR_SIZE, $lane); + $canvas->avatar($owner, OpenGraphCanvas::CONTENT_X + 10, $top, self::AVATAR_SIZE, $lane); $textX = OpenGraphCanvas::CONTENT_X + 10 + self::AVATAR_SIZE + 10 + 36; $textWidth = OpenGraphCanvas::CONTENT_RIGHT - $textX; - $canvas->text($canvas->truncate($canvas->name($organization), 'Geist-SemiBold', 60, $textWidth), 'Geist-SemiBold', 60, $textX, $top + 74, OpenGraphCanvas::INK); - $canvas->handle($organization, 32, $textX, $top + 124, $textWidth, $lane); + $canvas->text($canvas->truncate($canvas->name($owner), 'Geist-SemiBold', 60, $textWidth), 'Geist-SemiBold', 60, $textX, $top + 74, OpenGraphCanvas::INK); + $canvas->handle($owner, 32, $textX, $top + 124, $textWidth, $lane); $bioTop = $top + self::AVATAR_SIZE + 48; @@ -70,7 +79,7 @@ private function render(Organization $organization): string } $canvas->footer(); - $canvas->text('Organization', 'Geist-Regular', 30, OpenGraphCanvas::CONTENT_X, OpenGraphCanvas::FOOTER_BASELINE, OpenGraphCanvas::INK_MUTED); + $canvas->text($owner instanceof Profile ? 'Not on /dev/null yet' : 'Organization', 'Geist-Regular', 30, OpenGraphCanvas::CONTENT_X, OpenGraphCanvas::FOOTER_BASELINE, OpenGraphCanvas::INK_MUTED); return $canvas->png(); } diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 211313875..3a7a30750 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -35,7 +35,8 @@ public function __construct(private UpdateCommitHotScores $updateCommitHotScores * What they posted for organizations stays, and only forgets who posted it. * The database cascades the image rows of erased commits and the user's scheduled commits, and the voice notes of their * messages, but the files are erased here. Orgs the user was the last one in go too, with everything sent in them, - * as they do when the last member leaves. + * as they do when the last member leaves. Only their GitHub ID is kept, so looking their handle up never brings their + * page back as an unclaimed one, until they sign in again. */ public function handle(User $user): void { @@ -85,6 +86,7 @@ public function handle(User $user): void $user->delete(); $user->profile->delete(); + DB::table('deleted_github_accounts')->insertOrIgnore(['github_id' => $user->github_id]); foreach ($translationKeys as $key) { Cache::forget($key); diff --git a/app/Actions/UpdateMentionedOrganizations.php b/app/Actions/UpdateMentionedOrganizations.php index acd26dd59..18e70791c 100644 --- a/app/Actions/UpdateMentionedOrganizations.php +++ b/app/Actions/UpdateMentionedOrganizations.php @@ -8,6 +8,7 @@ use App\Enums\ProfileType; use App\Models\Organization; use App\Models\Profile; +use App\Queries\GitHubLookupPauseQuery; use Carbon\CarbonImmutable; use Closure; use Illuminate\Http\Client\RequestException; @@ -22,15 +23,11 @@ */ private const int MaxLookups = 5; - /** - * Below this many requests left, lookups wait for the rate limit to reset, leaving the rest to SSH key syncs. - */ - private const int RateLimitFloor = 1000; - private const int NotFoundDays = 1; public function __construct( private GitHubProvider $github, + private GitHubLookupPauseQuery $pause, private UpdateOrCreateOrganization $updateOrCreateOrganization, private DeleteOrganization $deleteOrganization, ) { @@ -75,19 +72,10 @@ function () use ($organization): void { return null; } - $pausedUntil = Cache::integer('github-organizations:paused', 0); - - if ($pausedUntil === 0) { - $rateLimit = $this->github->rateLimit(); - - if ($rateLimit !== null && $rateLimit['remaining'] < self::RateLimitFloor) { - $pausedUntil = $rateLimit['resets_at']->getTimestamp(); - Cache::put('github-organizations:paused', $pausedUntil, $rateLimit['resets_at']); - } - } + $pausedUntil = $this->pause->get(); - if ($pausedUntil > 0) { - return CarbonImmutable::createFromTimestamp($pausedUntil); + if ($pausedUntil instanceof CarbonImmutable) { + return $pausedUntil; } foreach ($lookups as [$lookUp, $notFound]) { diff --git a/app/Actions/UpdateOrCreateUser.php b/app/Actions/UpdateOrCreateUser.php index 9330cc350..416a90308 100644 --- a/app/Actions/UpdateOrCreateUser.php +++ b/app/Actions/UpdateOrCreateUser.php @@ -19,6 +19,10 @@ public function __construct(private GitHubProvider $github, private UpdateStaleH // } + /** + * Signing in makes the GitHub account's profile a user's: an unclaimed one, looked up from GitHub before, becomes + * theirs with its ID. Someone who deleted their account and comes back is no longer remembered as gone. + */ public function handle(GitHubUser $githubUser): User { $githubId = (string) $githubUser->getId(); @@ -40,6 +44,8 @@ public function handle(GitHubUser $githubUser): User 'github_synced_at' => now(), ])->id; + DB::table('deleted_github_accounts')->where('github_id', $githubId)->delete(); + return User::query()->updateOrCreate([ 'id' => $id, 'github_id' => $githubId, diff --git a/app/Actions/UpdateStaleHandles.php b/app/Actions/UpdateStaleHandles.php index c2ecf8da5..21b9e7184 100644 --- a/app/Actions/UpdateStaleHandles.php +++ b/app/Actions/UpdateStaleHandles.php @@ -11,7 +11,9 @@ /** * GitHub handles are unique case-insensitively, so another profile holding this one renamed away on GitHub. The * placeholder starts with an underscore, which no GitHub handle can, and their next sync restores a real one. - * Bots keep their identity under reserved handles so they can continue replying. + * Bots keep their identity under reserved handles so they can continue replying. An unclaimed profile, a developer + * looked up from GitHub who never signed in, has nothing of its own, so it goes: looking its new handle up brings it + * back. */ public function handle(string $handle, string $githubId): void { @@ -19,6 +21,12 @@ public function handle(string $handle, string $githubId): void ->where('handle', $handle) ->where('github_id', '!=', $githubId) ->each(function (Profile $profile): void { + if ($profile->isUnclaimed()) { + $profile->delete(); + + return; + } + $profile->update(['handle' => match ($profile->github_id) { CreateWorldReply::GITHUB_ID => CreateWorldReply::FALLBACK_HANDLE, CreateTomFriendship::GITHUB_ID => CreateTomFriendship::FALLBACK_HANDLE, diff --git a/app/Http/Controllers/ProfileLookupController.php b/app/Http/Controllers/ProfileLookupController.php new file mode 100644 index 000000000..b27b3bb54 --- /dev/null +++ b/app/Http/Controllers/ProfileLookupController.php @@ -0,0 +1,38 @@ + $request->handle(), + 'lookup' => fn (): ?array => $currentUser instanceof User ? $lookups->get($request->handle()) : null, + ])->withViewData(['robots' => 'noindex'])->toResponse($request)->setStatusCode($currentUser instanceof User ? 200 : 404); + } + + public function store(CreateProfileLookupRequest $request, CreateProfileLookup $action): RedirectResponse + { + $action->handle($request->handle()); + + return back(); + } +} diff --git a/app/Http/Controllers/ProfileOpenGraphImageController.php b/app/Http/Controllers/ProfileOpenGraphImageController.php index 0bac7f6e3..0a892b4fb 100644 --- a/app/Http/Controllers/ProfileOpenGraphImageController.php +++ b/app/Http/Controllers/ProfileOpenGraphImageController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers; -use App\Actions\CreateOrganizationOpenGraphImage; +use App\Actions\CreateProfileOpenGraphImage; use App\Actions\CreateUserOpenGraphImage; use App\Models\Organization; use App\Models\Profile; @@ -14,12 +14,16 @@ final readonly class ProfileOpenGraphImageController { /** - * Users and organizations share the "@handle" paths, so the profile at the handle says which card to draw. A - * profile that is neither a user's nor an organization's has none. + * Users, organizations and unclaimed developers share the "@handle" paths, so the profile at the handle says which + * card to draw. */ - public function show(Profile $profile, CreateUserOpenGraphImage $users, CreateOrganizationOpenGraphImage $organizations): Response + public function show(Profile $profile, CreateUserOpenGraphImage $users, CreateProfileOpenGraphImage $profiles): Response { - return response($profile->user instanceof User ? $users->handle($profile->user) : $organizations->handle(Organization::query()->findOrFail($profile->id)), headers: [ + return response(match (true) { + $profile->user instanceof User => $users->handle($profile->user), + $profile->isUnclaimed() => $profiles->handle($profile), + default => $profiles->handle(Organization::query()->findOrFail($profile->id)), + }, headers: [ 'Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=3600', ]); diff --git a/app/Http/Controllers/UnclaimedProfileController.php b/app/Http/Controllers/UnclaimedProfileController.php new file mode 100644 index 000000000..e0e9f6596 --- /dev/null +++ b/app/Http/Controllers/UnclaimedProfileController.php @@ -0,0 +1,39 @@ +profile(); + assert($profile instanceof Profile); + + if ($request->route('user') !== $profile->handle) { + // The raw query, since Symfony's normalized one keeps only the last value of a repeated key... + $query = $request->server->getString('QUERY_STRING'); + + return redirect(route('user.show', $profile->handle).($query === '' ? '' : "?{$query}"), 301, ['Cache-Control' => 'max-age=86400, private']); + } + + return Inertia::render('profile/unclaimed', [ + 'profile' => $profile->only(['handle', 'name', 'avatar_url', 'bio']), + ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->unclaimedProfile($profile)]); + } +} diff --git a/app/Http/Requests/CreateApiOrgMemberRequest.php b/app/Http/Requests/CreateApiOrgMemberRequest.php index b9bc91ca1..4fd79be86 100644 --- a/app/Http/Requests/CreateApiOrgMemberRequest.php +++ b/app/Http/Requests/CreateApiOrgMemberRequest.php @@ -5,14 +5,15 @@ namespace App\Http\Requests; use App\Enums\OrgRole; -use App\Enums\ProfileType; use App\Models\Conversation; use App\Models\User; use App\Queries\RemoteCandidatesQuery; use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Exists; use Illuminate\Validation\Validator; @@ -38,7 +39,7 @@ public function rules(): array { return [ 'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)], - 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)], + 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where(fn (QueryBuilder $query): QueryBuilder => $query->whereIn('id', DB::table('users')->select('id')))], 'share_history' => ['required', 'boolean'], ]; } diff --git a/app/Http/Requests/CreateApiOrgRequest.php b/app/Http/Requests/CreateApiOrgRequest.php index 201009b6c..9a9bbc99b 100644 --- a/app/Http/Requests/CreateApiOrgRequest.php +++ b/app/Http/Requests/CreateApiOrgRequest.php @@ -5,14 +5,15 @@ namespace App\Http\Requests; use App\Enums\ConversationKind; -use App\Enums\ProfileType; use App\Models\Conversation; use App\Models\User; use App\Queries\RemoteCandidatesQuery; use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Exists; use Illuminate\Validation\Validator; @@ -35,7 +36,7 @@ public function rules(): array return [ 'name' => ['nullable', 'string', 'max:60'], 'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)], - 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)], + 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where(fn (QueryBuilder $query): QueryBuilder => $query->whereIn('id', DB::table('users')->select('id')))], ]; } diff --git a/app/Http/Requests/CreateOrgMemberRequest.php b/app/Http/Requests/CreateOrgMemberRequest.php index c963912de..ac10a8ff3 100644 --- a/app/Http/Requests/CreateOrgMemberRequest.php +++ b/app/Http/Requests/CreateOrgMemberRequest.php @@ -5,14 +5,15 @@ namespace App\Http\Requests; use App\Enums\OrgRole; -use App\Enums\ProfileType; use App\Models\Conversation; use App\Models\User; use App\Queries\RemoteCandidatesQuery; use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Exists; use Illuminate\Validation\Validator; @@ -38,7 +39,7 @@ public function rules(): array { return [ 'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)], - 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)], + 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where(fn (QueryBuilder $query): QueryBuilder => $query->whereIn('id', DB::table('users')->select('id')))], 'share_history' => ['required', 'boolean'], ]; } diff --git a/app/Http/Requests/CreateOrgRequest.php b/app/Http/Requests/CreateOrgRequest.php index 86f66d2dd..c7fb56f26 100644 --- a/app/Http/Requests/CreateOrgRequest.php +++ b/app/Http/Requests/CreateOrgRequest.php @@ -5,14 +5,15 @@ namespace App\Http\Requests; use App\Enums\ConversationKind; -use App\Enums\ProfileType; use App\Models\Conversation; use App\Models\User; use App\Queries\RemoteCandidatesQuery; use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Exists; use Illuminate\Validation\Validator; @@ -35,7 +36,7 @@ public function rules(): array return [ 'name' => ['nullable', 'string', 'max:60'], 'members' => ['required', 'array', 'min:1', 'max:'.(Conversation::MAX_MEMBERS - 1)], - 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)], + 'members.*' => ['required', 'string', 'distinct:ignore_case', Rule::exists('profiles', 'handle')->where(fn (QueryBuilder $query): QueryBuilder => $query->whereIn('id', DB::table('users')->select('id')))], ]; } diff --git a/app/Http/Requests/CreateProfileLookupRequest.php b/app/Http/Requests/CreateProfileLookupRequest.php new file mode 100644 index 000000000..9e1405dc3 --- /dev/null +++ b/app/Http/Requests/CreateProfileLookupRequest.php @@ -0,0 +1,40 @@ +> + */ + public function rules(): array + { + return [ + 'handle' => [ + 'required', + 'string', + 'regex:/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/', + Rule::unique('profiles', 'handle'), + // GitHub said there's no such account a moment ago, so it isn't asked again for a day... + function (string $attribute, string $value, Closure $fail): void { + if (Cache::has('github-profiles:not-found:'.Str::lower($value))) { + $fail(__('GitHub has no @:handle.', ['handle' => $value])); + } + }, + ], + ]; + } + + public function handle(): string + { + return $this->string('handle')->value(); + } +} diff --git a/app/Http/Requests/ListApiSuggestedUsersRequest.php b/app/Http/Requests/ListApiSuggestedUsersRequest.php index 90fcb7cb0..7f9a41015 100644 --- a/app/Http/Requests/ListApiSuggestedUsersRequest.php +++ b/app/Http/Requests/ListApiSuggestedUsersRequest.php @@ -4,12 +4,13 @@ namespace App\Http\Requests; -use App\Enums\ProfileType; use App\Models\Commit; use App\Models\User; use App\Rules\VisibleCommit; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Query\Builder; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Exists; @@ -29,7 +30,7 @@ public function rules(): array return [ 'round' => ['nullable', 'integer', 'min:0'], - 'handle' => ['nullable', 'string', Rule::exists('profiles', 'handle')->where('type', ProfileType::User->value)], + 'handle' => ['nullable', 'string', Rule::exists('profiles', 'handle')->where(fn (Builder $query): Builder => $query->whereIn('id', DB::table('users')->select('id')))], 'commit_id' => ['bail', 'nullable', 'uuid', 'prohibits:handle', new VisibleCommit($user, __('That commit is no longer available.'))], ]; } diff --git a/app/Http/Requests/ShowProfileLookupRequest.php b/app/Http/Requests/ShowProfileLookupRequest.php new file mode 100644 index 000000000..bc205818e --- /dev/null +++ b/app/Http/Requests/ShowProfileLookupRequest.php @@ -0,0 +1,45 @@ +handle()); + + return preg_match('/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/', $handle) === 1 && Cache::get("github-profiles:not-found:{$handle}") !== 'deleted' + ? Response::allow() + : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } + + /** + * The "@handle" no user or organization has, as typed. + */ + public function handle(): string + { + $handle = $this->route('user'); + assert(is_string($handle)); + + return $handle; + } +} diff --git a/app/Http/Requests/ShowUnclaimedProfileRequest.php b/app/Http/Requests/ShowUnclaimedProfileRequest.php new file mode 100644 index 000000000..81f039fd5 --- /dev/null +++ b/app/Http/Requests/ShowUnclaimedProfileRequest.php @@ -0,0 +1,38 @@ +profile()?->isUnclaimed() === true ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } + + /** + * Unclaimed developers share the "@handle" pages with users, so this is the profile at the handle, whatever its case. + */ + public function profile(): ?Profile + { + $handle = $this->route('user'); + assert(is_string($handle)); + + return $this->profile ??= Profile::query()->firstWhere('handle', $handle); + } +} diff --git a/app/Jobs/LookUpGitHubProfile.php b/app/Jobs/LookUpGitHubProfile.php new file mode 100644 index 000000000..033b1cbda --- /dev/null +++ b/app/Jobs/LookUpGitHubProfile.php @@ -0,0 +1,55 @@ +handle; + } + + public function handle(GitHubLookupPauseQuery $pause, CreateGitHubProfile $action): void + { + try { + if (! $pause->get() instanceof CarbonImmutable) { + $action->handle($this->handle); + } + } finally { + Cache::forget("github-profiles:looking-up:{$this->handle}"); + } + } +} diff --git a/app/Models/Profile.php b/app/Models/Profile.php index e82d793b8..f220eb800 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -18,7 +18,8 @@ /** * A GitHub account at its "@handle", mirrored from GitHub: what GitHub calls a profile owner. The handle is GitHub's * login, which can be renamed, so the account is matched by its GitHub ID. Users and organizations share the ID of - * their profile, so it names either, and what only they have lives on `User` or `Organization`. + * their profile, so it names either, and what only they have lives on `User` or `Organization`. A user's profile with + * no user is an unclaimed one: a developer looked up from GitHub, who isn't on /dev/null yet. * * Like on GitHub, a handle is one whatever its case: the column is `citext`, so a plain `where('handle', ...)` matches * any case and its unique index refuses the same handle in another case. Never lowercase it in a query to compare @@ -141,4 +142,20 @@ public function owner(): User|Organization|null { return $this->user ?? $this->organization; } + + /** + * A developer looked up from GitHub who never signed in, so no user owns the profile yet: signing in makes it theirs. + */ + public function isUnclaimed(): bool + { + return $this->type === ProfileType::User && ! $this->user instanceof User; + } + + /** + * Changes whenever what an unclaimed profile's link preview draws changes, so a shared link shows the new card. + */ + public function openGraphImageVersion(): string + { + return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio])); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b1a2463c3..9c99c61e3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -179,6 +179,13 @@ private function bootRateLimitingDefaults(): void }); }); + // Every lookup spends GitHub's shared budget, so a member gets a few an hour. Their page says so... + RateLimiter::for('profile-lookups', fn (Request $request): Limit => Limit::perHour(30)->by($this->userId($request))->response(function (): RedirectResponse { + Inertia::flash('lookupThrottled', true); + + return back(); + })); + // The mobile app's 429 comes with a code. The public API's stays as it is... $rateLimited = fn (Request $request, array $headers): JsonResponse => new ApiException(ApiErrorCode::RateLimited, 'Too Many Attempts.', $headers)->render(); diff --git a/app/Queries/BioProfilesQuery.php b/app/Queries/BioProfilesQuery.php index 3289cb518..353c04e21 100644 --- a/app/Queries/BioProfilesQuery.php +++ b/app/Queries/BioProfilesQuery.php @@ -17,7 +17,7 @@ public function __construct(private Mentions $mentions) } /** - * The users and organizations on dev/null that the user's bio and company mention, as their handle by lowercase + * The users, organizations and unclaimed developers on dev/null that the user's bio and company mention, as their handle by lowercase * handle, so the profile links them here rather than to GitHub. * * @return array diff --git a/app/Queries/GitHubLookupPauseQuery.php b/app/Queries/GitHubLookupPauseQuery.php new file mode 100644 index 000000000..5a421359f --- /dev/null +++ b/app/Queries/GitHubLookupPauseQuery.php @@ -0,0 +1,47 @@ +github->rateLimit(); + + if ($rateLimit !== null && $rateLimit['remaining'] < self::RateLimitFloor) { + $pausedUntil = $rateLimit['resets_at']->getTimestamp(); + Cache::put(self::CACHE_KEY, $pausedUntil, $rateLimit['resets_at']); + } + } + + return $pausedUntil > 0 ? CarbonImmutable::createFromTimestamp($pausedUntil) : null; + } +} diff --git a/app/Queries/ProfileLookupQuery.php b/app/Queries/ProfileLookupQuery.php new file mode 100644 index 000000000..551583418 --- /dev/null +++ b/app/Queries/ProfileLookupQuery.php @@ -0,0 +1,37 @@ + match (true) { + Cache::has("github-profiles:not-found:{$handle}") => 'missing', + $pausedUntil > 0 => 'paused', + Cache::has("github-profiles:looking-up:{$handle}") => 'pending', + default => 'idle', + }, + 'resumesAt' => $pausedUntil > 0 ? now()->setTimestamp($pausedUntil)->toIso8601String() : null, + ]; + } +} diff --git a/app/Services/OpenGraphCanvas.php b/app/Services/OpenGraphCanvas.php index 25df4c263..d38984975 100644 --- a/app/Services/OpenGraphCanvas.php +++ b/app/Services/OpenGraphCanvas.php @@ -6,6 +6,7 @@ use App\Contracts\Services\GitHubProvider; use App\Models\Organization; +use App\Models\Profile; use App\Models\User; use GdImage; use Illuminate\Support\Arr; @@ -85,7 +86,7 @@ public function node(int $y, int $lane): void * * @param positive-int $size */ - public function avatar(User|Organization $owner, int $x, int $y, int $size, int $lane): void + public function avatar(User|Organization|Profile $owner, int $x, int $y, int $size, int $lane): void { $radius = $size / 2; $corner = $owner instanceof Organization ? $size * 0.18 : $radius; @@ -130,7 +131,7 @@ public function avatar(User|Organization $owner, int $x, int $y, int $size, int /** * The mono handle, led by a dot in the user's lane. */ - public function handle(User|Organization $owner, int $size, int $x, int $y, int $width, int $lane): void + public function handle(User|Organization|Profile $owner, int $size, int $x, int $y, int $width, int $lane): void { $this->circle($x + $size * 0.22, $y - $size * 0.34, $size * 0.22, $lane); @@ -182,7 +183,7 @@ public function footer(): void /** * The name as Geist can draw it, or the handle when nothing is left. */ - public function name(User|Organization $owner): string + public function name(User|Organization|Profile $owner): string { $name = $this->clean($owner->name); diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 0729583f0..fab1c6c5a 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -23,6 +23,7 @@ * @phpstan-type CommitMetadata array{type: string, title: string, description: string, url: string, robots: string, images: list, publishedAt: string, author: string, feed?: Feed, structuredData: array} * @phpstan-type UserMetadata array{type: string, title: string, description: string, url: string, robots: string, images: list, feed: Feed, handle: string, structuredData: array} * @phpstan-type OrganizationMetadata array{type: string, title: string, description: string, url: string, robots: string, images: list, handle: string, structuredData: array} + * @phpstan-type UnclaimedProfileMetadata array{type: string, title: string, description: string, url: string, robots: string, images: list, handle: string} * * @phpstan-import-type MacAppRelease from MacAppReleaseQuery */ @@ -342,6 +343,32 @@ public function organization(Organization $organization): array ]; } + /** + * The link preview of an unclaimed developer's page, which search engines leave out: they never chose to be here. + * + * @return UnclaimedProfileMetadata + */ + public function unclaimedProfile(Profile $profile): array + { + $title = "{$profile->nameWithHandle()} on /dev/null"; + + return [ + 'type' => 'profile', + 'title' => $title, + 'description' => $profile->bio ?? "{$profile->mention()} isn't on /dev/null yet.", + 'url' => route('user.show', $profile->handle), + 'robots' => 'noindex', + 'images' => [[ + 'url' => route('user-open-graph-image.show', ['profile' => $profile->handle, 'v' => $profile->openGraphImageVersion()]), + 'width' => OpenGraphCanvas::WIDTH, + 'height' => OpenGraphCanvas::HEIGHT, + 'type' => 'image/png', + 'alt' => $title, + ]], + 'handle' => $profile->handle, + ]; + } + /** * @return list */ diff --git a/database/migrations/2026_11_02_120000_create_deleted_github_accounts_table.php b/database/migrations/2026_11_02_120000_create_deleted_github_accounts_table.php new file mode 100644 index 000000000..af41beb9f --- /dev/null +++ b/database/migrations/2026_11_02_120000_create_deleted_github_accounts_table.php @@ -0,0 +1,21 @@ +string('github_id')->primary(); + }); + } +}; diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index c5d8b6e77..c0acd0801 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -1,5 +1,5 @@ import { Head, Link, usePage } from '@inertiajs/react'; -import { AlertCircleIcon, Link2, MapPin, Settings } from 'lucide-react'; +import { AlertCircleIcon, Flag, Link2, MapPin, Settings } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; import { show } from '@/actions/App/Http/Controllers/UserController'; @@ -79,19 +79,6 @@ export default function Show({ />
- {canClaim && ( - - )} {settingsUrl && (
)} + {canClaim && ( +
+ +
+

+ Nobody has claimed @{organization.handle} yet +

+

+ This page is waiting for its team. A member of{' '} + {organization.name} on GitHub can claim it, then + post as the organization and add teammates. +

+
+ +
+ )} + {claimFailed && ( diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index 5dd4d061d..4cc272e8a 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -57,6 +57,16 @@ export default function Show() { listing, your public SSH key fingerprints, and who you follow and who follows you. , + <> + + From GitHub, before you sign up: + {' '} + when a member opens the page of your GitHub + username, your GitHub ID, username, name, + avatar, and bio, to show that page until you + sign in and make it yours. Never your location + or website. + , <> From GitHub, when you claim an @@ -280,6 +290,11 @@ export default function Show() { stops a banned GitHub account from signing up again, and keeps a timeout from ending early by signing up again.

+

+ When you delete your account, we keep your GitHub ID + until you sign in again, so that your page doesn't come + back before you sign up. +

diff --git a/resources/js/pages/profile/lookup.tsx b/resources/js/pages/profile/lookup.tsx new file mode 100644 index 000000000..ee205ed82 --- /dev/null +++ b/resources/js/pages/profile/lookup.tsx @@ -0,0 +1,321 @@ +import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; +import { Search } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { store } from '@/actions/App/Http/Controllers/ProfileLookupController'; +import { GitHubIcon } from '@/components/github-icon'; +import { Button } from '@/components/ui/button'; +import { useNow } from '@/hooks/use-now'; +import AppLayout from '@/layouts/app-layout'; +import { home, login } from '@/routes'; +import { index as searchProfiles } from '@/routes/search'; + +type Lookup = { + status: 'idle' | 'pending' | 'paused' | 'missing'; + resumesAt: string | null; +}; + +/** + * How long the page waits for GitHub before giving up, like the job on the server. + */ +const WAIT_MS = 30_000; + +/** + * A handle nobody here has yet. Members look it up on GitHub, and the page turns into the profile it finds; guests are + * asked to sign in first. + */ +export default function ProfileLookup({ + handle, + lookup, +}: { + handle: string; + lookup: Lookup | null; +}) { + return ( + <> + + +
+

@{handle}

+
+ +
+ {lookup === null ? ( + + ) : ( + + )} +
+ + ); +} + +ProfileLookup.layout = AppLayout; + +function GuestPrompt({ handle }: { handle: string }) { + return ( + <> +
+ @? +
+

+ Who's @{handle}? +

+

+ /dev/null hasn't looked up this GitHub handle yet. Sign in and + we'll fetch their profile from GitHub. +

+ +

+ Are you @{handle}? Signing in creates your profile. +

+ + ); +} + +function MemberLookup({ handle, lookup }: { handle: string; lookup: Lookup }) { + const { flash } = usePage(); + const [failed, setFailed] = useState(false); + // Each lookup waits its own half minute, even when the page already showed it pending. + const [attempt, setAttempt] = useState(0); + const [previousStatus, setPreviousStatus] = useState(lookup.status); + const { start, stop } = usePoll( + 2_000, + { only: ['lookup'] }, + { autoStart: false }, + ); + + // A lookup that stops being pending without an answer failed, whoever started it. + if (lookup.status !== previousStatus) { + setPreviousStatus(lookup.status); + + if (previousStatus === 'pending' && lookup.status === 'idle') { + setFailed(true); + } + } + + const lookUp = () => { + setFailed(false); + router.post( + store.url(), + { handle }, + { + preserveScroll: true, + preserveState: true, + only: ['lookup'], + onSuccess: (page) => { + // Done, and failed, before the page could show it pending. + if ( + (page.props.lookup as Lookup | null)?.status === 'idle' + ) { + setFailed(true); + } + + setAttempt((attempt) => attempt + 1); + }, + }, + ); + }; + + // Opening the page is what looks the handle up, once: trying again is the member's call. + useEffect(() => { + if (lookup.status === 'idle') { + lookUp(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // The job ends with the profile, which replaces this page, or with an answer this page shows. + useEffect(() => { + if (lookup.status !== 'pending' || failed) { + return; + } + + start(); + const timeout = window.setTimeout(() => setFailed(true), WAIT_MS); + + return () => { + stop(); + window.clearTimeout(timeout); + }; + }, [lookup.status, failed, attempt, start, stop]); + + if (flash.lookupThrottled) { + return ( + + Each lookup asks GitHub, which /dev/null shares with everyone. + Try again in an hour. + + ); + } + + if (lookup.status === 'missing') { + return ( + + + + + } + > + No GitHub account or organization uses this handle. Check the + spelling, or search /dev/null. + + ); + } + + if (lookup.status === 'paused') { + return ( + + /dev/null asked GitHub a lot this hour. Lookups resume{' '} + : come back then to + look @{handle} up. + + ); + } + + if (failed) { + return ( + Try again} + > + The lookup of @{handle} didn't come back. It's usually + temporary. + + ); + } + + return ( + <> +
+
+
+
+ +
+
+

+ Looking into this GitHub profile +

+

+ @{handle} isn't on /dev/null yet. Asking GitHub who they are. +

+ +

+ This page updates by itself. It usually takes a few seconds. +

+ + ); +} + +function Answer({ + command, + output, + title, + actions, + children, +}: { + command: string; + output: string; + title: string; + actions?: ReactNode; + children: ReactNode; +}) { + return ( + <> + +

+ {title} +

+

+ {children} +

+ {actions && ( +
+ {actions} +
+ )} + + ); +} + +function Terminal({ + command, + output, + waiting = false, +}: { + command: string; + output: string; + waiting?: boolean; +}) { + return ( +
+

+ $ {command} +

+

{output}

+ {waiting && ( +

+ ${' '} + ▍ +

+ )} +
+ ); +} + +function ResumesIn({ resumesAt }: { resumesAt: string | null }) { + const now = useNow(); + const minutes = Math.max( + 1, + Math.ceil( + (new Date(resumesAt ?? now).getTime() - now.getTime()) / 60_000, + ), + ); + + return ( + + in {minutes} {minutes === 1 ? 'minute' : 'minutes'} + + ); +} diff --git a/resources/js/pages/profile/unclaimed.tsx b/resources/js/pages/profile/unclaimed.tsx new file mode 100644 index 000000000..fffc8d19f --- /dev/null +++ b/resources/js/pages/profile/unclaimed.tsx @@ -0,0 +1,110 @@ +import { Head, usePage } from '@inertiajs/react'; +import { show } from '@/actions/App/Http/Controllers/UserController'; +import { BioText } from '@/components/bio-text'; +import { GitHubIcon } from '@/components/github-icon'; +import { ProfileShareButton } from '@/components/profile-share-button'; +import { Button } from '@/components/ui/button'; +import { UserAvatar } from '@/components/user-avatar'; +import AppLayout from '@/layouts/app-layout'; +import { login } from '@/routes'; + +type UnclaimedProfile = { + handle: string; + name: string; + avatar_url: string | null; + bio: string | null; +}; + +/** + * A developer looked up from GitHub who isn't on /dev/null yet. Signing in as them makes the page theirs. Where they + * are is left out, even when GitHub shows it: they never chose to share it here. + */ +export default function UnclaimedProfile({ + profile, +}: { + profile: UnclaimedProfile; +}) { + const { auth } = usePage().props; + + return ( + <> + + +
+

{profile.name}

+

+ not here yet +

+
+ +
+
+
+ +
+ +
+ + {auth.user === null && ( + + )} +
+
+ +

+ {profile.name} +

+

+ @{profile.handle} + + Not on /dev/null yet + +

+ + {profile.bio && ( +

+ +

+ )} + + +
+ +
+

+ $ git log --author={profile.handle} +

+

+ @{profile.handle} hasn't committed here yet. When they sign + in with GitHub, this page becomes theirs. +

+
+ + ); +} + +UnclaimedProfile.layout = AppLayout; diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index f8c1fd689..0fa82ab6e 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -22,6 +22,7 @@ declare module '@inertiajs/core' { localSignIn?: boolean; signedUp?: boolean; claimFailed?: { message: string; githubUrl: string | null }; + lookupThrottled?: true; }; sharedPageProps: { name: string; diff --git a/routes/web.php b/routes/web.php index 15732268d..0474f2fa1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\Log; +use App\Enums\ProfileType; use App\Http\Controllers\AboutController; use App\Http\Controllers\AppController; use App\Http\Controllers\BanController; @@ -102,6 +103,7 @@ use App\Http\Controllers\PartnerController; use App\Http\Controllers\PrivacyController; use App\Http\Controllers\ProductMetricsController; +use App\Http\Controllers\ProfileLookupController; use App\Http\Controllers\ProfileOpenGraphImageController; use App\Http\Controllers\ProfileSearchController; use App\Http\Controllers\PushSubscriptionController; @@ -121,6 +123,7 @@ use App\Http\Controllers\TermsController; use App\Http\Controllers\TestEmailController; use App\Http\Controllers\TrafficController; +use App\Http\Controllers\UnclaimedProfileController; use App\Http\Controllers\UnsubscriptionController; use App\Http\Controllers\UserActivityGraphController; use App\Http\Controllers\UserAutomaticTranslationController; @@ -153,6 +156,7 @@ use App\Http\Middleware\SerializePushSessions; use App\Http\Middleware\SerializePushSubscriptions; use App\Http\Middleware\ThrottleCommits; +use App\Models\Profile; use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Illuminate\Http\Request; use Illuminate\Routing\Middleware\SubstituteBindings; @@ -224,8 +228,9 @@ // Public pages, readable by guests and crawlers... Route::middleware([RememberIntendedUrl::class, RedirectToHandleCase::class])->group(function (): void { - // A handle no user has may be an organization's. Bindings run before the rest of the route's middleware, like - // the one sharing the page's props, so the organization's page goes through that rest first, like any page. + // A handle no user has may be an organization's, an unclaimed developer's, or one to look up on GitHub. Bindings + // run before the rest of the route's middleware, like the one sharing the page's props, so the page goes through + // that rest first, like any page. Route::get('@{user:handle}', [UserController::class, 'show']) ->missing(function (Request $request): mixed { $router = resolve(Router::class); @@ -233,7 +238,11 @@ return new Pipeline(app()) ->send($request) ->through(collect($router->gatherRouteMiddleware(resolve(RoutingRoute::class)))->skipUntil(SubstituteBindings::class)->skip(1)->all()) - ->then(fn (Request $request): SymfonyResponse => $router->prepareResponse($request, app()->call(resolve(OrganizationController::class)->show(...)))); + ->then(fn (Request $request): SymfonyResponse => $router->prepareResponse($request, app()->call(resolve(match (Profile::query()->firstWhere('handle', $request->route('user'))?->type) { + ProfileType::Organization => OrganizationController::class, + ProfileType::User => UnclaimedProfileController::class, + null => ProfileLookupController::class, + })->show(...)))); }) ->name('user.show'); // Who follows whom is for members, so scrapers can't walk the graph page by page... @@ -352,6 +361,11 @@ // Banned, where a banned user is sent from everywhere else... Route::get('banned', [BannedController::class, 'show'])->name('banned.show'); + // Looking a handle up on GitHub from its page, which spends GitHub's shared budget... + Route::post('profile-lookups', [ProfileLookupController::class, 'store']) + ->middleware('throttle:profile-lookups') + ->name('profile-lookup.store'); + // Claiming an organization's page, which GitHub confirms the user belongs to... Route::post('organizations/{organization}/claim', [OrganizationAdminController::class, 'store']) ->whereUuid('organization') diff --git a/tests/Feature/Controllers/Api/OrgControllerTest.php b/tests/Feature/Controllers/Api/OrgControllerTest.php index b9d0b5b3f..6e0b43ba3 100644 --- a/tests/Feature/Controllers/Api/OrgControllerTest.php +++ b/tests/Feature/Controllers/Api/OrgControllerTest.php @@ -5,6 +5,7 @@ use App\Enums\OrgEvent; use App\Models\Conversation; use App\Models\Message; +use App\Models\Profile; use App\Models\User; use App\Models\UserTimeout; use Illuminate\Support\Facades\RateLimiter; @@ -124,6 +125,7 @@ 'no members' => [fn (User $nuno): array => ['name' => 'core'], ['members']], 'an empty list' => [fn (User $nuno): array => ['members' => []], ['members']], 'someone who does not exist' => [fn (User $nuno): array => ['members' => [$nuno->handle, 'nobody-by-that-name']], ['members.1']], + 'a developer not on /dev/null yet' => [fn (User $nuno): array => ['members' => [$nuno->handle, Profile::factory()->create()->handle]], ['members.1']], 'the same person twice' => [fn (User $nuno): array => ['members' => [$nuno->handle, $nuno->handle]], ['members.0', 'members.1']], 'a long name' => [fn (User $nuno): array => ['name' => Str::repeat('a', 61), 'members' => [$nuno->handle]], ['name']], ]); diff --git a/tests/Feature/Controllers/Api/OrgMemberControllerTest.php b/tests/Feature/Controllers/Api/OrgMemberControllerTest.php index d9d3c411c..ecef01c2b 100644 --- a/tests/Feature/Controllers/Api/OrgMemberControllerTest.php +++ b/tests/Feature/Controllers/Api/OrgMemberControllerTest.php @@ -6,6 +6,7 @@ use App\Enums\OrgRole; use App\Models\Conversation; use App\Models\Message; +use App\Models\Profile; use App\Models\User; use App\Models\UserTimeout; @@ -42,6 +43,9 @@ signedApiRequest($this->keyPair, 'POST', route('api-org-member.store', $org), ['members' => ['nobody-by-that-name'], 'share_history' => true]) ->assertUnprocessable() ->assertJsonValidationErrors('members.0'); + signedApiRequest($this->keyPair, 'POST', route('api-org-member.store', $org), ['members' => [Profile::factory()->create()->handle], 'share_history' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors('members.0'); signedApiRequest($this->keyPair, 'POST', route('api-org-member.store', $org), ['members' => [$nuno->handle]]) ->assertUnprocessable() ->assertJsonValidationErrors('share_history'); diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php index a513ec1d7..83b70e4ab 100644 --- a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php +++ b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Commit; +use App\Models\Profile; use App\Models\User; use App\Queries\SuggestedUsersQuery; @@ -207,6 +208,12 @@ ->assertJsonPath('data.0.id', $participant->id); }); +it('refuses the profile of a developer who is not on /dev/null yet', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['handle' => Profile::factory()->create()->handle])) + ->assertUnprocessable() + ->assertJsonValidationErrors('handle'); +}); + it('refuses a commit whose page the user cannot see', function (Closure $commit): void { $participant = User::factory()->has(Commit::factory())->create(); $root = $commit($this->user); diff --git a/tests/Feature/Controllers/OrgControllerTest.php b/tests/Feature/Controllers/OrgControllerTest.php index c610be9a6..39648da8b 100644 --- a/tests/Feature/Controllers/OrgControllerTest.php +++ b/tests/Feature/Controllers/OrgControllerTest.php @@ -6,6 +6,7 @@ use App\Http\Middleware\HandleInertiaRequests; use App\Models\Conversation; use App\Models\Message; +use App\Models\Profile; use App\Models\User; use Inertia\Testing\AssertableInertia; @@ -147,6 +148,15 @@ ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe(OrgEvent::Renamed); }); +it('starts an org only with people on /dev/null, not developers looked up from GitHub', function (): void { + $mark = User::factory()->create(); + [$nuno] = followersOf($mark, 1); + + $this->actingAs($mark)->postJson(route('org.store'), ['members' => [$nuno->handle, Profile::factory()->create()->handle]]) + ->assertUnprocessable() + ->assertOnlyJsonValidationErrors('members.1'); +}); + it('requires remotes and authentication', function (): void { $this->postJson(route('org.store'), ['members' => ['x']])->assertUnauthorized(); $this->actingAs(User::factory()->create(['remotes_enabled' => false]))->postJson(route('org.store'), ['members' => ['x']])->assertNotFound(); diff --git a/tests/Feature/Controllers/OrgMemberControllerTest.php b/tests/Feature/Controllers/OrgMemberControllerTest.php index b80296e20..b79394718 100644 --- a/tests/Feature/Controllers/OrgMemberControllerTest.php +++ b/tests/Feature/Controllers/OrgMemberControllerTest.php @@ -7,6 +7,7 @@ use App\Models\Message; use App\Models\MessageImage; use App\Models\MessageVoice; +use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\Storage; @@ -39,6 +40,8 @@ ->assertUnprocessable()->assertJsonValidationErrors('members'); $this->postJson(route('org-member.store', $org), ['members' => ['nobody-by-that-name'], 'share_history' => true]) ->assertUnprocessable()->assertJsonValidationErrors('members.0'); + $this->postJson(route('org-member.store', $org), ['members' => [Profile::factory()->create()->handle], 'share_history' => true]) + ->assertUnprocessable()->assertJsonValidationErrors('members.0'); $this->postJson(route('org-member.store', $org), ['members' => [$last->handle], 'share_history' => true])->assertNoContent(); $this->postJson(route('org-member.store', $org), ['members' => [$extra->handle], 'share_history' => true]) ->assertUnprocessable()->assertJsonValidationErrors(['members.0' => 'the org is full ('.Conversation::MAX_MEMBERS.')']); diff --git a/tests/Feature/Controllers/ProfileLookupControllerTest.php b/tests/Feature/Controllers/ProfileLookupControllerTest.php new file mode 100644 index 000000000..35367b313 --- /dev/null +++ b/tests/Feature/Controllers/ProfileLookupControllerTest.php @@ -0,0 +1,118 @@ +get('/@octocat') + ->assertNotFound() + ->assertSee('', false) + ->assertInertia(fn ($page) => $page + ->component('profile/lookup') + ->where('handle', 'octocat') + ->where('lookup', null)); + + Queue::assertNothingPushed(); +}); + +it('shows members where looking the handle up stands', function (Closure $setUp, string $status, bool $resumes): void { + $setUp(); + + $this->actingAs(User::factory()->create()) + ->get('/@Octocat') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('profile/lookup') + ->where('handle', 'Octocat') + ->where('lookup.status', $status) + ->where('lookup.resumesAt', fn (?string $resumesAt): bool => $resumes === ($resumesAt !== null))); +})->with([ + 'never looked up' => [fn (): null => null, 'idle', false], + 'being looked up' => [fn () => Cache::put('github-profiles:looking-up:octocat', true, 30), 'pending', false], + 'no GitHub account' => [fn () => Cache::put('github-profiles:not-found:octocat', 'missing', 60), 'missing', false], + 'waiting for the rate limit' => [fn () => Cache::put('github-organizations:paused', now()->addMinutes(30)->getTimestamp(), 60), 'paused', true], +]); + +it('answers a missing page for handles GitHub could not have, or of people who deleted their account', function (string $handle): void { + Cache::put('github-profiles:not-found:gone', 'deleted', 60); + + $this->actingAs(User::factory()->create())->get("/@{$handle}")->assertNotFound(); +})->with(['an underscore' => 'oct_cat', 'a trailing dash' => 'octocat-', 'too long' => str_repeat('a', 40), 'deleted' => 'gone']); + +it('turns into the profile once the lookup finds it', function (): void { + $member = User::factory()->create(); + Profile::factory()->create(['handle' => 'octocat']); + + $this->actingAs($member) + ->withHeaders([ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), + 'X-Inertia-Partial-Component' => 'profile/lookup', + 'X-Inertia-Partial-Data' => 'lookup', + ]) + ->get('/@octocat') + ->assertOk() + ->assertJsonPath('component', 'profile/unclaimed'); +}); + +it('looks a handle up for a member, which the page then shows pending', function (): void { + Queue::fake(); + + $this->actingAs(User::factory()->create()) + ->from('/@Octocat') + ->post(route('profile-lookup.store'), ['handle' => 'Octocat']) + ->assertRedirect('/@Octocat'); + + Queue::assertPushed(LookUpGitHubProfile::class, fn (LookUpGitHubProfile $job): bool => $job->handle === 'octocat'); + + expect(Cache::has('github-profiles:looking-up:octocat'))->toBeTrue(); + + $this->travel(LookUpGitHubProfile::WAIT_SECONDS + 1)->seconds(); + + expect(Cache::has('github-profiles:looking-up:octocat'))->toBeFalse(); +}); + +it('looks up only handles GitHub could have that nobody here has, and not again those it has no account for', function (string $handle): void { + Queue::fake(); + Profile::factory()->create(['handle' => 'taken']); + Cache::put('github-profiles:not-found:nobody', 'missing', 60); + + $this->actingAs(User::factory()->create()) + ->post(route('profile-lookup.store'), ['handle' => $handle]) + ->assertSessionHasErrors('handle'); + + Queue::assertNothingPushed(); +})->with(['taken, whatever the case' => 'Taken', 'no GitHub account, whatever the case' => 'Nobody', 'an underscore' => 'oct_cat', 'empty' => '']); + +it('lets only members look handles up', function (): void { + Queue::fake(); + + $this->post(route('profile-lookup.store'), ['handle' => 'octocat'])->assertRedirect(route('home')); + + Queue::assertNothingPushed(); +}); + +it('lets a member look up 30 handles an hour, then tells them on the page', function (): void { + Queue::fake(); + $member = User::factory()->create(); + + foreach (range(1, 30) as $index) { + $this->actingAs($member)->post(route('profile-lookup.store'), ['handle' => "octocat-{$index}"])->assertSessionHasNoErrors(); + } + + $this->actingAs($member) + ->from('/@octocat-31') + ->post(route('profile-lookup.store'), ['handle' => 'octocat-31']) + ->assertRedirect('/@octocat-31') + ->assertInertiaFlash('lookupThrottled', true); + + Queue::assertPushed(LookUpGitHubProfile::class, 30); +}); diff --git a/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php b/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php index cc7da3392..d90cefd11 100644 --- a/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php +++ b/tests/Feature/Controllers/ProfileOpenGraphImageControllerTest.php @@ -4,6 +4,7 @@ use App\Contracts\Services\GitHubProvider; use App\Models\Organization; +use App\Models\Profile; use App\Models\User; it('renders a link preview image of a profile for guests', function (): void { @@ -106,6 +107,21 @@ $this->get('/@laravel/og.png')->assertOk(); }); +it('renders the link preview image of an unclaimed developer, again once their GitHub profile changes', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('avatar')->once()->andReturn(fakeAvatar()); + + $profile = Profile::factory()->create(['handle' => 'octocat']); + + $this->get('/@octocat/og.png')->assertOk()->assertHeader('Content-Type', 'image/png'); + $this->get('/@octocat/og.png')->assertOk(); + + $github->shouldReceive('avatar')->once()->andReturn(fakeAvatar()); + $profile->update(['bio' => 'Updated on GitHub.']); + + $this->get('/@octocat/og.png')->assertOk(); +}); + it('returns not found for unknown handles', function (): void { $this->get('/@missing/og.png')->assertNotFound(); }); diff --git a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php new file mode 100644 index 000000000..f60ea3b1f --- /dev/null +++ b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php @@ -0,0 +1,58 @@ +create([ + 'handle' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + ]); + + $this->get('/@octocat') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('profile/unclaimed') + ->where('profile', [ + 'handle' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + ])); +}); + +it('previews the page of an unclaimed developer for guests, kept out of search engines', function (): void { + $profile = Profile::factory()->create(['handle' => 'octocat', 'name' => 'The Octocat', 'bio' => null]); + + $this->get('/@octocat') + ->assertOk() + ->assertSee('', false) + ->assertSee('', false) + ->assertSee('', false) + ->assertSee(e(route('user-open-graph-image.show', ['profile' => 'octocat', 'v' => $profile->openGraphImageVersion()])), false); + + $this->actingAs(User::factory()->create()) + ->get('/@octocat') + ->assertOk() + ->assertDontSee('The Octocat (@octocat) on /dev/null', false); +}); + +it('moves for good to the handle of the unclaimed developer in its own case', function (): void { + Profile::factory()->create(['handle' => 'OctoCat']); + + $this->get('/@octocat?tab=mentions')->assertRedirect('/@OctoCat?tab=mentions')->assertStatus(301); +}); + +it('shows a member their own page, not the unclaimed one, once they signed in', function (): void { + $user = User::factory()->create(['handle' => 'octocat']); + + $this->get('/@octocat') + ->assertOk() + ->assertInertia(fn ($page) => $page->component('user/show')->where('user.id', $user->id)); +}); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 8d0ccf75c..52c8c4ba2 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -344,13 +344,6 @@ ->where('followsYou', true)); }); -it('returns not found for unknown handles', function (): void { - $response = $this->actingAs(User::factory()->create()) - ->get('/@missing'); - - $response->assertNotFound(); -}); - it('shows guests the profile with its approved commits and a link preview', function (): void { $user = User::factory()->create([ 'handle' => 'octocat', diff --git a/tests/Feature/ErrorPageTest.php b/tests/Feature/ErrorPageTest.php index 28be550a8..795879319 100644 --- a/tests/Feature/ErrorPageTest.php +++ b/tests/Feature/ErrorPageTest.php @@ -33,8 +33,8 @@ ->where('auth.user.id', $user->id)); }); -it('renders the not found page for a missing profile', function (): void { - $response = $this->actingAs(User::factory()->create())->get('/@nobody'); +it('renders the not found page for a profile GitHub could not have', function (): void { + $response = $this->actingAs(User::factory()->create())->get('/@no_body'); $response->assertNotFound() ->assertInertia(fn ($page) => $page diff --git a/tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php b/tests/Unit/Actions/CreateProfileOpenGraphImageTest.php similarity index 70% rename from tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php rename to tests/Unit/Actions/CreateProfileOpenGraphImageTest.php index c249c804b..1efb0a652 100644 --- a/tests/Unit/Actions/CreateOrganizationOpenGraphImageTest.php +++ b/tests/Unit/Actions/CreateProfileOpenGraphImageTest.php @@ -2,9 +2,10 @@ declare(strict_types=1); -use App\Actions\CreateOrganizationOpenGraphImage; +use App\Actions\CreateProfileOpenGraphImage; use App\Actions\CreateUserOpenGraphImage; use App\Models\Organization; +use App\Models\Profile; use App\Models\User; use App\Services\OpenGraphCanvas; @@ -12,7 +13,7 @@ $organization = Organization::factory()->create(['avatar_url' => null, 'bio' => null]); $user = User::factory()->create(['avatar_url' => null, 'bio' => null]); - $organizationImage = imagecreatefromstring(resolve(CreateOrganizationOpenGraphImage::class)->handle($organization)); + $organizationImage = imagecreatefromstring(resolve(CreateProfileOpenGraphImage::class)->handle($organization)); $userImage = imagecreatefromstring(resolve(CreateUserOpenGraphImage::class)->handle($user)); expect($organizationImage)->toBeInstanceOf(GdImage::class) @@ -24,7 +25,7 @@ it('draws the rail in the blue of /dev/null while the admins chose no lane', function (): void { $organization = Organization::factory()->create(['avatar_url' => null, 'bio' => null]); - $image = imagecreatefromstring(resolve(CreateOrganizationOpenGraphImage::class)->handle($organization)); + $image = imagecreatefromstring(resolve(CreateProfileOpenGraphImage::class)->handle($organization)); expect($image)->toBeInstanceOf(GdImage::class) ->and(imagecolorat($image, OpenGraphCanvas::RAIL_X, 440))->toBe(OpenGraphCanvas::PRIMARY); @@ -32,7 +33,7 @@ it('draws the rail in the lane the admins chose, drawing it again once they change it', function (): void { $organization = Organization::factory()->create(['avatar_url' => null, 'bio' => null, 'preferred_lane' => 1]); - $action = resolve(CreateOrganizationOpenGraphImage::class); + $action = resolve(CreateProfileOpenGraphImage::class); $palette = imagecreatefromstring($action->handle($organization)); $organization->update(['preferred_lane' => null, 'lane_color' => '#1a2b3c']); @@ -50,7 +51,17 @@ 'bio' => str_repeat('The PHP framework for web artisans, shipping before lunch. ', 4), ]); - $image = resolve(CreateOrganizationOpenGraphImage::class)->handle($organization); + $image = resolve(CreateProfileOpenGraphImage::class)->handle($organization); expect(array_slice((array) getimagesizefromstring($image), 0, 2))->toBe([1200, 630]); }); + +it('draws an unclaimed developer round, with a grey rail and what they are missing', function (): void { + $profile = Profile::factory()->create(['avatar_url' => null, 'bio' => null]); + + $image = imagecreatefromstring(resolve(CreateProfileOpenGraphImage::class)->handle($profile)); + + expect($image)->toBeInstanceOf(GdImage::class) + ->and(imagecolorat($image, OpenGraphCanvas::RAIL_X, 440))->toBe(OpenGraphCanvas::INK_MUTED) + ->and(imagecolorat($image, 204, 162))->toBe(0xF6F7F8); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index a68fe02cb..278d99252 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -44,6 +44,7 @@ expect($user->exists)->toBeFalse(); $this->assertModelMissing($user); $this->assertModelMissing($user->profile); + $this->assertDatabaseHas('deleted_github_accounts', ['github_id' => $user->github_id]); }); it('erases the commits of a deleted user along with the replies under them', function (): void { diff --git a/tests/Unit/Actions/UpdateOrCreateUserTest.php b/tests/Unit/Actions/UpdateOrCreateUserTest.php index c9912592f..fbc615066 100644 --- a/tests/Unit/Actions/UpdateOrCreateUserTest.php +++ b/tests/Unit/Actions/UpdateOrCreateUserTest.php @@ -6,6 +6,7 @@ use App\Contracts\Services\GitHubProvider; use App\Models\Profile; use App\Models\User; +use Illuminate\Support\Facades\DB; use Laravel\Socialite\Two\User as GitHubUser; it('creates a user from github', function (): void { @@ -269,3 +270,21 @@ 'tom' => ['tom', 'tom', 'devnull-tom'], 'mixed-case tom' => ['tom', 'ToM', 'devnull-tom'], ]); + +it('makes the unclaimed profile of the account theirs, and forgets they once deleted their account', function (): void { + $this->mock(GitHubProvider::class)->shouldReceive('hasSponsorsListing')->andReturnFalse(); + $unclaimed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octocat', 'location' => null]); + DB::table('deleted_github_accounts')->insert(['github_id' => '583231']); + + $user = resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake([ + 'id' => 583231, + 'nickname' => 'octocat', + 'name' => 'The Octocat', + 'location' => 'San Francisco', + ])); + + expect($user->id)->toBe($unclaimed->id) + ->and($user->location)->toBe('San Francisco') + ->and(Profile::query()->count())->toBe(1); + $this->assertDatabaseEmpty('deleted_github_accounts'); +}); diff --git a/tests/Unit/Jobs/LookUpGitHubProfileTest.php b/tests/Unit/Jobs/LookUpGitHubProfileTest.php new file mode 100644 index 000000000..8976df29f --- /dev/null +++ b/tests/Unit/Jobs/LookUpGitHubProfileTest.php @@ -0,0 +1,170 @@ + 'octocat', + 'id' => 583231, + 'type' => 'User', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'location' => 'San Francisco', + 'blog' => 'https://github.blog', + 'company' => '@github', + ...$attributes, + ]; +} + +it('gives a GitHub developer an unclaimed profile, with only what their page shows', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + Cache::put('github-profiles:looking-up:octocat', true, 30); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + $profile = Profile::query()->sole(); + + expect($profile->type)->toBe(ProfileType::User) + ->and($profile->isUnclaimed())->toBeTrue() + ->and($profile->github_id)->toBe('583231') + ->and($profile->handle)->toBe('octocat') + ->and($profile->name)->toBe('The Octocat') + ->and($profile->avatar_url)->toBe('https://avatars.githubusercontent.com/u/583231') + ->and($profile->bio)->toBe('I am the Octocat.') + ->and($profile->location)->toBeNull() + ->and($profile->website)->toBeNull() + ->and($profile->github_synced_at)->not->toBeNull() + ->and(User::query()->count())->toBe(0) + ->and(Cache::has('github-profiles:looking-up:octocat'))->toBeFalse(); +}); + +it('gives a GitHub organization its page, followed by those waiting for it', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->with('laravel')->andReturn(['login' => 'laravel', 'id' => 958072, 'type' => 'Organization', 'name' => 'Laravel', 'avatar_url' => null, 'bio' => null, 'location' => null, 'blog' => '', 'company' => null]); + $follower = User::factory()->create(); + DB::table('pending_organization_follows')->insert(['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()]); + + dispatch_sync(new LookUpGitHubProfile('laravel')); + + $organization = Organization::query()->sole(); + + expect($organization->handle)->toBe('laravel') + ->and($follower->followedOrganizations()->pluck('organizations.id')->all())->toBe([$organization->id]); +}); + +it('remembers for a day that no GitHub account has the handle', function (?array $account): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->andReturn($account); + + dispatch_sync(new LookUpGitHubProfile('nobody')); + + expect(Cache::get('github-profiles:not-found:nobody'))->toBe('missing') + ->and(Profile::query()->count())->toBe(0); + + $this->travel(1)->day(); + + expect(Cache::has('github-profiles:not-found:nobody'))->toBeFalse(); +})->with([ + 'no account' => [null], + 'a bot' => [['login' => 'nobody', 'id' => 1, 'type' => 'Bot']], +]); + +it('never brings back the page of someone who deleted their account', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->andReturn(octocatAccount()); + DB::table('deleted_github_accounts')->insert(['github_id' => '583231']); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + expect(Cache::get('github-profiles:not-found:octocat'))->toBe('deleted') + ->and(Profile::query()->count())->toBe(0); +}); + +it('moves the unclaimed profile of a developer who renamed, and drops the one whose handle someone else took', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $renamed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octo-old']); + $previous = Profile::factory()->create(['github_id' => '1', 'handle' => 'octocat']); + $member = User::factory()->create(['handle' => 'octocat-member']); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + expect($renamed->refresh()->handle)->toBe('octocat') + ->and($member->profile->refresh()->handle)->toBe('octocat-member'); + $this->assertModelMissing($previous); +}); + +it('only moves a user who renamed on GitHub to their new handle, leaving the rest to their next sign-in', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $user = User::factory()->create(['github_id' => '583231', 'handle' => 'octo-old', 'name' => 'Old name', 'github_synced_at' => now()->subWeek()]); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + $profile = $user->profile->refresh(); + + expect($profile->handle)->toBe('octocat') + ->and($profile->name)->toBe('Old name') + ->and($profile->github_synced_at?->isBefore(now()->subDay()))->toBeTrue() + ->and(Profile::query()->count())->toBe(1); +}); + +it('leaves the profile of a user who renamed away under a placeholder, as it was', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $member = User::factory()->create(['github_id' => '1', 'handle' => 'octocat']); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + expect($member->profile->refresh()->handle)->toBe('_renamed-1') + ->and(Profile::query()->where('handle', 'octocat')->sole()->isUnclaimed())->toBeTrue(); +}); + +it('asks GitHub nothing while lookups wait for the rate limit to reset', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldNotReceive('accountByLogin'); + Cache::put('github-organizations:paused', now()->addMinutes(30)->getTimestamp(), now()->addMinutes(30)); + Cache::put('github-profiles:looking-up:octocat', true, 30); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + expect(Cache::has('github-profiles:looking-up:octocat'))->toBeFalse() + ->and(Profile::query()->count())->toBe(0); +}); + +it('stops showing the lookup pending when GitHub fails', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->andThrow(new ConnectionException('GitHub is down.')); + Cache::put('github-profiles:looking-up:octocat', true, 30); + + expect(fn () => new LookUpGitHubProfile('octocat')->handle(resolve(GitHubLookupPauseQuery::class), resolve(CreateGitHubProfile::class))) + ->toThrow(ConnectionException::class) + ->and(Cache::has('github-profiles:looking-up:octocat'))->toBeFalse(); +}); + +it('looks a handle up once at a time', function (): void { + expect(new LookUpGitHubProfile('octocat')->uniqueId())->toBe('octocat'); +}); From d712d60d740ed65193b8ae31f264d777841f1fa3 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 3 Nov 2026 01:11:43 +0100 Subject: [PATCH 2/3] fix: bring guests back to the handle they wanted to look up once they sign in --- app/Http/Controllers/ProfileLookupController.php | 6 ++++++ tests/Feature/Controllers/ProfileLookupControllerTest.php | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/app/Http/Controllers/ProfileLookupController.php b/app/Http/Controllers/ProfileLookupController.php index b27b3bb54..71729e448 100644 --- a/app/Http/Controllers/ProfileLookupController.php +++ b/app/Http/Controllers/ProfileLookupController.php @@ -23,6 +23,12 @@ { public function show(ShowProfileLookupRequest $request, #[CurrentUser] ?User $currentUser, ProfileLookupQuery $lookups): Response { + // A missing page isn't remembered for after sign-in (RememberIntendedUrl), but this one is where the guest + // comes back to, to look the handle up... + if (! $currentUser instanceof User && ! $request->prefetch()) { + redirect()->setIntendedUrl($request->fullUrl()); + } + return Inertia::render('profile/lookup', [ 'handle' => $request->handle(), 'lookup' => fn (): ?array => $currentUser instanceof User ? $lookups->get($request->handle()) : null, diff --git a/tests/Feature/Controllers/ProfileLookupControllerTest.php b/tests/Feature/Controllers/ProfileLookupControllerTest.php index 35367b313..2eaf8d208 100644 --- a/tests/Feature/Controllers/ProfileLookupControllerTest.php +++ b/tests/Feature/Controllers/ProfileLookupControllerTest.php @@ -21,6 +21,7 @@ ->where('lookup', null)); Queue::assertNothingPushed(); + expect(session('url.intended'))->toBe(url('/@octocat')); }); it('shows members where looking the handle up stands', function (Closure $setUp, string $status, bool $resumes): void { @@ -45,6 +46,10 @@ Cache::put('github-profiles:not-found:gone', 'deleted', 60); $this->actingAs(User::factory()->create())->get("/@{$handle}")->assertNotFound(); + auth()->logout(); + $this->get("/@{$handle}")->assertNotFound(); + + expect(session('url.intended'))->toBeNull(); })->with(['an underscore' => 'oct_cat', 'a trailing dash' => 'octocat-', 'too long' => str_repeat('a', 40), 'deleted' => 'gone']); it('turns into the profile once the lookup finds it', function (): void { From 099d1e4f9b77de4c7c95d2e3d5cb3136b0347636 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 3 Nov 2026 14:27:35 +0100 Subject: [PATCH 3/3] fix: keep the guest sign-in prompt on screen, and show a profile deleted while open as gone The layout's notification polling reloads the current URL, and the lookup page answers guests with a 404. That 404 was read as the page being deleted, covering the sign-in prompt, and the polls overwrote where the guest returns after sign-in. A profile deleted while open now answers its reload with a missing page, rather than turning into a lookup of its handle. --- .../Controllers/ProfileLookupController.php | 5 ++- .../Requests/ShowProfileLookupRequest.php | 8 +++- resources/js/layouts/app-layout.tsx | 8 +++- resources/js/lib/deleted-resources.ts | 17 ++++++++ .../ProfileLookupControllerTest.php | 34 ++++++++++++++++ tests/Unit/DeletedResources.test.ts | 39 ++++++++++++++++++- 6 files changed, 105 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/ProfileLookupController.php b/app/Http/Controllers/ProfileLookupController.php index 71729e448..4a8361f84 100644 --- a/app/Http/Controllers/ProfileLookupController.php +++ b/app/Http/Controllers/ProfileLookupController.php @@ -24,8 +24,9 @@ public function show(ShowProfileLookupRequest $request, #[CurrentUser] ?User $currentUser, ProfileLookupQuery $lookups): Response { // A missing page isn't remembered for after sign-in (RememberIntendedUrl), but this one is where the guest - // comes back to, to look the handle up... - if (! $currentUser instanceof User && ! $request->prefetch()) { + // comes back to, to look the handle up. Like there, prefetches and partial reloads, such as the layout's + // notification polling, aren't the guest opening the page. + if (! $currentUser instanceof User && ! $request->prefetch() && ! $request->hasHeader('X-Inertia-Partial-Data')) { redirect()->setIntendedUrl($request->fullUrl()); } diff --git a/app/Http/Requests/ShowProfileLookupRequest.php b/app/Http/Requests/ShowProfileLookupRequest.php index bc205818e..ca61d6a0c 100644 --- a/app/Http/Requests/ShowProfileLookupRequest.php +++ b/app/Http/Requests/ShowProfileLookupRequest.php @@ -13,13 +13,17 @@ final class ShowProfileLookupRequest extends FormRequest { /** * Only a handle GitHub could have can be looked up. Someone who deleted their account here left for good: their - * handle answers a missing page, like one GitHub could not have. + * handle answers a missing page, like one GitHub could not have. So does a reload from a profile that was open at + * this URL and has since gone, rather than turning it into a lookup of its handle. */ public function authorize(): Response { $handle = Str::lower($this->handle()); + $reloadedPage = $this->header('X-Inertia-Partial-Component'); - return preg_match('/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/', $handle) === 1 && Cache::get("github-profiles:not-found:{$handle}") !== 'deleted' + return preg_match('/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/', $handle) === 1 + && Cache::get("github-profiles:not-found:{$handle}") !== 'deleted' + && in_array($reloadedPage, [null, 'profile/lookup'], true) ? Response::allow() : Response::denyAsNotFound(); } diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 82b827c10..e3db62882 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -34,6 +34,7 @@ import { useNavOpen } from '@/hooks/use-nav-open'; import { useTabSlide } from '@/hooks/use-tab-slide'; import { signIn } from '@/lib/auth'; import { backgroundPollOptions } from '@/lib/background-poll'; +import { isPageGone } from '@/lib/deleted-resources'; import type { IssueKind } from '@/lib/issue-commands'; import type { SearchAction } from '@/lib/search-actions'; import { cn } from '@/lib/utils'; @@ -112,6 +113,7 @@ export default function AppLayout({ children }: AppLayoutProps) { > {deletedPage === null && ( @@ -234,9 +236,11 @@ function NotificationTitle() { * page then stays as it was, instead of turning into a 404, and the polling stops until another page is visited. */ function NotificationCountPolling({ + component, isInbox, onNotFound, }: { + component: string; isInbox: boolean; onNotFound: () => void; }) { @@ -249,7 +253,9 @@ function NotificationCountPolling({ return; } - onNotFound(); + if (isPageGone(response, component)) { + onNotFound(); + } return false; }, diff --git a/resources/js/lib/deleted-resources.ts b/resources/js/lib/deleted-resources.ts index e37043c28..ee096328c 100644 --- a/resources/js/lib/deleted-resources.ts +++ b/resources/js/lib/deleted-resources.ts @@ -1,3 +1,5 @@ +import type { HttpExceptionResponse } from '@inertiajs/core'; + /** * What was deleted, or hidden from the viewer, while they were still looking at it: those commits and pages stay on * screen as they were last seen, with every action off, instead of turning into a 404 before they reload. @@ -32,3 +34,18 @@ export function createDeletedResources() { } export const deletedResources = createDeletedResources(); + +/** + * Whether a 404 says the open page is gone. Most pages answer the error page then, but some are 404s from the start, like + * the sign-in prompt guests get for a handle nobody looked up: reloading one answers the same page, which is still there. + */ +export function isPageGone( + response: HttpExceptionResponse, + component: string, +): boolean { + return ( + response.status === 404 && + (typeof response.data !== 'object' || + response.data.component !== component) + ); +} diff --git a/tests/Feature/Controllers/ProfileLookupControllerTest.php b/tests/Feature/Controllers/ProfileLookupControllerTest.php index 2eaf8d208..a16c7f5db 100644 --- a/tests/Feature/Controllers/ProfileLookupControllerTest.php +++ b/tests/Feature/Controllers/ProfileLookupControllerTest.php @@ -24,6 +24,17 @@ expect(session('url.intended'))->toBe(url('/@octocat')); }); +it('leaves where guests return after sign-in alone when a lookup page polls in the background', function (): void { + $this->withHeaders([ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), + 'X-Inertia-Partial-Component' => 'profile/lookup', + 'X-Inertia-Partial-Data' => 'auth.unreadNotificationsCount', + ])->get('/@octocat')->assertNotFound(); + + expect(session('url.intended'))->toBeNull(); +}); + it('shows members where looking the handle up stands', function (Closure $setUp, string $status, bool $resumes): void { $setUp(); @@ -52,6 +63,29 @@ expect(session('url.intended'))->toBeNull(); })->with(['an underscore' => 'oct_cat', 'a trailing dash' => 'octocat-', 'too long' => str_repeat('a', 40), 'deleted' => 'gone']); +it('answers a missing page when a profile that was open is reloaded after it was deleted, instead of looking its handle up', function (?User $viewer): void { + Queue::fake(); + + if ($viewer instanceof User) { + $this->actingAs($viewer); + } + + $this->withHeaders([ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), + 'X-Inertia-Partial-Component' => 'user/show', + 'X-Inertia-Partial-Data' => 'auth.unreadNotificationsCount', + ]) + ->get('/@alice') + ->assertNotFound() + ->assertJsonPath('component', 'error'); + + Queue::assertNothingPushed(); +})->with([ + 'a guest' => fn (): null => null, + 'a member' => fn (): User => User::factory()->create(), +]); + it('turns into the profile once the lookup finds it', function (): void { $member = User::factory()->create(); Profile::factory()->create(['handle' => 'octocat']); diff --git a/tests/Unit/DeletedResources.test.ts b/tests/Unit/DeletedResources.test.ts index 8c986cb52..7bc1ed9e0 100644 --- a/tests/Unit/DeletedResources.test.ts +++ b/tests/Unit/DeletedResources.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { createDeletedResources } from '../../resources/js/lib/deleted-resources.ts'; +import type { HttpExceptionResponse } from '@inertiajs/core'; +import { + createDeletedResources, + isPageGone, +} from '../../resources/js/lib/deleted-resources.ts'; void test('remembers deleted commits and pages separately', () => { const deleted = createDeletedResources(); @@ -28,3 +32,36 @@ void test('notifies subscribers once per newly deleted resource', () => { assert.equal(notifications, 2); }); + +function notFound(data: HttpExceptionResponse['data']): HttpExceptionResponse { + return { status: 404, headers: {}, data }; +} + +void test('reads a 404 as the open page being gone when it answers another page or no page at all', () => { + assert.equal( + isPageGone(notFound({ component: 'error' }), 'user/show'), + true, + ); + assert.equal( + isPageGone(notFound({ component: 'profile/lookup' }), 'user/show'), + true, + ); + assert.equal( + isPageGone(notFound(''), 'user/show'), + true, + ); +}); + +void test('reads a page that is a 404 from the start, like the guest sign-in prompt for a handle, as still there', () => { + assert.equal( + isPageGone(notFound({ component: 'profile/lookup' }), 'profile/lookup'), + false, + ); + assert.equal( + isPageGone( + { status: 500, headers: {}, data: { component: 'error' } }, + 'user/show', + ), + false, + ); +});