Vodiči

Symfony Bookmarks: Secure Link Previews with Production-Ready Screenshot API Integration

Symfony Bookmarks: Sigurni pregledi poveznica s integracijom API-ja za snimke zaslona spremnom za produkciju

Popis oznaka postaje mnogo korisniji kada su poveznice prepoznatljive na prvi pogled. Nažalost, generiranje minijatura pokretanjem preglednika uz PHP aplikaciju stvara nezgodno operativno opterećenje: paketi za Chromium, dozvole sandboxa, skokovi potrošnje memorije, vremenska ograničenja navigacije i još jedan proces koji treba ažurirati.

Ovaj vodič donosi praktičnu alternativu: Symfony aplikaciju koja zahtijeva predmemorirane PNG snimke od Screenshot API-ja, lokalno ih pohranjuje za ponovljene prikaze i isporučuje ih putem rute za pregled istog izvorišta. Integracija snimke zaslona tretira kao nepouzdani binarni ulaz, validira URL-ove oznaka, poštuje upute uzvodne predmemorije, bilježi metapodatke o kvoti te razlikuje kvarove koje treba ponovno pokušati od onih koje ne treba.

Prije pisanja integracijskog koda pribavite pristup

  1. Izradite račun na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.
  2. Otvorite stranicu usluge Screenshot API. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  3. Posjetite službenu dokumentaciju Screenshot API-ja.
  4. Pronađite ploču Service token i kopirajte token ograničen na tu uslugu. Ova usluga zahtijeva autentikaciju: nije API bez tokena.
  5. Pohranite token u konfiguraciju podržanu varijablama okruženja. Ako ga ponovno generirate, prethodno aktivni token se opoziva, stoga se okruženja za implementaciju moraju odmah ažurirati.

Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Zahtijeva parametar upita url i prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita tokena. Dajte prednost zaglavlju: vjerovatnije je da će se vjerodajnice u nizu upita pojaviti u zapisnicima pristupa i dijagnostici.

Prije rada sa Symfonyjem provjerite račun i token jednim minimalnim zahtjevom:

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'X-API-Token: YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://example.com/' \
  --output preview.png

Tijelo uspješnog odgovora je image/png. Tijekom ručne dijagnostike pregledajte zaglavlja odgovora s --dump-header response-headers.txt; integracija u nastavku zadržava metapodatke predmemorije i kvote bez pretpostavljanja nedokumentiranih naziva zaglavlja specifičnih za dobavljača.

Pripremite Symfony projekt

Potrebni su vam PHP 8.3 ili noviji, Composer i Symfony aplikacija s entitetom Bookmark koji sadrži cjelobrojni ID i URL. Mala aplikacija može lokalno upotrebljavati SQLite i bazu podataka koja je već odabrana za produkciju.

composer create-project symfony/skeleton bookmark-preview
cd bookmark-preview
composer require symfony/http-client symfony/cache symfony/orm-pack \
  symfony/twig-bundle symfony/monolog-bundle
composer require --dev symfony/test-pack symfony/maker-bundle

php bin/console make:entity Bookmark
# Add: url, string, length 2048
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Dobivena značajka ima četiri namjerno postavljene granice:

  • Entitet oznake upravlja poslanim URL-om.
  • Pravila za URL odbacuju nepodržana ili očito opasna odredišta.
  • Namjenski klijent upravlja autentikacijom, ponovnim pokušajima, validacijom odgovora i uzvodnim metapodacima.
  • Spremište pregleda predmemorira rezultat domene, dok kontroler isporučuje samo validirane PNG bajtove.

Ovaj dizajn ostaje sinkron zato što predmemoriranu krajnju točku za snimke zaslona prirodno zahtijeva element slike, a usluga uklanja infrastrukturu preglednika iz aplikacije. Ako latencija snimanja nikada ne smije utjecati na HTTP zahtjev, isti klijent kasnije može raditi iza Symfony Messengera; nemojte uvoditi red dok taj operativni kompromis ne postane opravdan.

Konfigurirajte tajne i skup predmemorije

Razvojnu vjerodajnicu stavite u .env.local, koji se ne smije predati u repozitorij. U zajedničkim primjerima zadržite samo bezopasni rezervirani tekst.

# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
# config/services.yaml
parameters:
    screenshot_api.endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'

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

    App\Screenshot\PreviewStore:
        arguments:
            $cache: '@cache.preview'
# config/packages/cache.yaml
framework:
    cache:
        pools:
            cache.preview:
                adapter: cache.adapter.filesystem

Odbacite neprikladna odredišta oznaka

Udaljena usluga obavlja navigaciju, ali vašoj aplikaciji i dalje je potrebna granica protiv zloupotrebe. Proizvoljni obrazac za snimanje može postati alat za ispitivanje ili stvarati neželjene troškove kvote. Za opću aplikaciju oznaka zahtijevajte HTTPS, zabranite vjerodajnice i nestandardne portove, odbacite lokalne nazive te privatne ili rezervirane doslovne IP adrese.

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

final class BookmarkUrlPolicy
{
    public function assertAllowed(string $url): void
    {
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            throw new \InvalidArgumentException('The bookmark URL is invalid.');
        }

        $parts = parse_url($url);
        $scheme = strtolower($parts['scheme'] ?? '');
        $host = strtolower(rtrim($parts['host'] ?? '', '.'));

        if ($scheme !== 'https' || $host === '') {
            throw new \InvalidArgumentException('Only absolute HTTPS URLs are allowed.');
        }

        if (isset($parts['user']) || isset($parts['pass']) ||
            (isset($parts['port']) && $parts['port'] !== 443)) {
            throw new \InvalidArgumentException('Credentials and nonstandard ports are forbidden.');
        }

        if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
            throw new \InvalidArgumentException('Local targets are forbidden.');
        }

        if (filter_var($host, FILTER_VALIDATE_IP) !== false &&
            filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            ) === false) {
            throw new \InvalidArgumentException('Private or reserved targets are forbidden.');
        }
    }
}

Ovo je koristan filtar na razini aplikacije, a ne potpuna obrana od DNS ponovnog povezivanja. Pružatelj snimki zaslona mora provoditi vlastitu mrežnu izolaciju. Za privatne timske zbirke oznaka eksplicitan popis dopuštenih naziva hostova snažniji je od prihvaćanja cijelog javnog weba.

Izradite obrambeni klijent za Screenshot API

Preslikajte HTTP odgovor u mali domenski objekt umjesto da kroz aplikaciju propuštate Symfony objekte odgovora. Time kontroleri ostaju jednostavni, a ponašanje pri kvaru postaje testabilno.

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

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

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

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

Klijent ponovno pokušava samo kod transportnih kvarova i pogrešaka poslužitelja. Kvarovi validacije, autentikacije i kvote zahtijevaju ljudsku intervenciju ili vrijeme; njihovo slijepo ponavljanje rasipa kapacitet. Povratni odmak je kratak, eksponencijalan, s jitterom i ograničen na dva ponovna pokušaja.

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

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class ScreenshotClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
        private string $endpoint,
    ) {}

    public function capture(string $url): CapturedScreenshot
    {
        $started = microtime(true);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'headers' => ['X-API-Token' => $this->token],
                    'query' => ['url' => $url],
                    'timeout' => 3.0,
                    'max_duration' => 12.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);
                [$cache, $quota] = $this->metadata($headers);

                if ($status === 401 || $status === 403) {
                    throw new ScreenshotFailure('authentication', false, $status, $quota);
                }
                if ($status === 400 || $status === 422) {
                    throw new ScreenshotFailure('request_rejected', false, $status, $quota);
                }
                if ($status === 429) {
                    throw new ScreenshotFailure('quota_or_rate_limit', false, $status, $quota);
                }
                if ($status >= 500 && $attempt < 3) {
                    $this->backoff($attempt);
                    continue;
                }
                if ($status !== 200) {
                    throw new ScreenshotFailure('upstream_http', $status >= 500, $status, $quota);
                }

                $contentType = strtolower($headers['content-type'][0] ?? '');
                $png = $response->getContent(false);

                if (!str_starts_with($contentType, 'image/png') ||
                    !str_starts_with($png, "\x89PNG\r\n\x1a\n") ||
                    strlen($png) > 10_000_000) {
                    throw new ScreenshotFailure('invalid_image', false, $status, $quota);
                }

                $this->logger->info('Screenshot capture completed', [
                    'attempt' => $attempt,
                    'duration_ms' => (int) ((microtime(true) - $started) * 1000),
                    'target_hash' => hash('sha256', $url),
                    'quota' => $quota,
                ]);

                return new CapturedScreenshot($png, $cache, $quota);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure('transport', true, metadata: [
                        'exception' => $e::class,
                    ]);
                }
                $this->backoff($attempt);
            }
        }

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

    private function metadata(array $headers): array
    {
        $cache = [];
        $quota = [];

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

            if (in_array($lower, ['cache-control', 'age', 'etag', 'expires'], true)) {
                $cache[$lower] = $values;
            }
            if (str_contains($lower, 'quota') ||
                str_contains($lower, 'ratelimit') ||
                str_contains($lower, 'rate-limit')) {
                $quota[$lower] = $values;
            }
        }

        return [$cache, $quota];
    }

    private function backoff(int $attempt): void
    {
        $milliseconds = 100 * (2 ** ($attempt - 1)) + random_int(0, 50);
        usleep($milliseconds * 1000);
    }
}

Nazivi zaglavlja koja se upotrebljavaju za kvote mogu se mijenjati ili razlikovati po planu. Bilježenje vraćenih zaglavlja povezanih s kvotom prema normaliziranom nazivu izbjegava izmišljanje ugovora. Te vrijednosti čuvajte u strukturiranoj telemetriji; nemojte izlagati kapacitet računa javnim klijentima.

Predmemorirajte snimke i poslužite sliku istog izvorišta

Symfony Cache pruža zaključavanje povratnog poziva, što smanjuje navalu na hladnu predmemoriju kada više učitavanja stranice zahtijeva istu oznaku. Spremište koristi uzvodni Cache-Control: max-age kada je prisutan, ograničava ga na lokalni raspon i izbjegava trajnu pohranu kada se pojavi no-store.

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

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class PreviewStore
{
    public function __construct(
        private CacheInterface $cache,
        private ScreenshotClient $client,
        private BookmarkUrlPolicy $policy,
    ) {}

    public function get(string $url): CapturedScreenshot
    {
        $this->policy->assertAllowed($url);

        return $this->cache->get('bookmark_preview.'.hash('sha256', $url),
            function (ItemInterface $item) use ($url): CapturedScreenshot {
                $capture = $this->client->capture($url);
                $control = implode(',', $capture->cacheHeaders['cache-control'] ?? []);

                $ttl = 600;
                if (preg_match('/(?:^|,)\s*max-age=(\d+)/i', $control, $match)) {
                    $ttl = max(60, min(3600, (int) $match[1]));
                }

                $item->expiresAfter(str_contains(strtolower($control), 'no-store') ? 0 : $ttl);
                return $capture;
            }
        );
    }
}
<?php
// src/Controller/BookmarkPreviewController.php
namespace App\Controller;

use App\Repository\BookmarkRepository;
use App\Screenshot\PreviewStore;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final readonly class BookmarkPreviewController
{
    public function __construct(
        private BookmarkRepository $bookmarks,
        private PreviewStore $previews,
    ) {}

    #[Route('/bookmarks/{id<\d+>}/preview', name: 'bookmark_preview', methods: ['GET'])]
    public function __invoke(int $id, Request $request): Response
    {
        $bookmark = $this->bookmarks->find($id);
        if ($bookmark === null) {
            return new Response('', Response::HTTP_NOT_FOUND);
        }

        try {
            $capture = $this->previews->get($bookmark->getUrl());
        } catch (\InvalidArgumentException) {
            return new Response('', Response::HTTP_UNPROCESSABLE_ENTITY);
        } catch (\Throwable) {
            return new Response('', Response::HTTP_BAD_GATEWAY);
        }

        $response = new Response($capture->png, Response::HTTP_OK, [
            'Content-Type' => 'image/png',
            'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'private, max-age=300',
        ]);
        $response->setEtag(hash('sha256', $capture->png));

        return $response->isNotModified($request) ? $response : $response;
    }
}

Twig stranica sada može prikazati <img src="{{ path('bookmark_preview', {id: bookmark.id}) }}" alt="" loading="lazy">. Autorizacija mora odgovarati stranici oznake: ako su oznake privatne, primijenite isto voter pravilo ili pravilo kontrole pristupa na rutu pregleda.

Testirajte granicu bez upotrebe kvote

MockHttpClient čini transport determinističkim. Testirajte uspješno preslikavanje binarnih podataka i dokažite da se kvarovi autentikacije ne pokušavaju ponovno.

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

use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testMapsPngAndCacheHeaders(): void
    {
        $http = new MockHttpClient(function (string $method, string $url): MockResponse {
            self::assertSame('GET', $method);
            parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
            self::assertSame('https://example.com/', $query['url']);

            return new MockResponse("\x89PNG\r\n\x1a\npayload", [
                'http_code' => 200,
                'response_headers' => [
                    'content-type: image/png',
                    'cache-control: max-age=120',
                ],
            ]);
        });

        $client = new ScreenshotClient($http, new NullLogger(), 'test-token',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');

        $result = $client->capture('https://example.com/');
        self::assertStringStartsWith("\x89PNG", $result->png);
        self::assertSame(['max-age=120'], $result->cacheHeaders['cache-control']);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $calls = 0;
        $http = new MockHttpClient(function () use (&$calls): MockResponse {
            $calls++;
            return new MockResponse('', ['http_code' => 401]);
        });

        $client = new ScreenshotClient($http, new NullLogger(), 'bad-token',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');

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

Kvarovi u produkciji, opažljivost i implementacija

Pratite broj snimanja, latenciju, omjer pogodaka predmemorije, vrstu kvara, uzvodni status i opažene metapodatke kvote. U zapisnicima heširajte ciljne URL-ove jer URL-ovi mogu sadržavati privatne putanje ili podatke upita pod kontrolom korisnika. Nikada nemojte zapisivati token, zaglavlja zahtjeva ni PNG tijelo.

  • 401 ili 403: provjerite aktivaciju i ubrizgavanje tajne. Ponovno generirani token poništava prethodni.
  • 400 ili 422: pregledajte validaciju URL-a i dokumentirani ugovor zahtjeva. Nemojte ponovno pokušavati s nepromijenjenim ulazom.
  • 429: sačuvajte zaglavlja kvote u telemetriji, poslužite postojeći zastarjeli pregled ako to vaša politika predmemorije dopušta i pričekajte umjesto da odmah pokušavate ponovno.
  • 5xx ili transportni kvar: ograničeni ponovni pokušaji su primjereni. Trajni kvarovi trebali bi vratiti neutralni rezervirani prikaz u korisničkom sučelju oznaka.
  • Neočekivani sadržaj: odbacite ga. Sam odgovor 200 nije dovoljan; važni su vrsta sadržaja, PNG potpis i ograničenje veličine.

U produkciji ubrizgajte SCREENSHOT_API_TOKEN putem upravitelja tajni hosting platforme, zagrijte Symfonyjev produkcijski spremnik, pokrenite migracije i osigurajte da je direktorij predmemorije trajan i upisiv. Više instanci aplikacije trebalo bi dijeliti prilagodnik predmemorije ako je važno izbjeći dvostruka snimanja. Rotirajte token ažuriranjem svake instance odmah nakon ponovne generacije, zatim ponovno pokrenite ili ponovno implementirajte procese koji zadržavaju konfiguraciju okruženja.

Završni kontrolni popis za provjeru

  • Plan usluge je aktivan, a trenutačni token ograničen na uslugu prisutan je samo u konfiguraciji podržanoj varijablama okruženja.
  • Klijent poziva točnu GET krajnju točku s obaveznim parametrom upita url i zaglavljem X-API-Token.
  • Samo prihvatljivi HTTPS URL-ovi oznaka dolaze do API-ja.
  • Aplikacija prihvaća samo ograničene odgovore image/png s valjanim PNG potpisom.
  • Zaglavlja predmemorije i kvote zadržavaju se kao strukturirani metapodaci bez javnog izlaganja.
  • Kvarovi autentikacije, validacije i kvote ne pokušavaju se slijepo ponovno.
  • Testovi prolaze bez kontaktiranja vanjske usluge, a ruta pregleda dijeli politiku autorizacije oznake.

Najvrjedniji dio ove integracije nije HTTP zahtjev. To je granica oko njega. Kada su vjerodajnice, pravila za URL, ponovni pokušaji, binarna validacija, predmemoriranje, autorizacija i telemetrija eksplicitni, vizualne oznake prestaju biti krhki eksperiment automatizacije preglednika i postaju uobičajena Symfony značajka koju je moguće podržavati.

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.