Туториали

Laravel Bookmarks: Secure Visual Link Previews with API Screenshot Integration

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

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

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

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

Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.

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

Повторното генерирање на сервисниот токен го поништува претходно активниот токен, затоа третирајте ја ротацијата како промена при распоредување: ажурирајте ја секоја средина што го користи, рестартирајте ги worker-процесите, а потоа намерно поништете или повторно генерирајте го.

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

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

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

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

SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN

curl --fail-with-body \
  --header "Authorization: Bearer ${SCREENSHOT_TOKEN}" \
  --header "Accept: image/png" \
  --get \
  --data-urlencode "url=https://example.com" \
  --output example.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Сега зачувајте го акредитивот во конфигурацијата на околината на Laravel. Никогаш не ја ставајте во commit пополнетата датотека .env.

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

Додајте ја границата на услугата во config/services.php:

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

Архитектура: држете го снимањето надвор од патеката на барањето

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

Оваа мала асинхрона граница е важна. Генерирањето слики од екранот вклучува друга мрежа и далечински рендерер; ако корисниците чекаат, латентноста на формуларот би зависела од двете. Редицата исто така им дава на минливите неуспеси контролирано место за повторен обид.

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

app/
  Data/ScreenshotResult.php
  Exceptions/ScreenshotFailure.php
  Http/Controllers/BookmarkController.php
  Http/Requests/StoreBookmarkRequest.php
  Jobs/GenerateBookmarkPreview.php
  Models/Bookmark.php
  Services/ScreenshotClient.php
database/migrations/..._create_bookmarks_table.php
tests/Unit/ScreenshotClientTest.php

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

php artisan make:model Bookmark -m
php artisan make:request StoreBookmarkRequest
php artisan make:controller BookmarkController
php artisan make:job GenerateBookmarkPreview
php artisan make:test ScreenshotClientTest --unit
php artisan make:queue-table
php artisan migrate

Моделирајте го прегледот како состојба, а не како nullable слика

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

<?php
// database/migrations/..._create_bookmarks_table.php

Schema::create('bookmarks', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('url');
    $table->string('preview_status')->default('queued');
    $table->string('preview_path')->nullable();
    $table->string('preview_error')->nullable();
    $table->json('preview_meta')->nullable();
    $table->timestamp('preview_generated_at')->nullable();
    $table->timestamps();
});

// app/Models/Bookmark.php

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

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

Изградете дефанзивна API граница

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

<?php
// app/Data/ScreenshotResult.php
namespace App\Data;

final readonly class ScreenshotResult
{
    public function __construct(
        public string $png,
        public array $operationalHeaders,
    ) {}
}

// app/Exceptions/ScreenshotFailure.php
namespace App\Exceptions;

use RuntimeException;

final class ScreenshotFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly ?int $retryAfter = null,
        string $message = 'Screenshot capture failed',
    ) {
        parent::__construct($message);
    }
}
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;

use App\Data\ScreenshotResult;
use App\Exceptions\ScreenshotFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

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

        if ($token === '') {
            throw new ScreenshotFailure(
                'configuration', false, null, 'Screenshot token is missing'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(3)
                    ->timeout(20)
                    ->get(config('services.screenshot.endpoint'), [
                        'url' => $url,
                    ]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure(
                        'connection', true, null, 'Screenshot connection failed'
                    );
                }

                $this->backoff($attempt);
                continue;
            }

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

            if ($response->status() === 429) {
                throw new ScreenshotFailure(
                    'quota', true, $this->retryAfter($response),
                    'Screenshot quota is temporarily unavailable'
                );
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new ScreenshotFailure(
                    'authentication', false, null,
                    'Screenshot credentials were rejected'
                );
            }

            if (in_array($response->status(), [400, 422], true)) {
                throw new ScreenshotFailure(
                    'request_rejected', false, null,
                    'Screenshot request was rejected'
                );
            }

            if ($response->status() === 408 || $response->serverError()) {
                if ($attempt < 3) {
                    $this->backoff($attempt);
                    continue;
                }

                throw new ScreenshotFailure(
                    'upstream_transient', true, null,
                    'Screenshot service remained unavailable'
                );
            }

            throw new ScreenshotFailure(
                'upstream_rejected', false, null,
                'Screenshot service returned an unexpected status'
            );
        }

        throw new ScreenshotFailure('unknown', true);
    }

    private function mapPng(Response $response): ScreenshotResult
    {
        $body = $response->body();
        $contentType = strtolower((string) $response->header('Content-Type'));
        $limit = (int) config('services.screenshot.max_bytes');

        if (! str_starts_with($contentType, 'image/png')
            || ! str_starts_with($body, "\x89PNG\r\n\x1a\n")
            || strlen($body) > $limit) {
            throw new ScreenshotFailure(
                'invalid_image', true, null,
                'Screenshot response was not an acceptable PNG'
            );
        }

        return new ScreenshotResult($body, $this->operationalHeaders($response));
    }

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

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

            if (in_array($normalized, [
                'cache-control', 'age', 'etag', 'expires', 'retry-after',
            ], true) || str_contains($normalized, 'quota')
                || str_contains($normalized, 'ratelimit')) {
                $result[$normalized] = implode(', ', $values);
            }
        }

        return $result;
    }

    private function retryAfter(Response $response): int
    {
        $value = $response->header('Retry-After');

        return is_numeric($value)
            ? min(max((int) $value, 60), 3600)
            : 300;
    }

    private function backoff(int $attempt): void
    {
        usleep($attempt === 1 ? 250_000 : 750_000);
    }
}

Маперот на заглавија намерно препознава стандардни заглавија за кеш и заглавија што изречно се означени како метаподатоци за квота или ограничување на стапката. Ова обработува оперативни информации без да го зачувува секое заглавие од upstream или да измислува недокументирани полиња за одговор. Закачете точни имиња од официјалната документација ако вашиот систем за мониторинг бара фиксни метрики.

Валидирајте URL-адреси пред испраќање

API-то ќе преземе URL-адреса во име на апликацијата, затоа корисниците не треба да можат да доставуваат акредитиви, локални имиња на хостови или буквални IP-адреси. Ограничувањето на функцијата на HTTPS јавни имиња на хостови исто така го намалува случајното протекување на чувствителни внатрешни URL-адреси.

<?php
// app/Http/Requests/StoreBookmarkRequest.php
namespace App\Http\Requests;

use Closure;
use Illuminate\Foundation\Http\FormRequest;

final class StoreBookmarkRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:200'],
            'url' => [
                'required', 'string', 'max:2048',
                function (string $attribute, mixed $value, Closure $fail): void {
                    $parts = is_string($value) ? parse_url($value) : false;
                    $host = is_array($parts) ? ($parts['host'] ?? '') : '';

                    $invalid = $parts === false
                        || strtolower($parts['scheme'] ?? '') !== 'https'
                        || $host === ''
                        || isset($parts['user'])
                        || isset($parts['pass'])
                        || filter_var($host, FILTER_VALIDATE_IP) !== false
                        || $host === 'localhost'
                        || str_ends_with(strtolower($host), '.local');

                    if ($invalid) {
                        $fail('Use an HTTPS URL with a public hostname.');
                    }
                },
            ],
        ];
    }
}

Ова е спречување злоупотреба на страната на апликацијата, а не доказ дека името на хостот секогаш јавно се разрешува. Избегнувајте синхрони DNS-проверки во патеката на барањето; DNS може да се промени меѓу валидацијата и снимањето. Ако вашиот производ широко прифаќа недоверливи URL-адреси, потврдете ги тековните заштитни мерки за пристап до мрежата на услугата во официјалната документација и разгледајте листа на дозволени домени.

Генерирајте и сервирајте прегледи

<?php
// app/Jobs/GenerateBookmarkPreview.php
namespace App\Jobs;

use App\Exceptions\ScreenshotFailure;
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 Throwable;

final class GenerateBookmarkPreview implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $timeout = 75;

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

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

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

        if ($bookmark === null) {
            return;
        }

        try {
            $result = $client->capture($bookmark->url);
        } catch (ScreenshotFailure $failure) {
            Log::log(
                $failure->kind === 'authentication' ? 'critical' : 'warning',
                'Bookmark preview capture failed',
                ['bookmark_id' => $this->bookmarkId, 'kind' => $failure->kind]
            );

            if ($failure->kind === 'quota') {
                $bookmark->update(['preview_error' => 'quota_deferred']);
                $this->release($failure->retryAfter ?? 300);
                return;
            }

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

            $bookmark->update([
                'preview_status' => 'failed',
                'preview_error' => $failure->kind,
            ]);
            return;
        }

        $path = "bookmarks/previews/{$bookmark->id}.png";
        Storage::disk('local')->put($path, $result->png);

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

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

Детерминистичкото име на датотека ги прави дупликатните задачи безопасни: подоцнежното успешно снимање го заменува истиот преглед. Дневниците содржат ID на обележувачот и структурирана категорија на неуспех, никогаш доставената URL-адреса, токенот, PNG-от или телото на одговорот од upstream.

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

// app/Http/Controllers/BookmarkController.php
final class BookmarkController extends Controller
{
    public function store(StoreBookmarkRequest $request): RedirectResponse
    {
        $bookmark = Bookmark::create([
            ...$request->validated(),
            'preview_status' => 'queued',
        ]);

        GenerateBookmarkPreview::dispatch($bookmark->id);

        return back()->with('status', 'Bookmark saved; preview queued.');
    }

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

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

Одржувајте ги овие рути автентицирани. Во апликација со обележувачи за повеќе корисници, додајте идентификатор на сопственикот и авторизирајте ги и обележувачот и прегледот преку Laravel policy; само автентикацијата не е изолација на закупци.

Тестирајте ја надворешната граница без мрежа

Http::fake() на Laravel ги прави однесувањата при успех и квота детерминистички.

<?php
namespace Tests\Unit;

use App\Exceptions\ScreenshotFailure;
use App\Services\ScreenshotClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class ScreenshotClientTest extends TestCase
{
    public function test_it_maps_a_valid_png_and_cache_headers(): void
    {
        config()->set('services.screenshot.token', 'test-token');
        config()->set('services.screenshot.max_bytes', 1024);
        config()->set(
            'services.screenshot.endpoint',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
        );

        $png = "\x89PNG\r\n\x1a\nfake";
        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response($png, 200, [
                    'Content-Type' => 'image/png',
                    'Cache-Control' => 'public, max-age=60',
                ]),
        ]);

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

        $this->assertSame($png, $result->png);
        $this->assertSame(
            'public, max-age=60',
            $result->operationalHeaders['cache-control']
        );

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

    public function test_it_defers_instead_of_retrying_a_quota_response(): void
    {
        config()->set('services.screenshot.token', 'test-token');

        Http::fake([
            '*' => Http::response('', 429, ['Retry-After' => '120']),
        ]);

        try {
            app(ScreenshotClient::class)->capture('https://example.com');
            $this->fail('Expected ScreenshotFailure');
        } catch (ScreenshotFailure $failure) {
            $this->assertSame('quota', $failure->kind);
            $this->assertSame(120, $failure->retryAfter);
        }

        Http::assertSentCount(1);
    }
}

Распоредување, мониторинг и чести неуспеси

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

php artisan test
php artisan config:cache
php artisan queue:work --tries=5 --timeout=90

По промена или ротација на токенот, повторно изградете го кешот на конфигурацијата и рестартирајте ги долгоизвршуваните worker-процеси со php artisan queue:restart. Осигурете се дека storage е запишлив и траен низ распоредувањата, или заменете го локалниот диск со соодветно конфигуриран споделен Laravel filesystem диск.

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

  • 401 или 403: проверете ги токенот со опсег на услугата, активацијата на планот, внесувањето во околината и кешот на конфигурацијата.
  • 429 или повторени одложувања поради квота: проверете ги вратените метаподатоци за квота, намалете ги непотребните повторни снимања или прегледајте го активниот план.
  • Неважечка слика: проверете ги статусот и типот на содржина во контролирана средина, но не го запишувајте бинарното тело во дневник.
  • Задачите остануваат во редица: потврдете дека worker-процесот ја користи истата врска со редицата како веб-процесот.
  • Прегледот враќа 404: проверете ја состојбата на обележувачот, трајноста на складиштето, дозволите за датотеки и записите за неуспешни задачи.

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

  • Токенот постои само во конфигурација поткрепена со околината.
  • Барањето ја користи точната GET крајна точка за снимање и задолжителниот параметар url.
  • Само прифатливи HTTPS јавни имиња на хостови стигнуваат до редицата.
  • Клиентот спроведува временски ограничувања, ограничени повторни обиди, PNG валидација и локално ограничување на големината.
  • Неуспесите на автентикацијата и валидацијата не се повторуваат слепо.
  • Одговорите за квота ја одложуваат работата наместо да создадат бура од повторни обиди.
  • Метаподатоците за кеш и квота ја преминуваат API границата без изложување произволни заглавија.
  • Датотеките за преглед остануваат приватни и се сервираат преку автентицирана рута.
  • Тестовите користат Http::fake() и никогаш не трошат жива квота.

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

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

Mihajlo

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