Native PHP 8.3: Безбедно прикажувајте прегледи на веб-страници во вашата апликација за обележувачи
A bookmark is more useful when you can recognize it at a glance. Titles help, but a visual preview often reveals the page faster than a hostname ever could. The difficult part is generating that preview safely without running browsers, managing Chromium processes, or letting arbitrary URLs turn your server into an internal-network scanner.
This tutorial builds a small Native PHP 8.3 bookmarks application that queues screenshot work, calls a hosted Screenshot API, validates the returned PNG, and serves it through an application-controlled route. The design is deliberately compact, but it includes the boundaries a production integration needs: URL policy, timeouts, bounded retries, quota handling, atomic storage, deterministic tests, structured logs, and deployment controls.
Get access and copy the service token
Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Screenshot API service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official Screenshot API documentation.
- Find the Service token panel and copy the service-scoped token shown there.
- Store it in project environment configuration, never in PHP source or version control.
Regenerating the service token revokes the previously active token. Coordinate rotation with deployment so workers receive the replacement before relying on it. This service requires authentication; it is not a token-free endpoint. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps credentials out of URLs and access logs.
Verify the exact endpoint
The API call is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. The required url query parameter identifies the page to capture. A successful response contains an image/png body plus cache and quota response headers.
export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
curl --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.txt \
--output preview.png \
--fail-with-body
Confirm that preview.png opens as a PNG and inspect response-headers.txt. Do not assume undocumented header names or values. The client below records headers case-insensitively and extracts cache, quota, rate-limit, expiry, and retry metadata defensively.
Architecture: queue first, capture outside the request
A screenshot can take longer than an ordinary page request. Keeping that work out of the bookmark-creation request prevents slow upstream responses from consuming PHP web workers.
- The web controller validates the URL and inserts a queued bookmark into SQLite.
- A short-lived CLI worker claims queued records using a transaction.
- A dedicated API client requests and validates the PNG.
- The worker writes the image atomically outside the public directory.
- An internal route serves only previews attached to known bookmarks.
SQLite is appropriate for a single small deployment. Multiple application hosts should use a shared database and equivalent row-locking semantics. Likewise, DNS filtering is useful defense in depth, but an explicit hostname allowlist is stronger when users may bookmark only known domains.
Project files and environment
bookmarks/
├── bin/capture-previews
├── public/index.php
├── src/BookmarkRepository.php
├── src/Screenshot.php
├── src/UrlPolicy.php
├── tests/ScreenshotClientTest.php
├── var/previews/
├── bootstrap.php
├── composer.json
└── .env.local
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-pdo": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"classmap": ["src/"]
}
}
Create .env.local, exclude it from version control, and use this shape:
SCREENSHOT_API_TOKEN="YOUR_SERVICE_TOKEN"
APP_DB="var/bookmarks.sqlite"
PREVIEW_DIR="var/previews"
The bootstrap prefers real process environment variables, allowing a deployment secret manager to override the local file.
<?php
declare(strict_types=1);
$local = is_file(__DIR__ . '/.env.local')
? parse_ini_file(__DIR__ . '/.env.local', false, INI_SCANNER_RAW)
: [];
$env = static function (string $name, ?string $default = null) use ($local): ?string {
$value = getenv($name);
return $value !== false ? $value : ($local[$name] ?? $default);
};
return [
'token' => $env('SCREENSHOT_API_TOKEN'),
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'db' => __DIR__ . '/' . $env('APP_DB', 'var/bookmarks.sqlite'),
'preview_dir' => __DIR__ . '/' . $env('PREVIEW_DIR', 'var/previews'),
];
Build a strict API boundary
The transport caps connection time, total response time, and response size. Redirects are disabled for the API endpoint. The domain client distinguishes authentication, validation, quota, temporary, and malformed-response failures instead of reducing everything to an exception.
<?php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers
) {}
}
interface Transport
{
public function get(string $url, array $headers): HttpResponse;
}
final class CurlTransport implements Transport
{
public function get(string $url, array $requestHeaders): HttpResponse
{
$curl = curl_init($url);
if ($curl === false) {
throw new \RuntimeException('Unable to initialize cURL');
}
$body = '';
$headers = [];
$tooLarge = false;
$limit = 6_000_000;
curl_setopt_array($curl, [
CURLOPT_HTTPHEADER => $requestHeaders,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 2_000,
CURLOPT_TIMEOUT_MS => 12_000,
CURLOPT_NOSIGNAL => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_USERAGENT => 'BookmarksPreview/1.0',
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk)
use (&$body, &$tooLarge, $limit): int {
if (strlen($body) + strlen($chunk) > $limit) {
$tooLarge = true;
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
CURLOPT_HEADERFUNCTION => static function ($handle, string $line)
use (&$headers): int {
$trimmed = trim($line);
if (str_starts_with($trimmed, 'HTTP/')) {
$headers = [];
} elseif (str_contains($trimmed, ':')) {
[$name, $value] = explode(':', $trimmed, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($tooLarge) {
throw new \RuntimeException('Screenshot response exceeded size limit');
}
if ($ok === false) {
throw new \RuntimeException('Screenshot transport failed: ' . $error);
}
return new HttpResponse($status, $body, $headers);
}
}
final readonly class CaptureResult
{
public function __construct(
public string $state,
public ?string $png,
public array $metadata,
public string $message
) {}
}
final class ScreenshotClient
{
private \Closure $sleep;
public function __construct(
private readonly string $token,
private readonly Transport $transport,
private readonly string $endpoint =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
?\Closure $sleep = null
) {
$this->sleep = $sleep ?? static fn(int $milliseconds) =>
usleep($milliseconds * 1000);
}
public function capture(string $target): CaptureResult
{
$url = $this->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',
]);
} catch (\RuntimeException $exception) {
if ($attempt < 3) {
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
return new CaptureResult(
'temporary', null, [], $exception->getMessage()
);
}
$metadata = $this->operationalHeaders($response->headers);
if ($response->status === 200) {
$type = strtolower(trim(explode(
';',
$response->headers['content-type'] ?? ''
)[0]));
if ($type !== 'image/png'
|| !str_starts_with($response->body, "\x89PNG\r\n\x1a\n")) {
return new CaptureResult(
'invalid_response',
null,
$metadata,
'Upstream response was not a valid PNG'
);
}
return new CaptureResult(
'ready', $response->body, $metadata, 'Captured'
);
}
if (in_array($response->status, [401, 403], true)) {
return new CaptureResult(
'unauthorized', null, $metadata, 'Check the service token'
);
}
if (in_array($response->status, [400, 422], true)) {
return new CaptureResult(
'rejected', null, $metadata, 'Target URL was rejected'
);
}
if ($response->status === 429) {
$retry = trim($response->headers['retry-after'] ?? '');
if ($attempt < 3 && ctype_digit($retry)
&& (int) $retry > 0 && (int) $retry <= 5) {
($this->sleep)((int) $retry * 1000);
continue;
}
return new CaptureResult(
'quota', null, $metadata, 'Quota or rate limit reached'
);
}
if ($response->status >= 500 && $response->status <= 599) {
if ($attempt < 3) {
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
return new CaptureResult(
'temporary', null, $metadata, 'Upstream service unavailable'
);
}
return new CaptureResult(
'rejected',
null,
$metadata,
'Unexpected HTTP status ' . $response->status
);
}
throw new \LogicException('Retry loop exhausted unexpectedly');
}
private function operationalHeaders(array $headers): array
{
return array_filter(
$headers,
static fn(string $name): bool =>
preg_match('/cache|quota|rate.?limit/i', $name) === 1
|| in_array($name, [
'age', 'etag', 'expires', 'last-modified', 'retry-after'
], true),
ARRAY_FILTER_USE_KEY
);
}
}
Only connection failures and server-side failures receive exponential backoff. Authentication and request-validation failures are never retried. A 429 is retried only when the server supplies a short numeric Retry-After; otherwise it becomes an explicit quota state.
Validate targets before queueing them
Never accept every syntactically valid URL. Reject credentials, local names, and any hostname resolving to private or reserved addresses. Run the same policy again in the worker because queued data must not be trusted indefinitely.
<?php
declare(strict_types=1);
namespace App;
final class UrlPolicy
{
public function assertPublicHttpUrl(string $url): void
{
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new \InvalidArgumentException('Invalid URL');
}
$parts = parse_url($url);
$scheme = strtolower($parts['scheme'] ?? '');
$host = strtolower($parts['host'] ?? '');
if (!in_array($scheme, ['http', 'https'], true)
|| $host === ''
|| isset($parts['user'])
|| isset($parts['pass'])
|| $host === 'localhost'
|| str_ends_with($host, '.local')) {
throw new \InvalidArgumentException('URL is not permitted');
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
$this->assertPublicIp($host);
return;
}
$records = dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false || $records === []) {
throw new \InvalidArgumentException('Hostname did not resolve');
}
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip !== null) {
$this->assertPublicIp($ip);
}
}
}
private function assertPublicIp(string $ip): void
{
$valid = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($valid === false) {
throw new \InvalidArgumentException(
'Private or reserved targets are forbidden'
);
}
}
}
DNS can change between validation and capture, and redirects can lead elsewhere. Do not represent this check as a complete SSRF solution. For sensitive deployments, restrict bookmarks to an approved host list and confirm the service's current URL and redirect policies in its official documentation.
Persist jobs and expose safe routes
The repository should create a bookmarks table containing id, url, title, status, attempts, next_attempt_at, preview_file, last_error, created_at, and updated_at. Valid states are queued, processing, ready, and failed.
The controller's bookmark action should validate CSRF, apply UrlPolicy, and insert a queued row. Escape URLs and titles with htmlspecialchars when rendering. Preview markup should reference an internal route such as /previews/42.png, never a user-provided filesystem path.
That route must load bookmark 42, require the ready state, derive the path from the database-controlled basename, and return it with Content-Type: image/png, X-Content-Type-Options: nosniff, and a deliberate Cache-Control policy. Keep var/previews outside the web root.
Capture worker
<?php
declare(strict_types=1);
use App\BookmarkRepository;
use App\CurlTransport;
use App\ScreenshotClient;
use App\UrlPolicy;
require dirname(__DIR__) . '/vendor/autoload.php';
$config = require dirname(__DIR__) . '/bootstrap.php';
if (!is_string($config['token']) || $config['token'] === '') {
throw new RuntimeException('SCREENSHOT_API_TOKEN is missing');
}
if (!is_dir($config['preview_dir'])
&& !mkdir($config['preview_dir'], 0750, true)
&& !is_dir($config['preview_dir'])) {
throw new RuntimeException('Cannot create preview directory');
}
$pdo = new PDO('sqlite:' . $config['db'], options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$repository = new BookmarkRepository($pdo);
$policy = new UrlPolicy();
$client = new ScreenshotClient(
$config['token'],
new CurlTransport(),
$config['endpoint']
);
while ($bookmark = $repository->claimNext()) {
$result = null;
try {
$policy->assertPublicHttpUrl($bookmark['url']);
$result = $client->capture($bookmark['url']);
if ($result->state === 'ready') {
$name = $bookmark['id'] . '-'
. substr(hash('sha256', $bookmark['url']), 0, 16) . '.png';
$final = $config['preview_dir'] . '/' . $name;
$temporary = tempnam($config['preview_dir'], 'capture-');
if ($temporary === false
|| file_put_contents($temporary, $result->png, LOCK_EX) === false
|| !rename($temporary, $final)) {
throw new RuntimeException('Atomic preview write failed');
}
chmod($final, 0640);
$repository->markReady((int) $bookmark['id'], $name);
continue;
}
if ($result->state === 'temporary'
&& (int) $bookmark['attempts'] < 3) {
$repository->releaseForRetry(
(int) $bookmark['id'],
60 * (2 ** ((int) $bookmark['attempts'] - 1)),
$result->message
);
} else {
$repository->markFailed(
(int) $bookmark['id'],
$result->state . ': ' . $result->message
);
}
} catch (Throwable $exception) {
$repository->markFailed(
(int) $bookmark['id'],
substr($exception->getMessage(), 0, 500)
);
}
error_log(json_encode([
'event' => 'bookmark_preview_failed',
'bookmark_id' => (int) $bookmark['id'],
'target_host' => parse_url($bookmark['url'], PHP_URL_HOST),
'target_hash' => hash('sha256', $bookmark['url']),
'state' => $result?->state ?? 'exception',
'response_metadata' => $result?->metadata ?? [],
], JSON_THROW_ON_ERROR));
}
claimNext() should use BEGIN IMMEDIATE, select one eligible queued row, update it to processing, increment attempts, and commit. That serializes claims between SQLite workers. It should also recover records left in processing beyond a conservative timeout after a crashed process.
Logs deliberately contain a hostname and hash instead of the complete URL. Query strings often carry reset links, document identifiers, or other secrets. Never log the Authorization header, service token, image bytes, or full target URL.
Test without calling the service
A transport interface makes the integration deterministic. Tests can exercise response mapping and retry rules without network access or quota consumption.
<?php
declare(strict_types=1);
use App\CaptureResult;
use App\HttpResponse;
use App\ScreenshotClient;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $results) {}
public function get(string $url, array $headers): HttpResponse
{
$this->calls++;
$next = array_shift($this->results);
if ($next instanceof Throwable) {
throw $next;
}
return $next;
}
}
final class ScreenshotClientTest extends TestCase
{
public function testMapsValidPngAndCacheMetadata(): void
{
$fake = new FakeTransport([
new HttpResponse(200, "\x89PNG\r\n\x1a\npayload", [
'content-type' => 'image/png',
'x-cache' => 'example-cache-value',
]),
]);
$result = $this->client($fake)->capture('https://example.com/');
self::assertSame('ready', $result->state);
self::assertSame('example-cache-value', $result->metadata['x-cache']);
self::assertSame(1, $fake->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([
new HttpResponse(401, '', ['content-type' => 'application/json']),
]);
self::assertSame(
'unauthorized',
$this->client($fake)->capture('https://example.com/')->state
);
self::assertSame(1, $fake->calls);
}
public function testRetriesServerFailureThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResponse(503, '', []),
new HttpResponse(200, "\x89PNG\r\n\x1a\nok", [
'content-type' => 'image/png',
]),
]);
self::assertSame(
'ready',
$this->client($fake)->capture('https://example.com/')->state
);
self::assertSame(2, $fake->calls);
}
public function testRejectsAFalsePng(): void
{
$fake = new FakeTransport([
new HttpResponse(200, '<html>error</html>', [
'content-type' => 'image/png',
]),
]);
self::assertSame(
'invalid_response',
$this->client($fake)->capture('https://example.com/')->state
);
}
private function client(FakeTransport $fake): ScreenshotClient
{
return new ScreenshotClient(
'test-token',
$fake,
sleep: static function (int $milliseconds): void {}
);
}
}
composer install
composer dump-autoload
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public
php bin/capture-previews
Production hardening and common failures
Point the web server document root at public, give the PHP user write access only to the database and preview directory, and inject SCREENSHOT_API_TOKEN through the platform's secret facility. Run the worker from a scheduler or supervised process, ensuring overlapping invocations cannot claim the same row.
- 401 or 403: verify the service-scoped token, plan activation, and whether token regeneration revoked the deployed value.
- 400 or 422: inspect URL construction and local policy; do not retry unchanged input.
- 429: retain returned quota and rate-limit metadata, honor a reasonable
Retry-After, and avoid tight retry loops. - HTML instead of PNG: treat it as an invalid upstream response. Never store it with a PNG extension merely because the status was 200.
- Stuck previews: monitor queue age, processing age, failure counts, capture latency, and failure state. Recover abandoned claims after worker crashes.
Protect bookmark creation with application authentication, authorization, CSRF validation, request-size limits, and per-user submission limits. Remember that the target URL is sent to an external screenshot service; do not submit private dashboards, signed URLs, or confidential pages unless that data flow is explicitly acceptable.
Final verification checklist
- The exact capture endpoint is called with
GETand an encodedurlparameter. - The token comes from environment-backed configuration and never appears in logs or source.
- Public HTTP and HTTPS targets are validated both before queueing and before capture.
- Connection, response-time, response-size, and retry bounds are enforced.
- Authentication and validation failures are not retried.
- The response status, content type, PNG signature, cache metadata, and quota metadata are handled.
- Files are written atomically outside the public directory and served through a controlled route.
- Automated tests pass without making real API requests.
- Worker failures and queue age are observable in production.
The screenshot is the visible feature, but the boundaries around it make the integration dependable. Once URLs are treated as hostile input, upstream responses as untrusted data, retries as a limited budget, and tokens as deploy-time secrets, visual bookmarks stop being a browser-infrastructure problem. They become a small, understandable PHP service boundary that can be operated with confidence.