Symfony Обележувачи: Безбедни прегледи на врски со генерирање слики од екранот со ВИ
Списокот со обележувачи станува многу покорисен кога секоја зачувана URL-адреса има препознатлив визуелен преглед. Исто така станува посложен за работа: за снимање страници е потребен прелистувач, на недоверливите URL-адреси им е потребно внимателно ракување, а бавното рендерирање не припаѓа во веб-барање.
Овој туторијал гради Symfony апликација за обележувачи што го става генерирањето прегледи во редица, повикува управуван Screenshot API, го валидира вратениот PNG и го испорачува преку контролирана рута на апликацијата. Резултатот избегнува одржување Chromium работници, притоа зачувувајќи јасни безбедносни граници и граници за неуспеси.
Добијте пристап до Screenshot API
Започнете со создавање сметка на страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка.
- Отворете ја страницата на услугата Screenshot API.
- Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за Screenshot API.
- Пронајдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.
Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer автентикација бидејќи ги задржува ингеренциите надвор од URL-адресите, историјата на прелистувачот, дневниците за параметри на проксија и аналитичките системи.
Повторното генерирање на токенот за услугата го поништува претходно активниот токен. Сметајте ја ротацијата за настан при распоредување: ажурирајте ја тајната на апликацијата, рестартирајте ги работниците, потврдете едно снимање и дури потоа сметајте дека ротацијата е завршена.
Потврдете ја точната крајна точка
Барањето е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, со целната адреса во задолжителниот параметар за пребарување url. Успешниот одговор има тело image/png. Зачувајте ги неговите заглавија на одговорот поврзани со кешот и квотата, наместо да очекувате недокументирана JSON обвивка.
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
Проверете ги статусот и заглавијата пред да ѝ верувате на датотеката. За разлика од --fail-with-body, командата погоре зачувува одговор со грешка за дијагностика; датотека со име screenshot.png не е доказ дека нејзината содржина е PNG.
Чувајте го вистинскиот токен во Symfony-овата непоставена под верзиска контрола .env.local при локален развој. Во продукција, внесете ја истата променлива преку менаџерот за тајни на хостинг-платформата.
# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=async
Создадете го Symfony проектот
Имплементацијата претпоставува PHP 8.3 или понов, Composer, Symfony апликација со Doctrine и база на податоци поддржана од вашата Doctrine конфигурација. Messenger е корисен тука бидејќи снимањето страници е надворешна работа со променлива латентност; создавањето обележувачи треба да остане брзо дури и кога добавувачот е зафатен.
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
Апликацијата има четири намерни граници:
- Контролерот валидира и зачувува обележувач, па испраќа порака.
- Обработувач на пораки го извршува снимањето надвор од HTTP-барањето.
- Наменски клиент ги поседува автентикацијата, повторните обиди, PNG валидацијата и мапирањето на одговорот.
- Генерираните датотеки остануваат под
var/и се изложени само преку Symfony одговор.
Чувањето на прегледите надвор од јавниот директориум спречува директниот пристап да ги заобиколи идните правила за авторизација. Компромисот е што Symfony мора да ја испорача секоја слика; за поголем сообраќај, заменете го тој контролер со потпишани URL-адреси за складирање објекти, задржувајќи ја истата граница на доменот.
Моделирајте ја состојбата на обележувачот и прегледот
Прегледот не е само присутен или отсутен. Може да биде на чекање, подготвен или неуспешен, со структуриран код за неуспех погоден за контроли за повторен обид и дијагностика за поддршка.
<?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;
}
}
Генерирајте ја и применете ја миграцијата по додавањето на ентитетот:
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Изградете одбранбен Screenshot API клиент
Границата подолу користи ограничени траења на конекцијата и вкупното барање, ги оневозможува пренасочувањата на крајната точка, повторува само транспортни неуспеси и серверски грешки, го ограничува телото на осум MiB и ги проверува и типот на медиум и PNG потписот. Неуспесите на автентикација, валидација и одговорите за квота никогаш не се повторуваат слепо.
<?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
);
}
}
Совпаѓањето на квотата е намерно одбранбено: договорот за услугата бара ракување со заглавија за квота, но не оправдува вградување недокументирано име на заглавие. Клиентот ги задржува совпаѓачките заглавија токму како што се вратени. Стандардната вредност Retry-After може да насочи подоцнежен повторен обид инициран од корисник или закажан повторен обид, но работникот не треба да спие неодредено време додека држи порака во редица.
Поврзете го клиентот преку конфигурација поткрепена со променливи на околината:
# 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
Снимајте прегледи во Messenger обработувач
Кратките повторни обиди на API клиентот ги покриваат минливите неуспеси на конекцијата и серверот. Политиката за повторни обиди на Messenger е резервирана за неочекувани неуспеси на обработувачот, како привремен проблем со датотечниот систем или базата на податоци.
<?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),
]);
}
}
Атомското преименување спречува веб-барање да прочита делумно запишана слика. Дневниците содржат идентификатор на обележувач и име на домаќин, но не и токен или целосна URL-адреса. Целосните URL-адреси може да содржат чувствителни патеки и вредности на параметри за пребарување.
Валидирајте URL-адреси и изложете контролирани рути
Прифаќајте само апсолутни HTTP или HTTPS URL-адреси, отфрлајте вградени ингеренции, нестандардни порти, имиња localhost и IP-адреси што се приватни или резервирани. Разрешете ги имињата на домаќините и отфрлете ја URL-адресата ако која било вратена адреса не е јавна. DNS валидацијата е дополнителна одбрана, а не целосен одговор на нападите со повторно врзување или пренасочување; строг список на дозволени имиња на домаќини е најсилната опција кога производот нема потреба од произволни јавни страници.
<?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;
}
}
Додајте CSRF заштита кога рутата за создавање се повикува од формулар во прелистувач. Ако обележувачите им припаѓаат на корисници или тимови, заштитете ги двете рути со Symfony Security и повикајте гласач за авторизација пред да го вратите прегледот.
Тестирајте ја надворешната граница детерминистички
MockHttpClient ја извршува вистинската логика за мапирање без мрежни повици или ставање ингеренции во фикстури.
<?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
);
}
}
Распоредете, следете и отстранувајте проблеми
Извршете ги миграциите пред објавување на кодот на апликацијата, создајте директориум за прегледи во кој работникот може да запишува и надгледувајте го Messenger работникот со systemd, Supervisor или менаџерот на процеси што го обезбедува вашата платформа.
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
Следете ги броевите на снимања по исход, староста на редицата, неуспесите на работниците, латентноста и големината на PNG. Поставете предупредување за трајни неуспеси на автентикација бидејќи тие често укажуваат на истечен, поништен или неконзистентно распореден токен. Следете ги неуспесите поради квота одделно од грешките нагоре по синџирот; зголемувањето на општите повторни обиди не може да поправи исцрпен план.
Вообичаени обрасци на неуспех
- Секое снимање враќа неуспех на автентикација: потврдете дека токенот припаѓа на услугата Screenshot API, проверете дали има празни места во тајната и рестартирајте ги сите работници по ротацијата.
- Обележувачите остануваат на чекање: потврдете дека Messenger работник троши
asyncи проверетеmessenger:failed:show. - Телото не е PNG: задржете ги статусот и безбедните заглавија во дневниците, но никогаш не го објавувајте или рендерирајте телото како HTML.
- Неуспесите поради квота се повторуваат: проверете ги вратените заглавија за квота или
Retry-Afterи активниот план. Ставајте повторно во редица само кога се очекува капацитетот да биде достапен. - Прегледите исчезнуваат по распоредувањето:
var/може да е привремен на хостинг-платформата. Користете траен волумен или приватно складирање објекти. - URL-адреса не ја поминува валидацијата: проверете ги нејзината шема, порта, DNS записи и дали некоја разрешена адреса е приватна или резервирана.
Конечна листа за проверка
- Токенот доаѓа од конфигурација на тајни поткрепена со променливи на околината и никогаш не се појавува во изворна контрола или дневници.
- Создавањето обележувач враќа
202без да чека снимање. - Работникот ја повикува точната GET крајна точка со задолжителниот параметар
urlи Bearer автентикација. - Само валидирани PNG одговори под ограничувањето на големината стигнуваат до трајното складирање.
- Заглавијата поврзани со кешот и квотата ја преминуваат API границата во структурирана состојба на апликацијата.
- Неуспесите на автентикација, валидација, квота, транспорт и слика остануваат разликувачки.
- Приватните и резервираните целни адреси се отфрлаат, со построго дозволување по список каде што е практично.
- Одговорите за преглед декларираат
image/png, го оневозможуваат душкањето на содржината и поминуваат низ авторизацијата на апликацијата. - Автоматизираните тестови се извршуваат без мрежен пристап, а надгледуван Messenger работник работи во продукција.
Сликата од екранот може да изгледа како декоративно подобрување, но продукциската функционалност всушност е синџир од одлуки за доверба. Обележувачот прифаќа ограничена URL-адреса, редицата ја изолира латентноста, клиентот не верува на ниту еден одговор, а рутата за испорака изложува само проверена слика. Кога секоја граница има една јасна одговорност, визуелните прегледи остануваат корисни без да претворат мала апликација за обележувачи во проект за инфраструктура на прелистувачи.