Symfony Обележувачи: Безбедни прегледи на врски со интеграција на API за слики од екран подготвена за продукција
Списокот со обележувачи станува многу покорисен кога врските се препознатливи на прв поглед. За жал, генерирањето сликички со извршување на прелистувач покрај вашата PHP апликација создава незгоден оперативен товар: Chromium пакети, дозволи за sandbox, скокови на меморијата, истекувања на времето за навигација и уште еден процес што треба да се закрпува.
Овој туторијал ја создава практичната алтернатива: Symfony апликација што бара кеширани PNG снимки од Screenshot API, ги складира локално за повторени прегледи и ги испорачува преку рута за преглед од исто потекло. Интеграцијата ги третира сликите од екранот како недоверлив бинарен влез, ги валидира URL-адресите на обележувачите, ги почитува насоките за кешот од надворешниот сервис, бележи метаподатоци за квотата и ги разликува неуспесите што треба да се повторат од оние што не треба.
Добијте пристап пред да пишувате код за интеграција
- Создајте сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.
- Отворете ја страницата на услугата Screenshot API. Изберете го достапниот план Free, Plus или Pro и завршете ја неговата активација.
- Посетете ја официјалната документација за Screenshot API.
- Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата. Оваа услуга бара автентикација: не е API без токен.
- Складирајте го токенот во конфигурација поддржана од околината. Ако го регенерирате, претходно активниот токен се поништува, па околините за распоредување мора веднаш да се ажурираат.
Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Го бара параметарот за пребарување url и прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување со токен. Претпочитајте заглавие: ингеренциите во низа за пребарување поверојатно ќе се појават во дневниците за пристап и дијагностиката.
Пред да го допрете Symfony, проверете ги сметката и токенот со едно минимално барање:
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
Телото на успешниот одговор е image/png. Проверете ги заглавијата на одговорот при рачна дијагностика со --dump-header response-headers.txt; интеграцијата подолу ги задржува метаподатоците за кешот и квотата без да претпоставува недокументирани имиња на заглавија специфични за добавувачот.
Подгответе го Symfony проектот
Ви треба PHP 8.3 или понов, Composer и Symfony апликација со ентитет Bookmark што содржи целоброен ID и URL. Мала апликација може локално да користи SQLite и базата на податоци веќе избрана за продукција.
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
Резултирачката функционалност има четири намерни граници:
- Ентитетот на обележувачот ја поседува поднесената URL-адреса.
- Политика за URL-адреси отфрла неподдржани или очигледно опасни цели.
- Посветен клиент ги поседува автентикацијата, повторните обиди, валидацијата на одговорите и метаподатоците од надворешниот сервис.
- Складиштето за прегледи го кешира резултатот на доменот, додека контролерот испорачува само валидирани PNG бајти.
Овој дизајн останува синхрон бидејќи крајна точка за кеширани слики од екранот природно се бара од елемент за слика, а услугата ја отстранува инфраструктурата на прелистувачот од апликацијата. Ако доцнењето при снимањето никогаш не смее да влијае на HTTP барање, истиот клиент подоцна може да стои зад Symfony Messenger; не воведувајте редица додека тој оперативен компромис не биде оправдан.
Конфигурирајте ги тајните и кеш-пулот
Ставете ја развојната ингеренција во .env.local, која не треба да се зачувува во комити. Во споделените примери задржете само безопасен заменски текст.
# .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
Отфрлете несоодветни цели за обележувачи
Оддалечената услуга ја извршува навигацијата, но на вашата апликација сè уште ѝ е потребна граница против злоупотреба. Формулар за произволно снимање може да стане алатка за испитување или да создаде несакани трошоци за квота. За општа апликација со обележувачи, барајте HTTPS, забранете ингеренции и нестандардни порти, отфрлете локални имиња и отфрлете приватни или резервирани буквални IP-адреси.
<?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.');
}
}
}
Ова е корисен филтер на ниво на апликација, а не целосна одбрана од DNS rebinding. Добавувачот на слики од екранот мора да спроведе сопствена мрежна изолација. За приватни тимски колекции на обележувачи, изречна листа на дозволени имиња на домаќини е посилна од прифаќање на целиот јавен веб.
Изградете дефанзивен клиент за Screenshot API
Мапирајте го HTTP одговорот во мал доменски објект наместо да пропуштате Symfony објекти за одговор низ апликацијата. Тоа ги одржува контролерите едноставни и го прави однесувањето при неуспех тестираливо.
<?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);
}
}
Клиентот повторува само транспортни неуспеси и грешки на серверот. Неуспесите при валидација, автентикација и квота бараат човечка интервенција или време; слепото повторување троши капацитет. Повратното чекање е кратко, експоненцијално, со случајна варијација и ограничено на два повторни обиди.
<?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);
}
}
Имињата на заглавијата што се користат за квоти може да се менуваат или да се разликуваат по план. Снимањето на вратените заглавија поврзани со квотата според нормализирано име избегнува измислување договор. Чувајте ги овие вредности во структурирана телеметрија; не го изложувајте капацитетот на сметката на јавни клиенти.
Кеширајте ги снимките и испорачајте слика од исто потекло
Symfony Cache обезбедува заклучување со callback, што го намалува напливот кон студениот кеш кога неколку вчитувања на страница го бараат истиот обележувач. Складиштето го користи надворешното Cache-Control: max-age кога е присутно, го ограничува во локален опсег и избегнува трајно складирање кога се појавува 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 сега може да прикаже <img src="{{ path('bookmark_preview', {id: bookmark.id}) }}" alt="" loading="lazy">. Авторизацијата мора да одговара на страницата со обележувачот: ако обележувачите се приватни, применете го истото правило за voter или access-control на рутата за преглед.
Тестирајте ја границата без да користите квота
MockHttpClient го прави транспортот детерминистички. Тестирајте успешно мапирање на бинарни податоци и докажете дека неуспесите при автентикација не се повторуваат.
<?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);
}
}
}
Продукциски неуспеси, набљудливост и распоредување
Следете го бројот на снимања, доцнењето, односот на погоден кеш, видот на неуспех, статусот од надворешниот сервис и забележаните метаподатоци за квота. Хеширајте ги целните URL-адреси во дневниците бидејќи URL-адресите може да содржат приватни патеки или податоци за пребарување контролирани од корисникот. Никогаш не ги евидентирајте токенот, заглавијата на барањето или PNG телото.
- 401 or 403: проверете ги активацијата и вбризгувањето на тајната. Регенериран токен го поништува претходниот.
- 400 or 422: проверете ги валидацијата на URL-адресата и документираниот договор за барањето. Не повторувајте непроменет влез.
- 429: зачувајте ги заглавијата за квота во телеметријата, испорачајте постоечки застарен преглед ако вашата политика за кеш го дозволува тоа и почекајте наместо веднаш да повторувате.
- 5xx or transport failure: соодветни се ограничени повторни обиди. Постојаните неуспеси треба да вратат неутрален заменски приказ во интерфејсот за обележувачи.
- Unexpected content: отфрлете го. Сам одговор 200 не е доволен; важни се типот на содржина, PNG потписот и ограничувањето на големината.
Во продукција, вбризгајте SCREENSHOT_API_TOKEN преку управувачот со тајни на платформата за хостирање, загрејте го продукцискиот контејнер на Symfony, извршете миграции и осигурете се дека директориумот за кеш е траен и запишлив. Повеќе инстанци на апликацијата треба да делат адаптер за кеш ако е важно избегнувањето дупликат снимања. Ротирајте го токенот со ажурирање на секоја инстанца веднаш по регенерацијата, а потоа рестартирајте или повторно распоредете ги процесите што ја задржуваат конфигурацијата на околината.
Конечна контролна листа за верификација
- Планот за услугата е активен, а тековниот токен ограничен на услугата е присутен само во конфигурација поддржана од околината.
- Клиентот ја повикува точната GET крајна точка со задолжителниот параметар за пребарување
urlи заглавиетоX-API-Token. - Само прифатливи HTTPS URL-адреси на обележувачи стигнуваат до API.
- Апликацијата прифаќа само ограничени одговори
image/pngсо валиден PNG потпис. - Заглавијата за кеш и квота се задржуваат како структурирани метаподатоци без јавно изложување.
- Неуспесите при автентикација, валидација и квота не се повторуваат слепо.
- Тестовите поминуваат без контактирање на надворешната услуга, а рутата за преглед ја дели политиката за авторизација на обележувачот.
Највредниот дел од оваа интеграција не е HTTP барањето. Тоа е границата околу него. Штом ингеренциите, политиката за URL-адреси, повторните обиди, бинарната валидација, кеширањето, авторизацијата и телеметријата се изречни, визуелните обележувачи престануваат да бидат кревок експеримент со автоматизација на прелистувач и стануваат обична, одржлива Symfony функционалност.