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..4a8361f84
--- /dev/null
+++ b/app/Http/Controllers/ProfileLookupController.php
@@ -0,0 +1,45 @@
+prefetch() && ! $request->hasHeader('X-Inertia-Partial-Data')) {
+ redirect()->setIntendedUrl($request->fullUrl());
+ }
+
+ return Inertia::render('profile/lookup', [
+ 'handle' => $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..ca61d6a0c
--- /dev/null
+++ b/app/Http/Requests/ShowProfileLookupRequest.php
@@ -0,0 +1,49 @@
+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'
+ && in_array($reloadedPage, [null, 'profile/lookup'], true)
+ ? 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
+ 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.
+
+ 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. +
@{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 ( ++ @{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 ( + <> ++ {children} +
+ {actions && ( ++ $ {command} +
+{output}
+ {waiting && ( ++ ${' '} + ▍ +
+ )} +