Native PHP 8.3: Безбедно генерирајте визуелни прегледи на линкови за обележувачи со Screenshot API
Обележувачот станува многу покорисен кога е препознатлив на прв поглед. Насловите помагаат, но визуелниот преглед често разликува страница на производ, дизајнерска референца или истражувачка статија побрзо од уште една линија текст. Незгодниот дел е сигурното генерирање на тие прегледи: извршувањето на Chromium носи ажурирања на прелистувачот, изолација, скокови во потрошувачката на меморија, истекувања на време и привлечна цел за злонамерни URL-адреси.
Овој туторијал гради мал API за обележувачи во Native PHP 8.3 што го делегира рендерирањето на Screenshot API, го валидира вратениот PNG, го складира надвор од јавниот директориум и го изложува преку контролирана рута. Интеграцијата вклучува ограничени истекувања на време, селективни повторни обиди, справување со квоти, структурирани неуспеси, детерминистички тестови и заштитни мерки при распоредување.
Добијте пристап до Screenshot API
Прво, регистрирајте сметка, или најавете се ако веќе имате. Отворете ја страницата на услугата Screenshot API, изберете достапен план Free, Plus или Pro и завршете ја неговата активација.
Потоа, отворете ја официјалната документација. Најдете го панелот Service token и копирајте го токенот ограничен на услугата. Повторното генерирање на овој токен го поништува претходно активниот токен, па ротацијата мора да ја ажурира секоја распоредена инстанца што го користи.
Оваа услуга не е без токен. Секое снимање мора да се автентицира со Bearer токен, заглавие X-API-Token или параметарот за барање token. Ќе го користиме заглавието Bearer бидејќи ингеренциите во барањата може да протечат во URL-адреси, дневници за пристап, аналитика и историја на прелистувачот.
Потврдете го API-договорот
Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Неговиот задолжителен параметар за барање url ја идентификува страницата што треба да се сними. Успешниот одговор содржи тело image/png плус заглавија на одговор поврзани со кешот и квотите.
Откако привремено ќе го извезете копираниот токен, направете едно минимално барање:
export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body --silent --show-error \
--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 \
--output preview.png
file preview.png
Проверете ги response.headers, како и датотеката. Апликацијата нема да претпоставува недокументирани имиња на заглавија за кеш или квоти; таа ги снима заглавијата на одговорот и одбранбено ги мапира релевантните групи.
Чувајте ја ингеренцијата во проектно ниво .env датотека, никогаш во PHP изворниот код:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_PREVIEW_DIR=var/previews
Додајте ги .env, var/bookmarks.sqlite и var/previews/ во .gitignore. Во контрола на верзии чувајте само .env.example без токен.
Изберете намерно мала архитектура
Апликацијата има три граници: SQLite ги чува метаподатоците за обележувачите, ScreenshotClient го преведува далечинскиот HTTP-договор во доменски резултати, а предниот контролер ги обработува JSON и рутите за слики. PNG-датотеките се наоѓаат надвор од веб-коренот и може да се читаат само преку пребарување на обележувач.
Снимањето е синхроно за овој туторијал да може да се изврши без worker. Тоа е разумно за лична апликација или апликација за мал тим со ограничен сообраќај. Ако создавањето обележувач мора веднаш да се врати, задржете го истиот клиент и модел на состојба, но повикајте го снимањето од надгледуван worker во позадина.
Создадете ја оваа структура:
bookmarks/
├── composer.json
├── .env
├── public/index.php
├── src/
│ ├── Http.php
│ └── ScreenshotClient.php
├── tests/ScreenshotClientTest.php
└── var/previews/
Користете Composer за автоматско вчитување, dotenv конфигурација и PHPUnit:
{
"name": "example/safe-bookmarks",
"type": "project",
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Bookmarks\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Bookmarks\\Tests\\": "tests/"
}
}
}
mkdir -p bookmarks/{public,src,tests,var/previews}
cd bookmarks
composer install
Изолирајте HTTP и нормализирајте ги одговорите
Тесен транспортен интерфејс го прави cURL заменлив во тестовите. Производствениот транспорт одбива пренасочувања на границата на API, применува одделни истекувања на време за поврзување и за вкупно време и задржува повторени заглавија.
<?php
// src/Http.php
namespace Bookmarks;
interface Transport
{
public function get(string $url, array $headers): HttpResult;
}
final readonly class HttpResult
{
public function __construct(
public int $status,
public string $body,
public array $headers
) {}
public function firstHeader(string $name): ?string
{
return $this->headers[strtolower($name)][0] ?? null;
}
}
final class CurlTransport implements Transport
{
public function __construct(
private int $connectTimeoutMs = 1500,
private int $timeoutMs = 12000
) {}
public function get(string $url, array $headers): HttpResult
{
$responseHeaders = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $this->timeoutMs,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADERFUNCTION => static function (
$curl,
string $line
) use (&$responseHeaders): int {
$length = strlen($line);
if (str_starts_with($line, 'HTTP/')) {
$responseHeaders = [];
return $length;
}
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))][] = trim($value);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new \RuntimeException('Screenshot transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResult($status, $body, $responseHeaders);
}
}
Мапирајте PNG-датотеки, неуспеси, податоци за кеш и квоти
Класата на услугата ги валидира URL-адресите пред да троши квота, повторува само преодни транспортни неуспеси, HTTP 429 и одговори 5xx и отфрла неочекувани тела. Неуспесите при автентикација и валидација никогаш не се повторуваат слепо.
<?php
// src/ScreenshotClient.php
namespace Bookmarks;
final readonly class Preview
{
public function __construct(
public string $png,
public array $cacheHeaders,
public array $quotaHeaders
) {}
}
final class ScreenshotException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
public readonly array $metadata = [],
string $message = 'Screenshot capture failed'
) {
parent::__construct($message);
}
}
final class ScreenshotClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
private \Closure $pause;
public function __construct(
private Transport $transport,
private string $token,
?\Closure $pause = null
) {
if ($token === '') {
throw new \InvalidArgumentException('Screenshot token is missing');
}
$this->pause = $pause ?? static fn (int $ms) => usleep($ms * 1000);
}
public function capture(string $url): Preview
{
$this->assertPublicHttpUrl($url);
$endpoint = self::ENDPOINT . '?' . http_build_query(
['url' => $url],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->transport->get($endpoint, [
'Authorization: Bearer ' . $this->token,
'Accept: image/png',
]);
} catch (\RuntimeException $exception) {
if ($attempt === 2) {
throw new ScreenshotException(
'transport_error',
true,
[],
$exception->getMessage()
);
}
($this->pause)(250 * (2 ** $attempt));
continue;
}
$transient = $response->status === 429 ||
$response->status >= 500;
if ($transient && $attempt < 2) {
$retryAfter = (int) ($response->firstHeader('retry-after') ?? 0);
$delay = $retryAfter > 0
? min(2000, $retryAfter * 1000)
: 250 * (2 ** $attempt);
($this->pause)($delay);
continue;
}
break;
}
[$cache, $quota] = $this->operationalHeaders($response->headers);
if ($response->status === 401 || $response->status === 403) {
throw new ScreenshotException('authentication_error', false);
}
if ($response->status === 429) {
throw new ScreenshotException('quota_limited', true, $quota);
}
if ($response->status < 200 || $response->status >= 300) {
throw new ScreenshotException(
'upstream_error',
$response->status >= 500,
['status' => $response->status]
);
}
$type = strtolower($response->firstHeader('content-type') ?? '');
$isPng = str_starts_with($type, 'image/png') &&
str_starts_with($response->body, "\x89PNG\r\n\x1a\n");
if (!$isPng || strlen($response->body) > 8 * 1024 * 1024) {
throw new ScreenshotException('invalid_image', false);
}
return new Preview($response->body, $cache, $quota);
}
private function assertPublicHttpUrl(string $url): void
{
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
$host = strtolower($parts['host'] ?? '');
if (
!filter_var($url, FILTER_VALIDATE_URL) ||
!in_array($scheme, ['http', 'https'], true) ||
$host === '' ||
isset($parts['user']) ||
isset($parts['pass']) ||
$host === 'localhost' ||
str_ends_with($host, '.local')
) {
throw new ScreenshotException('invalid_url', false);
}
if (
filter_var($host, FILTER_VALIDATE_IP) &&
!filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)
) {
throw new ScreenshotException('invalid_url', false);
}
}
private function operationalHeaders(array $headers): array
{
$cache = [];
$quota = [];
foreach ($headers as $name => $values) {
if (str_contains($name, 'cache')) {
$cache[$name] = $values;
}
if (
str_contains($name, 'quota') ||
str_contains($name, 'rate') ||
$name === 'retry-after'
) {
$quota[$name] = $values;
}
}
return [$cache, $quota];
}
}
Ограничувањето од осум мегабајти е безбедносна граница на апликацијата, а не тврдење за услугата. Приспособете го намерно ако вашиот план или случај на употреба бара поголеми снимки од екранот. За распоредувања со повисок ризик, применете експлицитна листа на дозволени имиња на домаќини или робусна DNS/IP политика пред поднесување; едноставните текстуални проверки на URL-адреси не можат да го елиминираат секој трик со резолуција на име на домаќин.
Поврзете ги рутите за обележувачи
Предниот контролер поддржува листање и создавање обележувачи, плус читање преглед. Неуспесите стануваат трајни доменски состојби, па привремен прекин не се претставува како слика што недостига.
<?php
// public/index.php
declare(strict_types=1);
use Bookmarks\CurlTransport;
use Bookmarks\ScreenshotClient;
use Bookmarks\ScreenshotException;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$root = dirname(__DIR__);
$previewDir = $root . '/' . ($_ENV['SCREENSHOT_PREVIEW_DIR'] ?? 'var/previews');
if (!is_dir($previewDir)) {
mkdir($previewDir, 0750, true);
}
$db = new PDO('sqlite:' . $root . '/var/bookmarks.sqlite', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec(
'CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
preview_path TEXT,
preview_state TEXT NOT NULL,
upstream_meta TEXT NOT NULL DEFAULT "{}",
created_at TEXT NOT NULL
)'
);
$client = new ScreenshotClient(
new CurlTransport(),
$_ENV['SCREENSHOT_API_TOKEN'] ?? ''
);
function respond(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($method === 'GET' && $path === '/bookmarks') {
$rows = $db->query(
'SELECT id, title, url, preview_state, created_at
FROM bookmarks ORDER BY id DESC'
)->fetchAll();
foreach ($rows as &$row) {
$row['preview_url'] = $row['preview_state'] === 'ready'
? '/previews/' . $row['id']
: null;
}
respond(['bookmarks' => $rows]);
}
if ($method === 'POST' && $path === '/bookmarks') {
try {
$input = json_decode(
file_get_contents('php://input'),
true,
flags: JSON_THROW_ON_ERROR
);
} catch (JsonException) {
respond(['error' => 'invalid_json'], 400);
}
$title = trim((string) ($input['title'] ?? ''));
$url = trim((string) ($input['url'] ?? ''));
if ($title === '' || strlen($title) > 200 || $url === '') {
respond(['error' => 'invalid_bookmark'], 422);
}
$insert = $db->prepare(
'INSERT INTO bookmarks(title, url, preview_state, created_at)
VALUES (?, ?, "pending", ?)'
);
$insert->execute([$title, $url, gmdate(DATE_ATOM)]);
$id = (int) $db->lastInsertId();
try {
$preview = $client->capture($url);
$filename = $id . '.png';
$temporary = tempnam($previewDir, 'capture-');
if ($temporary === false ||
file_put_contents($temporary, $preview->png, LOCK_EX) === false ||
!rename($temporary, $previewDir . '/' . $filename)) {
throw new RuntimeException('Could not persist preview');
}
chmod($previewDir . '/' . $filename, 0640);
$metadata = json_encode([
'cache' => $preview->cacheHeaders,
'quota' => $preview->quotaHeaders,
], JSON_THROW_ON_ERROR);
$update = $db->prepare(
'UPDATE bookmarks
SET preview_path = ?, preview_state = "ready", upstream_meta = ?
WHERE id = ?'
);
$update->execute([$filename, $metadata, $id]);
respond(['id' => $id, 'preview_state' => 'ready',
'preview_url' => '/previews/' . $id], 201);
} catch (ScreenshotException $exception) {
$update = $db->prepare(
'UPDATE bookmarks SET preview_state = ?, upstream_meta = ?
WHERE id = ?'
);
$update->execute([
$exception->kind,
json_encode($exception->metadata, JSON_THROW_ON_ERROR),
$id,
]);
error_log(json_encode([
'event' => 'screenshot_failed',
'bookmark_id' => $id,
'kind' => $exception->kind,
'retryable' => $exception->retryable,
], JSON_THROW_ON_ERROR));
respond(['id' => $id, 'preview_state' => $exception->kind], 201);
}
}
if ($method === 'GET' && preg_match('#^/previews/(\d+)$#', $path, $match)) {
$query = $db->prepare(
'SELECT preview_path FROM bookmarks
WHERE id = ? AND preview_state = "ready"'
);
$query->execute([(int) $match[1]]);
$filename = $query->fetchColumn();
$file = $filename ? $previewDir . '/' . basename($filename) : '';
if (!$filename || !is_file($file)) {
respond(['error' => 'preview_not_found'], 404);
}
header('Content-Type: image/png');
header('X-Content-Type-Options: nosniff');
header("Content-Security-Policy: default-src 'none'; sandbox");
header('Cache-Control: private, max-age=3600');
readfile($file);
exit;
}
respond(['error' => 'not_found'], 404);
Тестирајте без да ја контактирате услугата
Детерминистички лажен објект го докажува мапирањето на одговорите и политиката за повторни обиди без трошење квота или зависност од мрежата.
<?php
// tests/ScreenshotClientTest.php
namespace Bookmarks\Tests;
use Bookmarks\HttpResult;
use Bookmarks\ScreenshotClient;
use Bookmarks\ScreenshotException;
use Bookmarks\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, array $headers): HttpResult
{
$this->calls++;
return array_shift($this->responses);
}
}
final class ScreenshotClientTest extends TestCase
{
public function testMapsPngAndOperationalHeaders(): void
{
$fake = new FakeTransport([
new HttpResult(200, "\x89PNG\r\n\x1a\npayload", [
'content-type' => ['image/png'],
'cache-control' => ['public, max-age=60'],
'retry-after' => ['10'],
]),
]);
$preview = (new ScreenshotClient(
$fake,
'test-token',
static fn (int $ms) => null
))->capture('https://example.com/');
self::assertStringStartsWith("\x89PNG", $preview->png);
self::assertArrayHasKey('cache-control', $preview->cacheHeaders);
}
public function testRetriesServerFailureThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResult(503, '', []),
new HttpResult(200, "\x89PNG\r\n\x1a\nok", [
'content-type' => ['image/png'],
]),
]);
(new ScreenshotClient(
$fake,
'test-token',
static fn (int $ms) => null
))->capture('https://example.com/');
self::assertSame(2, $fake->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([new HttpResult(401, '', [])]);
$client = new ScreenshotClient(
$fake,
'test-token',
static fn (int $ms) => null
);
try {
$client->capture('https://example.com/');
self::fail('Expected ScreenshotException');
} catch (ScreenshotException $exception) {
self::assertSame('authentication_error', $exception->kind);
self::assertSame(1, $fake->calls);
}
}
}
composer dump-autoload
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public public/index.php
curl --request POST 'http://127.0.0.1:8080/bookmarks' \
--header 'Content-Type: application/json' \
--data '{"title":"Example","url":"https://example.com/"}'
curl 'http://127.0.0.1:8080/bookmarks'
Безбедност, набљудливост и распоредување
Третирајте ги снимките од екранот како недоверлив бинарен влез, иако треба да бидат PNG-датотеки. Имплементацијата ги проверува HTTP-типот на содржината, PNG-потписот и големината; доделува сопствено име на датотека; ги чува датотеките надвор од public; и враќа nosniff плус рестриктивна политика за безбедност на содржината. Додајте проверки за автентикација и сопственост на обележувачите пред да ги изложите овие рути на повеќе корисници.
Никогаш не евидентирајте токени, заглавија за авторизација, целосни тела од надворешниот извор или URL-адреси што може да содржат тајни. Примерот во дневникот запишува идентификатор на обележувач, категорија на неуспех и можност за повторен обид. Во продукција, броете ги исходите по состојба, следете ја латентноста и одговорите 429 и поставете аларм за трајни неуспеси при автентикација бидејќи тие најчесто укажуваат на истечен или ротиран токен.
Не користете го развојниот сервер на PHP во продукција. Извршувајте ја апликацијата зад PHP-FPM и одржуван веб-сервер со public/ како корен на документите. Дајте му на PHP worker-от пристап за запишување само до SQLite базата на податоци и директориумот за прегледи. Направете резервна копија на метаподатоците за обележувачите според нивната вредност, применете правила за задржување на старите PNG-датотеки и осигурете се дека секоја инстанца го добива токенот преку својот управувач со тајни при распоредување.
Повторните обиди го множат сообраќајот, затоа држете ги ограничени. Овој клиент прави најмногу три обиди, ги почитува кратките одложувања Retry-After и при исцрпување прикажува трајна состојба quota_limited. За асинхрони распоредувања, закажете подоцнежни повторни обиди наместо да држите worker во спиење за долги одложувања побарани од серверот.
Вообичаени неуспеси и конечна проверка
- 401 или 403: проверете го токенот ограничен на услугата, активацијата на планот и дали некој повторно го генерирал токенот.
- 429: проверете ги снимените метаподатоци за квотата, намалете ги непотребните повторни снимања и одложете ја работата наместо да создадете бура од повторни обиди.
- Неочекувана содржина: задржете ја состојбата
invalid_image, но никогаш не го зачувувајте или сервирајте телото како PNG. - Истекувања на време или одговори 5xx: дозволете ограничени повторни обиди, потоа зачувајте
transport_errorилиupstream_errorза подоцнежно опоравување. - Преглед што недостига по успех: проверете ги сопственоста на директориумот, слободниот простор на дискот, дозволите за атомско преименување и усогласеноста меѓу базата на податоци и датотеката.
- Потврдете дека
.envи генерираните датотеки се исклучени од контрола на верзии. - Извршете PHPUnit и потврдете дека неуспесите при автентикација прават точно едно барање.
- Создадете обележувач и потврдете дека неговата состојба станува
ready. - Отворете го неговиот
preview_urlи потврдете PNG-одговор со безбедносни заглавија. - Тестирајте невалидна URL-адреса, литерална приватна IP-адреса, лажен токен и симулиран 429.
- Потврдете дека дневниците содржат корисни категории, но немаат токен, заглавие за авторизација или тело на одговор.
- Ротирајте го токенот еднаш во непродукциска средина и потврдете дека старата вредност престанува да работи пред да ја ажурирате тајната за распоредување.
Најважниот резултат не е само снимка од екранот на картичка со обележувач. Тоа е тесна, тестирабилна граница околу работен товар сличен на прелистувач, кој вашата PHP-апликација не треба да го поседува. Со валидација пред снимањето, одбранбено мапирање на одговорите потоа и експлицитни состојби на неуспех насекаде, визуелните прегледи стануваат обична можност на апликацијата наместо скриен проект за операции со прелистувач.