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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -626,7 +635,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).

Expand Down
74 changes: 74 additions & 0 deletions app/Actions/CreateGitHubProfile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Contracts\Services\GitHubProvider;
use App\Dto\GitHubProfile;
use App\Enums\ProfileType;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

final readonly class CreateGitHubProfile
{
public function __construct(
private GitHubProvider $github,
private UpdateOrCreateOrganization $updateOrCreateOrganization,
private UpdateStaleHandles $updateStaleHandles,
) {
//
}

/**
* Gives the handle a page from its GitHub account: an organization's, or an unclaimed developer's, which keeps only
* what the page shows. A user here who renamed on GitHub moves to the handle. A handle that is no GitHub account,
* or the account of someone who deleted theirs here, is remembered for a day, so the page says so without asking
* GitHub again.
*/
public function handle(string $handle): void
{
$account = $this->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(),
]);
});
}
}
24 changes: 24 additions & 0 deletions app/Actions/CreateProfileLookup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Jobs\LookUpGitHubProfile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

final readonly class CreateProfileLookup
{
/**
* Queues the handle's lookup, which its page shows pending until the job is done or the page stops waiting.
*/
public function handle(string $handle): void
{
$handle = Str::lower($handle);

Cache::put("github-profiles:looking-up:{$handle}", true, LookUpGitHubProfile::WAIT_SECONDS);

dispatch(new LookUpGitHubProfile($handle));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

use App\Contracts\Services\GitHubProvider;
use App\Models\Organization;
use App\Models\Profile;
use App\Services\OpenGraphCanvas;
use Illuminate\Support\Facades\Cache;

final readonly class CreateOrganizationOpenGraphImage
final readonly class CreateProfileOpenGraphImage
{
private const int AVATAR_SIZE = 152;

Expand All @@ -24,40 +25,48 @@ public function __construct(private GitHubProvider $github)
//
}

public function handle(Organization $organization): string
/**
* The card of a profile no user owns: an organization's, or an unclaimed developer's.
*/
public function handle(Organization|Profile $owner): string
{
return Cache::remember(
"open-graph-image:organization:{$organization->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,
);

$blockHeight = self::AVATAR_SIZE + ($bio === [] ? 0 : 48 + count($bio) * self::BIO_LINE_HEIGHT);
$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;

Expand All @@ -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();
}
Expand Down
4 changes: 3 additions & 1 deletion app/Actions/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 5 additions & 17 deletions app/Actions/UpdateMentionedOrganizations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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]) {
Expand Down
6 changes: 6 additions & 0 deletions app/Actions/UpdateOrCreateUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand Down
Loading
Loading