Symfony oznake: sigurni pregledi poveznica uz generiranje AI snimaka zaslona
Popis oznaka postaje mnogo korisniji kada svaki spremljeni URL ima prepoznatljiv vizualni pregled. No postaje i operativno složeniji: snimanje stranica zahtijeva preglednik, nepouzdanim URL-ovima treba pažljivo rukovati, a sporo renderiranje ne pripada web zahtjevu.
Ovaj vodič izrađuje Symfony aplikaciju za oznake koja stavlja generiranje pregleda u red čekanja, poziva upravljani Screenshot API, validira vraćeni PNG i poslužuje ga putem kontrolirane rute aplikacije. Rezultat izbjegava održavanje Chromium radnika uz očuvanje jasnih sigurnosnih granica i granica neuspjeha.
Dobijte pristup Screenshot API-ju
Započnite stvaranjem računa na stranici za registraciju ili upotrijebite stranicu za prijavu ako ga već imate.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju Screenshot API-ja.
- Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer autentikaciju jer vjerodajnicu zadržava izvan URL-ova, povijesti preglednika, zapisnika proxy upita i analitičkih sustava.
Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao događaj implementacije: ažurirajte tajnu aplikacije, ponovno pokrenite radnike, provjerite jedno snimanje i tek tada smatrajte rotaciju dovršenom.
Potvrdite točnu krajnju točku
Zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, s ciljanom adresom u obaveznom parametru upita url. Uspješan odgovor ima tijelo image/png. Sačuvajte njegova zaglavlja odgovora povezana s predmemorijom i kvotom umjesto da očekujete nedokumentiranu JSON omotnicu.
read -rsp "Service token: " SCREENSHOT_API_TOKEN
curl --silent --show-error \
--dump-header screenshot.headers \
--output screenshot.png \
--get \
--data-urlencode "url=https://example.com" \
--header "Accept: image/png" \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file screenshot.png
unset SCREENSHOT_API_TOKEN
Pregledajte status i zaglavlja prije nego što datoteci povjerujete. Za razliku od --fail-with-body, gornja naredba čuva odgovor s greškom radi dijagnostike; datoteka nazvana screenshot.png nije dokaz da je njezin sadržaj PNG.
Stvarni token pohranite u Symfonyjev nepredani .env.local tijekom lokalnog razvoja. U produkciji ubrizgajte istu varijablu putem upravitelja tajnama hosting platforme.
# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async
Izradite Symfony projekt
Implementacija pretpostavlja PHP 8.3 ili noviji, Composer, Symfony aplikaciju s Doctrineom i bazu podataka koju podržava vaša Doctrine konfiguracija. Messenger je ovdje vrijedan jer je snimanje stranice vanjski posao s promjenjivom latencijom; stvaranje oznake treba ostati brzo čak i kada je pružatelj zauzet.
composer create-project symfony/skeleton bookmark-previews
cd bookmark-previews
composer require symfony/framework-bundle symfony/http-client \
symfony/orm-pack symfony/messenger symfony/validator
composer require --dev symfony/test-pack doctrine/doctrine-fixtures-bundle
Aplikacija ima četiri namjerne granice:
- Kontroler validira i sprema oznaku, a zatim šalje poruku.
- Obrađivač poruke obavlja snimanje izvan HTTP zahtjeva.
- Namjenski klijent upravlja autentikacijom, ponovnim pokušajima, PNG validacijom i mapiranjem odgovora.
- Generirane datoteke ostaju pod
var/i izlažu se samo putem Symfony odgovora.
Zadržavanje pregleda izvan javnog direktorija sprječava da izravan pristup zaobiđe buduća pravila autorizacije. Kompromis je to što Symfony mora poslužiti svaku sliku; za veći promet taj kontroler zamijenite potpisanim URL-ovima za pohranu objekata uz zadržavanje iste domenske granice.
Modelirajte stanje oznake i pregleda
Pregled nije samo prisutan ili odsutan. Može biti na čekanju, spreman ili neuspješan, uz strukturirani kod neuspjeha pogodan za kontrole ponovnog pokušaja i dijagnostiku podrške.
<?php
// src/Entity/Bookmark.php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Bookmark
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 2048)]
private string $url;
#[ORM\Column(length: 16)]
private string $previewState = 'pending';
#[ORM\Column(length: 255, nullable: true)]
private ?string $previewFile = null;
#[ORM\Column(length: 40, nullable: true)]
private ?string $previewError = null;
#[ORM\Column(type: Types::JSON)]
private array $previewHeaders = [];
public function __construct(string $url)
{
$this->url = $url;
}
public function getId(): ?int { return $this->id; }
public function getUrl(): string { return $this->url; }
public function getPreviewState(): string { return $this->previewState; }
public function getPreviewFile(): ?string { return $this->previewFile; }
public function previewReady(string $file, array $headers): void
{
$this->previewState = 'ready';
$this->previewFile = $file;
$this->previewError = null;
$this->previewHeaders = $headers;
}
public function previewFailed(string $code, array $headers = []): void
{
$this->previewState = 'failed';
$this->previewFile = null;
$this->previewError = $code;
$this->previewHeaders = $headers;
}
}
Generirajte i primijenite migraciju nakon dodavanja entiteta:
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Izradite obrambeni Screenshot API klijent
Donja granica koristi ograničeno trajanje povezivanja i ukupno trajanje, onemogućuje preusmjeravanja krajnje točke, ponovno pokušava samo kod transportnih neuspjeha i grešaka poslužitelja, ograničava tijelo na osam MiB i provjerava i vrstu medija i PNG potpis. Neuspjesi autentikacije, neuspjesi validacije i odgovori kvote nikada se ne pokušavaju ponovno naslijepo.
<?php
// src/Screenshot/ScreenshotResult.php
namespace App\Screenshot;
final readonly class ScreenshotResult
{
public function __construct(
public bool $successful,
public ?string $body,
public ?string $failureCode,
public array $cacheHeaders = [],
public array $quotaHeaders = [],
) {}
}
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ScreenshotClient
{
public function __construct(
private HttpClientInterface $http,
private string $token,
private string $endpoint,
private int $maxBytes = 8_388_608,
) {}
public function capture(string $url): ScreenshotResult
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', $this->endpoint, [
'auth_bearer' => $this->token,
'headers' => ['Accept' => 'image/png'],
'query' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
'max_redirects' => 0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
$cache = $this->selectHeaders(
$headers,
'/^(cache-control|age|etag|expires)$/i'
);
$quota = $this->selectHeaders(
$headers,
'/quota|rate.?limit|remaining|reset|retry-after/i'
);
if ($status === 401 || $status === 403) {
return new ScreenshotResult(false, null, 'authentication', $cache, $quota);
}
if ($status === 429) {
return new ScreenshotResult(false, null, 'quota', $cache, $quota);
}
if ($status >= 400 && $status < 500) {
return new ScreenshotResult(false, null, 'invalid_request', $cache, $quota);
}
if ($status >= 500) {
$response->cancel();
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
return new ScreenshotResult(false, null, 'upstream', $cache, $quota);
}
if ($status < 200 || $status >= 300) {
return new ScreenshotResult(false, null, 'unexpected_status', $cache, $quota);
}
$type = strtolower($headers['content-type'][0] ?? '');
if (!str_starts_with($type, 'image/png')) {
$response->cancel();
return new ScreenshotResult(false, null, 'invalid_content_type', $cache, $quota);
}
$body = '';
foreach ($this->http->stream($response) as $chunk) {
if ($chunk->isTimeout()) {
throw new \RuntimeException('Screenshot response timed out.');
}
$body .= $chunk->getContent();
if (strlen($body) > $this->maxBytes) {
$response->cancel();
return new ScreenshotResult(false, null, 'image_too_large', $cache, $quota);
}
}
if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
return new ScreenshotResult(false, null, 'invalid_png', $cache, $quota);
}
return new ScreenshotResult(true, $body, null, $cache, $quota);
} catch (TransportExceptionInterface|\RuntimeException $exception) {
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
return new ScreenshotResult(false, null, 'transport');
}
}
return new ScreenshotResult(false, null, 'transport');
}
private function backoff(int $attempt): void
{
$milliseconds = 200 * (2 ** ($attempt - 1)) + random_int(0, 100);
usleep($milliseconds * 1000);
}
private function selectHeaders(array $headers, string $pattern): array
{
return array_filter(
$headers,
static fn (string $name): bool => preg_match($pattern, $name) === 1,
ARRAY_FILTER_USE_KEY
);
}
}
Uspoređivač kvote namjerno je obramben: ugovor usluge zahtijeva obradu zaglavlja kvote, ali ne opravdava čvrsto kodiranje nedokumentiranog naziva zaglavlja. Klijent zadržava odgovarajuća zaglavlja točno onako kako su vraćena. Standardna vrijednost Retry-After može usmjeriti kasniji ponovni pokušaj koji pokreće korisnik ili je zakazan, ali radnik ne bi smio neograničeno spavati dok zadržava poruku u redu čekanja.
Povežite klijent putem konfiguracije podržane varijablama okruženja:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(string:SCREENSHOT_API_TOKEN)%'
$endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
$maxBytes: 8388608
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 10000
failed: 'doctrine://default?queue_name=failed'
routing:
App\Message\GenerateBookmarkPreview: async
Snimajte preglede u Messenger obrađivaču
Kratki ponovni pokušaji API klijenta pokrivaju prolazne neuspjehe povezivanja i poslužitelja. Messengerova politika ponovnih pokušaja rezervirana je za neočekivane neuspjehe obrađivača, kao što je privremeni problem datotečnog sustava ili baze podataka.
<?php
// src/Message/GenerateBookmarkPreview.php
namespace App\Message;
final readonly class GenerateBookmarkPreview
{
public function __construct(public int $bookmarkId) {}
}
// src/MessageHandler/GenerateBookmarkPreviewHandler.php
namespace App\MessageHandler;
use App\Entity\Bookmark;
use App\Message\GenerateBookmarkPreview;
use App\Screenshot\ScreenshotClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GenerateBookmarkPreviewHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private ScreenshotClient $screenshots,
private LoggerInterface $logger,
private string $projectDir,
) {}
public function __invoke(GenerateBookmarkPreview $message): void
{
$bookmark = $this->entityManager->find(Bookmark::class, $message->bookmarkId);
if (!$bookmark || $bookmark->getPreviewState() === 'ready') {
return;
}
$result = $this->screenshots->capture($bookmark->getUrl());
$headers = $result->cacheHeaders + $result->quotaHeaders;
if (!$result->successful) {
$bookmark->previewFailed($result->failureCode ?? 'unknown', $headers);
$this->entityManager->flush();
$this->logger->warning('Bookmark preview capture failed.', [
'bookmark_id' => $bookmark->getId(),
'target_host' => parse_url($bookmark->getUrl(), PHP_URL_HOST),
'failure_code' => $result->failureCode,
'response_headers' => $headers,
]);
return;
}
$directory = $this->projectDir.'/var/previews';
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
throw new \RuntimeException('Cannot create the preview directory.');
}
$name = $bookmark->getId().'.png';
$temporary = $directory.'/'.$name.'.'.bin2hex(random_bytes(6)).'.tmp';
if (file_put_contents($temporary, $result->body, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write the preview file.');
}
if (!rename($temporary, $directory.'/'.$name)) {
throw new \RuntimeException('Cannot publish the preview file.');
}
$bookmark->previewReady($name, $headers);
$this->entityManager->flush();
$this->logger->info('Bookmark preview is ready.', [
'bookmark_id' => $bookmark->getId(),
'bytes' => strlen($result->body),
]);
}
}
Atomsko preimenovanje sprječava web zahtjev da pročita djelomično zapisanu sliku. Zapisnici sadrže identifikator oznake i naziv hosta, a ne token ili puni URL. Puni URL-ovi mogu sadržavati osjetljive putanje i vrijednosti upita.
Validirajte URL-ove i izložite kontrolirane rute
Prihvatite samo apsolutne HTTP ili HTTPS URL-ove, odbijte ugrađene vjerodajnice, nestandardne portove, nazive localhosta i IP adrese koje su privatne ili rezervirane. Razriješite nazive hostova i odbijte URL ako je bilo koja vraćena adresa nejavna. DNS validacija je dodatna obrana, a ne potpun odgovor na napade ponovnog vezivanja ili preusmjeravanja; strogi popis dopuštenih naziva hostova najjača je opcija kada proizvod ne treba proizvoljne javne stranice.
<?php
// src/Security/BookmarkUrlGuard.php
namespace App\Security;
final class BookmarkUrlGuard
{
public function validate(string $value): string
{
$url = trim($value);
$parts = parse_url($url);
if (!filter_var($url, FILTER_VALIDATE_URL)
|| !is_array($parts)
|| !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|| isset($parts['user'])
|| isset($parts['pass'])
|| (isset($parts['port']) && !in_array($parts['port'], [80, 443], true))) {
throw new \InvalidArgumentException('A public HTTP or HTTPS URL is required.');
}
$host = strtolower(rtrim($parts['host'] ?? '', '.'));
if ($host === '' || $host === 'localhost') {
throw new \InvalidArgumentException('The hostname is not allowed.');
}
$addresses = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: array_values(array_filter(array_map(
static fn (array $record): ?string => $record['ip'] ?? $record['ipv6'] ?? null,
dns_get_record($host, DNS_A | DNS_AAAA) ?: []
)));
if ($addresses === []) {
throw new \InvalidArgumentException('The hostname could not be resolved.');
}
foreach ($addresses as $address) {
if (!filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
throw new \InvalidArgumentException('Private or reserved targets are not allowed.');
}
}
return $url;
}
}
<?php
// src/Controller/BookmarkController.php
namespace App\Controller;
use App\Entity\Bookmark;
use App\Message\GenerateBookmarkPreview;
use App\Security\BookmarkUrlGuard;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Messenger\MessageBusInterface;
final class BookmarkController extends AbstractController
{
#[Route('/bookmarks', methods: ['POST'])]
public function create(
Request $request,
BookmarkUrlGuard $guard,
EntityManagerInterface $entityManager,
MessageBusInterface $bus,
): JsonResponse {
try {
$url = $guard->validate((string) $request->request->get('url'));
} catch (\InvalidArgumentException $exception) {
return $this->json(['error' => $exception->getMessage()], 422);
}
$bookmark = new Bookmark($url);
$entityManager->persist($bookmark);
$entityManager->flush();
$bus->dispatch(new GenerateBookmarkPreview($bookmark->getId()));
return $this->json([
'id' => $bookmark->getId(),
'preview_state' => 'pending',
], 202);
}
#[Route('/bookmarks/{id}/preview', methods: ['GET'])]
public function preview(Bookmark $bookmark, string $projectDir): BinaryFileResponse
{
if ($bookmark->getPreviewState() !== 'ready' || !$bookmark->getPreviewFile()) {
throw $this->createNotFoundException('Preview is not ready.');
}
$file = $projectDir.'/var/previews/'.$bookmark->getPreviewFile();
if (!is_file($file)) {
throw $this->createNotFoundException('Preview file is missing.');
}
$response = new BinaryFileResponse($file);
$response->headers->set('Content-Type', 'image/png');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
'bookmark-preview.png'
);
$response->setPrivate();
$response->setMaxAge(3600);
return $response;
}
}
Dodajte CSRF zaštitu kada se ruta za stvaranje poziva iz obrasca preglednika. Ako oznake pripadaju korisnicima ili timovima, zaštitite obje rute Symfony Securityjem i pozovite autorizacijski voter prije vraćanja pregleda.
Deterministički testirajte vanjsku granicu
MockHttpClient izvršava stvarnu logiku mapiranja bez mrežnih poziva ili postavljanja vjerodajnica u fiksture.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class ScreenshotClientTest extends TestCase
{
public function testItAcceptsAValidPngAndPreservesCacheHeaders(): void
{
$png = "\x89PNG\r\n\x1a\nfixture";
$http = new MockHttpClient(new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'cache-control: public, max-age=3600',
],
]));
$client = new ScreenshotClient($http, 'test-token', 'https://service.test/capture');
$result = $client->capture('https://example.com');
self::assertTrue($result->successful);
self::assertSame($png, $result->body);
self::assertArrayHasKey('cache-control', $result->cacheHeaders);
}
public function testQuotaResponseIsNotRetried(): void
{
$requests = 0;
$http = new MockHttpClient(function () use (&$requests): MockResponse {
$requests++;
return new MockResponse('', [
'http_code' => 429,
'response_headers' => ['retry-after: 60'],
]);
});
$client = new ScreenshotClient($http, 'test-token', 'https://service.test/capture');
$result = $client->capture('https://example.com');
self::assertFalse($result->successful);
self::assertSame('quota', $result->failureCode);
self::assertSame(1, $requests);
self::assertArrayHasKey('retry-after', $result->quotaHeaders);
}
public function testItRejectsAFalsePng(): void
{
$http = new MockHttpClient(new MockResponse('<html>error</html>', [
'http_code' => 200,
'response_headers' => ['content-type: image/png'],
]));
$client = new ScreenshotClient($http, 'test-token', 'https://service.test/capture');
self::assertSame(
'invalid_png',
$client->capture('https://example.com')->failureCode
);
}
}
Implementirajte, pratite i rješavajte probleme
Pokrenite migracije prije objave koda aplikacije, stvorite direktorij pregleda u koji radnik može pisati i nadzirite Messenger radnika pomoću systemd-a, Supervisora ili upravitelja procesa koji pruža vaša platforma.
APP_ENV=prod php bin/console doctrine:migrations:migrate --no-interaction
mkdir -p var/previews
php bin/console cache:clear --env=prod
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=128M \
--no-interaction
Pratite broj snimanja prema ishodu, starost reda čekanja, neuspjehe radnika, latenciju i veličinu PNG-a. Upozorite na trajne neuspjehe autentikacije jer oni često ukazuju na istekao, opozvan ili nedosljedno implementiran token. Neuspjehe kvote pratite odvojeno od uzvodnih grešaka; povećanje generičkih ponovnih pokušaja ne može popraviti iscrpljeni plan.
Uobičajeni obrasci neuspjeha
- Svako snimanje vraća neuspjeh autentikacije: potvrdite da token pripada usluzi Screenshot API, provjerite razmake u tajni i ponovno pokrenite sve radnike nakon rotacije.
- Oznake ostaju na čekanju: provjerite troši li Messenger radnik
asynci pregledajtemessenger:failed:show. - Tijelo nije PNG: zadržite status i sigurna zaglavlja u zapisnicima, ali nikada ne objavljujte niti renderirajte tijelo kao HTML.
- Neuspjesi kvote se ponavljaju: pregledajte vraćena zaglavlja kvote ili
Retry-Afteri aktivni plan. Ponovno stavite u red čekanja samo kada se očekuje dostupnost kapaciteta. - Pregledi nestaju nakon implementacije:
var/može biti efemeran na hosting platformi. Upotrijebite trajni volumen ili privatnu pohranu objekata. - URL ne prolazi validaciju: provjerite njegovu shemu, port, DNS zapise i je li neka razriješena adresa privatna ili rezervirana.
Završni kontrolni popis provjere
- Token dolazi iz konfiguracije tajni podržane varijablama okruženja i nikada se ne pojavljuje u kontroli izvornog koda ni zapisnicima.
- Stvaranje oznake vraća
202bez čekanja na snimanje. - Radnik poziva točnu GET krajnju točku s obaveznim parametrom
urli Bearer autentikacijom. - Samo validirani PNG odgovori ispod ograničenja veličine dolaze do trajne pohrane.
- Zaglavlja povezana s predmemorijom i kvotom prelaze granicu API-ja u strukturirano stanje aplikacije.
- Neuspjesi autentikacije, validacije, kvote, transporta i slike ostaju razlikovni.
- Privatne i rezervirane ciljne adrese odbijaju se, uz primjenu strožih popisa dopuštenih gdje je praktično.
- Odgovori pregleda navode
image/png, onemogućuju njuškanje sadržaja i prolaze kroz autorizaciju aplikacije. - Automatizirani testovi izvode se bez pristupa mreži, a nadzirani Messenger radnik radi u produkciji.
Snimka zaslona može izgledati kao dekorativno poboljšanje, ali produkcijska značajka zapravo je lanac odluka o povjerenju. Oznaka prihvaća ograničeni URL, red čekanja izolira latenciju, klijent ne vjeruje nijednom odgovoru, a ruta isporuke izlaže samo provjerenu sliku. Kada svaka granica ima jednu jasnu odgovornost, vizualni pregledi ostaju korisni bez pretvaranja male aplikacije za oznake u projekt infrastrukture preglednika.