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/Dto/EmbedOptions.php b/app/Dto/EmbedOptions.php new file mode 100644 index 0000000000..54455c07af --- /dev/null +++ b/app/Dto/EmbedOptions.php @@ -0,0 +1,93 @@ + + */ +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, + }, + ); + } + + /** + * 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 + */ + 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/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/CommitEmbedController.php b/app/Http/Controllers/CommitEmbedController.php new file mode 100644 index 0000000000..230e2a542e --- /dev/null +++ b/app/Http/Controllers/CommitEmbedController.php @@ -0,0 +1,49 @@ +withTrashed()->findOrFail($commit); + $options = EmbedOptions::fromRequest($request); + + Inertia::setRootView('embed'); + + $tree = $query->get($commit, $options); + + $response = Inertia::render('embed/show', [ + '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(), + '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/Controllers/CommitEmbedLinkController.php b/app/Http/Controllers/CommitEmbedLinkController.php new file mode 100644 index 0000000000..01bc27adeb --- /dev/null +++ b/app/Http/Controllers/CommitEmbedLinkController.php @@ -0,0 +1,27 @@ +json([...$links, 'snippets' => $snippets->for($commit, $options, $links)]); + } +} diff --git a/app/Http/Controllers/OEmbedController.php b/app/Http/Controllers/OEmbedController.php new file mode 100644 index 0000000000..0db484c4c9 --- /dev/null +++ b/app/Http/Controllers/OEmbedController.php @@ -0,0 +1,50 @@ +card(); + $options = EmbedOptions::fromRequest(Request::create($card)); + $width = min(self::WIDTH, $request->integer('maxwidth', self::WIDTH)); + $height = ($options->maxHeight ?: 640) + EmbedSnippets::FOOTER; + $author = $request->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, + EmbedSnippets::STYLE, + e(url('embed.js')), + ), + 'width' => $width, + 'height' => $height, + 'cache_age' => 3600, + ]); + } +} 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/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/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/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/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/Http/Resources/ShownStreams.php b/app/Http/Resources/ShownStreams.php index febea11a22..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; @@ -51,6 +52,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 */ @@ -68,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/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/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index df2e46465c..f3cbd46519 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -205,6 +205,15 @@ 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()}"), + ]); + // 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/Queries/CommitEmbedQuery.php b/app/Queries/CommitEmbedQuery.php new file mode 100644 index 0000000000..8e55790a43 --- /dev/null +++ b/app/Queries/CommitEmbedQuery.php @@ -0,0 +1,109 @@ +, 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())), + // The next commit of the thread reads below, on the trunk, so it isn't one of the replies too, like on the + // commit's page. + 'focal' => $this->node($focal, $options, 0, $thread === [] ? null : $thread[0]->id), + '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, ?string $except = null): array + { + $tombstone = self::tombstone($commit); + // The reply count already leaves out the commit's continuation, which `withContinuationId` picks. + $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), except: $except)->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 ? $commit : null, + 'replies' => $replies, + 'more' => max(0, $count - count($replies)), + ]; + } +} diff --git a/app/Support/EmbedLinks.php b/app/Support/EmbedLinks.php new file mode 100644 index 0000000000..bfb372c5ed --- /dev/null +++ b/app/Support/EmbedLinks.php @@ -0,0 +1,62 @@ + $commit->id, ...$options->query()], absolute: false)); + $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. + */ + public static function commitId(string $link): ?string + { + $parts = parse_url($link); + $app = parse_url(config()->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), absolute: false) ? $url : null; + } +} diff --git a/app/Support/EmbedSnippets.php b/app/Support/EmbedSnippets.php new file mode 100644 index 0000000000..c5adb06e31 --- /dev/null +++ b/app/Support/EmbedSnippets.php @@ -0,0 +1,83 @@ +}> + */ + public function for(Commit $commit, EmbedOptions $options, array $links): array + { + $hash = mb_substr(str_replace('-', '', $commit->id), -7); + $handle = $commit->author->handle ?? ''; + $height = ($options->maxHeight ?: 640) + self::FOOTER; + + $html = implode("\n", [ + '', + '', + ]); + + // 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/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/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/resources/js/components/commit-embed-dialog.tsx b/resources/js/components/commit-embed-dialog.tsx new file mode 100644 index 0000000000..27a8dc154d --- /dev/null +++ b/resources/js/components/commit-embed-dialog.tsx @@ -0,0 +1,468 @@ +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 { CodeBlock } from '@/components/code-block'; +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 Format = 'html' | 'markdown' | 'link'; +type Snippet = { + code: string; + block: { html: string; language: string | null }; +}; +type Links = { + card: string; + link: string; + snippets: Record; +}; + +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, + 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'); + 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 snippet = links?.snippets[tab] ?? null; + + const copyCode = async () => { + if (snippet !== null && (await copy(snippet.code))) { + toast( + + [embed {hash}] {formats[tab].copied} + , + ); + + return; + } + + toast.error( + + fatal: could not copy {formats[tab].noun} 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 && ( + ', + '', + ])) + ->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(); + + $card = $this->actingAs(User::factory()->create()) + ->getJson(route('commit-embed-link.show', ['commit' => $commit, 'replies' => 7, 'theme' => 'auto', 'max_height' => 640, 'extra' => 'x'])) + ->json('card'); + + expect(array_keys(Illuminate\Support\Arr::except(Illuminate\Support\Str::of($card)->after('?')->explode('&')->mapWithKeys(fn (string $part): array => [explode('=', $part)[0] => true])->all(), []))) + ->toBe(['signature']); +}); + +it('keeps embed links from guests', function (): void { + $commit = Commit::factory()->create(); + + $this->getJson(route('commit-embed-link.show', $commit))->assertUnauthorized(); +}); + +it('refuses commits that cannot be embedded, as if they did not exist', function (string $state): void { + $commit = match ($state) { + 'pending' => Commit::factory()->pending()->create(), + 'opted out' => Commit::factory()->for(User::factory()->state(['allows_embeds' => false]))->create(), + }; + + $this->actingAs(User::factory()->create()) + ->getJson(route('commit-embed-link.show', $commit)) + ->assertNotFound(); +})->with(['pending', 'opted out']); + +it('tells the web whether each commit can be embedded', function (): void { + $commit = Commit::factory()->create(); + $optedOut = Commit::factory()->for(User::factory()->state(['allows_embeds' => false]))->create(); + + $this->get(route('commit.show', ['profile' => $commit->author->handle, 'commit' => $commit])) + ->assertInertia(fn ($page) => $page->where('commit.embeddable', true)); + $this->get(route('commit.show', ['profile' => $optedOut->author->handle, 'commit' => $optedOut])) + ->assertInertia(fn ($page) => $page->where('commit.embeddable', false)); +}); 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/OEmbedControllerTest.php b/tests/Feature/Controllers/OEmbedControllerTest.php new file mode 100644 index 0000000000..52bf11d5b1 --- /dev/null +++ b/tests/Feature/Controllers/OEmbedControllerTest.php @@ -0,0 +1,84 @@ + $commit->id, ...$parameters], absolute: false); + $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'))->toContain('color-scheme:normal') + ->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); +}); 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'); +}); diff --git a/tests/Unit/EmbedScript.test.ts b/tests/Unit/EmbedScript.test.ts new file mode 100644 index 0000000000..35614bee35 --- /dev/null +++ b/tests/Unit/EmbedScript.test.ts @@ -0,0 +1,112 @@ +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; + +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, + 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)); +} + +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({ + 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); +}); + +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({ + 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); +}); + +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, + 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); +}); diff --git a/tests/Unit/EmbedShow.test.tsx b/tests/Unit/EmbedShow.test.tsx new file mode 100644 index 0000000000..a9739ea355 --- /dev/null +++ b/tests/Unit/EmbedShow.test.tsx @@ -0,0 +1,164 @@ +/// +import assert from 'node:assert/strict'; +import { createInertiaApp } from '@inertiajs/react'; +import type { ComponentType } from 'react'; +import { renderToString } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { TimeoutDialog } from '../../resources/js/components/timeout-dialog'; +import { Toaster } from '../../resources/js/components/ui/sonner'; +import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; +import type { EmbedNode } from '../../resources/js/pages/embed/show'; +import type { Commit } from '../../resources/js/types/commit'; + +function commit( + id: string, + handle: string, + body: string, + parentId: string | null = null, +): Commit { + return { + id, + parent_id: parentId, + hot_score: 0, + quoted_commit_id: null, + body, + mentions: {}, + body_segments: [{ type: 'text', text: body }], + preview_segments: null, + status: 'approved', + created_at: '2026-11-08T10:00:00Z', + updated_at: '2026-11-08T10:00:00Z', + stars_count: 2, + pasteis_count: 0, + starred: false, + author: { + type: 'user', + id: `${handle}-id`, + handle, + name: handle === 'mira' ? 'Mira Okafor' : 'Kofi Mensah', + avatar_url: null, + bio: null, + location: null, + website: null, + company: null, + has_sponsors_listing: false, + timezone: null, + show_local_time: false, + lane: 3, + } as Commit['author'], + }; +} + +const node = ( + value: Commit | null, + replies: EmbedNode[] = [], + tombstone: EmbedNode['tombstone'] = null, + more = 0, +): EmbedNode => ({ + id: value?.id ?? 'deleted-id', + tombstone, + commit: value, + replies, + more, +}); + +type ServerApp = Extract< + Parameters[0], + { render: unknown } +>; + +async function serverRender(props: Record): Promise { + // The card's props aren't the app's shared ones: it renders without the web group. + const page = { + component: 'embed/show', + props, + url: '/embed/root', + version: null, + clearHistory: false, + encryptHistory: false, + } as unknown as ServerApp['page']; + + const rendered: unknown = await createInertiaApp({ + page, + render: renderToString, + resolve: async () => + (await import('../../resources/js/pages/embed/show')) + .default as ComponentType, + // The same providers as resources/js/ssr.tsx, which renders every page, cards included. + setup: ({ App, props: appProps }) => ( + + + + + + ), + }); + + const html = + typeof rendered === 'object' && rendered !== null && 'body' in rendered + ? String(rendered.body) + : String(rendered); + + // React separates adjacent text with empty comments, which a reader never sees. + return html.replaceAll('', ''); +} + +test('the card reads in full from the server, without JavaScript', async () => { + const html = await serverRender({ + ancestors: [], + focal: node( + commit('root', 'mira', 'rgt 0.9 is out'), + [ + node(commit('reply', 'kofi', 'Finally streaming', 'root')), + node(null, [], 'deleted'), + ], + null, + 4, + ), + thread: [], + options: { + thread: false, + replies: 3, + depth: 3, + theme: 'auto', + maxHeight: 640, + }, + auth: { user: null, pasteis: null, organizations: [], isAdmin: false }, + now: '2026-11-08T12:00:00Z', + commitUrl: 'https://dev-null.sh/@mira/commits/root', + }); + + assert.match(html, /rgt 0\.9 is out/); + assert.match(html, /Mira Okafor/); + assert.match(html, /Finally streaming/); + assert.match(html, /\[deleted\]/); + assert.match(html, /4 more replies on \/dev\/null/); + assert.match(html, /href="https:\/\/dev-null\.sh\/@mira\/commits\/root"/); + assert.match(html, /View on \/dev\/null/); + assert.doesNotMatch(html, /mirrored live/); +}); + +test('the card reads like /dev/null: the thread badge after the date, then a star and the replies link', async () => { + const html = await serverRender({ + ancestors: [], + focal: node(commit('root', 'mira', 'rgt 0.9 is out'), [], null, 5), + thread: [node(commit('next', 'mira', 'Before / after', 'root'))], + options: { + thread: true, + replies: 3, + depth: 3, + theme: 'auto', + maxHeight: 640, + }, + auth: { user: null, pasteis: null, organizations: [], isAdmin: false }, + now: '2026-11-08T12:00:00Z', + commitUrl: 'https://dev-null.sh/@mira/commits/root', + }); + + assert.match( + html, + /@mira<\/span>]*>·<\/span>]*>Nov 8<\/span>]*data-test="thread-position"[^>]*>1\/2<\/span>/, + ); + assert.match(html, /aria-label="2 stars on \/dev\/null"/); + assert.match(html, />5 replies<\/a>/); +}); 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: [