Туториали

Native PHP 8.3: Safely Generate Visual Link Previews for Bookmarks with Screenshot API

Native PHP 8.3: Безбедно генерирајте визуелни прегледи на линкови за обележувачи со Screenshot API

A bookmark becomes far more useful when it is recognizable at a glance. Titles help, but a visual preview often distinguishes a product page, design reference, or research article faster than another line of text. The awkward part is generating those previews reliably: running Chromium introduces browser updates, sandboxing, memory spikes, timeouts, and an appealing target for hostile URLs.

This tutorial builds a small Native PHP 8.3 bookmarks API that delegates rendering to Screenshot API, validates the returned PNG, stores it outside the public directory, and exposes it through a controlled route. The integration includes bounded timeouts, selective retries, quota handling, structured failures, deterministic tests, and deployment safeguards.

Get access to Screenshot API

First, register an account, or sign in if you already have one. Open the Screenshot API service page, choose an available Free, Plus, or Pro plan, and complete its activation.

Next, open the official documentation. Find the Service token panel and copy the service-scoped token. Regenerating this token revokes the previously active token, so a rotation must update every deployed instance that uses it.

This service is not tokenless. Every capture must authenticate with a Bearer token, an X-API-Token header, or the token query parameter. We will use the Bearer header because query credentials can leak into URLs, access logs, analytics, and browser history.

Confirm the API contract

The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Its required url query parameter identifies the page to capture. A successful response contains an image/png body plus cache and quota-related response headers.

After temporarily exporting your copied token, make one minimal request:

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

Inspect response.headers as well as the file. The application will not assume undocumented cache or quota header names; it captures response headers and maps the relevant families defensively.

Store the credential in a project-level .env file, never in PHP source:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_PREVIEW_DIR=var/previews

Add .env, var/bookmarks.sqlite, and var/previews/ to .gitignore. Keep only a token-free .env.example in version control.

Choose a deliberately small architecture

The application has three boundaries: SQLite stores bookmark metadata, ScreenshotClient translates the remote HTTP contract into domain results, and the front controller handles JSON and image routes. PNG files live outside the web root and can be read only through a bookmark lookup.

Capture is synchronous to keep this tutorial runnable without a worker. That is reasonable for a personal or small-team application with bounded traffic. If bookmark creation must return immediately, preserve the same client and state model but invoke capture from a supervised background worker.

Create this structure:

bookmarks/
├── composer.json
├── .env
├── public/index.php
├── src/
│   ├── Http.php
│   └── ScreenshotClient.php
├── tests/ScreenshotClientTest.php
└── var/previews/

Use Composer for autoloading, dotenv configuration, and 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

Isolate HTTP and normalize responses

A narrow transport interface makes cURL replaceable in tests. The production transport refuses redirects at the API boundary, applies separate connection and total timeouts, and retains repeated headers.

<?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);
    }
}

Map PNGs, failures, cache data, and quotas

The service class validates URLs before spending quota, retries only transient transport failures, HTTP 429, and 5xx responses, and rejects unexpected bodies. Authentication and validation failures are never blindly retried.

<?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];
    }
}

The eight-megabyte ceiling is an application safety limit, not a claim about the service. Adjust it deliberately if your plan or use case needs larger screenshots. For higher-risk deployments, apply an explicit hostname allowlist or a robust DNS/IP policy before submission; simple textual URL checks cannot eliminate every hostname-resolution trick.

Wire the bookmarks routes

The front controller supports listing and creating bookmarks plus reading a preview. Failures become persistent domain states, so a temporary outage does not masquerade as a missing image.

<?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);

Test without contacting the service

A deterministic fake proves response mapping and retry policy without consuming quota or depending on the network.

<?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'

Security, observability, and deployment

Treat screenshots as untrusted binary input even though they should be PNGs. The implementation checks the HTTP content type, PNG signature, and size; assigns its own filename; stores files outside public; and returns nosniff plus a restrictive content security policy. Add authentication and bookmark ownership checks before exposing these routes to multiple users.

Never log tokens, authorization headers, full upstream bodies, or URLs that may contain secrets. The example log records a bookmark identifier, failure category, and retryability. In production, count outcomes by state, monitor latency and 429 responses, and alert on sustained authentication failures because they commonly indicate an expired or rotated token.

Do not use PHP’s development server in production. Run the application behind PHP-FPM and a maintained web server with public/ as the document root. Give the PHP worker write access only to the SQLite database and preview directory. Back up bookmark metadata according to its value, apply retention rules to old PNGs, and ensure every instance receives the token through its deployment secret manager.

Retries multiply traffic, so keep them bounded. This client makes at most three attempts, honors short Retry-After delays, and surfaces a durable quota_limited state after exhaustion. For asynchronous deployments, schedule later retries instead of sleeping a worker for long server-requested delays.

Common failures and final verification

  • 401 or 403: verify the service-scoped token, plan activation, and whether someone regenerated the token.
  • 429: inspect captured quota metadata, reduce unnecessary recaptures, and defer work instead of creating a retry storm.
  • Unexpected content: retain the invalid_image state, but never save or serve the body as a PNG.
  • Timeouts or 5xx responses: allow bounded retries, then preserve transport_error or upstream_error for later recovery.
  • Preview missing after success: check directory ownership, free disk space, atomic rename permissions, and database-to-file consistency.
  1. Confirm .env and generated files are excluded from version control.
  2. Run PHPUnit and verify authentication failures make exactly one request.
  3. Create a bookmark and confirm its state becomes ready.
  4. Open its preview_url and verify a PNG response with security headers.
  5. Test an invalid URL, a private IP literal, a fake token, and a simulated 429.
  6. Verify logs contain useful categories but no token, authorization header, or response body.
  7. Rotate the token once in a non-production environment and confirm the old value stops working before updating the deployment secret.

The most important result is not merely a screenshot on a bookmark card. It is a narrow, testable boundary around a browser-like workload that your PHP application should not have to own. With validation before capture, defensive response mapping afterward, and explicit failure states throughout, visual previews become an ordinary application feature instead of a hidden browser-operations project.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.