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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use App\Jobs\EmbedCommit;
use App\Jobs\ModerateCommit;
use App\Jobs\ReplyAsTom;
use App\Jobs\UnfurlLink;
use App\Models\Commit;
use App\Models\LinkPreview;
use App\Models\User;
Expand All @@ -31,6 +30,7 @@ public function __construct(
private CreateMentionNotifications $createMentionNotifications,
private CreateCommitImages $createCommitImages,
private CreateWorldReply $createWorldReply,
private CreateLinkPreviewJob $createLinkPreviewJob,
private UpdateCommitHotScores $updateCommitHotScores,
private TheAlgorithm $theAlgorithm,
private EmojiShortcodes $emojiShortcodes,
Expand All @@ -42,8 +42,6 @@ public function __construct(
}

/**
* The link preview is of the first URL in the body when true, of the given URL when a string, or none when false.
*
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
*/
Expand Down Expand Up @@ -86,8 +84,10 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
$this->createQuoteNotifications->handle($commit);
$this->createMentionNotifications->handle($commit);

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview));
if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) {
DB::afterCommit(function () use ($linkPreview): void {
$this->createLinkPreviewJob->handle($linkPreview);
});
}

$this->createWorldReply->handle($commit);
Expand Down Expand Up @@ -118,9 +118,6 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
});
}

/**
* A poll opens with its commit's approval, so polls of pending commits wait for moderation.
*/
private function createPoll(Commit $commit, NewPoll $newPoll): void
{
$poll = $commit->poll()->create([
Expand All @@ -135,9 +132,6 @@ private function createPoll(Commit $commit, NewPoll $newPoll): void
));
}

/**
* Commits that link to the same URL share one preview, so a page is fetched once however often it is linked.
*/
private function linkPreviewFor(?string $url): ?LinkPreview
{
if ($url === null) {
Expand Down
31 changes: 31 additions & 0 deletions app/Actions/CreateLinkPreviewJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Jobs\UnfurlLink;
use App\Models\LinkPreview;
use Throwable;

final readonly class CreateLinkPreviewJob
{
public function handle(LinkPreview $linkPreview): void
{
$preview = $linkPreview->fresh();

if (! $preview instanceof LinkPreview || $preview->fetched_at !== null) {
return;
}

$job = new UnfurlLink($preview);

try {
dispatch($job)->afterCommit();
} catch (Throwable $throwable) {
$job->failed();

throw $throwable;
}
}
}
15 changes: 5 additions & 10 deletions app/Actions/CreateMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use App\Enums\RemoteStatus;
use App\Jobs\SendRemotePush;
use App\Jobs\UnfurlLink;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\LinkPreview;
Expand All @@ -28,13 +27,11 @@ public function __construct(
private CreateMessageVoiceFile $createMessageVoiceFile,
private MentionedUsersQuery $mentionedUsers,
private Urls $urls,
private CreateLinkPreviewJob $createLinkPreviewJob,
) {
//
}

/**
* Makes the message the conversation's latest, for every member's inbox.
*/
public static function advance(Conversation $conversation, Message $message, bool $unstash): void
{
Conversation::query()
Expand All @@ -48,10 +45,6 @@ public static function advance(Conversation $conversation, Message $message, boo
}

/**
* Sending the same client id again, as a retry does, returns the message it already created, without storing its
* images or voice note twice. A new message brings the conversation back out of the stash for everyone in it. The link card is
* of the first link in the body when true, of the given link when a string, or none when false.
*
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
* @param list<int> $voiceWaveform
Expand Down Expand Up @@ -108,8 +101,10 @@ public function handle(User $sender, Conversation $conversation, string $body, s
->where('read_through_id', '<', $message->id)
->update(['read_through_id' => $message->id, 'read_at' => now()]);

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview))->afterCommit();
if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) {
DB::afterCommit(function () use ($linkPreview): void {
$this->createLinkPreviewJob->handle($linkPreview);
});
}

return $message;
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/UpdateLinkPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function handle(LinkPreview $linkPreview): void
{
$metadata = $this->fetchMetadata($linkPreview->url);

$linkPreview->update([...$metadata->toArray(), 'fetched_at' => now()]);
$linkPreview->completeUnfurl($metadata->toArray());
}

private function fetchMetadata(string $url): LinkPreviewMetadata
Expand Down
104 changes: 104 additions & 0 deletions app/Http/Controllers/DraftLinkPreviewController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreateLinkPreviewJob;
use App\Dto\LinkPreviewMetadata;
use App\Enums\EmbedKind;
use App\Http\Requests\ShowDraftLinkPreviewRequest;
use App\Models\LinkPreview;
use App\Models\User;
use App\Services\Embeds;
use Carbon\CarbonInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Uri\WhatWg\Url;

final readonly class DraftLinkPreviewController
{
public const int READS_PER_MINUTE = 120;

private const int UNFURLS_PER_MINUTE = 10;

public function __construct(private CreateLinkPreviewJob $createLinkPreviewJob)
{
//
}

public function show(ShowDraftLinkPreviewRequest $request, Embeds $embeds): JsonResponse
{
$user = $request->user();
assert($user instanceof User);

$url = $request->string('url')->value();

Log::info('Draft link preview requested', [
'user_id' => $user->id,
'host' => Url::parse($url)?->getAsciiHost(),
]);

if ($embeds->for($url)?->kind === EmbedKind::Gif) {
return $this->response($this->preview($url, new LinkPreviewMetadata));
}

$key = "draft-link-unfurls:{$user->id}";

return $this->response($this->cachedPreview($url, $key));
}

private function cachedPreview(string $url, string $key): LinkPreview
{
$cached = LinkPreview::query()->where('url_hash', hash('sha256', $url))->first();

if ($cached instanceof LinkPreview) {
return $cached;
}

if (RateLimiter::tooManyAttempts($key, self::UNFURLS_PER_MINUTE)) {
throw new TooManyRequestsHttpException(RateLimiter::availableIn($key));
}

RateLimiter::increment($key, CarbonInterface::SECONDS_PER_MINUTE);

return $this->createPreview($url);
}

private function createPreview(string $url): LinkPreview
{
return DB::transaction(function () use ($url): LinkPreview {
$preview = LinkPreview::query()->firstOrCreate(
['url_hash' => hash('sha256', $url)],
['url' => $url, ...(new LinkPreviewMetadata)->toArray()],
);

if ($preview->wasRecentlyCreated) {
DB::afterCommit(function () use ($preview): void {
$this->createLinkPreviewJob->handle($preview);
});
}

return $preview;
});
}

private function response(LinkPreview $preview): JsonResponse
{
return response()->json([
'link_preview' => $preview,
])->header('Cache-Control', 'private, no-store');
}

private function preview(string $url, LinkPreviewMetadata $metadata): LinkPreview
{
return new LinkPreview([
'url' => $url,
...$metadata->toArray(),
'fetched_at' => now(),
]);
}
}
26 changes: 26 additions & 0 deletions app/Http/Requests/ShowDraftLinkPreviewRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Services\Urls;
use Illuminate\Foundation\Http\FormRequest;

final class ShowDraftLinkPreviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, list<string>>
*/
public function rules(): array
{
return [
'url' => ['required', 'string', 'url:http,https', 'max:'.Urls::MAX_LENGTH],
];
}
}
44 changes: 31 additions & 13 deletions app/Jobs/UnfurlLink.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,59 @@

use App\Actions\UpdateLinkPreview;
use App\Models\LinkPreview;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\DB;

#[Backoff([60, 600])]
#[Tries(3)]
#[DeleteWhenMissingModels]
final class UnfurlLink implements ShouldQueue
final class UnfurlLink implements ShouldBeUnique, ShouldQueue
{
use Queueable;

// Allow foreign-key references while serializing workers.
private const string METADATA_LOCK = 'for no key update';

// Default supports older queued payloads.
public int $publicationUnfurlRetries = 0;

public function __construct(public readonly LinkPreview $linkPreview)
{
//
if ($linkPreview->publication_unfurl_retries === 0) {
return;
}

$this->publicationUnfurlRetries = $linkPreview->publication_unfurl_retries;
}

public function uniqueId(): string
{
return $this->linkPreview->id;
}

/**
* Commits that share a link may each queue this job before the first one runs, so only the first one fetches.
*/
public function handle(UpdateLinkPreview $action): void
{
if ($this->linkPreview->fetched_at !== null) {
return;
}
DB::transaction(function () use ($action): void {
$linkPreview = LinkPreview::query()->lock(self::METADATA_LOCK)->find($this->linkPreview->id);

if (! $linkPreview instanceof LinkPreview
|| $linkPreview->fetched_at !== null
|| $linkPreview->publication_unfurl_retries !== $this->publicationUnfurlRetries
) {
return;
}

$action->handle($this->linkPreview);
$action->handle($linkPreview);
});
}

/**
* Once every attempt has failed, the link preview is marked as fetched without metadata, so it is not tried again.
*/
public function failed(): void
{
$this->linkPreview->update(['fetched_at' => now()]);
$this->linkPreview->markUnfurlAsTransientFailure($this->publicationUnfurlRetries);
}
}
Loading