Туториали

Laravel Link Previews: Safely Capture and Display Website Thumbnails in Your Bookmarks

Laravel прегледи на врски: Безбедно снимајте и прикажувајте сликички од веб-страници во вашите обележувачи

Списокот со обележувачи станува многу покорисен кога секоја зачувана URL-адреса има препознатлив визуелен преглед. Незгодниот дел е сигурното создавање на тие прегледи: извршувањето на Chromium троши меморија, бара одржување на прелистувачот и го претвора обичното прикажување страници во инфраструктурна одговорност.

Овој туторијал создава Laravel апликација за обележувачи што снима PNG прегледи преку Screenshot API, ги обработува во редицата, ја валидира вратената слика и ја зачувува на јавниот диск на Laravel. Резултатот е одзивна апликација што никогаш не го изложува својот сервисен токен ниту ги тера посетителите да чекаат снимање од прелистувач.

Добијте пристап и копирајте го сервисниот токен

Завршете го воведувањето во услугата пред да напишете интеграциски код:

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Screenshot API.
  3. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

Повторното генерирање на овој токен го поништува претходно активниот токен, па ротацијата на токени мора да вклучува ажурирање на околината на апликацијата и рестартирање на долготрајните работници. Оваа услуга бара автентикација; не постои неавтентициран режим. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе ја користиме Bearer формата бидејќи ги задржува ингеренциите надвор од URL-адресите и дневниците за пристап.

Потврдете ја точната крајна точка

Операцијата за снимање е HTTP GET барање до https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Нејзиниот задолжителен параметар за барање url ја одредува страницата за снимање, а успешниот одговор содржи тело image/png заедно со заглавија за кеш и квота на одговорот.

Направете едно минимално барање од доверлив терминал. Излезот е бинарен, затоа запишете го во датотека, а задржете ги заглавијата за проверка:

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://example.com/' \
  --dump-header /tmp/screenshot-headers.txt \
  --output /tmp/example-preview.png

Не го внесувајте токенот во контрола на изворниот код или во историјата на школката на заедничка машина. Наместо тоа, ставете го во локалната конфигурација на околината на Laravel:

# .env
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_CONNECT_TIMEOUT=3
SCREENSHOT_RESPONSE_TIMEOUT=20

# Local development only; use a managed queue in production.
QUEUE_CONNECTION=database

Архитектура и компромиси

Веб-барањето треба брзо да валидира и зачува обележувач. Потоа задача во редицата ја повикува услугата за слики од екранот, ја проверува границата на одговорот и запишува локално контролирана PNG-слика. Страницата со обележувачи ја прикажува таа зачувана датотека наместо да вметнува произволна оддалечена слика.

Ова раздвојување ѝ дава на апликацијата ограничена HTTP-латентност, повторни обиди на ниво на редица и стабилен преглед дури и ако одредиштето подоцна се промени. Исто така троши простор за складирање и значи дека прегледите се евентуално конзистентни: нов обележувач накратко прикажува состојба на чекање.

Релевантната структура на проектот е намерно мала:

  • app/Http/Controllers/BookmarkController.php валидира и создава обележувачи.
  • app/Services/ScreenshotClient.php го поседува надворешниот HTTP-договор.
  • app/Services/ScreenshotResult.php ги пресликува транспортните одговори во исходи од доменот.
  • app/Jobs/CaptureBookmarkPreview.php го контролира однесувањето за зачувување и повторни обиди.
  • resources/views/bookmarks/index.blade.php безбедно ги прикажува прегледите.

Создајте го складиштето за обележувачи

Оваа имплементација претпоставува PHP 8.3 или понов, поддржана Laravel апликација, конфигурирана база на податоци и функционален бекенд за редицата. Генерирајте ги деловите на апликацијата и табелата за редицата во базата:

php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job CaptureBookmarkPreview
php artisan make:queue-table
php artisan migrate
php artisan storage:link

Дефинирајте ги полињата за обележувачот во генерираната миграција:

<?php

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::create('bookmarks', function (Blueprint $table): void {
            $table->id();
            $table->string('title');
            $table->text('url');
            $table->string('preview_status')->default('pending');
            $table->string('preview_path')->nullable();
            $table->string('preview_error')->nullable();
            $table->json('preview_meta')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bookmarks');
    }
};

Направете ги овие атрибути доделиви и претворете ги оперативните метаподатоци:

<?php
// app/Models/Bookmark.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class Bookmark extends Model
{
    protected $fillable = [
        'title', 'url', 'preview_status', 'preview_path',
        'preview_error', 'preview_meta',
    ];

    protected function casts(): array
    {
        return ['preview_meta' => 'array'];
    }
}

Конфигурирајте ја API-границата

Додајте посебен запис во config/services.php. Читањето на env() само од конфигурациски датотеки ја одржува апликацијата компатибилна со кешот за конфигурација на Laravel:

'screenshot' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
    'token' => env('SCREENSHOT_API_TOKEN'),
    'connect_timeout' => (int) env('SCREENSHOT_CONNECT_TIMEOUT', 3),
    'timeout' => (int) env('SCREENSHOT_RESPONSE_TIMEOUT', 20),
],

Пресликајте го секој оддалечен одговор во мал доменски објект. Не треба да се претпоставуваат имиња на заглавија надвор од документираните категории за кеш и квота, па пресликувачот ги задржува заглавијата чии имиња означуваат информации за кеш, квота или ограничување на стапката. Исто така го бележи стандардното заглавие Retry-After кога е присутно.

<?php
// app/Services/ScreenshotResult.php

namespace App\Services;

final readonly class ScreenshotResult
{
    public function __construct(
        public bool $successful,
        public ?string $png,
        public string $failure,
        public bool $retryable,
        public ?int $retryAfter,
        public array $operationalHeaders,
    ) {}
}

Клиентот наметнува временски ограничувања, ги проверува статусот и типот на содржина, ја ограничува прифатената големина на телото и го потврдува PNG-потписот. Неуспесите при поврзување и грешките на серверот добиваат два ограничени, кратки повторни обиди. Одговорите за автентикација, валидација и квота не се повторуваат слепо.

<?php
// app/Services/ScreenshotClient.php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

final class ScreenshotClient
{
    private const MAX_BYTES = 5_242_880;

    public function capture(string $url): ScreenshotResult
    {
        $token = config('services.screenshot.token');

        if (! is_string($token) || $token === '') {
            throw new RuntimeException('Screenshot API token is not configured.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(config('services.screenshot.connect_timeout'))
                    ->timeout(config('services.screenshot.timeout'))
                    ->get(config('services.screenshot.endpoint'), ['url' => $url]);
            } catch (ConnectionException $exception) {
                Log::warning('Screenshot transport failure', [
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);

                if ($attempt < 3) {
                    usleep($attempt === 1 ? 250_000 : 750_000);
                    continue;
                }

                return new ScreenshotResult(
                    false, null, 'transport_failure', true, null, []
                );
            }

            if ($response->serverError() && $attempt < 3) {
                Log::warning('Screenshot upstream server error', [
                    'attempt' => $attempt,
                    'status' => $response->status(),
                ]);
                usleep($attempt === 1 ? 250_000 : 750_000);
                continue;
            }

            return $this->map($response);
        }

        throw new RuntimeException('Unreachable screenshot retry state.');
    }

    private function map(Response $response): ScreenshotResult
    {
        $headers = [];

        foreach ($response->headers() as $name => $values) {
            if (preg_match('/cache|quota|rate.?limit|retry-after/i', $name)) {
                $headers[$name] = array_values((array) $values);
            }
        }

        $retryAfter = filter_var(
            $response->header('Retry-After'),
            FILTER_VALIDATE_INT,
            ['options' => ['min_range' => 0]]
        );

        if ($response->status() === 429) {
            return new ScreenshotResult(
                false, null, 'quota_limited', true,
                $retryAfter === false ? null : $retryAfter,
                $headers
            );
        }

        if (in_array($response->status(), [401, 403], true)) {
            return new ScreenshotResult(
                false, null, 'authentication_failed', false, null, $headers
            );
        }

        if ($response->serverError()) {
            return new ScreenshotResult(
                false, null, 'upstream_failure', true, null, $headers
            );
        }

        if (! $response->successful()) {
            return new ScreenshotResult(
                false, null, 'request_rejected', false, null, $headers
            );
        }

        $body = $response->body();
        $isPng = str_starts_with(
            strtolower((string) $response->header('Content-Type')),
            'image/png'
        ) && str_starts_with($body, "\x89PNG\r\n\x1a\n");

        if (! $isPng || strlen($body) > self::MAX_BYTES) {
            return new ScreenshotResult(
                false, null, 'invalid_image_response', false, null, $headers
            );
        }

        return new ScreenshotResult(true, $body, '', false, null, $headers);
    }
}

Валидирајте URL-адреси и испратете го снимањето

Валидацијата на URL-адресите е и безбедносна граница и контрола на квотата. Прифатете само HTTP и HTTPS, одбијте ингеренции вградени во URL-адресата и одбијте очигледни локални цели. Контролите на одредиштето од страна на давателот остануваат суштински бидејќи валидацијата на апликацијата не може да го елиминира DNS rebinding на границата на оддалеченото преземање.

<?php
// app/Http/Controllers/BookmarkController.php

namespace App\Http\Controllers;

use App\Jobs\CaptureBookmarkPreview;
use App\Models\Bookmark;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

final class BookmarkController
{
    public function index(): View
    {
        return view('bookmarks.index', [
            'bookmarks' => Bookmark::latest()->get(),
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:120'],
            'url' => [
                'required',
                'url:http,https',
                'max:2048',
                function (string $attribute, mixed $value, Closure $fail): void {
                    $parts = parse_url((string) $value);
                    $host = strtolower((string) ($parts['host'] ?? ''));

                    if (isset($parts['user']) || isset($parts['pass'])) {
                        $fail('URLs containing credentials are not allowed.');
                        return;
                    }

                    if ($host === 'localhost' || str_ends_with($host, '.local')) {
                        $fail('Local destinations are not allowed.');
                        return;
                    }

                    if (filter_var($host, FILTER_VALIDATE_IP)
                        && ! filter_var(
                            $host,
                            FILTER_VALIDATE_IP,
                            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
                        )) {
                        $fail('Private or reserved IP addresses are not allowed.');
                    }
                },
            ],
        ]);

        $bookmark = Bookmark::create($data + ['preview_status' => 'pending']);

        CaptureBookmarkPreview::dispatch($bookmark)->afterCommit();

        return redirect()->route('bookmarks.index');
    }
}

Задачата зачувува само проверени PNG-бајти. Одговорите за квота користат ограничено одложување врз основа на Retry-After кога е достапно; транспортните и серверските неуспеси го користат подолгиот распоред за повлекување на задачата. Трајните неуспеси се бележат без фрлање исклучок, со што се спречуваат бесцелни обиди во редицата.

<?php
// app/Jobs/CaptureBookmarkPreview.php

namespace App\Jobs;

use App\Models\Bookmark;
use App\Services\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

final class CaptureBookmarkPreview implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;

    public function __construct(public Bookmark $bookmark) {}

    public function backoff(): array
    {
        return [30, 120, 300];
    }

    public function handle(ScreenshotClient $client): void
    {
        $result = $client->capture($this->bookmark->url);

        if ($result->successful) {
            $path = "bookmark-previews/{$this->bookmark->id}.png";

            if (! Storage::disk('public')->put($path, $result->png)) {
                throw new RuntimeException('Preview storage failed.');
            }

            $this->bookmark->update([
                'preview_status' => 'ready',
                'preview_path' => $path,
                'preview_error' => null,
                'preview_meta' => $result->operationalHeaders,
            ]);
            return;
        }

        Log::warning('Bookmark preview capture failed', [
            'bookmark_id' => $this->bookmark->id,
            'host' => parse_url($this->bookmark->url, PHP_URL_HOST),
            'failure' => $result->failure,
        ]);

        if ($result->failure === 'quota_limited'
            && $this->attempts() < $this->tries) {
            $this->release(max(30, min(900, $result->retryAfter ?? 60)));
            return;
        }

        if ($result->retryable) {
            throw new RuntimeException($result->failure);
        }

        $this->bookmark->update([
            'preview_status' => 'failed',
            'preview_error' => $result->failure,
            'preview_meta' => $result->operationalHeaders,
        ]);
    }

    public function failed(?Throwable $exception): void
    {
        $this->bookmark->update([
            'preview_status' => 'failed',
            'preview_error' => 'retries_exhausted',
        ]);
    }
}

Прикажувајте само контролирани прегледи

Регистрирајте ги рутите и прикажете ги избегнатите податоци за обележувачите. Треба да се додаде авторизација ако обележувачите им припаѓаат на поединечни корисници.

// routes/web.php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;

Route::get('/bookmarks', [BookmarkController::class, 'index'])
    ->name('bookmarks.index');
Route::post('/bookmarks', [BookmarkController::class, 'store'])
    ->middleware('throttle:20,1')
    ->name('bookmarks.store');
<!-- resources/views/bookmarks/index.blade.php -->
<form method="post" action="{{ route('bookmarks.store') }}">
    @csrf
    <input name="title" required maxlength="120">
    <input name="url" type="url" required maxlength="2048">
    <button type="submit">Save bookmark</button>
</form>

@foreach ($bookmarks as $bookmark)
    <article>
        @if ($bookmark->preview_status === 'ready')
            <img
                src="{{ Storage::disk('public')->url($bookmark->preview_path) }}"
                alt=""
                loading="lazy"
                width="640"
                height="400"
            >
        @else
            <p>Preview: {{ $bookmark->preview_status }}</p>
        @endif

        <a href="{{ $bookmark->url }}"
           rel="noopener noreferrer"
           target="_blank">{{ $bookmark->title }}</a>
    </article>
@endforeach

Тестирајте без повикување на активната услуга

HTTP-лажното претставување на Laravel ја прави API-границата детерминистичка. Овие тестови ги проверуваат автентикацијата, кодирањето на параметрите за барање, PNG-валидацијата, зачувувањето на заглавијата и складирањето:

<?php
// tests/Feature/ScreenshotIntegrationTest.php

namespace Tests\Feature;

use App\Jobs\CaptureBookmarkPreview;
use App\Models\Bookmark;
use App\Services\ScreenshotClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class ScreenshotIntegrationTest extends TestCase
{
    use RefreshDatabase;

    public function test_client_maps_a_png_response(): void
    {
        config(['services.screenshot.token' => 'test-token']);

        Http::fake([
            'ai.mihajlo.mk/*' => Http::response(
                "\x89PNG\r\n\x1a\nfake",
                200,
                ['Content-Type' => 'image/png', 'X-Cache' => 'HIT']
            ),
        ]);

        $result = app(ScreenshotClient::class)
            ->capture('https://example.com/path?a=1');

        $this->assertTrue($result->successful);
        $this->assertArrayHasKey('X-Cache', $result->operationalHeaders);

        Http::assertSent(function (Request $request): bool {
            parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

            return $request->method() === 'GET'
                && $request->hasHeader(
                    'Authorization',
                    'Bearer test-token'
                )
                && ($query['url'] ?? null)
                    === 'https://example.com/path?a=1';
        });
    }

    public function test_job_stores_a_verified_preview(): void
    {
        Storage::fake('public');
        config(['services.screenshot.token' => 'test-token']);

        Http::fake([
            '*' => Http::response(
                "\x89PNG\r\n\x1a\nfake",
                200,
                ['Content-Type' => 'image/png']
            ),
        ]);

        $bookmark = Bookmark::create([
            'title' => 'Example',
            'url' => 'https://example.com/',
            'preview_status' => 'pending',
        ]);

        app(CaptureBookmarkPreview::class, ['bookmark' => $bookmark])
            ->handle(app(ScreenshotClient::class));

        Storage::disk('public')
            ->assertExists("bookmark-previews/{$bookmark->id}.png");
        $this->assertSame('ready', $bookmark->fresh()->preview_status);
    }
}

Распоредување, набљудливост и вообичаени неуспеси

Во продукција, внесете го токенот преку управувачот со тајни на платформата, извршете php artisan config:cache, применете ги миграциите, осигурете се дека јавниот диск е траен и одржувајте работник за редицата под процесен супервизор. По ротација на сервисниот токен или распоредување код, рестартирајте ги работниците со php artisan queue:restart.

Следете го бројот на обележувачи ready, failed и долготрајно pending. Дневниците намерно ги содржат ID на обележувачот, домаќинот, статусот и категоријата на неуспех, но не и токенот, целосната низа на параметри на URL-адресата или телото на одговорот.

  • Неуспеси при автентикација: потврдете ги активацијата и токенот ограничен на услугата, потоа повторно изградете го кешот за конфигурација и рестартирајте ги работниците.
  • Снимања ограничени со квота: прегледајте ги зачуваните метаподатоци за квотата, намалете ја зачестеноста на повторното снимање и потврдете дека избраниот план одговара на побарувачката.
  • Неважечки одговори со слика: задржете го одбивањето. Никогаш не зачувувајте неочекуван тип на содржина како датотека што може да ја прикаже прелистувач.
  • Постојани редови на чекање: проверете дали работник троши од конфигурираната редица и дали се следат неуспешните задачи.
  • Недостасуваат слики: потврдете ги storage:link, дозволите за дискот и трајното споделено складирање кога се користат повеќе инстанци на апликацијата.

Конечна контролна листа за проверка

  1. Зачувајте јавна HTTPS URL-адреса и потврдете дека веб-барањето се враќа веднаш.
  2. Извршете php artisan queue:work --tries=4 и набљудувајте како обележувачот станува ready.
  3. Потврдете дека зачуваната датотека започнува со PNG-потписот и се сервира од јавниот диск.
  4. Извршете php artisan test без активен API-сообраќај.
  5. Тестирајте неважечка локална URL-адреса, неважечки токен, симулиран 429 и симулиран серверски неуспех.
  6. Потврдете дека дневниците на апликацијата и зачуваните метаподатоци не содржат ингеренции или целосна чувствителна URL-адреса.

Самата слика од екранот е само видливиот резултат. Продукциската функционалност е границата околу неа: контролирани влезови, тајна задржана надвор од URL-адресите, детерминистичко пресликување на одговорите, ограничени повторни обиди, безбедно складирање и доволно оперативен контекст за дијагностицирање неуспеси. Штом овие делови се поставени, прегледите на врски престануваат да бидат инфраструктура на прелистувач и стануваат обичен, сигурен дел од доменот на обележувачите.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.