Туториали

Native PHP 8.3: Embed Safe Link Previews with Screenshot API Integration

Нативен PHP 8.3: Вградете безбедни прегледи на врски со интеграција на API за слики од екранот

A bookmark is more useful when you can recognize it at a glance. A screenshot preview supplies that visual cue, but operating a browser farm just to render thumbnails introduces patching, sandboxing, memory pressure, and failure modes that have little to do with your application.

This tutorial builds a small Native PHP 8.3 bookmarks application that delegates rendering to a Screenshot API, validates the returned PNG, stores it privately, and serves it through a controlled route. The integration includes bounded timeouts, selective retries, quota awareness, deterministic tests, and structured failure states.

Get access and copy the service token

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Screenshot API service page.
  3. Choose an available Free, Plus, or Pro plan and complete its activation.
  4. Open the official Screenshot API documentation.
  5. Find the Service token panel and copy the service-scoped token.

Regenerating this token revokes the previously active token, so treat rotation as a deployment operation: update every running instance before relying on the old credential again. This service requires authentication; it is not a token-free integration.

The API accepts a Bearer token, an X-API-Token header, or a token query parameter. Prefer a header because query parameters are more likely to appear in access logs, browser history, and diagnostic tooling.

Confirm the exact HTTP contract

The capture request is:

GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Its required query parameter is url. A successful response contains an image/png body plus cache and quota-related response headers. Start with a minimal request before writing application code:

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

The result should be identified as PNG data. Inspect the returned headers rather than guessing proprietary cache or quota header names; the application below retains recognized standard cache headers and any headers whose names indicate quota, rate, or limit information.

Create a project-local environment file and exclude it from version control:

# .env
SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=var/bookmarks.sqlite
PREVIEW_DIR=var/previews

# .gitignore
.env
/var/
/vendor/

For local execution, load that file into the process environment with set -a; . ./.env; set +a. In production, inject the same variables through the process manager, container platform, or secret store instead of baking them into an image.

Design the boundary before the UI

The application performs one synchronous capture when a bookmark is created. That keeps this small project understandable, while its explicit pending, ready, and failed states make a later queue migration straightforward.

The API boundary has four responsibilities:

  • Build only the documented URL-only request and authenticate with a Bearer header.
  • Apply connection, total-response, and response-size limits.
  • Retry transport errors, HTTP 429, and server errors with bounded backoff, but never blindly retry authentication or validation failures.
  • Accept a response only when both its media type and PNG signature are valid.

The PNG files live outside the public document root. A PHP route authorizes and serves them with a fixed content type, preventing uploaded or unexpected bytes from becoming executable public content.

Project structure and dependencies

{
  "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/

Run composer install, then create src/Screenshot.php.

Implement a defensive Screenshot client

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

The maximum body size prevents an unexpected response from consuming unbounded memory. The client preserves operational headers without making business logic depend on undocumented names. Authentication and ordinary request failures stop immediately; retrying them would consume latency without changing the result.

Store bookmark state explicitly

Create src/BookmarkStore.php. SQLite is enough for a freelancer or small team deployment, while the repository boundary keeps a future database change isolated.

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

Validate URLs and connect the routes

Create public/index.php. This policy accepts only normal HTTP and HTTPS URLs, rejects credentials, local names, non-public IP literals, and unusual ports. It reduces abuse but is not a complete SSRF defense: the screenshot provider remains the network-fetch boundary and must enforce its own egress controls.

<?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>Bookmark URL
<input name="url" type="url" required></label>
<button type="submit">Save bookmark</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>Preview status: '
        . htmlspecialchars($bookmark['status'], ENT_QUOTES, 'UTF-8')
        . '</p>';

    if ($bookmark['status'] === 'ready') {
        echo "<img src=\"/previews/{$id}.png\" alt=\"Preview of {$safeUrl}\">";
    }

    echo '</article>';
}

The logs deliberately omit the service token and target URL. Bookmark identifiers, failure kinds, HTTP status codes, and non-secret operational headers are sufficient for investigating latency, quota exhaustion, authentication failures, and malformed upstream responses without unnecessarily recording browsing data.

Test retries without calling the network

The transport interface makes tests deterministic. Create 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);
        }
    }
}

Run the suite with composer test. The illustrative quota header belongs only to the fake response; production handling does not assume that exact name.

Deploy and operate it deliberately

Start a local verification server with:

set -a
. ./.env
set +a

composer install
composer test
php -S 127.0.0.1:8080 -t public public/index.php

For deployment, point a real web server at public/, deny direct access to .env and var/, run PHP as a user that can write only the database and preview directory, and terminate TLS at the web server or platform. Set request limits on the bookmark form and add CSRF protection if accounts or authenticated sessions are introduced.

SQLite suits one application instance. Multiple replicas need shared durable storage and a database supporting concurrent writers. At higher traffic, move captures into a background worker while preserving the same state transitions and idempotent filename; synchronous capture holds a PHP worker for the duration of the upstream request.

Common failures

  • HTTP 401 or 403: confirm the service-scoped token, plan activation, and deployment secret. If the token was regenerated, the previous value has been revoked.
  • HTTP 429: inspect retained quota or rate headers, reduce duplicate captures, and avoid adding more retries. Cached bookmark previews should be reused.
  • Transport failures: check outbound HTTPS, DNS, CA certificates, and the configured timeouts.
  • Invalid response: retain the structured status and operational headers, but never save or serve a body that fails media-type or PNG-signature validation.
  • Persistent pending rows: the process probably stopped between insertion and completion. A scheduled cleanup can mark old pending rows failed before they are retried.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The token comes from the documentation page’s Service token panel and exists only in environment-backed configuration.
  • The client calls the exact GET capture endpoint with the required url parameter.
  • Connection, total-response, and body-size limits are active.
  • Only transport errors, HTTP 429, and server failures are retried.
  • The content type and PNG signature are checked before storage.
  • Cache and quota-related headers are retained for operations.
  • Preview files remain outside the public root and are served with nosniff.
  • Tests pass using a deterministic fake transport.
  • A real bookmark progresses from pending to ready and displays its preview.

A screenshot preview looks like a small feature, but its production quality is determined at the boundaries: which URLs you accept, how long you wait, what you retry, which bytes you trust, and what you record. With those decisions made explicitly, the bookmarks application gains a useful visual layer without inheriting the operational burden of maintaining Chromium infrastructure.

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

Mihajlo

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