Vodiči

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

Laravel pretpregledi poveznica: sigurno snimajte i prikazujte minijature web-stranica u svojim oznakama

Popis oznaka postaje mnogo korisniji kada svaki spremljeni URL ima prepoznatljiv vizualni pregled. Nezgodan dio je pouzdana izrada tih pregleda: pokretanje Chromiuma troši memoriju, zahtijeva zakrpavanje preglednika i pretvara uobičajeno renderiranje stranica u infrastrukturnu odgovornost.

Ovaj vodič izrađuje Laravel aplikaciju za oznake koja snima PNG preglede putem Screenshot API-ja, obrađuje ih u redu čekanja, provjerava vraćenu sliku i pohranjuje je na Laravelov javni disk. Rezultat je responzivna aplikacija koja nikada ne izlaže svoj servisni token niti posjetitelje tjera da čekaju snimanje preglednika.

Dobijte pristup i kopirajte servisni token

Dovršite uvođenje u servis prije pisanja integracijskog koda:

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Screenshot API.
  3. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte token ograničen na servis.

Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora uključivati ažuriranje aplikacijskog okruženja i ponovno pokretanje dugotrajnih radnika. Ova usluga zahtijeva autentikaciju; ne postoji neautentificirani način rada. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer zadržava vjerodajnicu izvan URL-ova i zapisnika pristupa.

Potvrdite točnu krajnju točku

Operacija snimanja je HTTP GET zahtjev prema https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita url određuje stranicu za snimanje, a uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora za predmemoriju i kvotu.

Pošaljite jedan minimalan zahtjev s pouzdanog terminala. Izlaz je binaran, zato ga zapišite u datoteku uz zadržavanje zaglavlja za pregled:

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

Ne lijepite token u kontrolu izvornog koda ni u povijest ljuske na zajedničkom računalu. Umjesto toga, stavite ga u Laravelovu lokalnu konfiguraciju okruženja:

# .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

Arhitektura i kompromisi

Web zahtjev treba brzo provjeriti i spremiti oznaku. Posao u redu čekanja zatim poziva servis za snimke zaslona, provjerava granicu odgovora i zapisuje lokalno kontrolirani PNG. Stranica s oznakama prikazuje tu pohranjenu datoteku umjesto ugrađivanja proizvoljne udaljene slike.

Ovo razdvajanje aplikaciji daje ograničenu HTTP latenciju, ponovne pokušaje na razini reda čekanja i stabilan pregled čak i ako se odredište kasnije promijeni. Također troši prostor za pohranu i znači da su pregledi eventualno konzistentni: nova oznaka nakratko prikazuje stanje na čekanju.

Relevantna struktura projekta namjerno je mala:

  • app/Http/Controllers/BookmarkController.php provjerava i stvara oznake.
  • app/Services/ScreenshotClient.php upravlja vanjskim HTTP ugovorom.
  • app/Services/ScreenshotResult.php mapira transportne odgovore u ishode domene.
  • app/Jobs/CaptureBookmarkPreview.php upravlja postojanošću i ponašanjem ponovnih pokušaja.
  • resources/views/bookmarks/index.blade.php sigurno prikazuje preglede.

Izradite pohranu oznaka

Ova implementacija pretpostavlja PHP 8.3 ili noviji, podržanu Laravel aplikaciju, konfiguriranu bazu podataka i funkcionalan pozadinski sustav reda čekanja. Generirajte dijelove aplikacije i tablicu reda čekanja u bazi podataka:

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

Definirajte polja oznake u generiranoj migraciji:

<?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');
    }
};

Učinite te atribute dodjeljivima i pretvorite operativne metapodatke:

<?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'];
    }
}

Konfigurirajte API granicu

Dodajte namjenski unos u config/services.php. Čitanje env() samo iz konfiguracijskih datoteka održava aplikaciju kompatibilnom s Laravelovom predmemorijom konfiguracije:

'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),
],

Mapirajte svaki udaljeni odgovor u mali objekt domene. Nazive zaglavlja izvan dokumentiranih kategorija predmemorije i kvote ne treba nagađati, stoga mapper zadržava zaglavlja čiji nazivi označavaju podatke o predmemoriji, kvoti ili ograničenju brzine. Također bilježi standardno zaglavlje Retry-After kada je prisutno.

<?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,
    ) {}
}

Klijent provodi vremenska ograničenja, provjerava status i vrstu sadržaja, ograničava veličinu prihvaćenog tijela i potvrđuje PNG potpis. Neuspjesi povezivanja i pogreške poslužitelja dobivaju dva ograničena, kratka ponovna pokušaja. Odgovori autentikacije, provjere valjanosti i kvote ne pokušavaju se naslijepo ponovno.

<?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);
    }
}

Provjerite URL-ove i pokrenite snimanje

Provjera URL-a istovremeno je sigurnosna granica i kontrola kvote. Prihvatite samo HTTP i HTTPS, odbijte vjerodajnice ugrađene u URL i odbijte očita lokalna odredišta. Kontrole odredišta na strani pružatelja ostaju ključne jer provjera aplikacije ne može ukloniti DNS ponovno vezanje na granici udaljenog dohvaćanja.

<?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');
    }
}

Posao pohranjuje samo provjerene PNG bajtove. Odgovori kvote koriste ograničeno odgađanje na temelju Retry-After kada je dostupno; neuspjesi prijenosa i poslužitelja koriste dulji raspored odgode posla. Trajni neuspjesi bilježe se bez izbacivanja iznimke, čime se sprječavaju besmisleni pokušaji u redu čekanja.

<?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',
        ]);
    }
}

Prikazujte samo kontrolirane preglede

Registrirajte rute i prikažite izbjegnute podatke oznaka. Autorizaciju treba dodati ako oznake pripadaju pojedinačnim korisnicima.

// 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

Testirajte bez pozivanja aktivne usluge

Laravelov HTTP lažnjak čini API granicu determinističkom. Ovi testovi provjeravaju autentikaciju, kodiranje upita, provjeru PNG-a, očuvanje zaglavlja i pohranu:

<?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);
    }
}

Implementacija, nadzornosti i uobičajeni neuspjesi

U produkciji ubrizgajte token putem upravitelja tajnama platforme, pokrenite php artisan config:cache, primijenite migracije, osigurajte da je javni disk trajan i držite radnika reda čekanja pod nadzorom upravitelja procesa. Nakon rotacije servisnog tokena ili implementacije koda ponovno pokrenite radnike pomoću php artisan queue:restart.

Pratite broj oznaka ready, failed i dugotrajno pending. Zapisnici namjerno sadrže ID oznake, host, status i kategoriju neuspjeha, ali ne token, cijeli niz upita URL-a ni tijelo odgovora.

  • Neuspjesi autentikacije: potvrdite aktivaciju i token ograničen na servis, zatim ponovno izgradite predmemoriju konfiguracije i ponovno pokrenite radnike.
  • Snimanja ograničena kvotom: pregledajte očuvane metapodatke kvote, smanjite učestalost ponovnog snimanja i provjerite odgovara li odabrani plan potražnji.
  • Nevažeći odgovori slika: zadržite odbijanje. Nikada ne spremajte neočekivanu vrstu sadržaja kao datoteku koju preglednik može prikazati.
  • Trajno redovi na čekanju: provjerite obrađuje li radnik konfigurirani red čekanja te nadziru li se neuspjeli poslovi.
  • Nedostajuće slike: potvrdite storage:link, dozvole diska i trajnu dijeljenu pohranu kada se koristi više instanci aplikacije.

Završni kontrolni popis provjere

  1. Spremite javni HTTPS URL i potvrdite da se web zahtjev odmah vraća.
  2. Pokrenite php artisan queue:work --tries=4 i promatrajte kako oznaka postaje ready.
  3. Provjerite da pohranjena datoteka počinje PNG potpisom i poslužuje se s javnog diska.
  4. Pokrenite php artisan test bez aktivnog API prometa.
  5. Testirajte nevažeći lokalni URL, nevažeći token, simulirani 429 i simulirani neuspjeh poslužitelja.
  6. Potvrdite da zapisnici aplikacije i pohranjeni metapodaci ne sadrže vjerodajnicu ni potpuni osjetljivi URL.

Sama snimka zaslona samo je vidljivi rezultat. Produkcijska značajka je granica oko nje: kontrolirani unosi, tajna zadržana izvan URL-ova, determinističko mapiranje odgovora, ograničeni ponovni pokušaji, sigurna pohrana i dovoljno operativnog konteksta za dijagnosticiranje neuspjeha. Kada su ti dijelovi postavljeni, pregledi poveznica prestaju biti infrastruktura preglednika i postaju običan, pouzdan dio domene oznaka.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.