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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,13 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes

### Profiles and GitHub sync

Every developer has a profile at `/@{username}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context. Follow and follower counts open the corresponding people lists. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link.
Every developer has a profile at `/@{username}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link.

Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile and the **Edit profile** menu on your own profile offer **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-<github id>` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged.

Profiles also show [achievement badges and an XP-based Unix rank](#achievement-badges-and-xp). Click a badge or rank to inspect unlock requirements and progress.

Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [identity sync](app/Actions/UpdateOrCreateUser.php).
Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php).

### Going live

Expand Down
16 changes: 11 additions & 5 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Http\Requests\ShowUserRequest;
use App\Models\Commit;
use App\Models\User;
use App\Queries\GitHubReadmeQuery;
use App\Queries\PinnedCommitQuery;
use App\Queries\PushAccessQuery;
use App\Queries\UserAchievementsQuery;
Expand All @@ -20,6 +21,7 @@
use App\Services\PageMetadata;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
Expand All @@ -29,7 +31,7 @@
/**
* Profiles are public, so guests and crawlers read the same page, with the profile's link preview.
*/
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, PageMetadata $metadata): Response
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, GitHubReadmeQuery $readme, PageMetadata $metadata): Response
{
$blockerId = $currentUser?->blockerIdWith($user);

Expand Down Expand Up @@ -64,10 +66,14 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser
'achievements' => fn (): array => $achievements->get($user, $currentUser, $getStreaks()),
'moderation' => fn (): ?array => $currentUser?->isAdmin() === true && ! $user->isAdmin() ? $moderation->get([$user])[$user->id] : null,
'tab' => $request->tab(...),
'pinnedCommit' => fn (): ?Commit => $request->tab() === 'replies' ? null : $pinned->get($user, $currentUser),
'commits' => Inertia::scroll(fn () => $commits->builder($user, $currentUser, $request->tab() === 'replies')
->cursorPaginate(20)
->appends(['tab' => $request->tab()])),
'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null,
'readme' => fn (): string|false|null => $request->tab() === 'readme' ? $readme->get($user) : null,
// The README tab shows no commits, so it doesn't look for any.
'commits' => Inertia::scroll(fn () => $request->tab() === 'readme'
? new CursorPaginator([], 20)
: $commits->builder($user, $currentUser, $request->tab() === 'replies')
->cursorPaginate(20)
->appends(['tab' => $request->tab()])),
])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->user($getCountedUser())]);
}

Expand Down
8 changes: 6 additions & 2 deletions app/Http/Requests/ShowUserRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ public function authorize(): bool
public function rules(): array
{
return [
'tab' => ['nullable', 'string', 'in:commits,replies'],
'tab' => ['nullable', 'string', 'in:commits,replies,readme'],
];
}

public function tab(): string
{
return $this->input('tab') === 'replies' ? 'replies' : 'commits';
return match ($this->input('tab')) {
'replies' => 'replies',
'readme' => 'readme',
default => 'commits',
};
}
}
134 changes: 134 additions & 0 deletions app/Queries/GitHubReadmeQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\User;
use App\Services\GitHub;
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

final readonly class GitHubReadmeQuery
{
/**
* Profile READMEs are mostly badges and banners, whose long proxied image URLs add up.
*/
private const int MaxLength = 200_000;

/**
* Images uploaded to GitHub are served from links signed for five minutes.
*/
private const int SignedImagesTtl = 240;

private const int FailureTtl = 300;

public function __construct(private GitHub $github)
{
//
}

/**
* The README of the developer's GitHub profile repository, the one named after them, as GitHub renders it. Its
* images come from GitHub's own hosts, so readers never reach the hosts they come from. Cached for a day, a missing
* README included, but only for a few minutes when it holds uploaded images, whose signed links expire after five.
* Null when there is none, false when GitHub didn't answer, which is remembered for a few minutes so an outage
* doesn't call GitHub again on every view.
*/
public function get(User $user): string|false|null
{
$key = "github-readme:{$user->github_id}";
$failedKey = "github-readme-failed:{$user->github_id}";

/** @var string|null $html */
$html = Cache::get($key);

if ($html === null) {
if (Cache::has($failedKey)) {
return false;
}

try {
$html = $this->fetch($user);
} catch (HttpClientException) {
Cache::put($failedKey, true, self::FailureTtl);

return false;
}

Cache::put($key, $html, str_contains($html, 'private-user-images.githubusercontent.com') ? self::SignedImagesTtl : 86400);
}

return $html === '' ? null : $html;
}

/**
* @throws HttpClientException
*/
private function fetch(User $user): string
{
$login = $this->github->login($user->github_id);

if ($login === null) {
return '';
}

$response = $this->github->request()
->replaceHeaders(['Accept' => 'application/vnd.github.html+json'])
->get("repos/{$login}/{$login}/readme");

return $response->notFound() ? '' : $this->sanitize($response->throw()->body());
}

/**
* Never trusted as is: only plain formatting, links and images from GitHub's hosts are kept. Classes, ids and
* styles are dropped, so a README can't take on the page's own styles, cover it or pose as part of it.
*/
private function sanitize(string $html): string
{
$config = new HtmlSanitizerConfig()
// Only what a README is written with: a fixed list, so attributes like srcset or name, and elements like
// dialog, never come along with the browser's idea of what is safe.
->allowElement('a', ['href', 'title'])
->allowElement('img', ['src', 'alt', 'title', 'width', 'height', 'align'])
->allowElement('p', ['align'])
->allowElement('div', ['align'])
->allowElement('h1', ['align'])
->allowElement('h2', ['align'])
->allowElement('h3', ['align'])
->allowElement('h4', ['align'])
->allowElement('h5', ['align'])
->allowElement('h6', ['align'])
->allowElement('th', ['align', 'colspan', 'rowspan'])
->allowElement('td', ['align', 'colspan', 'rowspan'])
->allowElement('ol', ['start'])
->allowElement('details', ['open']);

foreach (['article', 'span', 'ul', 'li', 'table', 'thead', 'tbody', 'tr', 'pre', 'code', 'blockquote', 'summary', 'picture', 'br', 'hr', 'b', 'strong', 'i', 'em', 'sub', 'sup', 'del', 's', 'kbd'] as $element) {
$config = $config->allowElement($element);
}

// Any other element goes, but what it holds stays: GitHub's own wrappers like g-emoji or themed-picture, and
// the rarer text elements like samp or dl, keep their words. Only these go with everything in them.
$config = $config->defaultAction(HtmlSanitizerAction::Block);

foreach (['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'svg', 'math', 'dialog', 'map', 'form', 'textarea', 'select', 'button', 'head', 'title', 'source', 'video', 'audio'] as $element) {
$config = $config->dropElement($element);
}

$config = $config
->allowLinkSchemes(['https', 'http', 'mailto'])
->allowRelativeLinks(false)
->allowMediaSchemes(['https'])
->allowMediaHosts(['camo.githubusercontent.com', 'github.com', 'raw.githubusercontent.com', 'user-images.githubusercontent.com', 'private-user-images.githubusercontent.com'])
->allowRelativeMedias(false)
->forceAttribute('a', 'rel', 'noopener noreferrer nofollow ugc')
->forceAttribute('a', 'target', '_blank')
->withMaxInputLength(self::MaxLength);

return mb_trim(new HtmlSanitizer($config)->sanitize($html));
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"nunomaduro/essentials": "^1.2.0",
"phpseclib/phpseclib": "^4.0.2",
"resend/resend-php": "^1.17",
"symfony/html-sanitizer": "^8.0",
"tempest/highlight": "^2.29.0"
},
"require-dev": {
Expand Down
74 changes: 73 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading