Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions app/Actions/UpdateOrganizationEmbeds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Organization;

final readonly class UpdateOrganizationEmbeds
{
public function handle(Organization $organization, bool $allowed): void
{
$organization->update(['allows_embeds' => $allowed]);
}
}
19 changes: 19 additions & 0 deletions app/Actions/UpdateUserEmbeds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\UsersUpdated;
use App\Models\User;

final readonly class UpdateUserEmbeds
{
public function handle(User $user, bool $allowed): void
{
$user->update(['allows_embeds' => $allowed]);

event(new UsersUpdated([$user->id], UserUpdate::Account));
}
}
93 changes: 93 additions & 0 deletions app/Dto/EmbedOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace App\Dto;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Request;

/**
* What an embed shows, from its signed URL. Each value outside the few allowed falls back to its default, so a commit
* has a small, closed set of cards.
*
* @implements Arrayable<string, bool|int|string>
*/
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<string, int|string>
*/
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<string, bool|int|string>
*/
public function toArray(): array
{
return [
'thread' => $this->thread,
'replies' => $this->replies,
'depth' => $this->depth,
'theme' => $this->theme,
'maxHeight' => $this->maxHeight,
];
}
}
9 changes: 8 additions & 1 deletion app/Http/Controllers/CommitController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/Http/Controllers/CommitEmbedController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

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;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;

/**
* The card another site frames to show a commit. It is served without a session, so it always shows what a guest sees
* and never sets a cookie, and its URL is signed, so only members create one.
*/
final readonly class CommitEmbedController
{
/**
* The route skips the web group, and its implicit bindings with it, so the commit is looked up here, deleted ones
* included, since they show as tombstones.
*/
public function show(Request $request, string $commit, CommitEmbedQuery $query): Response
{
$commit = Commit::query()->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;
}
}
27 changes: 27 additions & 0 deletions app/Http/Controllers/CommitEmbedLinkController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Dto\EmbedOptions;
use App\Http\Requests\ShowCommitEmbedLinkRequest;
use App\Models\Commit;
use App\Support\EmbedLinks;
use App\Support\EmbedSnippets;
use Illuminate\Http\JsonResponse;

/**
* Signs the links of the card a member chose in the embed dialog, with the code to paste them. Only members get here,
* so only they create embeds.
*/
final readonly class CommitEmbedLinkController
{
public function show(ShowCommitEmbedLinkRequest $request, Commit $commit, EmbedSnippets $snippets): JsonResponse
{
$options = EmbedOptions::fromRequest($request);
$links = EmbedLinks::for($commit, $options);

return response()->json([...$links, 'snippets' => $snippets->for($commit, $options, $links)]);
}
}
50 changes: 50 additions & 0 deletions app/Http/Controllers/OEmbedController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Dto\EmbedOptions;
use App\Http\Requests\ShowOEmbedRequest;
use App\Support\EmbedSnippets;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
* The oEmbed provider for commit embeds: blogging platforms turn the link from the embed dialog's Link tab into its card.
* Bare commit links aren't signed, so they get no card and the consumer falls back to a plain link.
*/
final readonly class OEmbedController
{
private const int WIDTH = 550;

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) + 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(
'<iframe src="%s" title="%s" width="%d" height="%d" loading="lazy" style="%s"></iframe><script async src="%s"></script>',
e($card),
e('Commit on /dev/null'),
$width,
$height,
EmbedSnippets::STYLE,
e(url('embed.js')),
),
'width' => $width,
'height' => $height,
'cache_age' => 3600,
]);
}
}
29 changes: 29 additions & 0 deletions app/Http/Controllers/OrganizationEmbedsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateOrganizationEmbeds;
use App\Http\Requests\UpdateOrganizationEmbedsRequest;
use App\Models\Organization;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

/**
* Whether others may embed the organization's commits, in the Profile tab of its settings. Only its admins decide.
*/
final readonly class OrganizationEmbedsController
{
public function update(UpdateOrganizationEmbedsRequest $request, Organization $organization, UpdateOrganizationEmbeds $action): RedirectResponse
{
$action->handle($organization, $request->boolean('enabled'));

Inertia::flash('toast', [
'type' => 'success',
'message' => 'Embeds preference saved.',
]);

return back();
}
}
1 change: 1 addition & 0 deletions app/Http/Controllers/OrganizationProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
}
27 changes: 27 additions & 0 deletions app/Http/Controllers/UserEmbedsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateUserEmbeds;
use App\Http\Requests\UpdateUserEmbedsRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

final readonly class UserEmbedsController
{
public function update(UpdateUserEmbedsRequest $request, #[CurrentUser] User $user, UpdateUserEmbeds $action): RedirectResponse
{
$action->handle($user, $request->boolean('enabled'));

Inertia::flash('toast', [
'type' => 'success',
'message' => 'Embeds preference saved.',
]);

return back();
}
}
1 change: 1 addition & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions app/Http/Middleware/PreventFraming.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading