Nativni PHP 8.3: Ugradite sigurne preglede poveznica uz integraciju Screenshot API-ja
Oznaka je korisnija kada je možete prepoznati na prvi pogled. Pregled snimke zaslona pruža taj vizualni znak, ali upravljanje farmom preglednika samo radi renderiranja minijatura uvodi zakrpavanje, izolaciju, pritisak na memoriju i načine kvara koji imaju malo veze s vašom aplikacijom.
Ovaj vodič izrađuje malu aplikaciju za oznake u izvornom PHP-u 8.3 koja renderiranje delegira API-ju za snimke zaslona, provjerava vraćeni PNG, pohranjuje ga privatno i poslužuje putem kontrolirane rute. Integracija uključuje ograničena vremenska ograničenja, selektivne ponovne pokušaje, svijest o kvotama, determinističke testove i strukturirana stanja neuspjeha.
Dobijte pristup i kopirajte servisni token
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupni Free, Plus ili Pro paket i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju za Screenshot API.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga rotaciju tretirajte kao operaciju implementacije: ažurirajte svaku pokrenutu instancu prije nego što se ponovno oslonite na staru vjerodajnicu. Ova usluga zahtijeva autentifikaciju; nije integracija bez tokena.
API prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Prednost dajte zaglavlju jer je vjerojatnije da će se parametri upita pojaviti u zapisnicima pristupa, povijesti preglednika i dijagnostičkim alatima.
Potvrdite točan HTTP ugovor
Zahtjev za snimanje je:
GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Njegov obavezni parametar upita je url. Uspješan odgovor sadrži tijelo image/png zajedno sa zaglavljima odgovora povezanima s predmemorijom i kvotom. Započnite s minimalnim zahtjevom prije pisanja koda aplikacije:
curl --fail-with-body --silent --show-error \
--get \
--data-urlencode 'url=https://example.com' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--dump-header /tmp/screenshot.headers \
--output /tmp/preview.png \
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
file /tmp/preview.png
sed -n '1,30p' /tmp/screenshot.headers
Rezultat bi trebao biti prepoznat kao PNG podaci. Pregledajte vraćena zaglavlja umjesto nagađanja o vlasničkim nazivima zaglavlja za predmemoriju ili kvotu; aplikacija u nastavku zadržava prepoznata standardna zaglavlja predmemorije i sva zaglavlja čiji nazivi ukazuju na informacije o kvoti, stopi ili ograničenju.
Stvorite lokalnu datoteku okruženja projekta i isključite je iz kontrole verzija:
# .env
SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=var/bookmarks.sqlite
PREVIEW_DIR=var/previews
# .gitignore
.env
/var/
/vendor/
Za lokalno izvršavanje učitajte tu datoteku u okruženje procesa pomoću set -a; . ./.env; set +a. U produkciji iste varijable unesite putem upravitelja procesa, platforme spremnika ili spremišta tajni umjesto da ih ugrađujete u sliku.
Osmislite granicu prije korisničkog sučelja
Aplikacija izvodi jedno sinkrono snimanje kada se stvori oznaka. To ovaj mali projekt čini razumljivim, dok njegova eksplicitna stanja pending, ready i failed kasniju migraciju na red čekanja čine jednostavnom.
Granica API-ja ima četiri odgovornosti:
- Izgraditi samo dokumentirani zahtjev koji sadrži URL i autentificirati se Bearer zaglavljem.
- Primijeniti ograničenja povezivanja, ukupnog odgovora i veličine odgovora.
- Ponoviti pokušaj kod pogrešaka prijenosa, HTTP 429 i pogrešaka poslužitelja uz ograničeno odgađanje, ali nikada naslijepo ne ponavljati pogreške autentifikacije ili validacije.
- Prihvatiti odgovor samo kada su valjani i njegova vrsta medija i PNG potpis.
PNG datoteke nalaze se izvan javnog korijena dokumenta. PHP ruta ih autorizira i poslužuje s fiksnom vrstom sadržaja, čime se sprječava da učitani ili neočekivani bajtovi postanu izvršni javni sadržaj.
Struktura projekta i ovisnosti
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-pdo": "*",
"ext-sqlite3": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Bookmarks\\": "src/"
}
},
"scripts": {
"test": "phpunit"
}
}
composer.json
.env
public/index.php
src/Screenshot.php
src/BookmarkStore.php
tests/ScreenshotClientTest.php
var/previews/
Pokrenite composer install, a zatim stvorite src/Screenshot.php.
Implementirajte obrambeni Screenshot klijent
<?php
namespace Bookmarks;
use Closure;
use RuntimeException;
final readonly class RawResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body
) {}
}
interface Transport
{
public function get(
string $url,
array $headers,
int $connectTimeout,
int $timeout,
int $maxBytes
): RawResponse;
}
final class CurlTransport implements Transport
{
public function get(
string $url,
array $headers,
int $connectTimeout,
int $timeout,
int $maxBytes
): RawResponse {
$handle = curl_init($url);
$responseHeaders = [];
$body = '';
$tooLarge = false;
curl_setopt_array($handle, [
CURLOPT_HTTPGET => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
if (str_starts_with($line, 'HTTP/')) {
$responseHeaders = [];
} elseif (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk)
use (&$body, &$tooLarge, $maxBytes): int {
if (strlen($body) + strlen($chunk) > $maxBytes) {
$tooLarge = true;
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
]);
$ok = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($ok === false) {
throw new RuntimeException(
$tooLarge ? 'Screenshot response exceeded the size limit' : $error
);
}
return new RawResponse($status, $responseHeaders, $body);
}
}
final class ScreenshotFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
public readonly array $metadata = []
) {
parent::__construct("Screenshot capture failed: {$kind}");
}
}
final readonly class CaptureResult
{
public function __construct(
public string $png,
public array $metadata
) {}
}
final class ScreenshotClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
private readonly Closure $sleep;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
?Closure $sleep = null
) {
if ($token === '') {
throw new RuntimeException('SCREENSHOT_TOKEN is missing');
}
$this->sleep = $sleep ?? static fn (int $milliseconds) =>
usleep($milliseconds * 1000);
}
public function capture(string $target): CaptureResult
{
$url = self::ENDPOINT . '?' . http_build_query(
['url' => $target],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(
$url,
[
'Authorization: Bearer ' . $this->token,
'Accept: image/png',
],
connectTimeout: 3,
timeout: 25,
maxBytes: 8 * 1024 * 1024
);
} catch (RuntimeException $exception) {
if ($attempt === 3) {
throw new ScreenshotFailure('transport');
}
($this->sleep)(200 * (2 ** ($attempt - 1)));
continue;
}
$metadata = $this->operationalHeaders($response->headers);
if ($response->status === 200) {
$type = strtolower($response->headers['content-type'] ?? '');
$signature = substr($response->body, 0, 8);
if (
!str_starts_with($type, 'image/png')
or $signature !== "\x89PNG\r\n\x1a\n"
) {
throw new ScreenshotFailure(
'invalid_response',
200,
$metadata
);
}
return new CaptureResult($response->body, $metadata);
}
$retryable = $response->status === 429
or $response->status >= 500;
if (!$retryable) {
$kind = in_array($response->status, [401, 403], true)
? 'authentication'
: 'request';
throw new ScreenshotFailure(
$kind,
$response->status,
$metadata
);
}
if ($attempt < 3) {
$retryAfter = $response->headers['retry-after'] ?? '';
$delay = ctype_digit($retryAfter)
? min(2000, (int) $retryAfter * 1000)
: 200 * (2 ** ($attempt - 1));
($this->sleep)($delay);
continue;
}
throw new ScreenshotFailure(
$response->status === 429 ? 'quota' : 'upstream',
$response->status,
$metadata
);
}
throw new ScreenshotFailure('unexpected');
}
private function operationalHeaders(array $headers): array
{
$selected = [];
foreach ($headers as $name => $value) {
$cacheHeader = in_array(
$name,
['cache-control', 'age', 'etag', 'expires', 'x-cache'],
true
);
$quotaHeader = str_contains($name, 'quota')
or str_contains($name, 'rate')
or str_contains($name, 'limit');
if ($cacheHeader or $quotaHeader) {
$selected[$name] = $value;
}
}
return $selected;
}
}
Maksimalna veličina tijela sprječava da neočekivani odgovor potroši neograničenu memoriju. Klijent zadržava operativna zaglavlja bez oslanjanja poslovne logike na nedokumentirane nazive. Autentifikacija i uobičajeni neuspjesi zahtjeva odmah se zaustavljaju; njihovo ponovno pokušavanje trošilo bi latenciju bez promjene rezultata.
Izričito pohranite stanje oznake
Stvorite src/BookmarkStore.php. SQLite je dovoljan za implementaciju samostalnog stručnjaka ili malog tima, dok granica repozitorija izolira buduću promjenu baze podataka.
<?php
namespace Bookmarks;
use PDO;
final class BookmarkStore
{
private PDO $pdo;
public function __construct(string $path)
{
$directory = dirname($path);
is_dir($directory) or mkdir($directory, 0770, true);
$this->pdo = new PDO('sqlite:' . $path, options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
status TEXT NOT NULL,
image_path TEXT,
api_metadata TEXT,
error_kind TEXT,
created_at TEXT NOT NULL
)'
);
}
public function create(string $url): int
{
$statement = $this->pdo->prepare(
'INSERT INTO bookmarks (url, status, created_at)
VALUES (?, ?, ?)'
);
$statement->execute([$url, 'pending', gmdate('c')]);
return (int) $this->pdo->lastInsertId();
}
public function ready(int $id, string $path, array $metadata): void
{
$statement = $this->pdo->prepare(
'UPDATE bookmarks
SET status = ?, image_path = ?, api_metadata = ?
WHERE id = ?'
);
$statement->execute([
'ready',
$path,
json_encode($metadata, JSON_THROW_ON_ERROR),
$id,
]);
}
public function failed(int $id, string $kind): void
{
$statement = $this->pdo->prepare(
'UPDATE bookmarks SET status = ?, error_kind = ? WHERE id = ?'
);
$statement->execute(['failed', $kind, $id]);
}
public function find(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT * FROM bookmarks WHERE id = ?'
);
$statement->execute([$id]);
return $statement->fetch() ?: null;
}
public function all(): array
{
return $this->pdo->query(
'SELECT * FROM bookmarks ORDER BY id DESC'
)->fetchAll();
}
}
Validirajte URL-ove i povežite rute
Stvorite public/index.php. Ovo pravilo prihvaća samo uobičajene HTTP i HTTPS URL-ove, odbija vjerodajnice, lokalne nazive, literale nejavnih IP adresa i neuobičajene portove. Smanjuje zloupotrebu, ali nije potpuna SSRF obrana: pružatelj snimaka zaslona ostaje granica mrežnog dohvaćanja i mora provoditi vlastite kontrole izlaznog prometa.
<?php
require dirname(__DIR__) . '/vendor/autoload.php';
use Bookmarks\BookmarkStore;
use Bookmarks\CurlTransport;
use Bookmarks\ScreenshotClient;
use Bookmarks\ScreenshotFailure;
header("Content-Security-Policy: default-src 'self'; img-src 'self'");
header('X-Content-Type-Options: nosniff');
$db = getenv('APP_DB') ?: dirname(__DIR__) . '/var/bookmarks.sqlite';
$previewDir = getenv('PREVIEW_DIR')
?: dirname(__DIR__) . '/var/previews';
$store = new BookmarkStore($db);
$client = new ScreenshotClient(
new CurlTransport(),
(string) getenv('SCREENSHOT_TOKEN')
);
$validateUrl = static function (string $url): string {
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new InvalidArgumentException('Enter a valid URL.');
}
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
$host = strtolower($parts['host'] ?? '');
if (
!in_array($scheme, ['http', 'https'], true)
or $host === ''
or isset($parts['user'])
or isset($parts['pass'])
or $host === 'localhost'
or str_ends_with($host, '.local')
) {
throw new InvalidArgumentException('This URL is not permitted.');
}
if (
filter_var($host, FILTER_VALIDATE_IP) !== false
and filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) === false
) {
throw new InvalidArgumentException('Private IP addresses are denied.');
}
$expectedPort = $scheme === 'https' ? 443 : 80;
if (isset($parts['port']) and $parts['port'] !== $expectedPort) {
throw new InvalidArgumentException('Non-standard ports are denied.');
}
return $url;
};
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($_SERVER['REQUEST_METHOD'] === 'POST' and $path === '/bookmarks') {
try {
$url = $validateUrl(trim((string) ($_POST['url'] ?? '')));
$id = $store->create($url);
$capture = $client->capture($url);
is_dir($previewDir) or mkdir($previewDir, 0770, true);
$finalPath = $previewDir . '/' . $id . '.png';
$temporary = $finalPath . '.' . bin2hex(random_bytes(6)) . '.tmp';
if (file_put_contents($temporary, $capture->png, LOCK_EX) === false) {
throw new RuntimeException('Unable to store preview');
}
chmod($temporary, 0640);
rename($temporary, $finalPath);
$store->ready($id, $finalPath, $capture->metadata);
error_log(json_encode([
'event' => 'screenshot.ready',
'bookmark_id' => $id,
'api_headers' => $capture->metadata,
], JSON_THROW_ON_ERROR));
} catch (ScreenshotFailure $failure) {
if (isset($id)) {
$store->failed($id, $failure->kind);
}
error_log(json_encode([
'event' => 'screenshot.failed',
'bookmark_id' => $id ?? null,
'kind' => $failure->kind,
'status' => $failure->status,
'api_headers' => $failure->metadata,
], JSON_THROW_ON_ERROR));
} catch (Throwable $failure) {
http_response_code(422);
echo '<p>The bookmark could not be created.</p>';
exit;
}
header('Location: /', true, 303);
exit;
}
if (
$_SERVER['REQUEST_METHOD'] === 'GET'
and preg_match('#^/previews/(\d+)\.png$#', $path, $matches)
) {
$bookmark = $store->find((int) $matches[1]);
if (
$bookmark === null
or $bookmark['status'] !== 'ready'
or !is_file($bookmark['image_path'])
) {
http_response_code(404);
exit;
}
header('Content-Type: image/png');
header('Cache-Control: private, max-age=3600');
readfile($bookmark['image_path']);
exit;
}
echo '<form method="post" action="/bookmarks">
<label>URL oznake
<input name="url" type="url" required></label>
<button type="submit">Spremi oznaku</button>
</form>';
foreach ($store->all() as $bookmark) {
$safeUrl = htmlspecialchars(
$bookmark['url'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
$id = (int) $bookmark['id'];
echo "<article><p><a href=\"{$safeUrl}\">{$safeUrl}</a></p>";
echo '<p>Status pregleda: '
. htmlspecialchars($bookmark['status'], ENT_QUOTES, 'UTF-8')
. '</p>';
if ($bookmark['status'] === 'ready') {
echo "<img src=\"/previews/{$id}.png\" alt=\"Pregled za {$safeUrl}\">";
}
echo '</article>';
}
Zapisnici namjerno izostavljaju servisni token i ciljni URL. Identifikatori oznaka, vrste neuspjeha, HTTP statusni kodovi i operativna zaglavlja koja nisu tajna dovoljni su za istraživanje latencije, iscrpljenja kvote, neuspjeha autentifikacije i neispravnih uzvodnih odgovora bez nepotrebnog bilježenja podataka o pregledavanju.
Testirajte ponovne pokušaje bez pozivanja mreže
Sučelje prijenosa čini testove determinističkima. Stvorite tests/ScreenshotClientTest.php:
<?php
use Bookmarks\CaptureResult;
use Bookmarks\RawResponse;
use Bookmarks\ScreenshotClient;
use Bookmarks\ScreenshotFailure;
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,
int $connectTimeout,
int $timeout,
int $maxBytes
): RawResponse {
$this->calls++;
return array_shift($this->responses);
}
}
final class ScreenshotClientTest extends TestCase
{
public function testRetriesServerFailureThenReturnsPng(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$transport = new FakeTransport([
new RawResponse(503, [], ''),
new RawResponse(200, [
'content-type' => 'image/png',
'cache-control' => 'public, max-age=60',
'x-quota-remaining' => '7',
], $png),
]);
$client = new ScreenshotClient(
$transport,
'test-token',
static fn (int $milliseconds) => null
);
$result = $client->capture('https://example.com');
self::assertInstanceOf(CaptureResult::class, $result);
self::assertSame($png, $result->png);
self::assertSame(2, $transport->calls);
self::assertSame(
'7',
$result->metadata['x-quota-remaining']
);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new RawResponse(401, [], ''),
]);
$client = new ScreenshotClient(
$transport,
'invalid-token',
static fn (int $milliseconds) => null
);
try {
$client->capture('https://example.com');
self::fail('Expected ScreenshotFailure');
} catch (ScreenshotFailure $failure) {
self::assertSame('authentication', $failure->kind);
self::assertSame(1, $transport->calls);
}
}
}
Pokrenite paket testova s composer test. Ilustrativno zaglavlje kvote pripada samo lažnom odgovoru; produkcijska obrada ne pretpostavlja taj točan naziv.
Implementirajte i upravljajte njime promišljeno
Pokrenite lokalni poslužitelj za provjeru pomoću:
set -a
. ./.env
set +a
composer install
composer test
php -S 127.0.0.1:8080 -t public public/index.php
Za implementaciju usmjerite pravi web-poslužitelj na public/, odbijte izravan pristup datotekama .env i var/, pokrenite PHP kao korisnik koji može pisati samo u bazu podataka i direktorij pregleda te završite TLS na web-poslužitelju ili platformi. Postavite ograničenja zahtjeva na obrazac za oznake i dodajte CSRF zaštitu ako se uvedu računi ili autentificirane sesije.
SQLite odgovara jednoj instanci aplikacije. Više replika treba zajedničku trajnu pohranu i bazu podataka koja podržava istodobne pisače. Pri većem prometu premjestite snimanja u pozadinski radnik uz zadržavanje istih prijelaza stanja i idempotentnog naziva datoteke; sinkrono snimanje drži PHP radnik zauzetim tijekom trajanja uzvodnog zahtjeva.
Uobičajeni neuspjesi
- HTTP 401 ili 403: potvrdite token ograničen na uslugu, aktivaciju paketa i tajnu za implementaciju. Ako je token ponovno generiran, prethodna je vrijednost opozvana.
- HTTP 429: pregledajte zadržana zaglavlja kvote ili stope, smanjite duplicirana snimanja i izbjegavajte dodavanje više ponovnih pokušaja. Predmemorirani pregledi oznaka trebaju se ponovno upotrebljavati.
- Neuspjesi prijenosa: provjerite izlazni HTTPS, DNS, CA certifikate i konfigurirana vremenska ograničenja.
- Neispravan odgovor: zadržite strukturirani status i operativna zaglavlja, ali nikada ne spremajte ni ne poslužujte tijelo koje ne prođe validaciju vrste medija ili PNG potpisa.
- Trajni redovi pending: proces se vjerojatno zaustavio između umetanja i dovršetka. Zakazano čišćenje može stare redove pending označiti neuspješnima prije ponovnog pokušaja.
Završni popis za provjeru
- Račun i Free, Plus ili Pro paket su aktivni.
- Token dolazi s ploče Service token na stranici dokumentacije i postoji samo u konfiguraciji temeljenoj na okruženju.
- Klijent poziva točnu GET krajnju točku za snimanje s obaveznim parametrom
url. - Ograničenja povezivanja, ukupnog odgovora i veličine tijela su aktivna.
- Ponovno se pokušavaju samo pogreške prijenosa, HTTP 429 i pogreške poslužitelja.
- Vrsta sadržaja i PNG potpis provjeravaju se prije pohrane.
- Zaglavlja povezana s predmemorijom i kvotom zadržavaju se za operacije.
- Datoteke pregleda ostaju izvan javnog korijena i poslužuju se s
nosniff. - Testovi prolaze pomoću determinističkog lažnog prijenosa.
- Stvarna oznaka prelazi iz pending u ready i prikazuje svoj pregled.
Pregled snimke zaslona izgleda kao mala značajka, ali njezina produkcijska kvaliteta određena je na granicama: koje URL-ove prihvaćate, koliko čekate, što ponovno pokušavate, kojim bajtovima vjerujete i što bilježite. Kada su te odluke izričito donesene, aplikacija za oznake dobiva koristan vizualni sloj bez preuzimanja operativnog tereta održavanja Chromium infrastrukture.