Vodiči

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

Symfony: Sigurno vizualizirajte oznake s integracijama API-ja za snimke zaslona spremnima za produkciju

Popis oznaka postaje znatno lakše pregledavati kada svaka poveznica ima vizualni pregled. Implementacija zvuči jednostavno dok se ne pojave produkcijski zahtjevi: spora snimanja, zlonamjerni URL-ovi, istekle vjerodajnice, preveliki odgovori, iscrpljivanje kvote i uzvodni prekid rada koji ne bi trebao srušiti stranicu s oznakama.

Ovaj vodič izrađuje malu Symfony aplikaciju koja poslužuje PNG preglede putem kontrolirane rute u vlasništvu aplikacije. Screenshot API obavlja rad preglednika i predmemorira snimke za stolna ili mobilna računala, pa aplikacija ne mora pokretati Chromium, upravljati procesima preglednika ni izlagati svoj servisni token posjetiteljima.

Dobijte pristup i izradite servisni token

Započnite registracijom na https://ai.mihajlo.mk/register. Ako već imate račun, prijavite se na https://ai.mihajlo.mk/login.

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

Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer oblik jer vjerodajnicu drži izvan URL-ova, zapisnika pristupa, povijesti preglednika i predmemorija posrednika.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: instalirajte zamjenu u svako pokrenuto okruženje, ponovno pokrenite ili ponovno implementirajte te instance, provjerite snimanja i tek tada uklonite zastarjelu konfiguraciju.

Potvrdite točan API ugovor

Operacija snimanja je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njezin obavezni parametar upita je url, a uspješan odgovor sadrži tijelo image/png zajedno sa zaglavljima odgovora za predmemoriju i kvotu.

Pošaljite jedan minimalni zahtjev prije pisanja koda aplikacije:

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

Pregledajte response-headers.txt umjesto da pretpostavljate određene nazive zaglavlja za predmemoriju ili kvotu. Aplikacija u nastavku čuva prepoznata standardna zaglavlja predmemorije i obrambeno bilježi zaglavlja povezana s kvotom na granici API-ja.

Token pohranite lokalno u .env.local, koji treba ostati izvan kontrole verzija:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

U produkciji istu varijablu ubrizgajte putem upravitelja tajni hosting platforme. Ne stavljajte stvarnu vrijednost u .env, slike spremnika, fixture podatke, poruke iznimki ni manifeste implementacije predane u Git.

Izradite Symfony projekt

Trebate PHP 8.3 ili noviji, Composer i uobičajena PHP proširenja potrebna za Symfony. Projekt koristi Symfonyjev HTTP klijent prve strane, Twig, Monolog i alate za testiranje:

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

Relevantna struktura projekta namjerno je mala:

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

Preglednik zahtijeva /bookmarks/{id}/preview, nikada izravno vanjski API. Kontroler razrješava identifikator u pohranjeni, provjereni URL i poziva namjenski klijent. Time se posjetiteljima onemogućuje slanje proizvoljnih ciljeva za snimanje, a token ostaje na poslužitelju.

Messenger bi bio vrijedan ako bi se snimanja generirala unaprijed za velike zbirke. Ovdje nije potreban: pregledi se učitavaju neovisno, a uzvodna usluga već pruža predmemorirana snimanja. Održavanje puta zahtjeva sinkronim izbjegava infrastrukturu reda čekanja, a ipak izolira neuspjelu sliku od same stranice.

Konfigurirajte ubrizgavanje ovisnosti

Dodajte krajnju točku i token podržan varijablom okruženja u 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)%'

Centraliziranje krajnje točke čini granicu API-ja eksplicitnom i omogućuje testovima ubrizgavanje bezopasnog osnovnog URL-a. Token ostaje pitanje okruženja, a ne podatak aplikacije.

Mapirajte odgovore u domenske objekte

Ne dopustite da objekti odgovora okvira procure u ostatak aplikacije. Uspješno snimanje ima tri korisna dijela: PNG bajtove, sigurna zaglavlja predmemorije preglednika i operativne metapodatke. Neuspjesi koriste stabilnu kategoriju na razini aplikacije umjesto izlaganja tijela uzvodnog odgovora.

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

Iznimka namjerno isključuje token, tijelo odgovora i ciljni URL. Stranice s pogreškama i centralizirani sustavi zapisivanja rutinski zadržavaju tekst iznimke, pa tajnost mora biti ugrađena u tip.

Izradite ograničen, obrambeni API klijent

Klijent koristi ograničenja povezivanja i ukupnog odgovora, provjerava status i vrstu sadržaja, ograničava veličinu slike u međuspremniku na osam MiB te ponovno pokušava samo prolazne pogreške pristupnika ili transportne pogreške. Neuspjesi autentifikacije, validacije i kvote ne pokušavaju se slijepo ponovno.

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

Samo izričito odobrena zaglavlja predmemorije dolaze do korisnika. Zaglavlja hop-by-hop, kolačići i nepoznati uzvodni metapodaci odbacuju se. Zaglavlja kvote ostaju operativni metapodaci, dok HTTP 429 postaje strukturirani neuspjeh quota_or_rate_limit. Dulja petlja ponovnih pokušaja pojačala bi prekide rada i potrošila više kvote bez poboljšanja korisničkog iskustva.

Izložite samo pohranjene oznake

Aplikacija podržana bazom podataka trebala bi provjeravati URL-ove pri stvaranju oznaka. Ovaj sažeti katalog pokazuje istu granicu povjerenja bez koda za postojanost koji bi odvlačio pažnju:

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

Za zapise koje stvaraju korisnici zahtijevajte apsolutni https URL, odbacite vjerodajnice ugrađene u autoritet, odbacite localhost i doslovne privatne ili rezervirane IP adrese te nametnite ograničenja duljine URL-a. Ako nepouzdani korisnici mogu stvarati oznake, popis dopuštenih odobrenih hostova najsnažnija je obrana od ponovnog povezivanja DNS-a i zloupotrebe snimanja. Nikada ne prihvaćajte neobrađeni URL na ruti pregleda samo zato što snimku dohvaća druga usluga.

Dodajte kontroler i prikaz

Kontroler bilježi kategorije neuspjeha i uzvodne statusne kodove, ali ne pune URL-ove ni vjerodajnice. Neuspjeli pregled vraća prazan odgovor koji se ne predmemorira, dok stranica s oznakama ostaje upotrebljiva.

<?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>Oznake</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="Pregled za {{ bookmark.title }}"
            loading="lazy"
            width="480"
            height="300"
        >
    </li>
{% endfor %}
</ul>

Twig prema zadanim postavkama izbjegava naslove i URL-ove. Lijeno učitavanje izbjegava zahtijevanje svake snimke prije nego što se približi prikazu, dok fiksne dimenzije smanjuju pomicanje rasporeda.

Testirajte bez kontaktiranja stvarne usluge

MockHttpClient čini granicu determinističkom. Ovi testovi provjeravaju uspješno mapiranje i dokazuju da se neuspjesi autentifikacije ne pokušavaju ponovno:

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

Dodajte ekvivalentne slučajeve za HTTP 429, vrstu sadržaja koja nije PNG, preveliko tijelo i 503 nakon kojeg slijedi uspjeh. Pokrenite paket pomoću php bin/phpunit.

Implementacija, nadzor i česti neuspjesi

Implementirajte s APP_ENV=prod, APP_DEBUG=0 i servisnim tokenom koji pruža platforma. Zagrijte Symfonyjevu predmemoriju nakon instaliranja produkcijskih ovisnosti. Osigurajte da je odlazni HTTPS pristup prema ai.mihajlo.mk dopušten i nikada ne izlažite .env.local putem web-poslužitelja.

Pratite latenciju snimanja, ishode grupirane prema kategoriji neuspjeha, pojave HTTP 429, prolazne ponovne pokušaje i neočekivane vrste sadržaja. Pokazatelji predmemorije i metapodaci kvote vrijedni su za planiranje kapaciteta, ali izbjegavajte prilaženje potpunih URL-ova oznaka jer nizovi upita mogu sadržavati privatne informacije.

  • HTTP 401 ili 403: potvrdite da token pripada usluzi Screenshot API. Ako je ponovno generiran, ažurirajte svaku implementaciju jer je prethodni token opozvan.
  • HTTP 429: tretirajte ga kao pritisak kvote ili ograničenja stope, pregledajte vraćena zaglavlja povezana s kvotom i smanjite nepotrebne zahtjeve. Nemojte stvarati brzu petlju ponovnih pokušaja.
  • HTTP 400 ili 422: provjerite sadrži li obavezni parametar upita url apsolutni, podržani URL.
  • HTML umjesto PNG-a: zadržite provjeru vrste sadržaja. Stranica s pogreškom nikada se ne smije poslužiti kao pouzdana slika.
  • Povremeni 502, 503 ili 504: jedan ograničeni ponovni pokušaj ublažava kratki prekid bez zauzimanja PHP radnika neograničeno dugo.
  • Pokvarene slike na stranici: najprije pregledajte strukturirani zapis neuspjeha aplikacije; indeks oznaka namjerno je neovisan o dostupnosti pregleda.

Završni kontrolni popis za provjeru

  1. Token postoji samo u tajnoj konfiguraciji podržanoj varijablom okruženja.
  2. Minimalni zahtjev vraća tijelo image/png i zaglavlja odgovora.
  3. /bookmarks se učitava čak i kada usluga snimanja zaslona nije dostupna.
  4. Rute pregleda prihvaćaju identifikatore oznaka, a ne proizvoljne ciljne URL-ove.
  5. Samo sigurna zaglavlja predmemorije prosljeđuju se preglednicima.
  6. Neuspjesi autentifikacije, validacije i kvote ne pokušavaju se slijepo ponovno.
  7. Transportni i odabrani neuspjesi pristupnika dobivaju jedan ograničeni ponovni pokušaj.
  8. Testovi se izvode s MockHttpClient i nikada ne troše stvarnu kvotu.
  9. Zapisnici sadrže operativne kategorije bez tokena ili osjetljivih URL-ova.

Trajni obrazac veći je od ove posebne značajke: držite vjerodajnice i proizvoljan unos podalje od preglednika, prevodite vanjske odgovore na jednoj uskoj granici i dopustite da opcionalni mediji ne uspiju neovisno o osnovnoj stranici. Uz ta ograničenja, koristan vizualni pregled oznake ostaje mala značajka umjesto da neprimjetno postane infrastruktura preglednika, sigurnosni proxy i umnoživač prekida rada.

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.