Туториали

Laravel: Safely Preview Bookmarks with AI-Powered Screenshot Generation

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

Обележувачот е покорисен кога можете да го препознаете на прв поглед. Насловите помагаат, но визуелниот преглед често многу побрзо го пренесува идентитетот на страницата. Тешкиот дел е сигурно да се генерира тој преглед без вашиот Laravel сервер да се претвори во хост за автоматизација на прелистувач.

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

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

Регистрирајте се преку страницата за регистрација на сметка, или користете ја страницата за најава ако веќе имате сметка.

Отворете ја страницата на услугата Screenshot API, изберете достапен Free, Plus или Pro план и завршете ја неговата активација. Потоа посетете ја официјалната документација. Пронајдете го панелот Service token и копирајте го токенот со опсег на услугата што е прикажан таму.

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

Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, со целната адреса во задолжителниот параметар за пребарување url. Пред да напишете Laravel код, направете минимално барање со токен-заменка:

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

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

Сега ставете го акредитивот во датотеката со околински променливи на проектот. Никогаш не ја предавајте вистинската вредност:

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

Архитектура и облик на проектот

Апликацијата веднаш создава обележувач со статус на преглед pending. Ставената во редица задача го повикува Screenshot API, го валидира вратениот PNG, го складира на Laravel-дискот public и го менува статусот во ready. Прелистувачот може да го анкетира ресурсот за обележувачот наместо да чека низ надворешно HTTP-барање.

Оваа заднинска граница е важна. Генерирањето слики од екранот има мрежна латентност, може да наиде на ограничувања на квотата и не треба да го држи отворено барањето што создава обележувач. Добиената структура е намерно мала:

  • SafePublicUrl отфрла несоодветни одредишта.
  • ScreenshotClient го поседува договорот за надворешниот HTTP.
  • GenerateBookmarkPreview ја координира перзистенцијата и состојбите на неуспех.
  • BookmarkController се справува со создавањето, анкетирането и испораката на PNG.

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

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

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

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

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

Создајте app/Services/Screenshots/CaptureResult.php и 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);
    }
}

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

Создајте 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];
    }
}

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

Прифаќајте само соодветни URL-адреси за обележувачи

Иако Laravel не ја презема самата страница, прифаќањето локални адреси, вградени акредитиви или приватни IP-адреси е непотребен ризик. Следното правило дозволува само јавни HTTP и HTTPS одредишта. DNS може да се промени по валидацијата, па ова останува еден слој, а не апсолутна SSRF-гаранција.

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

Зачувајте обележувачи и генерирајте прегледи

Во миграцијата за обележувачи, експлицитно додајте ја состојбата на доменот:

$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();

Направете ги тие полиња пополнливи во App\Models\Bookmark. Потоа создајте ја задачата:

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

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

Изложете создавање, анкетирање и испорака на слика

Контролерот враќа 202 Accepted при создавање. Клиентите ја анкетираат маршрутата за прикажување сè додека preview_status не стане ready, failed или 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,
        ];
    }
}

Регистрирајте ги маршрутите во routes/web.php, додавајќи стандардна посредна логика за автентикација и авторизација ако обележувачите им припаѓаат на поединечни корисници:

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

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

Http::fake() ги прави бинарните одговори детерминистички и спречува тестовите да трошат квота. Заглавијата-маркери подолу постојат само во лажниот одговор за да се провери мапирањето на границата; тие не потврдуваат конкретни имиња на продукциски заглавија.

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

Распоредете и управувајте со неа

Извршете ги миграциите, кеширајте ја конфигурацијата по инсталирањето на околинскиот токен и стартувајте надгледуван работник за редицата. Времето на истекување на работникот мора да го надминува ограничувањето од 70 секунди на задачата:

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

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

Обележувач заглавен на pending обично покажува дека ниту еден работник не ја обработува редицата. Неуспехот authentication обично значи дека токенот недостига, е застарен или бил отповикан со повторно генерирање. Неуспехот invalid_request укажува на цел што е отфрлена на границата на услугата. Состојбата quota_limited треба да остане видлива наместо да биде притискана со автоматски повторни обиди; обидете се повторно подоцна според планот и вратените информации за квотата. Неуспехот invalid_response значи дека номинално успешниот одговор всушност не бил PNG.

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

  • Вистинскиот токен постои само во конфигурација поддржана од околински променливи.
  • Јавен HTTPS обележувач враќа 202 и влегува во pending.
  • Работникот на редицата создава валиден PNG и го менува статусот во ready.
  • Маршрутата за преглед враќа image/png со nosniff.
  • Приватните, резервираните, адресите со акредитиви и не-HTTP URL-адресите се отфрлаат.
  • Неуспесите со автентикација, валидација, квота, неисправна слика, мрежа и складирање стануваат структурирани состојби.
  • Дневниците содржат ID на обележувачи и корисни оперативни заглавија, но немаат токен или тело на слика.
  • Автоматизираните тестови не прават надворешни барања и не трошат сервисна квота.

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

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

Mihajlo

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