Izvorni PHP 8.3: sigurno generirajte vizualne pretpreglede poveznica za oznake pomoću Screenshot API-ja
Oznaka postaje mnogo korisnija kada je prepoznatljiva na prvi pogled. Naslovi pomažu, ali vizualni pregled često razlikuje stranicu proizvoda, dizajnersku referencu ili istraživački članak brže nego još jedan red teksta. Neugodan dio je pouzdano generiranje tih pregleda: pokretanje Chromiuma uvodi ažuriranja preglednika, izolaciju, skokove potrošnje memorije, vremenska ograničenja i privlačnu metu za zlonamjerne URL-ove.
Ovaj vodič izrađuje mali Native PHP 8.3 API za oznake koji delegira renderiranje Screenshot API-ju, validira vraćeni PNG, pohranjuje ga izvan javnog direktorija i izlaže putem kontrolirane rute. Integracija uključuje ograničena vremenska ograničenja, selektivne ponovne pokušaje, upravljanje kvotama, strukturirane neuspjehe, determinističke testove i zaštitne mjere pri implementaciji.
Dobijte pristup Screenshot API-ju
Najprije registrirajte račun ili se prijavite ako ga već imate. Otvorite stranicu usluge Screenshot API, odaberite dostupan Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
Zatim otvorite službenu dokumentaciju. Pronađite ploču Service token i kopirajte token ograničen na uslugu. Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga rotacija mora ažurirati svaku implementiranu instancu koja ga koristi.
Ova usluga nije bez tokena. Svako snimanje mora se autentificirati Bearer tokenom, zaglavljem X-API-Token ili parametrom upita token. Koristit ćemo Bearer zaglavlje jer vjerodajnice u upitu mogu procuriti u URL-ove, zapisnike pristupa, analitiku i povijest preglednika.
Potvrdite API ugovor
Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita url određuje stranicu za snimanje. Uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.
Nakon privremenog izvoza kopiranog tokena, napravite jedan minimalni zahtjev:
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
Pregledajte response.headers kao i datoteku. Aplikacija neće pretpostavljati nedokumentirane nazive zaglavlja predmemorije ili kvote; bilježi zaglavlja odgovora i obrambeno mapira relevantne skupine.
Pohranite vjerodajnicu u .env datoteku na razini projekta, nikada u PHP izvorni kod:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_PREVIEW_DIR=var/previews
Dodajte .env, var/bookmarks.sqlite i var/previews/ u .gitignore. U kontroli verzija zadržite samo .env.example bez tokena.
Odaberite namjerno malu arhitekturu
Aplikacija ima tri granice: SQLite pohranjuje metapodatke oznaka, ScreenshotClient prevodi udaljeni HTTP ugovor u domenske rezultate, a prednji kontroler obrađuje JSON i slikovne rute. PNG datoteke nalaze se izvan web korijena i mogu se čitati samo putem pretraživanja oznake.
Snimanje je sinkrono kako bi ovaj vodič bio pokretljiv bez radnika. To je razumno za osobnu aplikaciju ili aplikaciju malog tima s ograničenim prometom. Ako izrada oznake mora odmah vratiti odgovor, zadržite isti klijent i model stanja, ali pokrenite snimanje iz nadziranog pozadinskog radnika.
Izradite ovu strukturu:
bookmarks/
├── composer.json
├── .env
├── public/index.php
├── src/
│ ├── Http.php
│ └── ScreenshotClient.php
├── tests/ScreenshotClientTest.php
└── var/previews/
Koristite Composer za automatsko učitavanje, dotenv konfiguraciju i 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
Izolirajte HTTP i normalizirajte odgovore
Usko transportno sučelje čini cURL zamjenjivim u testovima. Produkcijski transport odbija preusmjeravanja na API granici, primjenjuje zasebna vremenska ograničenja povezivanja i ukupna vremenska ograničenja te zadržava ponovljena zaglavlja.
<?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);
}
}
Mapirajte PNG-ove, neuspjehe, podatke predmemorije i kvote
Klasa usluge validira URL-ove prije trošenja kvote, ponovno pokušava samo prolazne neuspjehe transporta, HTTP 429 i odgovore 5xx te odbacuje neočekivana tijela. Neuspjesi autentifikacije i validacije nikada se ne pokušavaju ponovno naslijepo.
<?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];
}
}
Ograničenje od osam megabajta sigurnosno je ograničenje aplikacije, a ne tvrdnja o usluzi. Namjerno ga prilagodite ako vaš plan ili slučaj uporabe zahtijeva veće snimke zaslona. Za implementacije većeg rizika primijenite izričit popis dopuštenih naziva hostova ili pouzdanu DNS/IP politiku prije slanja; jednostavne tekstualne provjere URL-a ne mogu ukloniti svaki trik razrješavanja naziva hosta.
Povežite rute oznaka
Prednji kontroler podržava popisivanje i stvaranje oznaka te čitanje pregleda. Neuspjesi postaju trajna domenska stanja, tako da se privremeni prekid rada ne predstavlja kao slika koja nedostaje.
<?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);
Testirajte bez kontaktiranja usluge
Deterministička lažna implementacija dokazuje mapiranje odgovora i politiku ponovnih pokušaja bez trošenja kvote ili ovisnosti o mreži.
<?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'
Sigurnost, vidljivost i implementacija
Tretirajte snimke zaslona kao nepouzdan binarni ulaz iako bi trebale biti PNG-ovi. Implementacija provjerava HTTP vrstu sadržaja, PNG potpis i veličinu; dodjeljuje vlastiti naziv datoteke; pohranjuje datoteke izvan public; te vraća nosniff uz restriktivnu politiku sigurnosti sadržaja. Dodajte autentifikaciju i provjere vlasništva nad oznakama prije izlaganja ovih ruta većem broju korisnika.
Nikada nemojte bilježiti tokene, autorizacijska zaglavlja, potpuna uzvodna tijela ili URL-ove koji mogu sadržavati tajne. Primjer zapisnika bilježi identifikator oznake, kategoriju neuspjeha i mogućnost ponovnog pokušaja. U produkciji brojite ishode prema stanju, pratite latenciju i odgovore 429 te postavite upozorenje za trajne neuspjehe autentifikacije jer oni često upućuju na istekao ili rotiran token.
Nemojte koristiti PHP-ov razvojni poslužitelj u produkciji. Pokrenite aplikaciju iza PHP-FPM-a i održavanog web poslužitelja s public/ kao korijenom dokumenta. PHP radniku dodijelite pristup za pisanje samo SQLite bazi podataka i direktoriju pregleda. Izradite sigurnosne kopije metapodataka oznaka prema njihovoj vrijednosti, primijenite pravila zadržavanja na stare PNG-ove i osigurajte da svaka instanca prima token putem upravitelja tajni za implementaciju.
Ponovni pokušaji umnožavaju promet, stoga ih ograničite. Ovaj klijent radi najviše tri pokušaja, poštuje kratka kašnjenja Retry-After i nakon iscrpljivanja prikazuje trajno stanje quota_limited. Za asinkrone implementacije zakažite kasnije ponovne pokušaje umjesto uspavljivanja radnika tijekom dugih kašnjenja koja je zatražio poslužitelj.
Česti neuspjesi i završna provjera
- 401 ili 403: provjerite token ograničen na uslugu, aktivaciju plana i je li netko ponovno generirao token.
- 429: pregledajte zabilježene metapodatke kvote, smanjite nepotrebna ponovna snimanja i odgodite rad umjesto stvaranja oluje ponovnih pokušaja.
- Neočekivani sadržaj: zadržite stanje
invalid_image, ali nikada nemojte spremiti ili poslužiti tijelo kao PNG. - Vremenska ograničenja ili odgovori 5xx: dopustite ograničene ponovne pokušaje, zatim sačuvajte
transport_erroriliupstream_errorza kasniji oporavak. - Pregled nedostaje nakon uspjeha: provjerite vlasništvo nad direktorijem, slobodan prostor na disku, dozvole za atomsko preimenovanje i dosljednost baze podataka s datotekama.
- Potvrdite da su
.envi generirane datoteke isključeni iz kontrole verzija. - Pokrenite PHPUnit i provjerite da neuspjesi autentifikacije stvaraju točno jedan zahtjev.
- Izradite oznaku i potvrdite da njezino stanje postaje
ready. - Otvorite njezin
preview_urli provjerite PNG odgovor sa sigurnosnim zaglavljima. - Testirajte neispravan URL, literal privatne IP adrese, lažni token i simulirani 429.
- Provjerite sadrže li zapisnici korisne kategorije, ali ne token, autorizacijsko zaglavlje ili tijelo odgovora.
- Jednom rotirajte token u neprodukcijskom okruženju i potvrdite da stara vrijednost prestaje raditi prije ažuriranja tajne za implementaciju.
Najvažniji rezultat nije samo snimka zaslona na kartici oznake. To je uska granica koju je moguće testirati oko radnog opterećenja sličnog pregledniku, a koje vaša PHP aplikacija ne bi trebala morati posjedovati. Uz validaciju prije snimanja, obrambeno mapiranje odgovora nakon njega i izričita stanja neuspjeha posvuda, vizualni pregledi postaju obična značajka aplikacije umjesto skrivenog projekta upravljanja radom preglednika.