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
32 changes: 27 additions & 5 deletions app/Actions/UpdateLinkPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Services\LinkPreviews\LinkPreviewAddress;
use App\Services\LinkPreviews\LinkPreviewBudget;
use App\Services\LinkPreviews\LinkPreviewHttpClient;
use App\Services\PreviewLinkProviders\GifPreviewLink;
use App\Services\PreviewLinkProviders\HtmlPreviewLink;
use Illuminate\Http\Client\Response;
use Uri\WhatWg\Url;
Expand All @@ -21,9 +22,12 @@

private const string HEADER_ACCEPT = 'Accept';

private const string HEADER_RANGE = 'Range';

public function __construct(
private LinkPreviewAddress $addresses,
private LinkPreviewBudget $budget,
private GifPreviewLink $gifLinks,
private LinkPreviewHttpClient $http,
private HtmlPreviewLink $html,
) {
Expand All @@ -40,6 +44,8 @@ public function handle(LinkPreview $linkPreview): void
private function fetchMetadata(string $url): LinkPreviewMetadata
{
$page = Url::parse($url);
$isGif = $this->gifLinks->isGifUrl($page);
$previewLink = $isGif ? $this->gifLinks : $this->html;
$deadline = $this->budget->start();
$visited = [];

Expand All @@ -51,7 +57,7 @@ private function fetchMetadata(string $url): LinkPreviewMetadata
}

$visited[$source] = true;
$request = $this->prepareRequest($page, $deadline);
$request = $this->prepareRequest($page, $isGif, $deadline);

if (! $request instanceof LinkPreviewRequest) {
return new LinkPreviewMetadata;
Expand All @@ -61,7 +67,7 @@ private function fetchMetadata(string $url): LinkPreviewMetadata
$response->throwIf($response->serverError() || $response->tooManyRequests());

if (! $response->redirect()) {
return $this->responseMetadata($response, $page, $this->html);
return $this->responseMetadata($response, $page, $previewLink);
}

$page = $this->redirectPage($response, $page);
Expand All @@ -70,8 +76,12 @@ private function fetchMetadata(string $url): LinkPreviewMetadata
return new LinkPreviewMetadata;
}

private function prepareRequest(Url $page, float $deadline): ?LinkPreviewRequest
private function prepareRequest(Url $page, bool $isGif, float $deadline): ?LinkPreviewRequest
{
if ($isGif && $page->getScheme() !== 'https') {
return null;
}

$address = $this->addresses->get($page);
$remaining = $this->budget->remaining($deadline);

Expand All @@ -82,8 +92,8 @@ private function prepareRequest(Url $page, float $deadline): ?LinkPreviewRequest
return new LinkPreviewRequest(
url: $page,
address: $address,
headers: [self::HEADER_ACCEPT => HtmlPreviewLink::CONTENT_TYPE],
maxBodyBytes: HtmlPreviewLink::MAX_BODY_BYTES,
headers: $this->requestHeaders($isGif),
maxBodyBytes: $isGif ? GifPreviewLink::HEADER_BYTES : HtmlPreviewLink::MAX_BODY_BYTES,
timeoutSeconds: $remaining,
);
}
Expand All @@ -99,6 +109,18 @@ private function redirectPage(Response $response, Url $page): ?Url
return Url::parse($location, $page);
}

/**
* @return array<string, string>
*/
private function requestHeaders(bool $isGif): array
{
if (! $isGif) {
return [self::HEADER_ACCEPT => HtmlPreviewLink::CONTENT_TYPE];
}

return [self::HEADER_ACCEPT => GifPreviewLink::CONTENT_TYPE, self::HEADER_RANGE => sprintf('bytes=0-%d', GifPreviewLink::HEADER_BYTES - 1)];
}

private function responseMetadata(Response $response, Url $page, PreviewLink $previewLink): LinkPreviewMetadata
{
if (! $response->successful()) {
Expand Down
4 changes: 3 additions & 1 deletion app/Dto/LinkPreviewMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ public function __construct(
public ?string $description = null,
public ?string $imageUrl = null,
public ?string $siteName = null,
public ?string $gifUrl = null,
) {
//
}

/**
* @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null}
* @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null, gif_url: string|null}
*/
public function toArray(): array
{
Expand All @@ -30,6 +31,7 @@ public function toArray(): array
'description' => $this->description,
'image_url' => $this->imageUrl,
'site_name' => $this->siteName,
'gif_url' => $this->gifUrl,
];
}
}
2 changes: 2 additions & 0 deletions app/Models/LinkPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* @property-read string|null $description
* @property-read string|null $image_url
* @property-read string|null $site_name
* @property-read string|null $gif_url
* @property-read CarbonInterface|null $fetched_at
* @property-read CarbonInterface $created_at
* @property-read CarbonInterface $updated_at
Expand Down Expand Up @@ -51,6 +52,7 @@ public function casts(): array
'description' => 'string',
'image_url' => 'string',
'site_name' => 'string',
'gif_url' => 'string',
'fetched_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
Expand Down
104 changes: 104 additions & 0 deletions app/Services/PreviewLinkProviders/GifPreviewLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace App\Services\PreviewLinkProviders;

use App\Contracts\Services\PreviewLink;
use App\Dto\LinkPreviewMetadata;
use App\Services\Urls;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Str;
use Uri\WhatWg\Url;

final readonly class GifPreviewLink implements PreviewLink
{
public const string CONTENT_TYPE = 'image/gif';

public const int HEADER_BYTES = 32;

private const int MIN_HEADER_BYTES = 13;

private const int SIGNATURE_BYTES = 6;

private const int WIDTH_OFFSET = 6;

private const int HEIGHT_OFFSET = 8;

private const int DIMENSION_BYTES = 2;

private const array SIGNATURES = ['GIF87a', 'GIF89a'];

private const string ZERO_DIMENSION = "\0\0";

private const string BINARY_ENCODING = '8bit';

public function metadata(Response $response, Url $finalUrl): LinkPreviewMetadata
{
$body = $response->toPsrResponse()->getBody();
$body->rewind();

$url = $this->getVerifiedUrl(
$finalUrl,
$response->header('Content-Type'),
Utils::copyToString($body, self::HEADER_BYTES),
);

return new LinkPreviewMetadata(gifUrl: $url);
}

public function isGifUrl(?Url $url): bool
{
if ($url?->getScheme() !== 'https') {
return false;
}

return str_ends_with(mb_strtolower($url->getPath()), '.gif');
}

public function getVerifiedUrl(Url $url, string $contentType, string $header): ?string
{
$mediaType = mb_strtolower(mb_trim(Str::before($contentType, ';')));

if ($mediaType !== self::CONTENT_TYPE) {
return null;
}

$sourceUrl = $url->toAsciiString();

if (mb_strlen($sourceUrl) > Urls::MAX_LENGTH) {
return null;
}

if (! $this->isValidHeader($header)) {
return null;
}

return $sourceUrl;
}

private function isValidHeader(string $header): bool
{
if (mb_strlen($header, self::BINARY_ENCODING) < self::MIN_HEADER_BYTES) {
return false;
}

return $this->hasValidSignature($header) && $this->hasValidDimensions($header);
}

private function hasValidSignature(string $header): bool
{
$signature = mb_substr($header, 0, self::SIGNATURE_BYTES, self::BINARY_ENCODING);

return in_array($signature, self::SIGNATURES, true);
}

private function hasValidDimensions(string $header): bool
{
$width = mb_substr($header, self::WIDTH_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING);
$height = mb_substr($header, self::HEIGHT_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING);

return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('link_previews', function (Blueprint $table): void {
$table->string('gif_url', 2048)->nullable();
});
}
};
42 changes: 42 additions & 0 deletions tests/Fixtures/link-preview-http.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

$server = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage);

if ($server === false) {
fwrite(STDERR, "{$errorMessage} ({$errorCode})\n");

exit(1);
}

$address = stream_socket_get_name($server, false);
$port = (int) mb_substr(mb_strrchr($address, ':'), 1);

fwrite(STDOUT, "{$port}\n");

$connection = stream_socket_accept($server, 5);

if ($connection === false) {
exit(1);
}

$request = '';

while (($line = fgets($connection)) !== false) {
$request .= $line;

if ($line === "\r\n") {
break;
}
}

file_put_contents($argv[1], $request);

$body = "GIF89a\x01\x00\x01\x00\x80\x00\x00".str_repeat('x', 243);
$response = "HTTP/1.1 200 OK\r\nContent-Type: image/gif\r\nContent-Length: ".mb_strlen($body)."\r\nConnection: close\r\n\r\n".$body;

fwrite($connection, $response);

fclose($connection);
fclose($server);
Loading