Туториали

Symfony: Safely Visualize Bookmarks with Production-Ready Screenshot API Integrations

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

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

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

Добијте пристап и создајте сервисен токен

Започнете со регистрација на https://ai.mihajlo.mk/register. Ако веќе имате сметка, најавете се на https://ai.mihajlo.mk/login.

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

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

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

Потврдете го точниот API договор

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

Направете едно минимално барање пред да пишувате код за апликацијата:

export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --data-urlencode 'url=https://example.com/' \
  --dump-header response-headers.txt \
  --output preview.png

file preview.png

Проверете го response-headers.txt наместо да претпоставувате конкретни имиња на заглавија за кеш или квота. Апликацијата подолу ги зачувува препознаените стандардни заглавија за кеш и дефанзивно бележи заглавија поврзани со квота на API границата.

Зачувајте го токенот локално во .env.local, кој треба да остане надвор од контрола на верзии:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

Во продукција, внесете ја истата променлива преку менаџерот за тајни на хостинг-платформата. Не ја ставајте вистинската вредност во .env, слики од контејнери, фикстури, пораки за исклучоци или манифести за распоредување зачувани во Git.

Создајте го Symfony проектот

Потребни ви се PHP 8.3 или понов, Composer и вообичаените PHP екстензии што ги бара Symfony. Проектот ги користи HTTP клиентот од прва страна на Symfony, Twig, Monolog и алатките за тестирање:

composer create-project symfony/skeleton bookmark-previews
cd bookmark-previews

composer require \
  symfony/framework-bundle \
  symfony/http-client \
  symfony/twig-bundle \
  symfony/monolog-bundle

composer require --dev symfony/test-pack

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

bookmark-previews/
├── config/services.yaml
├── src/Bookmark/BookmarkCatalog.php
├── src/Controller/BookmarkController.php
├── src/Screenshot/PreviewCapture.php
├── src/Screenshot/ScreenshotException.php
├── src/Screenshot/ScreenshotClient.php
├── templates/bookmarks/index.html.twig
└── tests/Screenshot/ScreenshotClientTest.php

Прелистувачот бара /bookmarks/{id}/preview, никогаш директно надворешниот API. Контролерот го разрешува идентификаторот до зачувана, валидирана URL-адреса и повикува посветен клиент. Ова ги спречува посетителите да доставуваат произволни цели за снимање и го задржува токенот на серверската страна.

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

Конфигурирајте вбризгување зависности

Додајте ја крајната точка и токенот поткрепен со околински променливи во config/services.yaml:

parameters:
    screenshot_api.endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Screenshot\ScreenshotClient:
        arguments:
            $endpoint: '%screenshot_api.endpoint%'
            $apiToken: '%env(string:SCREENSHOT_API_TOKEN)%'

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

Пресликајте ги одговорите во доменски објекти

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

<?php
// src/Screenshot/PreviewCapture.php
namespace App\Screenshot;

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

// src/Screenshot/ScreenshotException.php
namespace App\Screenshot;

final class ScreenshotException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $upstreamStatus = null,
        public readonly array $metadata = [],
    ) {
        parent::__construct('Screenshot capture failed: '.$kind);
    }
}

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

Изградете ограничен, дефанзивен API клиент

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

<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ScreenshotClient
{
    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $endpoint,
        private readonly string $apiToken,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds)
            => usleep($microseconds);
    }

    public function capture(string $url): PreviewCapture
    {
        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->apiToken,
                        'Accept' => 'image/png',
                    ],
                    'query' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 12.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);
            } catch (TransportExceptionInterface $error) {
                if ($attempt === 1) {
                    ($this->sleep)(250_000);
                    continue;
                }

                throw new ScreenshotException('transport');
            }

            $quota = $this->quotaHeaders($headers);

            if (in_array($status, [502, 503, 504], true) && $attempt === 1) {
                $response->cancel();
                ($this->sleep)(250_000);
                continue;
            }

            if ($status === 401 || $status === 403) {
                throw new ScreenshotException('authentication', $status);
            }

            if ($status === 400 || $status === 422) {
                throw new ScreenshotException('rejected_url', $status);
            }

            if ($status === 429) {
                throw new ScreenshotException('quota_or_rate_limit', $status, $quota);
            }

            if ($status < 200 || $status >= 300) {
                throw new ScreenshotException('upstream', $status, $quota);
            }

            $contentType = strtolower($headers['content-type'][0] ?? '');
            if (!str_starts_with($contentType, 'image/png')) {
                throw new ScreenshotException('unexpected_content_type', $status);
            }

            $png = $response->getContent(false);
            if (strlen($png) > 8 * 1024 * 1024) {
                throw new ScreenshotException('image_too_large', $status);
            }

            return new PreviewCapture(
                $png,
                $this->cacheHeaders($headers),
                $quota,
            );
        }

        throw new ScreenshotException('transport');
    }

    private function cacheHeaders(array $headers): array
    {
        $allowed = ['cache-control', 'etag', 'expires', 'last-modified', 'age'];
        $result = [];

        foreach ($allowed as $name) {
            if (isset($headers[$name][0])) {
                $result[$name] = $headers[$name][0];
            }
        }

        return $result;
    }

    private function quotaHeaders(array $headers): array
    {
        $result = [];

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

            if (
                str_contains($normalized, 'quota')
                || str_contains($normalized, 'rate')
                || $normalized === 'retry-after'
            ) {
                $result[$normalized] = implode(', ', $values);
            }
        }

        return $result;
    }
}

Само изречно одобрените заглавија за кеш стигнуваат до корисникот. Заглавијата hop-by-hop, колачињата и непознатите надворешни метаподатоци се отфрлаат. Заглавијата за квота остануваат оперативни метаподатоци, додека HTTP 429 станува структурирана грешка quota_or_rate_limit. Подолг циклус на повторување би ги засилил прекините и би потрошил повеќе квота без да го подобри корисничкото искуство.

Изложете само зачувани обележувачи

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

<?php
// src/Bookmark/BookmarkCatalog.php
namespace App\Bookmark;

final class BookmarkCatalog
{
    private const ITEMS = [
        'example' => [
            'title' => 'Example Domain',
            'url' => 'https://example.com/',
        ],
        'symfony' => [
            'title' => 'Symfony',
            'url' => 'https://symfony.com/',
        ],
    ];

    public function all(): array
    {
        return self::ITEMS;
    }

    public function find(string $id): ?array
    {
        return self::ITEMS[$id] ?? null;
    }
}

За записи создадени од корисник, барајте апсолутна URL-адреса https, одбијте ингеренции вградени во авторитетот, одбијте localhost и буквални приватни или резервирани IP-адреси и наметнете ограничувања за должината на URL-адресата. Ако недоверливи корисници можат да создаваат обележувачи, списокот на дозволени одобрени хостови е најсилната одбрана од DNS rebinding и злоупотреба на снимањето. Никогаш не прифаќајте необработена URL-адреса на рутата за преглед само затоа што снимката ја презема друга услуга.

Додајте ги контролерот и приказот

Контролерот ги евидентира категориите на неуспех и статусните кодови на надворешната услуга, но не и целите URL-адреси или ингеренциите. Неуспешниот преглед враќа празен одговор што не се кешира, додека страницата со обележувачи останува употреблива.

<?php
// src/Controller/BookmarkController.php
namespace App\Controller;

use App\Bookmark\BookmarkCatalog;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class BookmarkController extends AbstractController
{
    #[Route('/bookmarks', name: 'bookmarks_index', methods: ['GET'])]
    public function index(BookmarkCatalog $catalog): Response
    {
        return $this->render('bookmarks/index.html.twig', [
            'bookmarks' => $catalog->all(),
        ]);
    }

    #[Route(
        '/bookmarks/{id}/preview',
        name: 'bookmark_preview',
        methods: ['GET']
    )]
    public function preview(
        string $id,
        BookmarkCatalog $catalog,
        ScreenshotClient $screenshots,
        LoggerInterface $logger,
    ): Response {
        $bookmark = $catalog->find($id);

        if ($bookmark === null) {
            return new Response('', Response::HTTP_NOT_FOUND);
        }

        try {
            $capture = $screenshots->capture($bookmark['url']);

            $logger->info('Bookmark preview captured', [
                'bookmark_id' => $id,
                'quota' => $capture->quotaHeaders,
            ]);

            return new Response($capture->png, Response::HTTP_OK, [
                ...$capture->cacheHeaders,
                'Content-Type' => 'image/png',
                'Content-Disposition' => 'inline',
                'X-Content-Type-Options' => 'nosniff',
            ]);
        } catch (ScreenshotException $error) {
            $logger->warning('Bookmark preview unavailable', [
                'bookmark_id' => $id,
                'failure' => $error->kind,
                'upstream_status' => $error->upstreamStatus,
                'quota' => $error->metadata,
            ]);

            return new Response('', Response::HTTP_BAD_GATEWAY, [
                'Cache-Control' => 'no-store',
            ]);
        }
    }
}
{# templates/bookmarks/index.html.twig #}
<h2>Обележувачи</h2>

<ul>
{% for id, bookmark in bookmarks %}
    <li>
        <a href="{{ bookmark.url }}" rel="noopener noreferrer">
            {{ bookmark.title }}
        </a>
        <img
            src="{{ path('bookmark_preview', {id: id}) }}"
            alt="Преглед на {{ bookmark.title }}"
            loading="lazy"
            width="480"
            height="300"
        >
    </li>
{% endfor %}
</ul>

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

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

MockHttpClient ја прави границата детерминистичка. Овие тестови го проверуваат успешното пресликување и докажуваат дека неуспесите во автентикацијата не се повторуваат:

<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;

use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testMapsPngAndCacheHeaders(): void
    {
        $http = new MockHttpClient(new MockResponse("\x89PNG\r\n", [
            'http_code' => 200,
            'response_headers' => [
                'content-type: image/png',
                'cache-control: public, max-age=300',
                'etag: "capture-1"',
            ],
        ]));

        $client = new ScreenshotClient(
            $http,
            'https://service.test/v1/capture',
            'test-token',
            static fn (int $microseconds) => null,
        );

        $capture = $client->capture('https://example.com/');

        self::assertSame("\x89PNG\r\n", $capture->png);
        self::assertSame(
            'public, max-age=300',
            $capture->cacheHeaders['cache-control']
        );
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $requests = 0;
        $http = new MockHttpClient(
            function () use (&$requests): MockResponse {
                $requests++;

                return new MockResponse('', ['http_code' => 401]);
            }
        );

        $client = new ScreenshotClient(
            $http,
            'https://service.test/v1/capture',
            'bad-token',
            static fn (int $microseconds) => null,
        );

        try {
            $client->capture('https://example.com/');
            self::fail('Expected ScreenshotException');
        } catch (ScreenshotException $error) {
            self::assertSame('authentication', $error->kind);
            self::assertSame(1, $requests);
        }
    }
}

Додајте еквивалентни случаи за HTTP 429, тип на содржина што не е PNG, преголемо тело и 503 по кој следи успех. Извршете го пакетот со php bin/phpunit.

Распоредување, набљудливост и вообичаени неуспеси

Распоредете со APP_ENV=prod, APP_DEBUG=0 и сервисниот токен доставен од платформата. Загрејте го кешот на Symfony по инсталирањето на продукциските зависности. Осигурете се дека е дозволен појдовен HTTPS пристап до ai.mihajlo.mk и никогаш не изложувајте .env.local преку веб-серверот.

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

  • HTTP 401 or 403: потврдете дека токенот ѝ припаѓа на услугата Screenshot API. Ако бил повторно генериран, ажурирајте го секое распоредување бидејќи претходниот токен е отповикан.
  • HTTP 429: третирајте го како притисок од квота или ограничување на стапката, проверете ги вратените заглавија поврзани со квота и намалете ги непотребните барања. Не создавајте брз циклус на повторување.
  • HTTP 400 or 422: проверете дали задолжителниот параметар за пребарување url содржи апсолутна, поддржана URL-адреса.
  • HTML instead of PNG: задржете ја проверката на типот на содржина. Страница со грешка никогаш не смее да се испорача како доверлива слика.
  • Intermittent 502, 503, or 504: единственото ограничено повторување апсорбира краток прекин без да ги држи PHP работниците зафатени неограничен период.
  • Broken images on the page: прво проверете го структурираниот дневник на неуспеси на апликацијата; индексот на обележувачи намерно е независен од достапноста на прегледите.

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

  1. Токенот постои само во тајна конфигурација поткрепена со околински променливи.
  2. Минималното барање враќа тело image/png и заглавија на одговорот.
  3. /bookmarks се вчитува дури и кога услугата за снимки не е достапна.
  4. Рутите за преглед прифаќаат идентификатори на обележувачи, а не произволни целни URL-адреси.
  5. Само безбедни заглавија за кеш се препраќаат до прелистувачите.
  6. Неуспесите во автентикација, валидација и квота не се повторуваат безусловно.
  7. Транспортните и избраните грешки на порти добиваат едно ограничено повторување.
  8. Тестовите се извршуваат со MockHttpClient и никогаш не трошат вистинска квота.
  9. Дневниците содржат оперативни категории без токени или чувствителни URL-адреси.

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

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

Mihajlo

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