From a65027d1391cdd75972625a8ef840f36b7b07a31 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sat, 7 Nov 2026 22:02:08 +0100 Subject: [PATCH 01/16] feat: let users and organizations turn commit embeds off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an allows_embeds preference, on by default, to users and to organizations. Users change it in Settings → Profile, organization admins in the organization's settings, each through a dedicated controller like the pastéis mode. The preference is exported with the account and hidden from serialization. 🤖 Generated with Claude Code --- app/Actions/CreateExport.php | 1 + app/Actions/UpdateOrganizationEmbeds.php | 15 ++++ app/Actions/UpdateUserEmbeds.php | 19 ++++ .../OrganizationEmbedsController.php | 29 ++++++ .../OrganizationProfileController.php | 1 + app/Http/Controllers/UserEmbedsController.php | 27 ++++++ app/Http/Middleware/HandleInertiaRequests.php | 1 + .../UpdateOrganizationEmbedsRequest.php | 37 ++++++++ app/Http/Requests/UpdateUserEmbedsRequest.php | 25 ++++++ app/Models/Organization.php | 9 ++ app/Models/User.php | 4 + ...lows_embeds_to_users_and_organizations.php | 21 +++++ resources/js/components/embeds-setting.tsx | 89 +++++++++++++++++++ .../js/pages/organization-profile/edit.tsx | 20 ++++- resources/js/pages/user-profile/edit.tsx | 10 ++- resources/js/types/auth.ts | 4 + routes/web.php | 7 ++ .../Controllers/ExportControllerTest.php | 1 + .../OrganizationEmbedsControllerTest.php | 53 +++++++++++ .../Controllers/UserEmbedsControllerTest.php | 47 ++++++++++ 20 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 app/Actions/UpdateOrganizationEmbeds.php create mode 100644 app/Actions/UpdateUserEmbeds.php create mode 100644 app/Http/Controllers/OrganizationEmbedsController.php create mode 100644 app/Http/Controllers/UserEmbedsController.php create mode 100644 app/Http/Requests/UpdateOrganizationEmbedsRequest.php create mode 100644 app/Http/Requests/UpdateUserEmbedsRequest.php create mode 100644 database/migrations/2026_11_07_200000_add_allows_embeds_to_users_and_organizations.php create mode 100644 resources/js/components/embeds-setting.tsx create mode 100644 tests/Feature/Controllers/OrganizationEmbedsControllerTest.php create mode 100644 tests/Feature/Controllers/UserEmbedsControllerTest.php diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 13b11294ce..64551a650f 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -107,6 +107,7 @@ public function handle(User $user): array 'push_mentions' => $user->push_mentions, 'push_direct_remotes' => $user->push_direct_remotes, 'push_org_remotes' => $user->push_org_remotes, + 'allows_embeds' => $user->allows_embeds, 'pinned_commit_id' => $user->pinned_commit_id, 'live_platform' => $user->live_platform?->value, 'live_url' => $user->live_url, diff --git a/app/Actions/UpdateOrganizationEmbeds.php b/app/Actions/UpdateOrganizationEmbeds.php new file mode 100644 index 0000000000..56a2869046 --- /dev/null +++ b/app/Actions/UpdateOrganizationEmbeds.php @@ -0,0 +1,15 @@ +update(['allows_embeds' => $allowed]); + } +} diff --git a/app/Actions/UpdateUserEmbeds.php b/app/Actions/UpdateUserEmbeds.php new file mode 100644 index 0000000000..e9ed293ec7 --- /dev/null +++ b/app/Actions/UpdateUserEmbeds.php @@ -0,0 +1,19 @@ +update(['allows_embeds' => $allowed]); + + event(new UsersUpdated([$user->id], UserUpdate::Account)); + } +} diff --git a/app/Http/Controllers/OrganizationEmbedsController.php b/app/Http/Controllers/OrganizationEmbedsController.php new file mode 100644 index 0000000000..04189c5c20 --- /dev/null +++ b/app/Http/Controllers/OrganizationEmbedsController.php @@ -0,0 +1,29 @@ +handle($organization, $request->boolean('enabled')); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Embeds preference saved.', + ]); + + return back(); + } +} diff --git a/app/Http/Controllers/OrganizationProfileController.php b/app/Http/Controllers/OrganizationProfileController.php index aa80bf0e59..aa654dfe21 100644 --- a/app/Http/Controllers/OrganizationProfileController.php +++ b/app/Http/Controllers/OrganizationProfileController.php @@ -20,6 +20,7 @@ public function edit(ShowOrganizationProfileRequest $request, Organization $orga return Inertia::render('organization-profile/edit', [ 'organization' => $organization, 'role' => OrganizationRole::Admin, + 'allowsEmbeds' => $organization->allows_embeds, ]); } } diff --git a/app/Http/Controllers/UserEmbedsController.php b/app/Http/Controllers/UserEmbedsController.php new file mode 100644 index 0000000000..dce7a3455d --- /dev/null +++ b/app/Http/Controllers/UserEmbedsController.php @@ -0,0 +1,27 @@ +handle($user, $request->boolean('enabled')); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Embeds preference saved.', + ]); + + return back(); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index ffe4a8cc35..25ecc7ab7d 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -140,6 +140,7 @@ public function share(Request $request): array : false, 'isAdmin' => $isAdmin, 'automaticTranslation' => $live(fn (): bool => $user->automatic_translation ?? true, UserUpdate::Account), + 'allowsEmbeds' => $live(fn (): bool => $user->allows_embeds ?? true, UserUpdate::Account), // The language translations are read in, saved or from the browser, so a change saved elsewhere reaches // the translations already shown. 'translationLanguage' => $live(fn (): string => resolve(CommitTranslations::class)->language(), UserUpdate::Account), diff --git a/app/Http/Requests/UpdateOrganizationEmbedsRequest.php b/app/Http/Requests/UpdateOrganizationEmbedsRequest.php new file mode 100644 index 0000000000..3f3d2aa928 --- /dev/null +++ b/app/Http/Requests/UpdateOrganizationEmbedsRequest.php @@ -0,0 +1,37 @@ +user(); + assert($user instanceof User); + + $organization = $this->route('organization'); + assert($organization instanceof Organization); + + return $organization->isAdmin($user) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateUserEmbedsRequest.php b/app/Http/Requests/UpdateUserEmbedsRequest.php new file mode 100644 index 0000000000..3b34eb0180 --- /dev/null +++ b/app/Http/Requests/UpdateUserEmbedsRequest.php @@ -0,0 +1,25 @@ +> + */ + public function rules(): array + { + return [ + 'enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 26471fe23f..1cf605050f 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -28,6 +28,7 @@ * @property-read string|null $website * @property-read CarbonInterface|null $github_synced_at * @property-read int|null $preferred_lane + * @property-read bool $allows_embeds * @property-read string|null $lane_color * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -60,6 +61,13 @@ final class Organization extends Model */ protected $with = ['profile']; + /** + * @var array + */ + protected $attributes = [ + 'allows_embeds' => true, + ]; + /** * @return array */ @@ -69,6 +77,7 @@ public function casts(): array 'id' => 'string', 'preferred_lane' => 'integer', 'lane_color' => 'string', + 'allows_embeds' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/User.php b/app/Models/User.php index 825d8d31b2..86522d9705 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -44,6 +44,7 @@ * @property-read string|null $lane_color * @property-read bool $pasteis_mode * @property-read bool $remotes_enabled + * @property-read bool $allows_embeds * @property-read RemotesFrom $remotes_from * @property-read bool $pull_receipts * @property-read bool $push_follows @@ -106,6 +107,7 @@ 'lane_color', 'pasteis_mode', 'remotes_enabled', + 'allows_embeds', 'pushSubscriptions', 'remotes_from', 'pull_receipts', @@ -173,6 +175,7 @@ final class User extends Authenticatable 'push_mentions' => true, 'push_direct_remotes' => true, 'push_org_remotes' => true, + 'allows_embeds' => true, 'pasteis_received' => 0, 'pasteis_given_today' => 0, 'pasteis_given_on' => null, @@ -216,6 +219,7 @@ public function casts(): array 'push_mentions' => 'boolean', 'push_direct_remotes' => 'boolean', 'push_org_remotes' => 'boolean', + 'allows_embeds' => 'boolean', 'pasteis_received' => 'integer', 'pasteis_given_today' => 'integer', 'pasteis_given_on' => 'date', diff --git a/database/migrations/2026_11_07_200000_add_allows_embeds_to_users_and_organizations.php b/database/migrations/2026_11_07_200000_add_allows_embeds_to_users_and_organizations.php new file mode 100644 index 0000000000..51ced676af --- /dev/null +++ b/database/migrations/2026_11_07_200000_add_allows_embeds_to_users_and_organizations.php @@ -0,0 +1,21 @@ +boolean('allows_embeds')->default(true); + }); + + Schema::table('organizations', function (Blueprint $table): void { + $table->boolean('allows_embeds')->default(true); + }); + } +}; diff --git a/resources/js/components/embeds-setting.tsx b/resources/js/components/embeds-setting.tsx new file mode 100644 index 0000000000..58235f00df --- /dev/null +++ b/resources/js/components/embeds-setting.tsx @@ -0,0 +1,89 @@ +import { router } from '@inertiajs/react'; +import { toast } from 'sonner'; +import { usePicked } from '@/hooks/use-picked'; +import { cn } from '@/lib/utils'; + +/** + * Whether others may embed a profile's commits on their sites: the user's in their settings, an organization's in its own. + */ +export function EmbedsSetting({ + enabled: saved, + url, + owner, +}: { + enabled: boolean; + url: string; + /** Who the commits belong to, as the description names them: "your" or "@laravel's". */ + owner: string; +}) { + const { picked, pick } = usePicked(); + const processing = picked !== null; + const enabled = picked ?? saved; + + const toggle = () => { + if (processing) { + return; + } + + router.put( + url, + { enabled: !enabled }, + { + preserveScroll: true, + onFinish: pick(!enabled), + onError: () => + toast.error( + 'Could not save the embeds preference. Please try again.', + ), + }, + ); + }; + + return ( +
+
+
+

+ Embeds +

+

+ Let developers show {owner} commits on their sites, as a + card that stays in sync with /dev/null. +

+

+ When off, nobody can create new embeds, and the cards + already out there read{' '} + + [embedding disabled] + + . +

+
+ +
+
+ ); +} diff --git a/resources/js/pages/organization-profile/edit.tsx b/resources/js/pages/organization-profile/edit.tsx index a609e77ccb..a2b9ff1d3e 100644 --- a/resources/js/pages/organization-profile/edit.tsx +++ b/resources/js/pages/organization-profile/edit.tsx @@ -1,7 +1,9 @@ import { Form, Head, router } from '@inertiajs/react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; +import OrganizationEmbedsController from '@/actions/App/Http/Controllers/OrganizationEmbedsController'; import OrganizationLaneController from '@/actions/App/Http/Controllers/OrganizationLaneController'; +import { EmbedsSetting } from '@/components/embeds-setting'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { LaneOptions } from '@/components/lane-picker'; @@ -12,7 +14,13 @@ import OrganizationSettingsLayout from '@/layouts/organization-settings/layout'; import { organizationLaneColor, withViewTransition } from '@/lib/utils'; import type { Lane, Organization } from '@/types'; -export default function Edit({ organization }: { organization: Organization }) { +export default function Edit({ + organization, + allowsEmbeds, +}: { + organization: Organization; + allowsEmbeds: boolean; +}) { const { lane } = organization; const [selected, setSelected] = useState(lane); @@ -98,6 +106,16 @@ export default function Edit({ organization }: { organization: Organization }) { )} + +
+ +
); } diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index 13647835eb..2805bde433 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -1,9 +1,11 @@ -import { Form, Head, Link, router } from '@inertiajs/react'; +import { Form, Head, Link, router, usePage } from '@inertiajs/react'; import { ArrowUpRight, Download, RefreshCw } from 'lucide-react'; import ExportController from '@/actions/App/Http/Controllers/ExportController'; +import UserEmbedsController from '@/actions/App/Http/Controllers/UserEmbedsController'; import UserProfileController from '@/actions/App/Http/Controllers/UserProfileController'; import ActivityGraphEmbed from '@/components/activity-graph-embed'; import DeleteUser from '@/components/delete-user'; +import { EmbedsSetting } from '@/components/embeds-setting'; import { GitHubProfile } from '@/components/github-profile'; import Heading from '@/components/heading'; import LanePicker from '@/components/lane-picker'; @@ -135,6 +137,12 @@ export default function Edit({ + + whereUuid('organization') ->name('organization-lane.update'); + Route::put('organizations/{organization}/settings/embeds', [OrganizationEmbedsController::class, 'update']) + ->whereUuid('organization') + ->name('organization-embeds.update'); // Followers the viewer knows... Route::get('@{user:handle}/followers-you-know', [UserFollowersYouKnowController::class, 'index']) @@ -664,6 +669,8 @@ Route::put('settings/remotes', [UserRemotesController::class, 'update'])->name('user-remotes.update'); + Route::put('settings/embeds', [UserEmbedsController::class, 'update'])->name('user-embeds.update'); + // User Live... Route::put('settings/live', [UserLiveController::class, 'update']) ->middleware(PreventPostingInTimeout::class) diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 1b043d513a..7ecd78d6a9 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -123,6 +123,7 @@ 'push_mentions' => true, 'push_direct_remotes' => true, 'push_org_remotes' => true, + 'allows_embeds' => true, 'pinned_commit_id' => null, 'live_platform' => null, 'live_url' => null, diff --git a/tests/Feature/Controllers/OrganizationEmbedsControllerTest.php b/tests/Feature/Controllers/OrganizationEmbedsControllerTest.php new file mode 100644 index 0000000000..a9d0306bc9 --- /dev/null +++ b/tests/Feature/Controllers/OrganizationEmbedsControllerTest.php @@ -0,0 +1,53 @@ +create(); + $organization = Organization::factory()->create(['allows_embeds' => ! $allowed]); + $organization->admins()->attach($user->id, ['created_at' => now()]); + + $this->actingAs($user) + ->fromRoute('organization-profile.edit', $organization) + ->put(route('organization-embeds.update', $organization), ['enabled' => $allowed]) + ->assertRedirectToRoute('organization-profile.edit', $organization) + ->assertSessionHasNoErrors() + ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'Embeds preference saved.']); + + expect($organization->refresh()->allows_embeds)->toBe($allowed) + ->and($organization->toArray())->not->toHaveKey('allows_embeds'); + + $this->get(route('organization-profile.edit', $organization)) + ->assertInertia(fn ($page) => $page->where('allowsEmbeds', $allowed)); +})->with([true, false]); + +it('allows embeds of organizations by default', function (): void { + expect(Organization::factory()->create()->refresh()->allows_embeds)->toBeTrue(); +}); + +it('hides the setting from members who are not admins, and from everyone else', function (): void { + $organization = Organization::factory()->create(); + $member = User::factory()->create(); + $organization->members()->attach($member->id, ['role' => OrganizationRole::Member]); + + $this->actingAs($member)->put(route('organization-embeds.update', $organization), ['enabled' => false])->assertNotFound(); + $this->actingAs(User::factory()->create())->put(route('organization-embeds.update', $organization), ['enabled' => false])->assertNotFound(); + + expect($organization->refresh()->allows_embeds)->toBeTrue(); +}); + +it('rejects an invalid preference', function (): void { + $user = User::factory()->create(); + $organization = Organization::factory()->create(); + $organization->admins()->attach($user->id, ['created_at' => now()]); + + $this->actingAs($user) + ->put(route('organization-embeds.update', $organization), ['enabled' => 'invalid']) + ->assertSessionHasErrors('enabled'); + + expect($organization->refresh()->allows_embeds)->toBeTrue(); +}); diff --git a/tests/Feature/Controllers/UserEmbedsControllerTest.php b/tests/Feature/Controllers/UserEmbedsControllerTest.php new file mode 100644 index 0000000000..90729a0115 --- /dev/null +++ b/tests/Feature/Controllers/UserEmbedsControllerTest.php @@ -0,0 +1,47 @@ +create(['allows_embeds' => ! $allowed]); + $other = User::factory()->create(['allows_embeds' => ! $allowed]); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('user-embeds.update'), ['enabled' => $allowed, 'user_id' => $other->id]) + ->assertRedirectToRoute('user-profile.edit') + ->assertInertiaFlash('toast', ['type' => 'success', 'message' => 'Embeds preference saved.']); + + expect($user->refresh()->allows_embeds)->toBe($allowed) + ->and($user->toArray())->not->toHaveKey('allows_embeds') + ->and($other->refresh()->allows_embeds)->toBe(! $allowed); + + $this->get(route('user-profile.edit'))->assertInertia(fn ($page) => $page + ->where('auth.allowsEmbeds', $allowed) + ->missing('auth.user.allows_embeds')); +})->with([true, false]); + +it('allows embeds by default', function (): void { + $user = User::factory()->create(); + + expect($user->refresh()->allows_embeds)->toBeTrue(); + + $this->actingAs($user)->get(route('user-profile.edit'))->assertInertia(fn ($page) => $page->where('auth.allowsEmbeds', true)); +}); + +it('rejects missing or invalid preferences without changing the saved choice', function (array $data): void { + $user = User::factory()->create(['allows_embeds' => false]); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('user-embeds.update'), $data) + ->assertRedirectToRoute('user-profile.edit') + ->assertSessionHasErrors('enabled'); + + expect($user->refresh()->allows_embeds)->toBeFalse(); +})->with(['missing' => [[]], 'null' => [['enabled' => null]], 'text' => [['enabled' => 'invalid']], 'array' => [['enabled' => []]]]); + +it('requires authentication', function (): void { + $this->put(route('user-embeds.update'), ['enabled' => false]) + ->assertRedirectToRoute('home'); +}); From 734135e6252a2bebc757a4d28b36605e210b0856 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sat, 7 Nov 2026 22:11:30 +0100 Subject: [PATCH 02/16] feat: serve signed commit embeds that other sites can frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /embed/{commit} renders a card of the commit, optionally with the rest of its self-thread and its replies down to three levels or all, as a guest sees it: the route skips the web group, so it never starts a session or sets a cookie, and PreventFraming leaves it out in favour of frame-ancestors *. The URL must be signed, so only the embed dialog can hand one out, and every value outside the allowed ones falls back to its default. Commits that can't be shown keep their place as [deleted], [embedding disabled] or [unavailable] tombstones. A card holds at most 40 commits, and an embeds limiter budgets each card per address with a global ceiling per address on top. The card has its own Vite entry, which loads only its page, and draws the thread's rail with the commit page's geometry. 🤖 Generated with Claude Code --- app/Dto/EmbedOptions.php | 73 ++ .../Controllers/CommitEmbedController.php | 44 + app/Http/Middleware/PreventFraming.php | 5 + app/Providers/AppServiceProvider.php | 7 + app/Queries/CommitEmbedQuery.php | 107 +++ resources/js/embed.tsx | 24 + resources/js/pages/embed/show.tsx | 803 ++++++++++++++++++ resources/views/embed.blade.php | 25 + routes/web.php | 9 + .../Controllers/CommitEmbedControllerTest.php | 184 ++++ vite.config.ts | 14 +- 11 files changed, 1290 insertions(+), 5 deletions(-) create mode 100644 app/Dto/EmbedOptions.php create mode 100644 app/Http/Controllers/CommitEmbedController.php create mode 100644 app/Queries/CommitEmbedQuery.php create mode 100644 resources/js/embed.tsx create mode 100644 resources/js/pages/embed/show.tsx create mode 100644 resources/views/embed.blade.php create mode 100644 tests/Feature/Controllers/CommitEmbedControllerTest.php diff --git a/app/Dto/EmbedOptions.php b/app/Dto/EmbedOptions.php new file mode 100644 index 0000000000..984b89be02 --- /dev/null +++ b/app/Dto/EmbedOptions.php @@ -0,0 +1,73 @@ + + */ +final readonly class EmbedOptions implements Arrayable +{ + public const array REPLIES = [1, 3, 5, 10]; + + public const array DEPTHS = [1, 2, 3]; + + /** + * "All" levels, which the cap on commits per card still bounds. + */ + public const int ALL_LEVELS = 99; + + public const array MAX_HEIGHTS = [480, 640, 800]; + + public function __construct( + public bool $thread = false, + public int $replies = 0, + public int $depth = 3, + public string $theme = 'auto', + public int $maxHeight = 640, + ) {} + + public static function fromRequest(Request $request): self + { + $replies = (int) $request->query('replies'); + $depth = $request->query('depth'); + $maxHeight = $request->query('max_height'); + + return new self( + thread: $request->query('thread') === '1', + replies: in_array($replies, self::REPLIES, true) ? $replies : 0, + depth: match (true) { + $depth === 'all' => self::ALL_LEVELS, + in_array((int) $depth, self::DEPTHS, true) && is_string($depth) && ctype_digit($depth) => (int) $depth, + default => 3, + }, + theme: in_array($request->query('theme'), ['light', 'dark'], true) ? (string) $request->query('theme') : 'auto', + maxHeight: match (true) { + $maxHeight === 'none' => 0, + is_string($maxHeight) && in_array((int) $maxHeight, self::MAX_HEIGHTS, true) => (int) $maxHeight, + default => 640, + }, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'thread' => $this->thread, + 'replies' => $this->replies, + 'depth' => $this->depth, + 'theme' => $this->theme, + 'maxHeight' => $this->maxHeight, + ]; + } +} diff --git a/app/Http/Controllers/CommitEmbedController.php b/app/Http/Controllers/CommitEmbedController.php new file mode 100644 index 0000000000..377d0bd4e8 --- /dev/null +++ b/app/Http/Controllers/CommitEmbedController.php @@ -0,0 +1,44 @@ +withTrashed()->findOrFail($commit); + $options = EmbedOptions::fromRequest($request); + + Inertia::setRootView('embed'); + + $response = Inertia::render('embed/show', [ + ...$query->get($commit, $options), + 'options' => $options->toArray(), + 'auth' => ['user' => null, 'pasteis' => null, 'organizations' => [], 'isAdmin' => false], + 'now' => now()->toIso8601String(), + 'commitUrl' => route('commit.show', ['profile' => $commit->author->handle ?? 'deleted', 'commit' => $commit->id]), + ])->toResponse($request); + + $response->headers->set('Content-Security-Policy', 'frame-ancestors *'); + $response->headers->set('Cache-Control', 'no-cache, private'); + + return $response; + } +} diff --git a/app/Http/Middleware/PreventFraming.php b/app/Http/Middleware/PreventFraming.php index 364e472780..a85aa4f4e3 100644 --- a/app/Http/Middleware/PreventFraming.php +++ b/app/Http/Middleware/PreventFraming.php @@ -19,6 +19,11 @@ public function handle(Request $request, Closure $next): Response { $response = $next($request); + // Commit embeds are made to be framed by other sites, and send their own policy... + if ($request->routeIs('commit-embed.show')) { + return $response; + } + $response->headers->set('X-Frame-Options', 'DENY'); $response->headers->set('Content-Security-Policy', implode('; ', array_filter([ $response->headers->get('Content-Security-Policy'), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index df2e46465c..5c7a1dd3bf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -205,6 +205,13 @@ private function bootRateLimitingDefaults(): void // Approving the devnull app on the web hands out a code each time... RateLimiter::for('mobile-authorizations', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request))); + // Commit embeds load for every reader of the page that frames them: each card gets its own budget per address, so + // a page with several cards still loads, and one address can't walk every commit... + RateLimiter::for('embeds', fn (Request $request): array => [ + Limit::perMinute(120)->by("embed:{$request->ip()}|{$request->route()?->originalParameter('commit')}"), + Limit::perMinute(600)->by("embeds:{$request->ip()}"), + ]); + // GitHub fetches every README image through a few proxy addresses, so each graph gets its own budget... RateLimiter::for('activity-graph', fn (Request $request): Limit => Limit::perMinute(60)->by("{$request->ip()}|{$request->path()}")); diff --git a/app/Queries/CommitEmbedQuery.php b/app/Queries/CommitEmbedQuery.php new file mode 100644 index 0000000000..3b255ca952 --- /dev/null +++ b/app/Queries/CommitEmbedQuery.php @@ -0,0 +1,107 @@ +|null, replies: list, more: int} + */ +final class CommitEmbedQuery +{ + /** + * The most commits one card holds, whatever depth it asks for. + */ + public const int MAX_COMMITS = 40; + + private int $budget = self::MAX_COMMITS; + + public function __construct( + private readonly CommitAncestorsQuery $ancestors, + private readonly CommitContinuationQuery $continuation, + private readonly CommitRepliesQuery $replies, + ) {} + + /** + * @return 'deleted'|'optout'|'unavailable'|null + */ + public static function tombstone(Commit $commit): ?string + { + if ($commit->trashed()) { + return 'deleted'; + } + + if ($commit->status !== CommitStatus::Approved) { + return 'unavailable'; + } + + $author = $commit->author; + + if ($author instanceof Profile && ($author->user?->allows_embeds === false || $author->organization?->allows_embeds === false)) { + return 'optout'; + } + + return null; + } + + /** + * @return array{ancestors: list, focal: EmbedNode, thread: list} + */ + public function get(Commit $commit, EmbedOptions $options): array + { + $this->budget = self::MAX_COMMITS; + + $focal = Commit::query() + ->withTrashed() + ->withVisibleReplyCount(null) + ->withContinuationId(null) + ->withCardDataFor(null) + ->findOrFail($commit->id); + + $thread = $options->thread && $focal->parent_id === null && self::tombstone($focal) === null + ? $this->continuation->get($focal, null)->commits->all() + : []; + + return [ + 'ancestors' => array_values(array_map(fn (Commit $ancestor): array => $this->node($ancestor), $this->ancestors->get($focal, null)->all())), + 'focal' => $this->node($focal, $options, 0), + 'thread' => array_values(array_map(fn (Commit $next): array => $this->node($next), $thread)), + ]; + } + + /** + * @return EmbedNode + */ + private function node(Commit $commit, ?EmbedOptions $options = null, int $level = 0): array + { + $tombstone = self::tombstone($commit); + $count = (int) ($commit->replies_count ?? 0); + $replies = []; + + if ($options instanceof EmbedOptions && $options->replies > 0 && $level < $options->depth && $count > 0 && $this->budget > 0) { + $children = $this->replies->paginate($commit, null, limit: min($options->replies, $this->budget))->items(); + $this->budget -= count($children); + + foreach ($children as $child) { + $replies[] = $this->node($child, $options, $level + 1); + } + } + + return [ + 'id' => $commit->id, + 'tombstone' => $tombstone, + 'commit' => $tombstone === null ? ShownStreams::commit($commit) : null, + 'replies' => $replies, + 'more' => max(0, $count - count($replies)), + ]; + } +} diff --git a/resources/js/embed.tsx b/resources/js/embed.tsx new file mode 100644 index 0000000000..83ce461171 --- /dev/null +++ b/resources/js/embed.tsx @@ -0,0 +1,24 @@ +import { createInertiaApp } from '@inertiajs/react'; +import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; +import type { ComponentType } from 'react'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import '../css/app.css'; + +// The card other sites frame: only its own page, none of the app's live updates, analytics or service worker. +void createInertiaApp({ + title: () => '/dev/null', + resolve: (name) => + resolvePageComponent( + `./pages/${name}.tsx`, + import.meta.glob('./pages/embed/*.tsx'), + ), + setup({ el, App, props }) { + createRoot(el).render( + + + , + ); + }, + progress: false, +}); diff --git a/resources/js/pages/embed/show.tsx b/resources/js/pages/embed/show.tsx new file mode 100644 index 0000000000..4a12badd39 --- /dev/null +++ b/resources/js/pages/embed/show.tsx @@ -0,0 +1,803 @@ +import { MessageSquare, Star } from 'lucide-react'; +import type { CSSProperties, MouseEvent, ReactNode } from 'react'; +import { useEffect, useRef } from 'react'; +import AppLogoIcon from '@/components/app-logo-icon'; +import { AuthorAvatar } from '@/components/author-avatar'; +import { CommitBody } from '@/components/commit-body'; +import { CommitImages } from '@/components/commit-images'; +import { LinkEmbed } from '@/components/link-embed'; +import { authorLaneColor } from '@/lib/utils'; +import type { Commit, LinkPreview, Poll } from '@/types'; + +type Tombstone = 'deleted' | 'optout' | 'unavailable'; + +export type EmbedNode = { + id: string; + tombstone: Tombstone | null; + commit: Commit | null; + replies: EmbedNode[]; + more: number; +}; + +type Options = { + thread: boolean; + replies: number; + depth: number; + theme: 'auto' | 'light' | 'dark'; + maxHeight: number; +}; + +const tombstoneLabels: Record = { + deleted: '[deleted]', + optout: '[embedding disabled]', + unavailable: '[unavailable]', +}; + +const maxIndentDepth = 3; + +const dateFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}); +const fullDateFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +}); +const timeFormat = new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', +}); + +function laneOfNode(node: EmbedNode): string { + return node.commit ? authorLaneColor(node.commit.author) : 'var(--rail)'; +} + +function shortHash(id: string): string { + return id.replaceAll('-', '').slice(-7); +} + +// Lines are placed with CSS expressions from the card's left edge, which a container query resizes on narrow cards. +const trunkX = 'calc(var(--rail) / 2)'; +const level = (depth: number) => Math.min(depth, maxIndentDepth); +const avatarLeft = (depth: number) => + `calc(var(--rail) + var(--ind) * ${level(depth) - 1})`; +const centerX = (depth: number) => + `calc(var(--rail) + var(--ind) * ${level(depth) - 1} + var(--av) / 2)`; +const spineX = (depth: number) => (depth === 1 ? trunkX : centerX(depth - 1)); + +type Guide = { x: string; color: string; dashed: boolean }; + +function Line({ + x, + color, + dashed, + top = '0px', + bottom = '0px', + height, +}: { + x: string; + color: string; + dashed: boolean; + top?: string; + bottom?: string; + height?: string; +}) { + return ( + + ); +} + +function Elbow({ + from, + to, + color, + onTrunk, +}: { + from: string; + to: string; + color: string; + onTrunk: boolean; +}) { + return ( + + + + ); +} + +function Counts({ commit, replies }: { commit: Commit; replies: number }) { + if (commit.stars_count === 0 && replies === 0) { + return null; + } + + return ( +
+ {commit.stars_count > 0 && ( + + + {commit.stars_count} + + )} + {replies > 0 && ( + + + {replies} + + )} +
+ ); +} + +function EmbedPoll({ poll, url }: { poll: Poll; url: string }) { + const total = poll.total_votes ?? 0; + const winner = Math.max( + ...poll.options.map((option) => option.votes_count ?? 0), + ); + + return ( +
+ {poll.options.map((option) => + poll.is_closed ? ( +
+ + {option.label} + + {total + ? Math.round( + ((option.votes_count ?? 0) / total) * 100, + ) + : 0} + % + +
+ ) : ( + + {option.label} + + ), + )} + + {poll.is_closed + ? `${total} votes · Final results` + : 'Vote on /dev/null'} + +
+ ); +} + +function LinkCard({ preview }: { preview: LinkPreview }) { + if (preview.embed) { + return ; + } + + return ( + + + + + {preview.site_name ?? new URL(preview.url).hostname} + + {preview.title && ( + + {preview.title} + + )} + {preview.description && ( + + {preview.description} + + )} + + + ); +} + +function Content({ commit, url }: { commit: Commit; url: string }) { + return ( + <> + + + {commit.poll && } + {commit.link_preview && } + + ); +} + +function Head({ + commit, + date, + step, + small = false, +}: { + commit: Commit; + date: string | null; + step?: string; + small?: boolean; +}) { + return ( +
+ {commit.author.name} + + @{commit.author.handle} + + {date && ( + <> + · + + {date} + + + )} + {!small && ( + + {step && {step}} + {shortHash(commit.id)} + + )} +
+ ); +} + +type Row = { key: string; node: ReactNode }; + +function commitRow( + node: EmbedNode, + options: { + focal?: boolean; + prevLane?: string | null; + trunk: 'continue' | 'dot'; + spine?: boolean; + step?: string; + url: string; + }, +): Row { + const lane = laneOfNode(node); + const commit = node.commit; + const replies = node.replies.length + node.more; + + return { + key: node.id, + node: ( +
+ {options.prevLane && ( + + )} + {options.trunk === 'continue' && ( + + )} + {options.trunk === 'dot' && ( + <> + + + {options.spine && ( + + )} + + )} + + {commit ? ( + + ) : ( + + )} + +
+ {commit ? ( + <> + +
+ +
+ {options.focal && ( +

+ {timeFormat.format( + new Date(commit.created_at), + )}{' '} + ·{' '} + {fullDateFormat.format( + new Date(commit.created_at), + )}{' '} + · commit {shortHash(commit.id)} +

+ )} + + {options.trunk === 'dot' && + commit.stars_count === 0 && + replies === 0 &&
} + + ) : ( + <> +

+ { + tombstoneLabels[ + node.tombstone ?? 'unavailable' + ] + } +

+ {options.trunk === 'dot' &&
} + + )} +
+
+ ), + }; +} + +function replyRows( + parent: EmbedNode, + depth: number, + guides: Guide[], + onTrunk: boolean, + url: string, + rows: Row[], +): void { + const parentLane = laneOfNode(parent); + + parent.replies.forEach((reply, index) => { + const hasNext = index < parent.replies.length - 1 || parent.more > 0; + const showsChildren = reply.replies.length > 0 || reply.more > 0; + const lane = laneOfNode(reply); + const commit = reply.commit; + const isFlattened = depth > maxIndentDepth; + + rows.push({ + key: reply.id, + node: ( +
+ {guides.map((guide, i) => ( + + ))} + {!isFlattened && ( + + )} + {!isFlattened && hasNext && !(onTrunk && depth === 1) && ( + + )} + {showsChildren && + depth < maxIndentDepth && + reply.replies.length > 0 && ( + + )} +
+ + {commit ? ( + + ) : ( + + )} + +
+ {commit ? ( + <> + +
+ +
+ + + ) : ( +

+ { + tombstoneLabels[ + reply.tombstone ?? 'unavailable' + ] + } +

+ )} +
+
+
+ ), + }); + + if (reply.replies.length > 0) { + const next = + hasNext && depth <= maxIndentDepth && !(onTrunk && depth === 1) + ? [ + ...guides, + { x: spineX(depth), color: parentLane, dashed: true }, + ] + : guides; + + replyRows(reply, depth + 1, next, false, url, rows); + } + }); + + if (parent.more > 0) { + const isFlattened = depth > maxIndentDepth; + const onTheTrunk = onTrunk && depth === 1; + const x = + onTheTrunk || isFlattened + ? `calc(${avatarLeft(depth)} + 7px)` + : spineX(depth); + + rows.push({ + key: `${parent.id}-more`, + node: ( +
+ {guides.map((guide, i) => ( + + ))} + {!isFlattened && + (onTheTrunk ? ( + + ) : ( + + ))} + + + {parent.more} more{' '} + {parent.more === 1 ? 'reply' : 'replies'} on /dev/null + +
+ ), + }); + } +} + +export default function Show({ + ancestors, + focal, + thread, + options, + commitUrl, +}: { + ancestors: EmbedNode[]; + focal: EmbedNode; + thread: EmbedNode[]; + options: Options; + commitUrl: string; +}) { + const card = useRef(null); + + // The page that frames the card sizes it from these messages, through embed.js. + useEffect(() => { + const element = card.current; + + if (!element || window.parent === window) { + return; + } + + const id = + new URLSearchParams(window.location.search).get('signature') ?? ''; + const observer = new ResizeObserver(() => + window.parent.postMessage( + { + type: 'devnull:embed:resize', + id, + height: element.offsetHeight, + }, + '*', + ), + ); + + observer.observe(element); + + return () => observer.disconnect(); + }, []); + + // Links open on /dev/null or their own site, never inside the frame. + const openOutside = (event: MouseEvent) => { + const link = (event.target as HTMLElement).closest('a'); + + if (!link || !link.href || link.href.startsWith('javascript:')) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + window.open(link.href, '_blank', 'noopener'); + }; + + const rows: Row[] = []; + const repliesShown = focal.replies.length > 0 || focal.more > 0; + + ancestors.forEach((ancestor, index) => + rows.push( + commitRow(ancestor, { + trunk: 'continue', + prevLane: index > 0 ? laneOfNode(ancestors[index - 1]) : null, + url: commitUrl, + }), + ), + ); + + if (thread.length > 0) { + const steps = thread.length + 1; + + rows.push( + commitRow(focal, { + focal: true, + trunk: 'continue', + prevLane: null, + step: `1/${steps}`, + url: commitUrl, + }), + ); + + if (repliesShown) { + replyRows( + focal, + 1, + [{ x: trunkX, color: laneOfNode(focal), dashed: false }], + true, + commitUrl, + rows, + ); + } + + thread.forEach((next, index) => + rows.push( + commitRow(next, { + trunk: index === thread.length - 1 ? 'dot' : 'continue', + prevLane: laneOfNode( + index === 0 ? focal : thread[index - 1], + ), + step: `${index + 2}/${steps}`, + url: commitUrl, + }), + ), + ); + } else { + rows.push( + commitRow(focal, { + focal: true, + trunk: 'dot', + spine: focal.replies.length > 0, + prevLane: + ancestors.length > 0 + ? laneOfNode(ancestors[ancestors.length - 1]) + : null, + url: commitUrl, + }), + ); + + if (focal.replies.length > 0 || focal.more > 0) { + replyRows(focal, 1, [], false, commitUrl, rows); + } + } + + return ( +
+ +
+ {rows.map((row) => ( +
{row.node}
+ ))} +
+ +
+ ); +} diff --git a/resources/views/embed.blade.php b/resources/views/embed.blade.php new file mode 100644 index 0000000000..479003b3c6 --- /dev/null +++ b/resources/views/embed.blade.php @@ -0,0 +1,25 @@ + + + + + + + /dev/null + + + @viteReactRefresh + @vite(['resources/js/embed.tsx']) + + + @inertia + + diff --git a/routes/web.php b/routes/web.php index 8d0584f0e0..ed320a6e93 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ use App\Http\Controllers\CommitChangeController; use App\Http\Controllers\CommitContinuationController; use App\Http\Controllers\CommitController; +use App\Http\Controllers\CommitEmbedController; use App\Http\Controllers\CommitImageController; use App\Http\Controllers\CommitLinkPreviewController; use App\Http\Controllers\CommitOpenGraphImageController; @@ -286,6 +287,14 @@ ->name('commit-sitemap.show'); }); +// Commit embeds, framed by other sites and loaded by their readers: no session or cookie, so every card is what a guest +// sees, and a signed URL, which only the embed dialog hands out... +Route::get('embed/{commit}', [CommitEmbedController::class, 'show']) + ->withoutMiddleware('web') + ->middleware(['throttle:embeds', 'signed']) + ->whereUuid('commit') + ->name('commit-embed.show'); + // User... Route::get('mentions/{user}', [MentionController::class, 'show']) ->whereUuid('user') diff --git a/tests/Feature/Controllers/CommitEmbedControllerTest.php b/tests/Feature/Controllers/CommitEmbedControllerTest.php new file mode 100644 index 0000000000..f3047aa4b0 --- /dev/null +++ b/tests/Feature/Controllers/CommitEmbedControllerTest.php @@ -0,0 +1,184 @@ + $commit->id, ...$parameters]); +} + +it('shows a signed embed of an approved commit as a guest, in a frame, without cookies', function (): void { + $commit = Commit::factory()->create(['body' => 'rgt 0.9 is out']); + + $this->actingAs(User::factory()->create()); + + $response = $this->get(embedUrl($commit)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('embed/show') + ->where('auth.user', null) + ->where('focal.commit.id', $commit->id) + ->where('focal.commit.body', 'rgt 0.9 is out') + ->where('focal.tombstone', null) + ->where('options.replies', 0) + ->where('options.theme', 'auto') + ->where('options.maxHeight', 640)); + + expect($response->headers->get('X-Frame-Options'))->toBeNull() + ->and($response->headers->get('Content-Security-Policy'))->toContain('frame-ancestors *') + ->and($response->headers->getCookies())->toBeEmpty(); +}); + +it('keeps every other page out of frames', function (): void { + $this->get(route('home'))->assertHeader('X-Frame-Options', 'DENY'); +}); + +it('refuses unsigned and tampered embeds', function (): void { + $commit = Commit::factory()->create(); + + $this->get(route('commit-embed.show', $commit))->assertForbidden(); + $this->get(embedUrl($commit, ['replies' => 3]).'&depth=99')->assertForbidden(); + $this->get(str_replace('replies=3', 'replies=10', embedUrl($commit, ['replies' => 3])))->assertForbidden(); +}); + +it('falls back to defaults for values outside the allowed ones', function (): void { + $commit = Commit::factory()->create(); + + $this->get(embedUrl($commit, ['replies' => 7, 'depth' => 'deep', 'theme' => 'neon', 'max_height' => 12])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('options.replies', 0) + ->where('options.depth', 3) + ->where('options.theme', 'auto') + ->where('options.maxHeight', 640)); +}); + +it('shows the first replies at each level, down to the chosen depth', function (): void { + $commit = Commit::factory()->create(); + $first = Commit::factory()->create(['parent_id' => $commit->id, 'created_at' => now()->subMinutes(3)]); + $second = Commit::factory()->create(['parent_id' => $commit->id, 'created_at' => now()->subMinutes(2)]); + Commit::factory()->create(['parent_id' => $commit->id, 'created_at' => now()->subMinute()]); + $nested = Commit::factory()->create(['parent_id' => $first->id]); + Commit::factory()->create(['parent_id' => $nested->id]); + + $this->get(embedUrl($commit, ['replies' => 1, 'depth' => 2])) + ->assertInertia(fn ($page) => $page + ->has('focal.replies', 1) + ->where('focal.more', 2) + ->where('focal.replies.0.commit.id', $first->id) + ->where('focal.replies.0.replies.0.commit.id', $nested->id) + ->has('focal.replies.0.replies.0.replies', 0) + ->where('focal.replies.0.replies.0.more', 1)); + + $this->get(embedUrl($commit, ['replies' => 3, 'depth' => 1])) + ->assertInertia(fn ($page) => $page + ->has('focal.replies', 3) + ->where('focal.replies.1.commit.id', $second->id) + ->has('focal.replies.0.replies', 0) + ->where('focal.replies.0.more', 1)); +}); + +it('leaves tombstones where commits cannot be shown, and keeps what follows them', function (): void { + $commit = Commit::factory()->create(); + $deleted = Commit::factory()->create(['parent_id' => $commit->id, 'created_at' => now()->subMinutes(2)]); + $survivor = Commit::factory()->create(['parent_id' => $deleted->id]); + $deleted->delete(); + $optedOut = Commit::factory()->for(User::factory()->state(['allows_embeds' => false]))->create(['parent_id' => $commit->id, 'body' => 'secret', 'created_at' => now()->subMinute()]); + + $this->get(embedUrl($commit, ['replies' => 3, 'depth' => 2])) + ->assertInertia(fn ($page) => $page + ->where('focal.replies.0.tombstone', 'deleted') + ->where('focal.replies.0.commit', null) + ->where('focal.replies.0.replies.0.commit.id', $survivor->id) + ->where('focal.replies.1.tombstone', 'optout') + ->where('focal.replies.1.commit', null) + ->where('focal.replies.1.id', $optedOut->id)); +}); + +it('shows the embedded commit as a tombstone when it is not public, opted out or deleted', function (string $state, string $tombstone): void { + $commit = match ($state) { + 'pending' => Commit::factory()->pending()->create(), + 'rejected' => Commit::factory()->rejected()->create(), + 'opted out' => Commit::factory()->for(User::factory()->state(['allows_embeds' => false]))->create(), + 'deleted' => tap(Commit::factory()->create())->delete(), + }; + + $this->get(embedUrl($commit)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('focal.tombstone', $tombstone) + ->where('focal.commit', null)); +})->with([ + ['pending', 'unavailable'], + ['rejected', 'unavailable'], + ['opted out', 'optout'], + ['deleted', 'deleted'], +]); + +it('shows the rest of the self-thread when asked', function (): void { + $author = User::factory()->create(); + $commit = Commit::factory()->for($author)->create(); + $next = Commit::factory()->for($author)->create(['parent_id' => $commit->id]); + + $this->get(embedUrl($commit, ['thread' => 1])) + ->assertInertia(fn ($page) => $page + ->where('options.thread', true) + ->where('thread.0.commit.id', $next->id)); + + $this->get(embedUrl($commit))->assertInertia(fn ($page) => $page->has('thread', 0)); +}); + +it('shows every ancestor of an embedded reply, from the root', function (): void { + $root = Commit::factory()->create(); + $middle = Commit::factory()->create(['parent_id' => $root->id]); + $reply = Commit::factory()->create(['parent_id' => $middle->id]); + + $this->get(embedUrl($reply)) + ->assertInertia(fn ($page) => $page + ->where('ancestors.0.commit.id', $root->id) + ->where('ancestors.1.commit.id', $middle->id) + ->where('focal.commit.id', $reply->id)); +}); + +it('caps how many commits a card holds', function (): void { + $commit = Commit::factory()->create(); + $parent = $commit; + + foreach (range(1, 60) as $level) { + $parent = Commit::factory()->create(['parent_id' => $parent->id]); + } + + $this->get(embedUrl($commit, ['replies' => 1, 'depth' => 'all'])) + ->assertInertia(function ($page): void { + $count = 0; + $node = $page->toArray()['props']['focal']; + + while ($node['replies'] !== []) { + $count++; + $node = $node['replies'][0]; + } + + expect($count)->toBeLessThan(50) + ->and($node['more'])->toBe(1); + }); +}); + +it('rejects ids that are not uuids before any lookup', function (): void { + $this->get('/embed/not-a-uuid')->assertNotFound(); +}); + +it('limits how often one address loads embeds', function (): void { + $commit = Commit::factory()->create(); + $url = embedUrl($commit); + + RateLimiter::for('embeds', fn () => Illuminate\Cache\RateLimiting\Limit::perMinute(2)->by('test')); + + $this->get($url)->assertOk(); + $this->get($url)->assertOk(); + $this->get($url)->assertTooManyRequests(); +}); diff --git a/vite.config.ts b/vite.config.ts index 56cc0d81ca..dd678c82e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,10 @@ -import {wayfinder} from '@laravel/vite-plugin-wayfinder'; +import { wayfinder } from '@laravel/vite-plugin-wayfinder'; import babel from '@rolldown/plugin-babel'; import tailwindcss from '@tailwindcss/vite'; -import react, {reactCompilerPreset} from '@vitejs/plugin-react'; +import react, { reactCompilerPreset } from '@vitejs/plugin-react'; import laravel from 'laravel-vite-plugin'; -import {bunny} from 'laravel-vite-plugin/fonts'; -import {defineConfig} from 'vite-plus'; +import { bunny } from 'laravel-vite-plugin/fonts'; +import { defineConfig } from 'vite-plus'; export default defineConfig({ lint: { @@ -59,7 +59,11 @@ export default defineConfig({ }, plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.tsx'], + input: [ + 'resources/css/app.css', + 'resources/js/app.tsx', + 'resources/js/embed.tsx', + ], ssr: 'resources/js/ssr.tsx', refresh: true, fonts: [ From 001c7fc1ca3c8ad941e9a45f9eab80bed02e8898 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sat, 7 Nov 2026 22:12:28 +0100 Subject: [PATCH 03/16] feat: size commit embeds to their content with embed.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card posts its height to the page that frames it whenever it changes. embed.js, a static script, listens for those messages, trusts only the ones from /dev/null's origin, and resizes the frame that sent each one. Including it twice listens once. 🤖 Generated with Claude Code --- public/embed.js | 29 ++++++++ tests/Unit/EmbedScript.test.ts | 124 +++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 public/embed.js create mode 100644 tests/Unit/EmbedScript.test.ts diff --git a/public/embed.js b/public/embed.js new file mode 100644 index 0000000000..6b8451d6d0 --- /dev/null +++ b/public/embed.js @@ -0,0 +1,29 @@ +/* + * Sizes /dev/null commit embeds to their content. Each card posts its height; only messages from /dev/null's origin + * are trusted, and only the frame that sent one is resized. + */ +(function () { + if (window.devNullEmbeds) { + return; + } + + window.devNullEmbeds = true; + + var origin = new URL(document.currentScript.src).origin; + + window.addEventListener('message', function (event) { + var data = event.data; + + if (event.origin !== origin || !data || data.type !== 'devnull:embed:resize' || typeof data.height !== 'number') { + return; + } + + var frames = document.querySelectorAll('iframe[src^="' + origin + '/embed/"]'); + + for (var i = 0; i < frames.length; i++) { + if (frames[i].contentWindow === event.source) { + frames[i].style.height = Math.ceil(data.height) + 'px'; + } + } + }); +})(); diff --git a/tests/Unit/EmbedScript.test.ts b/tests/Unit/EmbedScript.test.ts new file mode 100644 index 0000000000..6cd159fd79 --- /dev/null +++ b/tests/Unit/EmbedScript.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +type Listener = (event: { + origin: string; + source: unknown; + data: unknown; +}) => void; + +function load( + frames: { + src: string; + contentWindow: object; + style: { height?: string }; + }[], +) { + const listeners: Listener[] = []; + const context = { + URL, + document: { + currentScript: { src: 'https://dev-null.sh/embed.js' }, + querySelectorAll: (selector: string) => { + const prefix = /\^="([^"]+)"/.exec(selector)?.[1] ?? ''; + + return frames.filter((frame) => frame.src.startsWith(prefix)); + }, + }, + window: { + addEventListener: (type: string, listener: Listener) => { + if (type === 'message') { + listeners.push(listener); + } + }, + } as Record, + }; + + vm.runInNewContext(readFileSync('public/embed.js', 'utf8'), context); + + return (event: { origin: string; source: unknown; data: unknown }) => + listeners.forEach((listener) => listener(event)); +} + +test('sizes the frame that sent its height', () => { + const card = { + src: 'https://dev-null.sh/embed/1?signature=a', + contentWindow: {}, + style: {}, + }; + const other = { + src: 'https://dev-null.sh/embed/2?signature=b', + contentWindow: {}, + style: {}, + }; + const post = load([card, other]); + + post({ + origin: 'https://dev-null.sh', + source: card.contentWindow, + data: { type: 'devnull:embed:resize', height: 412.4 }, + }); + + assert.equal(card.style.height, '413px'); + assert.equal(other.style.height, undefined); +}); + +test('ignores messages from other origins, of other types, or with no usable height', () => { + const card = { + src: 'https://dev-null.sh/embed/1?signature=a', + contentWindow: {}, + style: {}, + }; + const post = load([card]); + + post({ + origin: 'https://evil.example', + source: card.contentWindow, + data: { type: 'devnull:embed:resize', height: 300 }, + }); + post({ + origin: 'https://dev-null.sh', + source: card.contentWindow, + data: { type: 'other', height: 300 }, + }); + post({ + origin: 'https://dev-null.sh', + source: card.contentWindow, + data: { type: 'devnull:embed:resize', height: 'tall' }, + }); + post({ + origin: 'https://dev-null.sh', + source: card.contentWindow, + data: null, + }); + + assert.equal(card.style.height, undefined); +}); + +test('listens once when the script is included several times', () => { + const card = { + src: 'https://dev-null.sh/embed/1?signature=a', + contentWindow: {}, + style: {}, + }; + const listeners: Listener[] = []; + const context = { + URL, + document: { + currentScript: { src: 'https://dev-null.sh/embed.js' }, + querySelectorAll: () => [card], + }, + window: { + addEventListener: (_: string, listener: Listener) => + listeners.push(listener), + } as Record, + }; + const source = readFileSync('public/embed.js', 'utf8'); + + vm.runInNewContext(source, context); + vm.runInNewContext(source, context); + + assert.equal(listeners.length, 1); +}); From 1111ccec14ecaf3890a46ce3d722e64cc3386b1b Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sat, 7 Nov 2026 22:14:43 +0100 Subject: [PATCH 04/16] feat: describe signed commit links through oEmbed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embed dialog's Link tab will hand out the commit's page flagged with `embed` and carrying the card's signed query. GET /oembed turns such a link into a rich response framing the card, with embed.js, so blogging platforms build the card from the link alone. Bare and tampered links answer 401, links that aren't one of our commits 404, commits that can't be embedded 401, and any format but JSON 501. The commit's page advertises the endpoint only when it is opened from such a link. The endpoint skips the session and has its own per-address limit. 🤖 Generated with Claude Code --- app/Http/Controllers/CommitController.php | 9 +- app/Http/Controllers/OEmbedController.php | 66 +++++++++++++++ app/Providers/AppServiceProvider.php | 2 + app/Support/EmbedLinks.php | 47 +++++++++++ resources/views/app.blade.php | 4 + routes/web.php | 5 ++ .../Controllers/OEmbedControllerTest.php | 84 +++++++++++++++++++ 7 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/OEmbedController.php create mode 100644 app/Support/EmbedLinks.php create mode 100644 tests/Feature/Controllers/OEmbedControllerTest.php diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index 02fc2bf2f7..050a301eb3 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -60,7 +60,14 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs 'ancestors' => fn (): array => ShownStreams::commits($ancestors->get($commit, $currentUser)), 'continuation' => fn (): array => ShownStreams::continuation($getContinuation()), 'replies' => fn (): ?CursorPaginator => $getContinuation()->commits->isEmpty() ? ShownStreams::page($getReplies()) : null, - ])->withViewData(['preview' => $preview]); + ])->withViewData([ + 'preview' => $preview, + // A link from the embed dialog points oEmbed consumers to its card. Its raw query is kept as is, in the + // order it was signed in. + 'oembedUrl' => $request->has(['embed', 'signature']) && ! $request->inertia() + ? route('oembed.show', ['url' => $request->getSchemeAndHttpHost().$request->getRequestUri(), 'format' => 'json']) + : null, + ]); } public function store(CreateCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, UserAchievementsQuery $achievements): RedirectResponse diff --git a/app/Http/Controllers/OEmbedController.php b/app/Http/Controllers/OEmbedController.php new file mode 100644 index 0000000000..ed88e0fba8 --- /dev/null +++ b/app/Http/Controllers/OEmbedController.php @@ -0,0 +1,66 @@ +query('format', 'json') !== 'json', 501); + + $link = (string) $request->query('url'); + $id = EmbedLinks::commitId($link); + $commit = $id === null ? null : Commit::query()->withTrashed()->with('author')->find($id); + + abort_if(! $commit instanceof Commit, 404); + + $card = EmbedLinks::signedCardUrl($link, $commit); + + abort_if($card === null || CommitEmbedQuery::tombstone($commit) !== null, 401); + + $options = EmbedOptions::fromRequest(Request::create($card)); + $width = min(self::WIDTH, max(1, (int) $request->query('maxwidth', (string) self::WIDTH))); + $height = ($options->maxHeight ?: 640) + self::FOOTER; + $author = $commit->author; + + return response()->json([ + 'version' => '1.0', + 'type' => 'rich', + 'provider_name' => '/dev/null', + 'provider_url' => config()->string('app.url'), + 'author_name' => $author?->name, + 'author_url' => $author === null ? null : route('user.show', $author->handle), + 'html' => sprintf( + '', + e($card), + e('Commit on /dev/null'), + $width, + $height, + e(url('embed.js')), + ), + 'width' => $width, + 'height' => $height, + 'cache_age' => 3600, + ]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5c7a1dd3bf..f3cbd46519 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -211,6 +211,8 @@ private function bootRateLimitingDefaults(): void Limit::perMinute(120)->by("embed:{$request->ip()}|{$request->route()?->originalParameter('commit')}"), Limit::perMinute(600)->by("embeds:{$request->ip()}"), ]); + // oEmbed consumers fetch a link once and keep what they get... + RateLimiter::for('oembed', fn (Request $request): Limit => Limit::perMinute(60)->by("oembed:{$request->ip()}")); // GitHub fetches every README image through a few proxy addresses, so each graph gets its own budget... RateLimiter::for('activity-graph', fn (Request $request): Limit => Limit::perMinute(60)->by("{$request->ip()}|{$request->path()}")); diff --git a/app/Support/EmbedLinks.php b/app/Support/EmbedLinks.php new file mode 100644 index 0000000000..cac604e248 --- /dev/null +++ b/app/Support/EmbedLinks.php @@ -0,0 +1,47 @@ +string('app.url')); + + if (! is_array($parts) || ! is_array($app) || ($parts['host'] ?? null) !== ($app['host'] ?? null) || ($parts['port'] ?? null) !== ($app['port'] ?? null)) { + return null; + } + + return preg_match(self::COMMIT_PATH, $parts['path'] ?? '', $matches) === 1 ? mb_strtolower($matches[1]) : null; + } + + /** + * The card's URL a commit link stands for, when its signature holds. + */ + public static function signedCardUrl(string $link, Commit $commit): ?string + { + $query = collect(explode('&', (string) parse_url($link, PHP_URL_QUERY))) + ->reject(fn (string $part): bool => $part === 'embed' || $part === '') + ->implode('&'); + + $url = mb_rtrim(route('commit-embed.show', $commit).'?'.$query, '?'); + + return URL::hasValidSignature(Request::create($url)) ? $url : null; + } +} diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index e519d45c19..4f73434a1f 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -11,6 +11,10 @@ @endif + @isset($oembedUrl) + + @endisset + @isset($preview) diff --git a/routes/web.php b/routes/web.php index ed320a6e93..e8753c78d5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,7 @@ use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationGroupController; use App\Http\Controllers\NotificationReadAtController; +use App\Http\Controllers\OEmbedController; use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OperationsController; use App\Http\Controllers\OperationsEventController; @@ -294,6 +295,10 @@ ->middleware(['throttle:embeds', 'signed']) ->whereUuid('commit') ->name('commit-embed.show'); +Route::get('oembed', [OEmbedController::class, 'show']) + ->withoutMiddleware('web') + ->middleware('throttle:oembed') + ->name('oembed.show'); // User... Route::get('mentions/{user}', [MentionController::class, 'show']) diff --git a/tests/Feature/Controllers/OEmbedControllerTest.php b/tests/Feature/Controllers/OEmbedControllerTest.php new file mode 100644 index 0000000000..acaa9ecac1 --- /dev/null +++ b/tests/Feature/Controllers/OEmbedControllerTest.php @@ -0,0 +1,84 @@ + $commit->id, ...$parameters]); + $query = (string) parse_url($signed, PHP_URL_QUERY); + + return route('commit.show', ['profile' => $commit->author->handle, 'commit' => $commit->id]).'?embed&'.$query; +} + +it('describes a signed commit link as a rich embed of its card', function (): void { + $commit = Commit::factory()->for(User::factory()->state(['name' => 'Mira Okafor', 'handle' => 'mira']))->create(); + $link = embedLink($commit, ['replies' => 3, 'max_height' => 480]); + + $response = $this->getJson(route('oembed.show', ['url' => $link, 'format' => 'json'])) + ->assertOk() + ->assertJson([ + 'version' => '1.0', + 'type' => 'rich', + 'provider_name' => '/dev/null', + 'provider_url' => config('app.url'), + 'author_name' => 'Mira Okafor', + 'author_url' => route('user.show', 'mira'), + 'width' => 550, + 'height' => 525, + ]); + + $html = $response->json('html'); + $src = html_entity_decode((string) str($html)->match('/src="([^"]+)"/')); + + expect($html)->toContain('toContain(url('embed.js')) + ->and($src)->toStartWith(route('commit-embed.show', $commit)); + + $this->get($src)->assertOk(); +}); + +it('fits the width the consumer asks for', function (): void { + $commit = Commit::factory()->create(); + + $this->getJson(route('oembed.show', ['url' => embedLink($commit), 'maxwidth' => 400])) + ->assertOk() + ->assertJsonPath('width', 400); +}); + +it('refuses bare, tampered and foreign links', function (callable $link, int $status): void { + $commit = Commit::factory()->create(); + + $this->getJson(route('oembed.show', ['url' => $link($commit)]))->assertStatus($status); +})->with([ + 'bare commit link' => [fn (Commit $commit): string => route('commit.show', ['profile' => $commit->author->handle, 'commit' => $commit->id]), 401], + 'tampered settings' => [fn (Commit $commit): string => str_replace('replies=1', 'replies=10', embedLink($commit, ['replies' => 1])), 401], + 'another site' => [fn (Commit $commit): string => 'https://example.com/@x/commits/'.$commit->id.'?embed', 404], + 'not a commit' => [fn (Commit $commit): string => url('/@'.$commit->author->handle), 404], + 'unknown commit' => [fn (Commit $commit): string => str_replace($commit->id, '01a1b7c7-0000-7000-8000-000000000000', embedLink($commit)), 404], +]); + +it('refuses commits that cannot be embedded', function (): void { + $commit = Commit::factory()->for(User::factory()->state(['allows_embeds' => false]))->create(); + + $this->getJson(route('oembed.show', ['url' => embedLink($commit)]))->assertUnauthorized(); +}); + +it('only speaks json', function (): void { + $commit = Commit::factory()->create(); + + $this->get(route('oembed.show', ['url' => embedLink($commit), 'format' => 'xml']))->assertStatus(501); +}); + +it('advertises the oembed endpoint on the page of a signed commit link only', function (): void { + $commit = Commit::factory()->create(); + $link = embedLink($commit); + + $this->get($link)->assertSee(e(route('oembed.show', ['url' => $link, 'format' => 'json'])), false); + $this->get(route('commit.show', ['profile' => $commit->author->handle, 'commit' => $commit->id]))->assertDontSee('application/json+oembed', false); +}); From eb101b5c939bf41a6d1feef05fddb40c5ce2dc44 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 8 Nov 2026 08:37:41 +0100 Subject: [PATCH 05/16] refactor: keep embed serialization and oEmbed checks in their layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arch tests keep queries away from HTTP resources and controllers away from abort_if. CommitEmbedQuery now returns models, which the controller shows through ShownStreams::embedNode, and the oEmbed statuses (501, 404, 401) move to ShowOEmbedRequest's authorization. 🤖 Generated with Claude Code --- .../Controllers/CommitEmbedController.php | 7 +- app/Http/Controllers/OEmbedController.php | 23 ++---- app/Http/Requests/ShowOEmbedRequest.php | 71 +++++++++++++++++++ app/Http/Resources/ShownStreams.php | 18 +++++ app/Queries/CommitEmbedQuery.php | 5 +- 5 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 app/Http/Requests/ShowOEmbedRequest.php diff --git a/app/Http/Controllers/CommitEmbedController.php b/app/Http/Controllers/CommitEmbedController.php index 377d0bd4e8..230e2a542e 100644 --- a/app/Http/Controllers/CommitEmbedController.php +++ b/app/Http/Controllers/CommitEmbedController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Dto\EmbedOptions; +use App\Http\Resources\ShownStreams; use App\Models\Commit; use App\Queries\CommitEmbedQuery; use Illuminate\Http\Request; @@ -28,8 +29,12 @@ public function show(Request $request, string $commit, CommitEmbedQuery $query): Inertia::setRootView('embed'); + $tree = $query->get($commit, $options); + $response = Inertia::render('embed/show', [ - ...$query->get($commit, $options), + 'ancestors' => array_map(ShownStreams::embedNode(...), $tree['ancestors']), + 'focal' => ShownStreams::embedNode($tree['focal']), + 'thread' => array_map(ShownStreams::embedNode(...), $tree['thread']), 'options' => $options->toArray(), 'auth' => ['user' => null, 'pasteis' => null, 'organizations' => [], 'isAdmin' => false], 'now' => now()->toIso8601String(), diff --git a/app/Http/Controllers/OEmbedController.php b/app/Http/Controllers/OEmbedController.php index ed88e0fba8..2b6c827c64 100644 --- a/app/Http/Controllers/OEmbedController.php +++ b/app/Http/Controllers/OEmbedController.php @@ -5,9 +5,7 @@ namespace App\Http\Controllers; use App\Dto\EmbedOptions; -use App\Models\Commit; -use App\Queries\CommitEmbedQuery; -use App\Support\EmbedLinks; +use App\Http\Requests\ShowOEmbedRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -24,24 +22,13 @@ */ private const int FOOTER = 45; - public function show(Request $request): JsonResponse + public function show(ShowOEmbedRequest $request): JsonResponse { - abort_if($request->query('format', 'json') !== 'json', 501); - - $link = (string) $request->query('url'); - $id = EmbedLinks::commitId($link); - $commit = $id === null ? null : Commit::query()->withTrashed()->with('author')->find($id); - - abort_if(! $commit instanceof Commit, 404); - - $card = EmbedLinks::signedCardUrl($link, $commit); - - abort_if($card === null || CommitEmbedQuery::tombstone($commit) !== null, 401); - + $card = $request->card(); $options = EmbedOptions::fromRequest(Request::create($card)); - $width = min(self::WIDTH, max(1, (int) $request->query('maxwidth', (string) self::WIDTH))); + $width = min(self::WIDTH, $request->integer('maxwidth', self::WIDTH)); $height = ($options->maxHeight ?: 640) + self::FOOTER; - $author = $commit->author; + $author = $request->commit()->author; return response()->json([ 'version' => '1.0', diff --git a/app/Http/Requests/ShowOEmbedRequest.php b/app/Http/Requests/ShowOEmbedRequest.php new file mode 100644 index 0000000000..dee640b4e2 --- /dev/null +++ b/app/Http/Requests/ShowOEmbedRequest.php @@ -0,0 +1,71 @@ +query('format', 'json') !== 'json') { + return Response::denyWithStatus(501); + } + + $id = EmbedLinks::commitId($this->link()); + $this->commit = $id === null ? null : Commit::query()->withTrashed()->with('author')->find($id); + + if (! $this->commit instanceof Commit) { + return Response::denyAsNotFound(); + } + + $this->card = EmbedLinks::signedCardUrl($this->link(), $this->commit); + + return $this->card !== null && CommitEmbedQuery::tombstone($this->commit) === null + ? Response::allow() + : Response::denyWithStatus(401); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'maxwidth' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function link(): string + { + return (string) $this->query('url'); + } + + public function commit(): Commit + { + assert($this->commit instanceof Commit); + + return $this->commit; + } + + public function card(): string + { + assert(is_string($this->card)); + + return $this->card; + } +} diff --git a/app/Http/Resources/ShownStreams.php b/app/Http/Resources/ShownStreams.php index febea11a22..039e5b6c60 100644 --- a/app/Http/Resources/ShownStreams.php +++ b/app/Http/Resources/ShownStreams.php @@ -51,6 +51,24 @@ public static function users(iterable $users): array return $shown; } + /** + * An embed's commits, each shown as a card or kept as its tombstone, with their replies under them. + * + * @param array{id: string, tombstone: string|null, commit: Commit|null, replies: list, more: int} $node + * @return array + */ + public static function embedNode(array $node): array + { + return [ + ...$node, + 'commit' => self::commit($node['commit']), + 'replies' => array_map( + fn (mixed $reply): array => self::embedNode($reply), // @phpstan-ignore argument.type + $node['replies'], + ), + ]; + } + /** * @return array|null */ diff --git a/app/Queries/CommitEmbedQuery.php b/app/Queries/CommitEmbedQuery.php index 3b255ca952..b4692e5ba3 100644 --- a/app/Queries/CommitEmbedQuery.php +++ b/app/Queries/CommitEmbedQuery.php @@ -6,7 +6,6 @@ use App\Dto\EmbedOptions; use App\Enums\CommitStatus; -use App\Http\Resources\ShownStreams; use App\Models\Commit; use App\Models\Profile; @@ -14,7 +13,7 @@ * The commits an embed shows, as a guest sees them, each one either shown or left as a tombstone so the rest of the * thread still reads. * - * @phpstan-type EmbedNode array{id: string, tombstone: 'deleted'|'optout'|'unavailable'|null, commit: array|null, replies: list, more: int} + * @phpstan-type EmbedNode array{id: string, tombstone: 'deleted'|'optout'|'unavailable'|null, commit: Commit|null, replies: list, more: int} */ final class CommitEmbedQuery { @@ -99,7 +98,7 @@ private function node(Commit $commit, ?EmbedOptions $options = null, int $level return [ 'id' => $commit->id, 'tombstone' => $tombstone, - 'commit' => $tombstone === null ? ShownStreams::commit($commit) : null, + 'commit' => $tombstone === null ? $commit : null, 'replies' => $replies, 'more' => max(0, $count - count($replies)), ]; From 71a00cf6f86efe45b4646fc7d46988994470c0c2 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 8 Nov 2026 08:37:41 +0100 Subject: [PATCH 06/16] feat: add Embed commit to the commit menu, with its dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members find Embed commit in a commit's "…" menu, after Send in Remotes, on approved commits whose author allows embeds: the web now tells each commit whether it is embeddable. The dialog follows the Settings pages: switches for the rest of the thread and the replies, segmented controls for replies per level, levels, max height and theme. Each change asks the new commit-embed-link endpoint, members only, for the card's signed URL and the commit link that stands for it; the preview frames the real card, and the HTML and Link tabs copy the code with a mono [embed ] copied toast. The card's dates now read in the reader's own time, like on /dev/null. 🤖 Generated with Claude Code --- app/Dto/EmbedOptions.php | 20 + .../Controllers/CommitEmbedLinkController.php | 22 + .../Requests/ShowCommitEmbedLinkRequest.php | 34 ++ app/Http/Resources/ShownStreams.php | 3 + app/Support/EmbedLinks.php | 14 + .../js/components/commit-embed-dialog.tsx | 427 ++++++++++++++++++ .../components/commit-secondary-actions.tsx | 30 +- resources/js/pages/embed/show.tsx | 10 +- resources/js/types/commit.ts | 2 + routes/web.php | 7 + .../CommitEmbedLinkControllerTest.php | 63 +++ 11 files changed, 623 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/CommitEmbedLinkController.php create mode 100644 app/Http/Requests/ShowCommitEmbedLinkRequest.php create mode 100644 resources/js/components/commit-embed-dialog.tsx create mode 100644 tests/Feature/Controllers/CommitEmbedLinkControllerTest.php diff --git a/app/Dto/EmbedOptions.php b/app/Dto/EmbedOptions.php index 984b89be02..54455c07af 100644 --- a/app/Dto/EmbedOptions.php +++ b/app/Dto/EmbedOptions.php @@ -57,6 +57,26 @@ public static function fromRequest(Request $request): self ); } + /** + * The query that asks for these options, with only what differs from the defaults, the way URLs are signed. + * + * @return array + */ + public function query(): array + { + return array_filter([ + 'thread' => $this->thread ? 1 : null, + 'replies' => $this->replies ?: null, + 'depth' => $this->replies === 0 ? null : ($this->depth === self::ALL_LEVELS ? 'all' : $this->depth), + 'theme' => $this->theme === 'auto' ? null : $this->theme, + 'max_height' => match ($this->maxHeight) { + 640 => null, + 0 => 'none', + default => $this->maxHeight, + }, + ], fn (int|string|null $value): bool => $value !== null); + } + /** * @return array */ diff --git a/app/Http/Controllers/CommitEmbedLinkController.php b/app/Http/Controllers/CommitEmbedLinkController.php new file mode 100644 index 0000000000..8462400d7a --- /dev/null +++ b/app/Http/Controllers/CommitEmbedLinkController.php @@ -0,0 +1,22 @@ +json(EmbedLinks::for($commit, EmbedOptions::fromRequest($request))); + } +} diff --git a/app/Http/Requests/ShowCommitEmbedLinkRequest.php b/app/Http/Requests/ShowCommitEmbedLinkRequest.php new file mode 100644 index 0000000000..6c549abf4c --- /dev/null +++ b/app/Http/Requests/ShowCommitEmbedLinkRequest.php @@ -0,0 +1,34 @@ +route('commit'); + assert($commit instanceof Commit); + + return CommitEmbedQuery::tombstone($commit) === null ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * Every setting falls back to its default instead of failing, like the card does. + * + * @return array> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/ShownStreams.php b/app/Http/Resources/ShownStreams.php index 039e5b6c60..5c28569eae 100644 --- a/app/Http/Resources/ShownStreams.php +++ b/app/Http/Resources/ShownStreams.php @@ -10,6 +10,7 @@ use App\Models\Notification; use App\Models\Profile; use App\Models\User; +use App\Queries\CommitEmbedQuery; use Illuminate\Pagination\CursorPaginator; use Illuminate\Support\Collection; @@ -86,6 +87,8 @@ public static function commit(?Commit $commit): ?array if (is_array($shown['author'] ?? null) && $commit->relationLoaded('author') && $commit->author instanceof Profile) { $shown['author'] = self::authored($shown['author'], $commit->author); + // Whether its "…" menu offers to embed it: approved, and its author allows embeds. + $shown['embeddable'] = CommitEmbedQuery::tombstone($commit) === null; } if (is_array($shown['quoted_commit'] ?? null) && $commit->relationLoaded('quotedCommit')) { diff --git a/app/Support/EmbedLinks.php b/app/Support/EmbedLinks.php index cac604e248..a5921cd273 100644 --- a/app/Support/EmbedLinks.php +++ b/app/Support/EmbedLinks.php @@ -4,6 +4,7 @@ namespace App\Support; +use App\Dto\EmbedOptions; use App\Models\Commit; use Illuminate\Http\Request; use Illuminate\Support\Facades\URL; @@ -16,6 +17,19 @@ final class EmbedLinks { private const string COMMIT_PATH = '#^/@[^/]+/commits/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$#i'; + /** + * The card's signed URL, and the commit link that stands for it. + * + * @return array{card: string, link: string} + */ + public static function for(Commit $commit, EmbedOptions $options): array + { + $card = URL::signedRoute('commit-embed.show', ['commit' => $commit->id, ...$options->query()]); + $page = route('commit.show', ['profile' => $commit->author->handle ?? '', 'commit' => $commit->id]); + + return ['card' => $card, 'link' => $page.'?embed&'.parse_url($card, PHP_URL_QUERY)]; + } + /** * The commit a link to one of our commit pages points at, or null for any other link. */ diff --git a/resources/js/components/commit-embed-dialog.tsx b/resources/js/components/commit-embed-dialog.tsx new file mode 100644 index 0000000000..1d6e90dd9c --- /dev/null +++ b/resources/js/components/commit-embed-dialog.tsx @@ -0,0 +1,427 @@ +import { Monitor, Moon, Sun } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import CommitEmbedLinkController from '@/actions/App/Http/Controllers/CommitEmbedLinkController'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useClipboard } from '@/hooks/use-clipboard'; +import { cn, shortHash } from '@/lib/utils'; +import type { Commit } from '@/types'; + +type Theme = 'light' | 'dark' | 'auto'; +type Links = { card: string; link: string }; + +const footerHeight = 45; + +function Switch({ + checked, + onChange, + labelledBy, + describedBy, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + labelledBy: string; + describedBy: string; +}) { + return ( + + ); +} + +function Section({ + title, + description, + control, + children, +}: { + title: string; + description: ReactNode; + control?: (ids: { label: string; description: string }) => ReactNode; + children?: ReactNode; +}) { + const id = useId(); + const ids = { label: `${id}-label`, description: `${id}-description` }; + + return ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ {control?.(ids)} +
+ {children} +
+ ); +} + +export function CommitEmbedDialog({ + commit, + open, + onOpenChange, +}: { + commit: Commit; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const isReply = commit.parent_id !== null; + const hash = shortHash(commit.id); + const [thread, setThread] = useState(true); + const [replies, setReplies] = useState((commit.replies_count ?? 0) > 0); + const [perLevel, setPerLevel] = useState(3); + const [depth, setDepth] = useState(3); + const [maxHeight, setMaxHeight] = useState(640); + const [theme, setTheme] = useState('auto'); + const [tab, setTab] = useState<'html' | 'link'>('html'); + const [links, setLinks] = useState(null); + const [previewHeight, setPreviewHeight] = useState(240); + const preview = useRef(null); + const [, copy] = useClipboard(); + + useEffect(() => { + if (!open) { + return; + } + + const controller = new AbortController(); + const query = { + ...(thread && !isReply ? { thread: 1 } : {}), + ...(replies ? { replies: perLevel, depth } : {}), + ...(theme === 'auto' ? {} : { theme }), + ...(maxHeight === 640 ? {} : { max_height: maxHeight }), + }; + + fetch(CommitEmbedLinkController.show.url(commit.id, { query }), { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + }) + .then((response) => + response.ok + ? (response.json() as Promise) + : Promise.reject(new Error(String(response.status))), + ) + .then(setLinks) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + toast.error( + `fatal: could not prepare the embed for ${hash}`, + ); + console.warn(error); + } + }); + + return () => controller.abort(); + }, [ + open, + commit.id, + hash, + isReply, + thread, + replies, + perLevel, + depth, + theme, + maxHeight, + ]); + + // The preview sizes itself from the card's messages, like embed.js does on other sites. + useEffect(() => { + const resize = (event: MessageEvent) => { + const data = event.data as { + type?: string; + height?: unknown; + } | null; + + if ( + event.origin === window.location.origin && + event.source === preview.current?.contentWindow && + data?.type === 'devnull:embed:resize' && + typeof data.height === 'number' + ) { + setPreviewHeight(Math.ceil(data.height)); + } + }; + + window.addEventListener('message', resize); + + return () => window.removeEventListener('message', resize); + }, []); + + const height = (maxHeight === 'none' ? 640 : maxHeight) + footerHeight; + const code = + links === null + ? '' + : tab === 'html' + ? `\n` + : links.link; + + const copyCode = async () => { + if (code !== '' && (await copy(code))) { + toast( + + [embed {hash}] {tab === 'html' ? 'copied' : 'link copied'} + , + ); + + return; + } + + toast.error( + + fatal: could not copy {tab === 'html' ? 'embed code' : 'link'}{' '} + for {hash} + , + ); + }; + + return ( + + + + Embed commit + + Paste this code into your page. The card follows + /dev/null, so if the commit is deleted, it disappears + from your page too. + + + +
+
+ {isReply ? ( +
+ Starts from the root commit and goes + down to this reply, like the commit's + page. + + } + /> + ) : ( +
( + + )} + /> + )} +
( + + )} + > + {replies && ( +
+
+ + Per level + + ({ + value: count, + label: String(count), + }), + )} + value={perLevel} + onChange={setPerLevel} + className="tabular-nums" + /> +
+
+ + Levels + + + label="Levels of replies" + name="embed-levels" + options={[ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 'all', label: 'All' }, + ]} + value={depth} + onChange={setDepth} + className="tabular-nums" + /> +
+
+ )} +
+
+ + label="Max height" + name="embed-max-height" + options={[ + { value: 480, label: '480px' }, + { value: 640, label: '640px' }, + { value: 800, label: '800px' }, + { value: 'none', label: 'None' }, + ]} + value={maxHeight} + onChange={setMaxHeight} + className="w-fit tabular-nums" + /> +
+
+ + label="Theme" + name="embed-theme" + options={[ + { + value: 'light', + icon: Sun, + label: 'Light', + }, + { + value: 'dark', + icon: Moon, + label: 'Dark', + }, + { + value: 'auto', + icon: Monitor, + label: 'Auto', + }, + ]} + value={theme} + onChange={setTheme} + className="w-fit" + /> +
+
+
+ + Preview + + {links && ( + ', + '', + ]); + + // Markdown renderers drop frames, so Markdown gets the commit's preview image, linked to the commit. + $markdown = sprintf( + '[![Commit %s by @%s on /dev/null](%s)](%s)', + $hash, + $handle, + route('commit-open-graph-image.show', ['profile' => $handle, 'commit' => $commit->id]), + route('commit.show', ['profile' => $handle, 'commit' => $commit->id]), + ); + + return [ + 'html' => ['code' => $html, 'block' => $this->block($html, 'html')], + // Highlighted as text: the Markdown grammar paints a whole image link in one color. + 'markdown' => ['code' => $markdown, 'block' => [...$this->block($markdown, 'text'), 'language' => 'markdown']], + 'link' => ['code' => $links['link'], 'block' => $this->block($links['link'], 'text')], + ]; + } + + /** + * @return array + */ + private function block(string $code, string $language): array + { + $segments = $this->codeSpans->highlight("```{$language}\n{$code}\n```"); + + return collect($segments)->first(fn (array $segment): bool => $segment['type'] === 'block') ?? [ + 'type' => 'block', + 'language' => $language, + 'filename' => null, + 'html' => e($code), + ]; + } +} diff --git a/resources/js/components/commit-embed-dialog.tsx b/resources/js/components/commit-embed-dialog.tsx index 1d6e90dd9c..539dceefce 100644 --- a/resources/js/components/commit-embed-dialog.tsx +++ b/resources/js/components/commit-embed-dialog.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useEffect, useId, useRef, useState } from 'react'; import { toast } from 'sonner'; import CommitEmbedLinkController from '@/actions/App/Http/Controllers/CommitEmbedLinkController'; +import { CodeBlock } from '@/components/code-block'; import { SegmentedControl } from '@/components/segmented-control'; import { Button } from '@/components/ui/button'; import { @@ -18,9 +19,49 @@ import { cn, shortHash } from '@/lib/utils'; import type { Commit } from '@/types'; type Theme = 'light' | 'dark' | 'auto'; -type Links = { card: string; link: string }; +type Format = 'html' | 'markdown' | 'link'; +type Snippet = { + code: string; + block: { html: string; language: string | null }; +}; +type Links = { + card: string; + link: string; + snippets: Record; +}; -const footerHeight = 45; +const formats: Record< + Format, + { + label: string; + action: string; + copied: string; + noun: string; + hint: string; + } +> = { + html: { + label: 'HTML', + action: 'Copy code', + copied: 'copied', + noun: 'embed code', + hint: 'For any site that accepts HTML. The script adjusts the height.', + }, + markdown: { + label: 'Markdown', + action: 'Copy Markdown', + copied: 'Markdown copied', + noun: 'Markdown', + hint: "For READMEs and docs, which leave frames out: the commit's preview image, linked to the commit. Where Markdown accepts HTML, the HTML tab gives the live card.", + }, + link: { + label: 'Link', + action: 'Copy link', + copied: 'link copied', + noun: 'link', + hint: 'WordPress, Ghost, Discourse, Notion and other sites that support oEmbed turn this link into the card, with these settings.', + }, +}; function Switch({ checked, @@ -108,7 +149,7 @@ export function CommitEmbedDialog({ const [depth, setDepth] = useState(3); const [maxHeight, setMaxHeight] = useState(640); const [theme, setTheme] = useState('auto'); - const [tab, setTab] = useState<'html' | 'link'>('html'); + const [tab, setTab] = useState('html'); const [links, setLinks] = useState(null); const [previewHeight, setPreviewHeight] = useState(240); const preview = useRef(null); @@ -184,19 +225,13 @@ export function CommitEmbedDialog({ return () => window.removeEventListener('message', resize); }, []); - const height = (maxHeight === 'none' ? 640 : maxHeight) + footerHeight; - const code = - links === null - ? '' - : tab === 'html' - ? `\n` - : links.link; + const snippet = links?.snippets[tab] ?? null; const copyCode = async () => { - if (code !== '' && (await copy(code))) { + if (snippet !== null && (await copy(snippet.code))) { toast( - [embed {hash}] {tab === 'html' ? 'copied' : 'link copied'} + [embed {hash}] {formats[tab].copied} , ); @@ -205,8 +240,7 @@ export function CommitEmbedDialog({ toast.error( - fatal: could not copy {tab === 'html' ? 'embed code' : 'link'}{' '} - for {hash} + fatal: could not copy {formats[tab].noun} for {hash} , ); }; @@ -376,7 +410,7 @@ export function CommitEmbedDialog({ aria-label="Embed format" className="flex gap-5 border-b" > - {(['html', 'link'] as const).map((value) => ( + {(Object.keys(formats) as Format[]).map((value) => ( ))}
-
-                        {code}
-                    
+
+ {snippet !== null && ( + + )} +

- {tab === 'html' - ? 'For any site that accepts HTML. The script adjusts the height.' - : 'WordPress, Ghost, Discourse and other sites that support oEmbed turn this link into the card, with these settings.'} + {formats[tab].hint}

@@ -418,7 +454,7 @@ export function CommitEmbedDialog({ onClick={() => void copyCode()} disabled={links === null} > - {tab === 'html' ? 'Copy code' : 'Copy link'} + {formats[tab].action}
diff --git a/tests/Feature/Controllers/CommitEmbedLinkControllerTest.php b/tests/Feature/Controllers/CommitEmbedLinkControllerTest.php index 4d366c1784..0a44a665bc 100644 --- a/tests/Feature/Controllers/CommitEmbedLinkControllerTest.php +++ b/tests/Feature/Controllers/CommitEmbedLinkControllerTest.php @@ -24,6 +24,37 @@ $this->getJson(route('oembed.show', ['url' => $link]))->assertOk(); }); +it('hands the code to paste, highlighted like code in commits, for HTML, Markdown and a link', function (): void { + $commit = Commit::factory()->for(User::factory()->state(['handle' => 'mira']))->create(); + + $response = $this->actingAs(User::factory()->create()) + ->getJson(route('commit-embed-link.show', ['commit' => $commit, 'max_height' => 480])) + ->assertOk(); + + $card = $response->json('card'); + $hash = mb_substr(str_replace('-', '', $commit->id), -7); + + expect($response->json('snippets.html.code'))->toBe(implode("\n", [ + '', + '', + ])) + ->and($response->json('snippets.html.block.type'))->toBe('block') + ->and($response->json('snippets.html.block.language'))->toBe('html') + ->and($response->json('snippets.html.block.html'))->toContain('iframe') + ->and($response->json('snippets.markdown.code'))->toBe( + '[![Commit '.$hash.' by @mira on /dev/null]('.route('commit-open-graph-image.show', ['profile' => 'mira', 'commit' => $commit]).')]('.route('commit.show', ['profile' => 'mira', 'commit' => $commit]).')', + ) + ->and($response->json('snippets.markdown.block.language'))->toBe('markdown') + ->and($response->json('snippets.link.code'))->toBe($response->json('link')); +}); + it('signs only the settings that differ from the defaults, in their allowed values', function (): void { $commit = Commit::factory()->create(); From bac34d55b054e93e59ac7c2a4edee8be89b99799 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 8 Nov 2026 11:04:57 +0100 Subject: [PATCH 11/16] fix: keep the framing page around a dark card's rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a dark page, a light corner showed around the card: the app's page background painted the card's document, and a frame whose color scheme differs from its document's gets an opaque backdrop. The card's page stays transparent, and every frame (preview, HTML code, oEmbed) keeps color-scheme: normal like the card. 🤖 Generated with Claude Code --- app/Http/Controllers/OEmbedController.php | 11 ++++------- resources/js/components/commit-embed-dialog.tsx | 7 ++++++- resources/views/embed.blade.php | 3 ++- tests/Feature/Controllers/OEmbedControllerTest.php | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/OEmbedController.php b/app/Http/Controllers/OEmbedController.php index 2b6c827c64..0db484c4c9 100644 --- a/app/Http/Controllers/OEmbedController.php +++ b/app/Http/Controllers/OEmbedController.php @@ -6,6 +6,7 @@ use App\Dto\EmbedOptions; use App\Http\Requests\ShowOEmbedRequest; +use App\Support\EmbedSnippets; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -17,17 +18,12 @@ { private const int WIDTH = 550; - /** - * The card's footer, under its scrolling thread. - */ - private const int FOOTER = 45; - public function show(ShowOEmbedRequest $request): JsonResponse { $card = $request->card(); $options = EmbedOptions::fromRequest(Request::create($card)); $width = min(self::WIDTH, $request->integer('maxwidth', self::WIDTH)); - $height = ($options->maxHeight ?: 640) + self::FOOTER; + $height = ($options->maxHeight ?: 640) + EmbedSnippets::FOOTER; $author = $request->commit()->author; return response()->json([ @@ -38,11 +34,12 @@ public function show(ShowOEmbedRequest $request): JsonResponse 'author_name' => $author?->name, 'author_url' => $author === null ? null : route('user.show', $author->handle), 'html' => sprintf( - '', + '', e($card), e('Commit on /dev/null'), $width, $height, + EmbedSnippets::STYLE, e(url('embed.js')), ), 'width' => $width, diff --git a/resources/js/components/commit-embed-dialog.tsx b/resources/js/components/commit-embed-dialog.tsx index 539dceefce..27a8dc154d 100644 --- a/resources/js/components/commit-embed-dialog.tsx +++ b/resources/js/components/commit-embed-dialog.tsx @@ -398,7 +398,12 @@ export function CommitEmbedDialog({ src={links.card} title={`Preview of the embed of ${hash}`} className="w-full max-w-[550px] border-0" - style={{ height: previewHeight }} + // A frame in the dark theme over a card in the normal scheme gets an opaque + // backdrop around its rounded corners. + style={{ + height: previewHeight, + colorScheme: 'normal', + }} /> )} diff --git a/resources/views/embed.blade.php b/resources/views/embed.blade.php index 479003b3c6..4636813a0f 100644 --- a/resources/views/embed.blade.php +++ b/resources/views/embed.blade.php @@ -14,7 +14,8 @@ })(); @viteReactRefresh @vite(['resources/js/embed.tsx']) diff --git a/tests/Feature/Controllers/OEmbedControllerTest.php b/tests/Feature/Controllers/OEmbedControllerTest.php index e5623fafa9..52bf11d5b1 100644 --- a/tests/Feature/Controllers/OEmbedControllerTest.php +++ b/tests/Feature/Controllers/OEmbedControllerTest.php @@ -37,7 +37,7 @@ function embedLink(Commit $commit, array $parameters = []): string $html = $response->json('html'); $src = html_entity_decode((string) str($html)->match('/src="([^"]+)"/')); - expect($html)->toContain('toContain(url('embed.js')) + expect($html)->toContain('toContain(url('embed.js'))->toContain('color-scheme:normal') ->and($src)->toStartWith(route('commit-embed.show', $commit)); $this->get($src)->assertOk(); From c86e703ea4d43307fdbed2b5dae6955e73878971 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 8 Nov 2026 11:28:35 +0100 Subject: [PATCH 12/16] test: type the embed.js test's frames and mark its tests as run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests tsconfig rejected setting a height on frames typed as empty objects, and the linter wants node:test calls marked with void, like the other node:test files. 🤖 Generated with Claude Code --- tests/Unit/EmbedScript.test.ts | 48 +++++++++++++--------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/tests/Unit/EmbedScript.test.ts b/tests/Unit/EmbedScript.test.ts index 6cd159fd79..35614bee35 100644 --- a/tests/Unit/EmbedScript.test.ts +++ b/tests/Unit/EmbedScript.test.ts @@ -9,13 +9,17 @@ type Listener = (event: { data: unknown; }) => void; -function load( - frames: { - src: string; - contentWindow: object; - style: { height?: string }; - }[], -) { +type Frame = { + src: string; + contentWindow: object; + style: { height?: string }; +}; + +function frame(src: string): Frame { + return { src, contentWindow: {}, style: {} }; +} + +function load(frames: Frame[]) { const listeners: Listener[] = []; const context = { URL, @@ -42,17 +46,9 @@ function load( listeners.forEach((listener) => listener(event)); } -test('sizes the frame that sent its height', () => { - const card = { - src: 'https://dev-null.sh/embed/1?signature=a', - contentWindow: {}, - style: {}, - }; - const other = { - src: 'https://dev-null.sh/embed/2?signature=b', - contentWindow: {}, - style: {}, - }; +void test('sizes the frame that sent its height', () => { + const card = frame('https://dev-null.sh/embed/1?signature=a'); + const other = frame('https://dev-null.sh/embed/2?signature=b'); const post = load([card, other]); post({ @@ -65,12 +61,8 @@ test('sizes the frame that sent its height', () => { assert.equal(other.style.height, undefined); }); -test('ignores messages from other origins, of other types, or with no usable height', () => { - const card = { - src: 'https://dev-null.sh/embed/1?signature=a', - contentWindow: {}, - style: {}, - }; +void test('ignores messages from other origins, of other types, or with no usable height', () => { + const card = frame('https://dev-null.sh/embed/1?signature=a'); const post = load([card]); post({ @@ -97,12 +89,8 @@ test('ignores messages from other origins, of other types, or with no usable hei assert.equal(card.style.height, undefined); }); -test('listens once when the script is included several times', () => { - const card = { - src: 'https://dev-null.sh/embed/1?signature=a', - contentWindow: {}, - style: {}, - }; +void test('listens once when the script is included several times', () => { + const card = frame('https://dev-null.sh/embed/1?signature=a'); const listeners: Listener[] = []; const context = { URL, From 8effb7ff2dc4a5387a1517e8dfffd1c4fb37ae04 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Sun, 8 Nov 2026 11:28:35 +0100 Subject: [PATCH 13/16] feat: keep commit embeds readable without JavaScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card was already part of the SSR bundle, which renders every page; a test now renders it the way ssr.tsx does and reads the commit, its replies, tombstones and footer in the server's HTML, so a reader with JavaScript off, or a sandboxed frame, still gets the whole card. Links opened outside the frame through a click handler only; a now does it without JavaScript too. 🤖 Generated with Claude Code --- resources/views/embed.blade.php | 2 + .../Controllers/CommitEmbedControllerTest.php | 6 + tests/Unit/EmbedShow.test.tsx | 138 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 tests/Unit/EmbedShow.test.tsx diff --git a/resources/views/embed.blade.php b/resources/views/embed.blade.php index 4636813a0f..822fbaee49 100644 --- a/resources/views/embed.blade.php +++ b/resources/views/embed.blade.php @@ -4,6 +4,8 @@ + {{-- Links open on /dev/null or their own site, never inside the frame, with or without JavaScript. --}} + /dev/null