Vodiči

Laravel: Safely Preview Bookmarks with AI-Powered Screenshot Generation

Laravel: Sigurno pregledajte oznake uz generiranje snimki zaslona pokretano umjetnom inteligencijom

Oznaka je korisnija kada je možete prepoznati na prvi pogled. Naslovi pomažu, ali vizualni pregled često mnogo brže prenosi identitet stranice. Težak dio je pouzdano generirati taj pregled bez pretvaranja Laravel poslužitelja u host za automatizaciju preglednika.

Ovaj vodič izrađuje mali Laravel API za oznake koji prihvaća javne web URL-ove, generira PNG preglede u pozadinskom redu čekanja, pohranjuje ih lokalno i izlaže njihov status kroz krajnju točku za anketiranje. Servis za snimke zaslona pruža infrastrukturu preglednika; Laravel ostaje odgovoran za validaciju, postojanost podataka, rukovanje neuspjesima i sigurnu isporuku.

Dobijte pristup Screenshot API-ju

Registrirajte se putem stranice za registraciju računa ili upotrijebite stranicu za prijavu ako već imate račun.

Otvorite stranicu usluge Screenshot API, odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Zatim posjetite službenu dokumentaciju. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.

Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer shemu kako se vjerodajnica nikada ne bi pojavila u URL-u. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati svaku implementiranu aplikaciju koja ga upotrebljava.

Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, s ciljnom adresom u obaveznom parametru upita url. Prije pisanja Laravel koda, pošaljite minimalni zahtjev s rezerviranim tokenom:

curl --get \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: image/png" \
  --data-urlencode "url=https://example.com" \
  --dump-header preview.headers \
  --output preview.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Uspješan odgovor ima tijelo tipa image/png. Pregledajte i preview.headers: zaglavlja odgovora za predmemoriju i kvotu operativni su podaci, a ne ukras. Pomažu razlikovati pogodak predmemorije od svježeg rada i iscrpljenu kvotu od neispravne integracije.

Sada stavite vjerodajnicu u datoteku okruženja projekta. Nikada ne predajte stvarnu vrijednost u repozitorij:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_API_ENDPOINT=https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Arhitektura i oblik projekta

Aplikacija odmah stvara oznaku sa statusom pregleda pending. Posao u redu čekanja poziva Screenshot API, validira vraćeni PNG, pohranjuje ga na Laravelov public disk i mijenja status u ready. Preglednik može anketirati resurs oznake umjesto čekanja tijekom vanjskog HTTP zahtjeva.

Ova pozadinska granica je važna. Generiranje snimki zaslona ima mrežnu latenciju, može naići na ograničenja kvote i ne bi trebalo držati otvorenim zahtjev koji stvara oznaku. Dobivena struktura namjerno je mala:

  • SafePublicUrl odbacuje neprikladna odredišta.
  • ScreenshotClient upravlja vanjskim HTTP ugovorom.
  • GenerateBookmarkPreview koordinira postojanost podataka i stanja neuspjeha.
  • BookmarkController upravlja stvaranjem, anketiranjem i isporukom PNG-a.

Počnite s Laravel aplikacijom koja koristi PHP 8.3 ili noviji, konfiguriranom bazom podataka i stvarnim upravljačkim programom reda čekanja za produkciju. Generirajte glavne komponente i stvorite tablice reda čekanja ako vaša aplikacija koristi Laravelov red čekanja u bazi podataka:

php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job GenerateBookmarkPreview
php artisan make:rule SafePublicUrl
php artisan make:queue-table
php artisan migrate

Konfigurirajte granicu API-ja

Dodajte uslugu u config/services.php. Čitanje env() samo iz konfiguracije zadržava aplikaciju kompatibilnom s Laravelovom predmemorijom konfiguracije.

'screenshot' => [
    'token' => env('SCREENSHOT_API_TOKEN'),
    'endpoint' => env(
        'SCREENSHOT_API_ENDPOINT',
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
    ),
],

Stvorite app/Services/Screenshots/CaptureResult.php i ScreenshotException.php:

<?php

namespace App\Services\Screenshots;

final readonly class CaptureResult
{
    public function __construct(
        public string $png,
        public array $cacheHeaders,
        public array $quotaHeaders,
    ) {}
}

final class ScreenshotException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly array $context = [],
    ) {
        parent::__construct($message);
    }
}

Ugovor odgovora je binaran, stoga granica ne bi trebala pokušavati dekodirati JSON. Mora provjeriti i vrstu medija i PNG potpis. Ugovor usluge obećava zaglavlja predmemorije i kvote, ali kod aplikacije ne bi trebao ovisiti o nedokumentiranom nazivu. Ovo mapiranje čuva zaglavlja čiji nazivi označavaju semantiku predmemorije, kvote ili ograničenja brzine.

Stvorite app/Services/Screenshots/ScreenshotClient.php:

<?php

namespace App\Services\Screenshots;

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

final class ScreenshotClient
{
    public function capture(string $url): CaptureResult
    {
        $token = config('services.screenshot.token');
        $endpoint = config('services.screenshot.endpoint');

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

        $response = null;

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(3)
                    ->timeout(20)
                    ->get($endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotException(
                        'unavailable',
                        'Screenshot service could not be reached.'
                    );
                }

                usleep(200_000 * $attempt);
                continue;
            }

            if (in_array($response->status(), [500, 502, 503, 504], true)
                && $attempt < 3) {
                usleep(200_000 * $attempt);
                continue;
            }

            break;
        }

        $context = $this->operationalHeaders($response);

        if (in_array($response->status(), [401, 403], true)) {
            throw new ScreenshotException(
                'authentication',
                'Screenshot service rejected its token.',
                $context
            );
        }

        if (in_array($response->status(), [400, 422], true)) {
            throw new ScreenshotException(
                'invalid_request',
                'Screenshot service rejected the target URL.',
                $context
            );
        }

        if ($response->status() === 429) {
            throw new ScreenshotException(
                'quota_limited',
                'Screenshot service quota or rate limit was reached.',
                $context
            );
        }

        if (! $response->successful()) {
            throw new ScreenshotException(
                'unavailable',
                'Screenshot service returned HTTP '.$response->status().'.',
                $context
            );
        }

        $body = $response->body();
        $type = strtolower($response->header('Content-Type', ''));

        if (! str_starts_with($type, 'image/png')
            || ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
            throw new ScreenshotException(
                'invalid_response',
                'Screenshot service did not return a valid PNG.',
                $context
            );
        }

        return new CaptureResult(
            $body,
            $context['cache_headers'],
            $context['quota_headers']
        );
    }

    private function operationalHeaders(Response $response): array
    {
        $cache = [];
        $quota = [];

        foreach ($response->headers() as $name => $values) {
            $normalized = strtolower($name);
            $value = implode(', ', $values);

            if (str_contains($normalized, 'cache')) {
                $cache[$name] = $value;
            }

            if (str_contains($normalized, 'quota')
                || str_contains($normalized, 'ratelimit')
                || str_contains($normalized, 'rate-limit')) {
                $quota[$name] = $value;
            }
        }

        return ['cache_headers' => $cache, 'quota_headers' => $quota];
    }
}

Samo neuspjesi povezivanja i odabrani neuspjesi poslužitelja dobivaju ograničene ponovne pokušaje. Odgovori za autentifikaciju, validaciju i kvotu vraćaju se odmah jer ponovni pokušaj ne može popraviti zahtjev i može potrošiti dodatnu kvotu.

Prihvaćajte samo prikladne URL-ove oznaka

Iako Laravel sam ne dohvaća stranicu, prihvaćanje lokalnih adresa, ugrađenih vjerodajnica ili privatnih IP adresa nepotreban je rizik. Sljedeće pravilo dopušta samo javna HTTP i HTTPS odredišta. DNS se može promijeniti nakon validacije, stoga ovo ostaje jedan sloj, a ne apsolutno SSRF jamstvo.

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

final class SafePublicUrl implements ValidationRule
{
    public function validate(
        string $attribute,
        mixed $value,
        Closure $fail
    ): void {
        if (! is_string($value) || ! filter_var($value, FILTER_VALIDATE_URL)) {
            $fail('The URL must be valid.');
            return;
        }

        $parts = parse_url($value);
        $scheme = strtolower($parts['scheme'] ?? '');
        $host = trim($parts['host'] ?? '', '[]');

        if (! in_array($scheme, ['http', 'https'], true)
            || $host === ''
            || isset($parts['user'])
            || isset($parts['pass'])) {
            $fail('The URL must be a public HTTP or HTTPS address.');
            return;
        }

        $addresses = filter_var($host, FILTER_VALIDATE_IP)
            ? [$host]
            : array_values(array_filter(array_map(
                fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null,
                dns_get_record($host, DNS_A | DNS_AAAA) ?: []
            )));

        if ($addresses === []) {
            $fail('The URL host could not be resolved.');
            return;
        }

        foreach ($addresses as $address) {
            if (! filter_var(
                $address,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            )) {
                $fail('Private and reserved destinations are not allowed.');
                return;
            }
        }
    }
}

Spremite oznake i generirajte preglede

U migraciji oznake izričito dodajte stanje domene:

$table->id();
$table->string('title', 160);
$table->text('url');
$table->string('preview_status', 32)->default('pending');
$table->string('preview_path')->nullable();
$table->string('preview_failure', 64)->nullable();
$table->timestamps();

Učinite ta polja popunjivima u App\Models\Bookmark. Zatim stvorite posao:

<?php

namespace App\Jobs;

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

final class GenerateBookmarkPreview implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;
    public int $timeout = 70;

    public function __construct(public readonly int $bookmarkId) {}

    public function handle(ScreenshotClient $client): void
    {
        $bookmark = Bookmark::find($this->bookmarkId);

        if (! $bookmark) {
            return;
        }

        try {
            $capture = $client->capture($bookmark->url);
            $path = "bookmark-previews/{$bookmark->id}.png";

            if (! Storage::disk('public')->put($path, $capture->png)) {
                throw new ScreenshotException(
                    'storage',
                    'Preview could not be stored.'
                );
            }

            $bookmark->update([
                'preview_status' => 'ready',
                'preview_path' => $path,
                'preview_failure' => null,
            ]);

            Log::info('Bookmark preview generated.', [
                'bookmark_id' => $bookmark->id,
                'cache_headers' => $capture->cacheHeaders,
                'quota_headers' => $capture->quotaHeaders,
            ]);
        } catch (ScreenshotException $exception) {
            $bookmark->update([
                'preview_status' => $exception->kind === 'quota_limited'
                    ? 'quota_limited'
                    : 'failed',
                'preview_failure' => $exception->kind,
            ]);

            Log::warning('Bookmark preview generation failed.', [
                'bookmark_id' => $bookmark->id,
                'failure' => $exception->kind,
                'service_headers' => $exception->context,
            ]);
        }
    }

    public function failed(?Throwable $exception): void
    {
        Bookmark::whereKey($this->bookmarkId)->update([
            'preview_status' => 'failed',
            'preview_failure' => 'job_failed',
        ]);
    }
}

Zapisnici namjerno isključuju token, tijelo odgovora i potpuni ciljni URL. Identifikatori oznaka i operativna zaglavlja obično su dovoljni za istraživanje neuspjeha bez otkrivanja vjerodajnica ili podataka o pregledavanju.

Izložite stvaranje, anketiranje i isporuku slike

Kontroler pri stvaranju vraća 202 Accepted. Klijenti anketiraju rutu prikaza dok preview_status ne postane ready, failed ili quota_limited.

<?php

namespace App\Http\Controllers;

use App\Jobs\GenerateBookmarkPreview;
use App\Models\Bookmark;
use App\Rules\SafePublicUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

final class BookmarkController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:160'],
            'url' => ['required', new SafePublicUrl()],
        ]);

        $bookmark = Bookmark::create($data);
        GenerateBookmarkPreview::dispatch($bookmark->id);

        return response()->json($this->payload($bookmark), 202);
    }

    public function show(Bookmark $bookmark): JsonResponse
    {
        return response()->json($this->payload($bookmark));
    }

    public function preview(Bookmark $bookmark)
    {
        abort_unless(
            $bookmark->preview_status === 'ready'
            && $bookmark->preview_path
            && Storage::disk('public')->exists($bookmark->preview_path),
            404
        );

        return Storage::disk('public')->response(
            $bookmark->preview_path,
            null,
            [
                'Content-Type' => 'image/png',
                'X-Content-Type-Options' => 'nosniff',
                'Cache-Control' => 'public, max-age=3600',
            ]
        );
    }

    private function payload(Bookmark $bookmark): array
    {
        return [
            'id' => $bookmark->id,
            'title' => $bookmark->title,
            'url' => $bookmark->url,
            'preview_status' => $bookmark->preview_status,
            'preview_failure' => $bookmark->preview_failure,
            'preview_url' => $bookmark->preview_status === 'ready'
                ? route('bookmarks.preview', $bookmark)
                : null,
        ];
    }
}

Registrirajte rute u routes/web.php, dodajući uobičajeni međuprogram za autentifikaciju i autorizaciju ako oznake pripadaju pojedinačnim korisnicima:

use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;

Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::get('/bookmarks/{bookmark}/preview', [BookmarkController::class, 'preview'])
    ->name('bookmarks.preview');

Testirajte bez pozivanja stvarne usluge

Http::fake() čini binarne odgovore determinističkima i sprječava testove da troše kvotu. Donja signalna zaglavlja postoje samo u lažnom odgovoru radi provjere mapiranja granice; ne potvrđuju određene nazive produkcijskih zaglavlja.

<?php

namespace Tests\Feature;

use App\Jobs\GenerateBookmarkPreview;
use App\Models\Bookmark;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class GenerateBookmarkPreviewTest extends TestCase
{
    use RefreshDatabase;

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

        $png = "\x89PNG\r\n\x1a\nfake-test-payload";

        Http::fake([
            '*' => Http::response($png, 200, [
                'Content-Type' => 'image/png',
                'X-Test-Cache' => 'hit',
                'X-Test-Quota' => '9',
            ]),
        ]);

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

        (new GenerateBookmarkPreview($bookmark->id))
            ->handle(app(ScreenshotClient::class));

        $bookmark->refresh();

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

        Http::assertSentCount(1);
        Http::assertSent(fn ($request) =>
            $request->hasHeader('Authorization', 'Bearer test-token')
            && str_contains($request->url(), 'url=https%3A%2F%2Fexample.com')
        );
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        config(['services.screenshot.token' => 'expired-token']);
        Storage::fake('public');
        Http::fake(['*' => Http::response('', 401)]);

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

        (new GenerateBookmarkPreview($bookmark->id))
            ->handle(app(ScreenshotClient::class));

        $this->assertSame(
            'authentication',
            $bookmark->refresh()->preview_failure
        );

        Http::assertSentCount(1);
    }
}

Implementirajte i upravljajte njime

Pokrenite migracije, predmemorirajte konfiguraciju nakon instaliranja tokena okruženja i pokrenite nadzirani radnik reda čekanja. Vremensko ograničenje radnika mora biti dulje od ograničenja posla od 70 sekundi:

php artisan migrate --force
php artisan config:cache
php artisan queue:work --tries=1 --timeout=80 --max-time=3600

Osigurajte da upravitelj procesa ponovno pokreće radnike nakon implementacija i da je konfigurirani javni disk upisiv. Budući da kontroler struji datoteku, storage:link nije potreban za ovu implementaciju. Pri većem opsegu rada, Laravelov disk podržan objektnom pohranom prirodna je zamjena bez mijenjanja API klijenta.

Oznaka koja ostane na pending obično pokazuje da nijedan radnik ne obrađuje red čekanja. Neuspjeh authentication obično znači da token nedostaje, zastario je ili je opozvan ponovnim generiranjem. Neuspjeh invalid_request upućuje na cilj koji je odbijen na granici usluge. Stanje quota_limited trebalo bi ostati vidljivo umjesto da ga se zatrpava automatskim ponovnim pokušajima; pokušajte ponovno kasnije prema planu i vraćenim informacijama o kvoti. Neuspjeh invalid_response znači da nominalno uspješan odgovor zapravo nije bio PNG.

Završni kontrolni popis za provjeru

  • Stvarni token postoji samo u konfiguraciji podržanoj okruženjem.
  • Javna HTTPS oznaka vraća 202 i ulazi u stanje pending.
  • Radnik reda čekanja proizvodi valjani PNG i mijenja status u ready.
  • Ruta pregleda vraća image/png s nosniff.
  • Privatni, rezervirani URL-ovi, URL-ovi s vjerodajnicama i ne-HTTP URL-ovi se odbacuju.
  • Neuspjesi autentifikacije, validacije, kvote, neispravne slike, mreže i pohrane postaju strukturirana stanja.
  • Zapisnici sadrže ID-ove oznaka i korisna operativna zaglavlja, ali ne token ni tijelo slike.
  • Automatizirani testovi ne šalju vanjske zahtjeve i ne troše kvotu usluge.

Važan rezultat nije samo snimka zaslona na kartici oznake. To je čista operativna granica: rad preglednika ostaje izvan vaše aplikacije, binarni podaci obrađuju se obrambeno, spor rad odvija se asinkrono, a svaki predvidivi neuspjeh ima stanje koje programeri i korisnici mogu razumjeti. To je ono što privlačan pregled pretvara u značajku koju možete sigurno zadržati u produkciji.

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.