Туториали

Laravel Bookmarks: Safely Preview Links with AI Screenshot API Integration

Laravel Bookmarks: Безбедно прегледувајте ги врските со интеграција на API за AI снимки од екран

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

Сликата од екранот е почиста граница. Овој туторијал создава мала Laravel апликација за обележувачи што валидира јавни URL-адреси, асинхроно прави прегледи преку Screenshot API, го проверува вратениот PNG, го складира приватно и го прикажува преку овластена рута. Апликацијата никогаш не ја прикажува самата обележана страница и нема потреба да одржува Chromium инфраструктура.

Добијте пристап до Screenshot API

Услугата бара автентикација; за ова API не постои режим без токен.

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

Регенерирањето на сервисниот токен го поништува претходниот активен токен. Сметајте ја ротацијата за операција на распоредување: ажурирајте ја тајната во секоја околина пред да го повлечете старото распоредување и бидете подготвени барањата во тек што ја користат поништената вредност да не успеат.

Потврдете ја крајната точка пред да пишувате апликациски код

Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Бара query параметар url и прифаќа автентикација преку Bearer токен, заглавие X-API-Token или query параметар token.

Овој проект ја користи формата Bearer. Најдобро е да се избегнуваат акредитиви во query-низата бидејќи URL-адресите често се задржуваат во дневници за пристап и дијагностички системи. Извршете ја оваа минимална проверка со заменски токен:

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

Успешниот одговор содржи тело image/png. Проверете го и preview.headers: информациите за кешот и квотата пристигнуваат во заглавијата на одговорот, а не во JSON објект на одговор.

Ставете ги акредитивите во проектната околина, никогаш во PHP код што се зачувува во репозиториум:

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

QUEUE_CONNECTION=database

Архитектура и предуслови

Ви треба PHP 8.3 или понов, Composer, Laravel апликација со функционална автентикација, конфигурирана база на податоци, backend за редици и локален диск на датотечниот систем со можност за запишување. Осигурете се дека постои стандардната табела за задачи во редица кога го користите Laravel двигателот за редици со база на податоци.

Патеката на барањето намерно останува кратка: контролерот валидира и зачувува обележувач, потоа испраќа задача. Посебен API клиент врши ограничени повторни обиди и го пресликува бинарниот одговор во доменски објект. Задачата го складира проверениот PNG и го означува обележувачот како подготвен. Автентициран контролер ја испорачува приватната слика.

Извршувањето во позадина е важно тука. Снимањето слика од екран е оддалечена, релативно бавна работа и може да биде одложено поради ограничување од надворешниот сервис. Задржувањето надвор од веб-барањето спречува бавното снимање да се претвори во бавна форма за обележувач.

composer create-project laravel/laravel bookmark-previews
cd bookmark-previews

php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job CaptureBookmarkPreview
php artisan make:rule PublicWebUrl
php artisan make:test ScreenshotClientTest

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

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

'screenshot' => [
    'endpoint' => env(
        'SCREENSHOT_API_ENDPOINT',
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
    ),
    'token' => env('SCREENSHOT_API_TOKEN'),
    'max_bytes' => (int) env('SCREENSHOT_MAX_BYTES', 8 * 1024 * 1024),
    'disk' => env('SCREENSHOT_DISK', 'local'),
],

Создадете app/Services/Screenshot/Screenshot.php и CaptureException.php. Објектот за резултат ги носи бајтовите и нормализираните заглавија на одговорот, така што заглавијата за кешот и квотата остануваат достапни без нагаѓање на недокументирани имиња на заглавија.

<?php

namespace App\Services\Screenshot;

final readonly class Screenshot
{
    public function __construct(
        public string $png,
        public array $responseHeaders,
    ) {}
}

final class CaptureException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $status = null,
    ) {
        parent::__construct("Screenshot capture failed: {$kind}");
    }
}

Сега создадете app/Services/Screenshot/ScreenshotClient.php. Клиентот повторува при неуспеси на поврзувањето, HTTP 429 и серверски грешки. Не повторува при неуспеси на автентикацијата или валидацијата. Три обиди, кратко чекање и ограничено доцнење Retry-After го прават времето на неуспех предвидливо.

<?php

namespace App\Services\Screenshot;

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

final class ScreenshotClient
{
    public function capture(string $url): Screenshot
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken(
                        (string) config('services.screenshot.token')
                    )
                    ->accept('image/png')
                    ->connectTimeout(3)
                    ->timeout(20)
                    ->get(
                        (string) config('services.screenshot.endpoint'),
                        ['url' => $url]
                    );
            } catch (ConnectionException) {
                if ($attempt === 3) {
                    throw new CaptureException('transport', true);
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($response->successful()) {
                return $this->mapSuccess($response);
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt < 3) {
                $this->pause($attempt, $response->header('Retry-After'));
                continue;
            }

            $kind = match (true) {
                in_array($status, [401, 403], true) => 'authentication',
                in_array($status, [400, 422], true) => 'invalid_request',
                $status === 429 => 'quota',
                $status >= 500 => 'upstream',
                default => 'http_error',
            };

            throw new CaptureException($kind, $retryable, $status);
        }

        throw new CaptureException('unexpected', false);
    }

    private function mapSuccess(Response $response): Screenshot
    {
        $body = $response->body();
        $contentType = strtolower(trim(explode(
            ';',
            (string) $response->header('Content-Type')
        )[0]));

        if ($contentType !== 'image/png') {
            throw new CaptureException('invalid_content_type', false);
        }

        if (strlen($body) > (int) config('services.screenshot.max_bytes')) {
            throw new CaptureException('image_too_large', false);
        }

        $image = @getimagesizefromstring($body);

        if ($image === false || ($image[2] ?? null) !== IMAGETYPE_PNG) {
            throw new CaptureException('invalid_png', false);
        }

        $headers = [];

        foreach ($response->headers() as $name => $values) {
            if (strtolower($name) === 'set-cookie') {
                continue;
            }

            $headers[strtolower($name)] = substr(
                implode(', ', (array) $values),
                0,
                512
            );
        }

        return new Screenshot($body, $headers);
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = $attempt === 1 ? 250 : 1000;

        if ($retryAfter !== null) {
            $value = trim($retryAfter);

            if (ctype_digit($value)) {
                $milliseconds = min((int) $value * 1000, 5000);
            } elseif (($time = strtotime($value)) !== false) {
                $milliseconds = min(max(0, $time - time()) * 1000, 5000);
            }
        }

        usleep($milliseconds * 1000);
    }
}

Моделирајте го животниот циклус на обележувачот

Миграцијата бара експлицитни состојби наместо само nullable слика од екран. Таа разлика му овозможува на интерфејсот да каже дали снимањето е на чекање, во обработка, подготвено или неуспешно.

Schema::create('bookmarks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('url');
    $table->string('preview_status')->default('pending');
    $table->string('screenshot_path')->nullable();
    $table->json('screenshot_headers')->nullable();
    $table->text('preview_error')->nullable();
    $table->timestamps();
});

Во Bookmark, направете ги полињата во сопственост на апликацијата fillable и претворете ги заглавијата:

protected $fillable = [
    'user_id',
    'title',
    'url',
    'preview_status',
    'screenshot_path',
    'screenshot_headers',
    'preview_error',
];

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

Отфрлете опасни облици на URL-адреси

Создадете правило за валидација PublicWebUrl што прифаќа само HTTP и HTTPS, отфрла акредитиви, IP литерали, невообичаени порти, localhost, DNS записи што недостигаат и секое име на домаќин што се разрешува во приватна или резервирана адреса.

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    $parts = is_string($value) ? parse_url($value) : false;

    if ($parts === false
        || ! in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
        || empty($parts['host'])
        || isset($parts['user'])
        || isset($parts['pass'])
        || (isset($parts['port']) && ! in_array($parts['port'], [80, 443], true))) {
        $fail('Enter a public HTTP or HTTPS URL.');
        return;
    }

    $host = strtolower(rtrim($parts['host'], '.'));

    if ($host === 'localhost'
        || str_ends_with($host, '.localhost')
        || filter_var($host, FILTER_VALIDATE_IP) !== false) {
        $fail('IP addresses and local hosts are not allowed.');
        return;
    }

    $records = dns_get_record($host, DNS_A | DNS_AAAA);

    if ($records === false || $records === []) {
        $fail('The hostname could not be resolved.');
        return;
    }

    foreach ($records as $record) {
        $ip = $record['ip'] ?? $record['ipv6'] ?? null;

        if ($ip === null || filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        ) === false) {
            $fail('The hostname must resolve only to public addresses.');
            return;
        }
    }
}

Ова е заштитна мерка на ниво на апликација, а не целосна одбрана од DNS rebinding. Давателот на услугата за снимки од екран мора исто така да спроведува сопствена политика за мрежен излез. За чувствителни распоредувања, allowlist со одобрени домени е посилен од прифаќањето произволни јавни домаќини.

Ставете го прегледот во редица, складирајте го и испорачајте го

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

<?php

namespace App\Jobs;

use App\Models\Bookmark;
use App\Services\Screenshot\CaptureException;
use App\Services\Screenshot\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 = 1;
    public int $timeout = 75;
    public bool $failOnTimeout = true;

    public function __construct(public int $bookmarkId) {}

    public function handle(ScreenshotClient $client): void
    {
        $claimed = Bookmark::query()
            ->whereKey($this->bookmarkId)
            ->where('preview_status', 'pending')
            ->update(['preview_status' => 'processing']);

        if ($claimed !== 1) {
            return;
        }

        $bookmark = Bookmark::findOrFail($this->bookmarkId);

        try {
            $shot = $client->capture($bookmark->url);
            $path = 'bookmark-previews/'.$bookmark->id.'/'
                .hash('sha256', $shot->png).'.png';

            if (! Storage::disk(config('services.screenshot.disk'))
                ->put($path, $shot->png)) {
                throw new RuntimeException('Screenshot storage failed.');
            }

            $bookmark->update([
                'preview_status' => 'ready',
                'screenshot_path' => $path,
                'screenshot_headers' => $shot->responseHeaders,
                'preview_error' => null,
            ]);
        } catch (CaptureException $exception) {
            $bookmark->update([
                'preview_status' => 'failed',
                'preview_error' => $exception->kind,
            ]);

            Log::warning('Bookmark screenshot failed', [
                'bookmark_id' => $bookmark->id,
                'host' => parse_url($bookmark->url, PHP_URL_HOST),
                'kind' => $exception->kind,
                'status' => $exception->status,
            ]);
        }
    }

    public function failed(?Throwable $exception): void
    {
        Bookmark::query()
            ->whereKey($this->bookmarkId)
            ->where('preview_status', 'processing')
            ->update([
                'preview_status' => 'failed',
                'preview_error' => 'internal',
            ]);
    }
}

Контролерот зачувува само валидиран влез, испраќа по commit, го ограничува секое читање на автентицираниот сопственик и ја испорачува сликата со фиксен тип на содржина:

public function store(Request $request)
{
    $data = $request->validate([
        'title' => ['required', 'string', 'max:200'],
        'url' => ['required', 'string', 'max:2048', new PublicWebUrl],
    ]);

    $bookmark = Bookmark::create([
        'user_id' => $request->user()->id,
        'title' => $data['title'],
        'url' => $data['url'],
        'preview_status' => 'pending',
    ]);

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

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

public function preview(Request $request, Bookmark $bookmark)
{
    abort_unless($bookmark->user_id === $request->user()->id, 404);
    abort_unless(
        $bookmark->preview_status === 'ready' && $bookmark->screenshot_path,
        404
    );

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

Регистрирајте автентицирани рути, вклучувајќи ги дејствата index и store имплементирани од контролерот:

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

Во Blade приказот, прикажете слика само кога состојбата е подготвена. Задржете ја оригиналната страница зад обична escaped врска со изолација на opener; никогаш не ја ставајте во iframe.

@if ($bookmark->preview_status === 'ready')
    <img
        src="{{ route('bookmarks.preview', $bookmark) }}"
        alt="Preview of {{ $bookmark->title }}"
        loading="lazy"
    >
@elseif ($bookmark->preview_status === 'failed')
    <p>Preview unavailable.</p>
@else
    <p>Generating preview…</p>
@endif

<a href="{{ $bookmark->url }}"
   target="_blank"
   rel="noopener noreferrer nofollow">
    Visit bookmark
</a>

Тестирајте ја надворешната граница

Laravel HTTP fake ги прави тестовите детерминистички и гарантира дека не се користи вистински токен или мрежно барање. Имињата на заглавијата подолу се намерно синтетички; тестот проверува општо зачувување наместо да тврди недокументирано име на заглавие од услугата.

public function test_it_maps_a_png_and_response_headers(): void
{
    config()->set('services.screenshot.endpoint', 'https://service.test/capture');
    config()->set('services.screenshot.token', 'test-token');
    config()->set('services.screenshot.max_bytes', 1024 * 1024);

    $png = base64_decode(
        'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC'
        .'AAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
    );

    Http::preventStrayRequests();
    Http::fake([
        'https://service.test/capture*' => Http::response($png, 200, [
            'Content-Type' => 'image/png',
            'Cache-Test' => 'hit',
            'Quota-Test' => 'remaining',
        ]),
    ]);

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

    $this->assertSame($png, $result->png);
    $this->assertSame('hit', $result->responseHeaders['cache-test']);
    $this->assertSame('remaining', $result->responseHeaders['quota-test']);

    Http::assertSent(fn ($request) =>
        $request->hasHeader('Authorization', 'Bearer test-token')
        && $request['url'] === 'https://example.com'
    );
}

public function test_it_does_not_retry_authentication_failures(): void
{
    config()->set('services.screenshot.endpoint', 'https://service.test/capture');
    config()->set('services.screenshot.token', 'bad-token');

    Http::preventStrayRequests();
    Http::fake([
        'https://service.test/capture*' => Http::response('', 401),
    ]);

    try {
        app(ScreenshotClient::class)->capture('https://example.com');
        $this->fail('Expected CaptureException.');
    } catch (CaptureException $exception) {
        $this->assertSame('authentication', $exception->kind);
        $this->assertFalse($exception->retryable);
    }

    Http::assertSentCount(1);
}

Управувајте со неа во продукција

Извршете миграции, кеширајте ја продукциската конфигурација и стартувајте надгледуван worker за редици:

php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:work --queue=default --timeout=75 --tries=1

Процесниот надгледувач треба да ги рестартира worker-ите по распоредување. Следете ја староста на редицата, неуспешните задачи, латентноста на снимањето, категориите на статусот на одговорот и заглавијата за кешот и квотата на услугата. Никогаш не го евидентирајте Bearer токенот, целото тело на одговорот или целосната URL-адреса на обележувачот: URL-адресите често содржат приватни query параметри. Евидентирањето на нормализираното име на домаќин и внатрешниот ID на обележувачот обично е доволно.

Вообичаените неуспеси се препознатливи. 401 или 403 укажува на токен што недостига, е поништен или е неправилно распореден. 400 или 422 укажува дека испратената URL-адреса е неприфатлива и не треба да се повторува. 429 значи притисок од квотата; клиентот почитува ограничен Retry-After, а потоа бележи неуспех поради квота. Повторени 5xx или грешки при поврзувањето укажуваат на проблем со надворешниот сервис или мрежата. Успешен статус со HTML или невалидни бајтови се отфрла пред складирање, спречувајќи страница со грешка да се претставува како слика.

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

  • Планот за услугата е активен, а токенот ограничен на услугата е зачуван само во конфигурација поддржана од околината.
  • Минималното барање враќа PNG плус очекуваните заглавија за кешот и квотата.
  • Се отфрлаат приватни, резервирани, локални URL-адреси, URL-адреси со акредитиви и URL-адреси што не се HTTP.
  • Зачувувањето обележувач се враќа веднаш и поставува една задача за снимање во редицата.
  • Worker-от го префрла записот од на чекање во обработка, потоа во подготвен или неуспешен.
  • Складираната датотека ги поминува валидациите за тип на содржина, големина и PNG.
  • Само сопственикот на обележувачот може да го преземе приватно складираниот преглед.
  • Не се повторуваат неуспесите на автентикација и валидација; привремените неуспеси имаат ограничени повторни обиди.
  • Дневниците содржат оперативен контекст, но не токени, тела на слики или целосни чувствителни URL-адреси.
  • Автоматизираните тестови користат Http::fake() и забрануваат неочекувани надворешни барања.

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

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

Mihajlo

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