From a766a15f0a5feb73c690230cfcb3b92fb8f14dc2 Mon Sep 17 00:00:00 2001 From: Houssem Eddine BENABDALLAH Date: Tue, 20 Oct 2026 21:19:34 +0200 Subject: [PATCH 1/4] feat: show the GitHub profile README on profiles --- KNOWLEDGE_BASE.md | 4 +- app/Http/Controllers/UserController.php | 6 +- app/Http/Requests/ShowUserRequest.php | 8 +- app/Queries/GitHubReadmeQuery.php | 81 +++++++++++++ composer.json | 1 + composer.lock | 74 +++++++++++- resources/css/app.css | 109 ++++++++++++++++++ resources/js/components/profile-readme.tsx | 59 ++++++++++ resources/js/pages/user/show.tsx | 109 ++++++++++-------- .../Controllers/UserControllerTest.php | 23 ++++ tests/Unit/Queries/GitHubReadmeQueryTest.php | 89 ++++++++++++++ 11 files changed, 511 insertions(+), 52 deletions(-) create mode 100644 app/Queries/GitHubReadmeQuery.php create mode 100644 resources/js/components/profile-readme.tsx create mode 100644 tests/Unit/Queries/GitHubReadmeQueryTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2d9ded45b..1cf245dc0 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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 served through GitHub's image proxy: no scripts, styles, or classes. 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-` 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 diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 11bd1b65d..a9535f2cc 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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; @@ -29,7 +30,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); @@ -64,7 +65,8 @@ 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), + 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null, + 'readme' => fn (): ?string => $request->tab() === 'readme' ? $readme->get($user) : null, 'commits' => Inertia::scroll(fn () => $commits->builder($user, $currentUser, $request->tab() === 'replies') ->cursorPaginate(20) ->appends(['tab' => $request->tab()])), diff --git a/app/Http/Requests/ShowUserRequest.php b/app/Http/Requests/ShowUserRequest.php index faf0eee49..a6cef64f6 100644 --- a/app/Http/Requests/ShowUserRequest.php +++ b/app/Http/Requests/ShowUserRequest.php @@ -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', + }; } } diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php new file mode 100644 index 000000000..114389469 --- /dev/null +++ b/app/Queries/GitHubReadmeQuery.php @@ -0,0 +1,81 @@ +github_id}", 86400, function () use ($user): string { + $login = $this->github->login($user->github_id); + + if ($login === null) { + return ''; + } + + $response = $this->github->request() + ->accept('application/vnd.github.html+json') + ->get("repos/{$login}/{$login}/readme"); + + return $response->notFound() ? '' : $this->sanitize($response->throw()->body()); + }); + } catch (HttpClientException) { + return null; + } + + return $html === '' ? null : $html; + } + + /** + * Never trusted as is: only plain formatting, links and proxied images 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() + ->allowSafeElements() + // GitHub wraps tables and theme-aware images in its own elements: those go, what they hold stays. + ->blockElement('markdown-accessiblity-table') + ->blockElement('themed-picture') + // A source's srcset is not checked against the allowed hosts, so pictures fall back to their image. + ->dropElement('source') + ->dropAttribute('class', '*') + ->dropAttribute('id', '*') + ->dropAttribute('style', '*') + ->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)); + } +} diff --git a/composer.json b/composer.json index 13b6d5152..68296d82a 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index 80c8c1c69..c0543560b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "169e47b41d0e981a7238dc3cb9e0b900", + "content-hash": "57d5dbde21fa224cebbfd54964e30eef", "packages": [ { "name": "aws/aws-crt-php", @@ -5442,6 +5442,78 @@ ], "time": "2026-09-26T06:33:49+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v8.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "c67061a71d3f8b55f899faee3167422efa4db027" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/c67061a71d3f8b55f899faee3167422efa4db027", + "reference": "c67061a71d3f8b55f899faee3167422efa4db027", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T08:00:04+00:00" + }, { "name": "symfony/http-foundation", "version": "v8.1.9", diff --git a/resources/css/app.css b/resources/css/app.css index b21124a20..72eed366d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -701,6 +701,115 @@ html body[data-scroll-locked] { .hl-deletion { color: var(--destructive); } + + /* A GitHub profile README, sanitized on the server: it keeps GitHub's layout without GitHub's stylesheet. */ + .readme { + overflow-wrap: anywhere; + font-size: 15px; + line-height: 1.6; + + & > * + *, + & article > * + * { + margin-top: 1em; + } + + & h1, + & h2, + & h3, + & h4 { + margin-top: 1.5em; + font-weight: 600; + line-height: 1.3; + } + + & h1 { + font-size: 1.5em; + } + + & h2 { + border-bottom: 1px solid var(--border); + padding-bottom: 0.3em; + font-size: 1.25em; + } + + & a { + color: var(--primary); + } + + & a:hover { + text-decoration: underline; + } + + & img { + display: inline-block; + max-width: 100%; + height: auto; + } + + & ul, + & ol { + padding-left: 1.5em; + } + + & ul { + list-style: disc; + } + + & ol { + list-style: decimal; + } + + & blockquote { + border-left: 3px solid var(--border); + padding-left: 1em; + color: var(--muted-foreground); + } + + & code { + border-radius: 0.25rem; + background: var(--muted); + padding: 0.1em 0.3em; + font-family: var(--font-mono); + font-size: 0.875em; + } + + & pre { + overflow-x: auto; + border-radius: 0.5rem; + background: var(--muted); + padding: 0.75rem 1rem; + + & code { + background: none; + padding: 0; + } + } + + & table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + } + + & th, + & td { + border: 1px solid var(--border); + padding: 0.4em 0.75em; + } + + & hr { + border-color: var(--border); + } + + & [align='center'] { + text-align: center; + } + + & [align='right'] { + text-align: right; + } + } } /* Commits that just arrived on their own, until the reader moves on. */ diff --git a/resources/js/components/profile-readme.tsx b/resources/js/components/profile-readme.tsx new file mode 100644 index 000000000..01afd3c73 --- /dev/null +++ b/resources/js/components/profile-readme.tsx @@ -0,0 +1,59 @@ +import { EmptyFeed } from '@/components/commit-feed'; + +/** + * The README of the developer's GitHub profile repository. The server only sends it once sanitized, with plain + * formatting, links and images through GitHub's image proxy. + */ +export function ProfileReadme({ + readme, + username, + isCurrentUser, +}: { + readme: string | null; + username: string; + isCurrentUser: boolean; +}) { + if (readme === null) { + return ( +
+ + {isCurrentUser ? ( + <> + Add a README.md to a GitHub repository named{' '} + {username}, and + it shows up here.{' '} + + How to + + + ) : ( + `@${username} doesn't have a profile README on GitHub.` + )} + +
+ ); + } + + return ( +
+
+ + {username}/README.md on GitHub + +
+ ); +} diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 5e099961c..1aa7378b6 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -30,6 +30,7 @@ import { LocalTime } from '@/components/local-time'; import { PageTabs } from '@/components/page-tabs'; import { PinCommitButton } from '@/components/pin-commit-button'; import { ProfileActionsMenu } from '@/components/profile-actions-menu'; +import { ProfileReadme } from '@/components/profile-readme'; import { ProfileShareButton } from '@/components/profile-share-button'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; @@ -71,6 +72,7 @@ export default function Show({ commits, tab, pinnedCommit, + readme, }: { user: Profile; pasteisReceived: number | null; @@ -83,8 +85,9 @@ export default function Show({ streaks: Streaks; achievements: Achievements; commits: CursorPaginated; - tab: 'commits' | 'replies'; + tab: 'commits' | 'replies' | 'readme'; pinnedCommit: Commit | null; + readme: string | null; }) { const { auth } = usePage().props; const isCurrentUser = auth.user?.id === user.id; @@ -428,7 +431,7 @@ export default function Show({ - ( - - ) - : undefined - } - highlightedPeriod={highlightedPeriod} - end={ - tab === 'replies' - ? isCurrentUser - ? 'That was your first reply.' - : `That's @${user.handle}'s first reply.` - : isCurrentUser - ? 'That was your first commit. Everyone starts somewhere.' - : `That's @${user.handle}'s root commit.` - } - empty={ - tab === 'replies' ? ( - - {isCurrentUser - ? 'Your replies show up here. Join a conversation from your log.' - : `@${user.handle} hasn't replied to anything yet.`} - - ) : isCurrentUser ? ( - - Your commits show up here. Make your first one - from your log. - - ) : ( - - @{user.handle} hasn't committed anything yet. - - ) - } - /> + {tab === 'readme' ? ( + + ) : ( + ( + + ) + : undefined + } + highlightedPeriod={highlightedPeriod} + end={ + tab === 'replies' + ? isCurrentUser + ? 'That was your first reply.' + : `That's @${user.handle}'s first reply.` + : isCurrentUser + ? 'That was your first commit. Everyone starts somewhere.' + : `That's @${user.handle}'s root commit.` + } + empty={ + tab === 'replies' ? ( + + {isCurrentUser + ? 'Your replies show up here. Join a conversation from your log.' + : `@${user.handle} hasn't replied to anything yet.`} + + ) : isCurrentUser ? ( + + Your commits show up here. Make your first + one from your log. + + ) : ( + + @{user.handle} hasn't committed anything + yet. + + ) + } + /> + )}
); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 4f167176c..9950a95b2 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -8,6 +8,7 @@ use App\Models\LinkPreview; use App\Models\User; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia; @@ -701,3 +702,25 @@ $this->actingAs($user)->get(route('user.show', $user)) ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page->where('canPush', false)); }); + +it('shows the github profile readme on the readme tab', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + Http::fake([ + "api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat']), + 'api.github.com/repos/octocat/octocat/readme' => Http::response('

Hi there

'), + ]); + + $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page + ->where('tab', 'readme') + ->where('readme', '

Hi there

') + ->where('pinnedCommit', null)); +}); + +it('does not ask github for the readme on the other tabs', function (string $tab): void { + Http::preventStrayRequests(); + $user = User::factory()->create(['username' => 'octocat']); + + $this->get(route('user.show', ['user' => $user, 'tab' => $tab]))->assertOk()->assertInertia(fn ($page) => $page + ->where('readme', null)); +})->with(['commits', 'replies']); diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php new file mode 100644 index 000000000..6e5d5b2e8 --- /dev/null +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -0,0 +1,89 @@ +github_id}" => Http::response(['login' => 'octocat']), + 'api.github.com/repos/octocat/octocat/readme' => $readme, + ]); +} + +it('keeps the formatting, links and proxied images of the readme', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +
+

Hi there

+

site banner

+
PHP
+
+ HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

Hi there

') + ->toContain('site') + ->toContain('banner') + ->toContain('
PHP
') + ->not->toContain('class=') + ->not->toContain('id='); +}); + +it('removes scripts, styles, event handlers and images from elsewhere', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +

Hi

+ click + + + + + HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

Hi

') + ->toContain('') + ->not->toContain('script') + ->not->toContain('style') + ->not->toContain('onclick') + ->not->toContain('javascript') + ->not->toContain('tracker.example.com') + ->not->toContain('relative.png') + ->not->toContain('iframe'); +}); + +it('remembers that there is no readme', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(status: 404)); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull() + ->and(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull(); + Http::assertSentCount(2); +}); + +it('does not cache a failed read', function (): void { + $user = User::factory()->create(); + + Http::fake([ + "api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat']), + 'api.github.com/repos/octocat/octocat/readme' => Http::sequence()->pushStatus(502)->push('

Hi

'), + ]); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull() + ->and(resolve(GitHubReadmeQuery::class)->get($user))->toBe('

Hi

'); +}); + +it('shows no readme for a github account that no longer exists', function (): void { + $user = User::factory()->create(); + + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(status: 404)]); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull(); +}); From a87ed82c08ab5bcac0733d2f54187e08e9eaa01a Mon Sep 17 00:00:00 2001 From: Houssem Eddine BENABDALLAH Date: Fri, 23 Oct 2026 16:32:46 +0200 Subject: [PATCH 2/4] fix: keep only readme markup, expire uploaded images in time and say when github is down --- KNOWLEDGE_BASE.md | 2 +- app/Http/Controllers/UserController.php | 12 ++- app/Queries/GitHubReadmeQuery.php | 100 +++++++++++++----- resources/js/components/profile-readme.tsx | 15 ++- resources/js/pages/user/show.tsx | 2 +- .../Controllers/UserControllerTest.php | 15 ++- tests/Unit/Queries/GitHubReadmeQueryTest.php | 48 ++++++++- 7 files changed, 155 insertions(+), 39 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 1cf245dc0..6184d488c 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,7 +98,7 @@ 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; 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 served through GitHub's image proxy: no scripts, styles, or classes. 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-` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged. diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index a9535f2cc..8315cdb7b 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -21,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; @@ -66,10 +67,13 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'moderation' => fn (): ?array => $currentUser?->isAdmin() === true && ! $user->isAdmin() ? $moderation->get([$user])[$user->id] : null, 'tab' => $request->tab(...), 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null, - 'readme' => fn (): ?string => $request->tab() === 'readme' ? $readme->get($user) : null, - 'commits' => Inertia::scroll(fn () => $commits->builder($user, $currentUser, $request->tab() === 'replies') - ->cursorPaginate(20) - ->appends(['tab' => $request->tab()])), + '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())]); } diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php index 114389469..13bf8424f 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -18,6 +18,13 @@ */ 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) { // @@ -25,32 +32,56 @@ 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 already go through GitHub's image proxy, so readers never reach the hosts they come from. Cached for a - * day, a missing README included. Null when there is none, or when GitHub didn't answer, which is not cached. + * 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 + public function get(User $user): string|false|null { - try { - $html = Cache::remember("github-readme:{$user->github_id}", 86400, function () use ($user): string { - $login = $this->github->login($user->github_id); - - if ($login === null) { - return ''; - } - - $response = $this->github->request() - ->accept('application/vnd.github.html+json') - ->get("repos/{$login}/{$login}/readme"); - - return $response->notFound() ? '' : $this->sanitize($response->throw()->body()); - }); - } catch (HttpClientException) { - return 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() + ->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 proxied images 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. @@ -58,15 +89,32 @@ public function get(User $user): ?string private function sanitize(string $html): string { $config = new HtmlSanitizerConfig() - ->allowSafeElements() - // GitHub wraps tables and theme-aware images in its own elements: those go, what they hold stays. + // 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); + } + + $config = $config + // GitHub wraps tables, theme-aware images and emoji in its own elements: those go, what they hold stays. ->blockElement('markdown-accessiblity-table') ->blockElement('themed-picture') - // A source's srcset is not checked against the allowed hosts, so pictures fall back to their image. - ->dropElement('source') - ->dropAttribute('class', '*') - ->dropAttribute('id', '*') - ->dropAttribute('style', '*') + ->blockElement('g-emoji') ->allowLinkSchemes(['https', 'http', 'mailto']) ->allowRelativeLinks(false) ->allowMediaSchemes(['https']) diff --git a/resources/js/components/profile-readme.tsx b/resources/js/components/profile-readme.tsx index 01afd3c73..68c850622 100644 --- a/resources/js/components/profile-readme.tsx +++ b/resources/js/components/profile-readme.tsx @@ -2,17 +2,28 @@ import { EmptyFeed } from '@/components/commit-feed'; /** * The README of the developer's GitHub profile repository. The server only sends it once sanitized, with plain - * formatting, links and images through GitHub's image proxy. + * formatting, links and images from GitHub's own hosts. False when GitHub didn't answer, which is not the same as + * having no README. */ export function ProfileReadme({ readme, username, isCurrentUser, }: { - readme: string | null; + readme: string | false | null; username: string; isCurrentUser: boolean; }) { + if (readme === false) { + return ( +
+ + GitHub didn't answer. Try again in a few minutes. + +
+ ); + } + if (readme === null) { return (
diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 1aa7378b6..42eddacdd 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -87,7 +87,7 @@ export default function Show({ commits: CursorPaginated; tab: 'commits' | 'replies' | 'readme'; pinnedCommit: Commit | null; - readme: string | null; + readme: string | false | null; }) { const { auth } = usePage().props; const isCurrentUser = auth.user?.id === user.id; diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 9950a95b2..fde0e559f 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -703,8 +703,9 @@ ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page->where('canPush', false)); }); -it('shows the github profile readme on the readme tab', function (): void { +it('shows the github profile readme on the readme tab, without looking for commits', function (): void { $user = User::factory()->create(['username' => 'octocat']); + Commit::factory()->for($user)->create(); Http::fake([ "api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat']), @@ -714,7 +715,17 @@ $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page ->where('tab', 'readme') ->where('readme', '

Hi there

') - ->where('pinnedCommit', null)); + ->where('pinnedCommit', null) + ->where('commits.data', [])); +}); + +it('tells the readme tab when github did not answer', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(status: 502)]); + + $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page + ->where('readme', false)); }); it('does not ask github for the readme on the other tabs', function (string $tab): void { diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php index 6e5d5b2e8..209bed01d 100644 --- a/tests/Unit/Queries/GitHubReadmeQueryTest.php +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -68,7 +68,7 @@ function fakeGitHubReadme(User $user, mixed $readme): void Http::assertSentCount(2); }); -it('does not cache a failed read', function (): void { +it('remembers a failed read for a few minutes only', function (): void { $user = User::factory()->create(); Http::fake([ @@ -76,8 +76,50 @@ function fakeGitHubReadme(User $user, mixed $readme): void 'api.github.com/repos/octocat/octocat/readme' => Http::sequence()->pushStatus(502)->push('

Hi

'), ]); - expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull() - ->and(resolve(GitHubReadmeQuery::class)->get($user))->toBe('

Hi

'); + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeFalse() + ->and(resolve(GitHubReadmeQuery::class)->get($user))->toBeFalse(); + Http::assertSentCount(2); + + $this->travel(5)->minutes(); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBe('

Hi

'); +}); + +it('keeps a readme with uploaded images for a few minutes, as their links expire', function (string $html, int $minutes, bool $isFetchedAgain): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response($html)); + + resolve(GitHubReadmeQuery::class)->get($user); + $this->travel($minutes)->minutes(); + resolve(GitHubReadmeQuery::class)->get($user); + + Http::assertSentCount($isFetchedAgain ? 4 : 2); +})->with([ + 'uploaded image, after 4 minutes' => ['', 4, true], + 'uploaded image, after 3 minutes' => ['', 3, false], + 'proxied image, after 4 minutes' => ['', 4, false], +]); + +it('keeps only the attributes a readme is written with', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +

badge

+ Sign in again +

👋 Hi

+ + HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

badge

') + ->toContain('

👋 Hi

') + ->not->toContain('srcset') + ->not->toContain('tracker.example.com') + ->not->toContain('name=') + ->not->toContain('dialog') + ->not->toContain('Sign in again') + ->not->toContain('area'); }); it('shows no readme for a github account that no longer exists', function (): void { From 6dcc805104f5c8b1988cfb041c3be9b7b7b92afc Mon Sep 17 00:00:00 2001 From: Houssem Eddine BENABDALLAH Date: Fri, 23 Oct 2026 16:54:49 +0200 Subject: [PATCH 3/4] fix: keep the words of readme elements outside the allowlist --- app/Queries/GitHubReadmeQuery.php | 17 +++++++++++------ tests/Unit/Queries/GitHubReadmeQueryTest.php | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php index 13bf8424f..754a6b53b 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -9,6 +9,7 @@ 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 @@ -83,8 +84,8 @@ private function fetch(User $user): string } /** - * Never trusted as is: only plain formatting, links and proxied images 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. + * 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 { @@ -110,11 +111,15 @@ private function sanitize(string $html): string $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 - // GitHub wraps tables, theme-aware images and emoji in its own elements: those go, what they hold stays. - ->blockElement('markdown-accessiblity-table') - ->blockElement('themed-picture') - ->blockElement('g-emoji') ->allowLinkSchemes(['https', 'http', 'mailto']) ->allowRelativeLinks(false) ->allowMediaSchemes(['https']) diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php index 209bed01d..fbd6f96eb 100644 --- a/tests/Unit/Queries/GitHubReadmeQueryTest.php +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -122,6 +122,26 @@ function fakeGitHubReadme(User $user, mixed $readme): void ->not->toContain('area'); }); +it('keeps the words of the rarer text elements, without the elements', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +

A resourceful programmer with five years of PHP.

+
Stack
Laravel
+
chart
Commits
+ secret + HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

A resourceful programmer with five years of PHP.

') + ->toContain('StackLaravel') + ->toContain('chartCommits') + ->not->toContain('samp') + ->not->toContain('not->toContain('secret') + ->not->toContain('hidden'); +}); + it('shows no readme for a github account that no longer exists', function (): void { $user = User::factory()->create(); From 1fe7eb3784422fc82db2b703c6673aa0b5f5800b Mon Sep 17 00:00:00 2001 From: Houssem Eddine BENABDALLAH Date: Sun, 25 Oct 2026 14:40:17 +0100 Subject: [PATCH 4/4] fix: ask GitHub for the readme as html only --- app/Queries/GitHubReadmeQuery.php | 2 +- tests/Unit/Queries/GitHubReadmeQueryTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php index 754a6b53b..92f25ff6d 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -77,7 +77,7 @@ private function fetch(User $user): string } $response = $this->github->request() - ->accept('application/vnd.github.html+json') + ->replaceHeaders(['Accept' => 'application/vnd.github.html+json']) ->get("repos/{$login}/{$login}/readme"); return $response->notFound() ? '' : $this->sanitize($response->throw()->body()); diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php index fbd6f96eb..a03d2be14 100644 --- a/tests/Unit/Queries/GitHubReadmeQueryTest.php +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -4,6 +4,7 @@ use App\Models\User; use App\Queries\GitHubReadmeQuery; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; function fakeGitHubReadme(User $user, mixed $readme): void @@ -34,6 +35,18 @@ function fakeGitHubReadme(User $user, mixed $readme): void ->not->toContain('id='); }); +it('asks GitHub for the readme as html only', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response('

Hi

')); + + resolve(GitHubReadmeQuery::class)->get($user); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/octocat/octocat/readme' + && $request->header('Accept') === ['application/vnd.github.html+json'] + && $request->hasHeader('X-GitHub-Api-Version', '2022-11-28')); +}); + it('removes scripts, styles, event handlers and images from elsewhere', function (): void { $user = User::factory()->create();