Туториали

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

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

Обележувачот е покорисен кога можете да го препознаете на прв поглед. Прегледот со слика го дава тој визуелен знак, но управувањето со фарма на прелистувачи само за прикажување сликички воведува ажурирања, изолација, притисок врз меморијата и режими на откажување што имаат малку врска со вашата апликација.

Овој туторијал создава мала апликација за обележувачи во Native PHP 8.3 што го делегира прикажувањето на Screenshot API, го валидира вратениот PNG, го складира приватно и го испорачува преку контролирана рута. Интеграцијата вклучува ограничени тајмаути, селективни повторни обиди, свесност за квоти, детерминистички тестови и структурирани состојби на неуспех.

Добијте пристап и копирајте го сервисниот токен

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Screenshot API.
  3. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за Screenshot API.
  5. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

Повторното генерирање на овој токен го поништува претходно активниот токен, затоа третирајте ја ротацијата како операција за распоредување: ажурирајте ја секоја активна инстанца пред повторно да се потпрете на старите акредитиви. Оваа услуга бара автентикација; не е интеграција без токен.

API прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Претпочитајте заглавие бидејќи параметрите за пребарување почесто се појавуваат во дневниците за пристап, историјата на прелистувачот и дијагностичките алатки.

Потврдете го точниот HTTP договор

Барањето за снимање е:

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

Неговиот задолжителен параметар за пребарување е url. Успешниот одговор содржи тело image/png, како и заглавија на одговор поврзани со кешот и квотите. Почнете со минимално барање пред да пишувате код за апликацијата:

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

Резултатот треба да биде препознаен како PNG податоци. Прегледајте ги вратените заглавија наместо да нагаѓате сопственички имиња на заглавија за кеш или квота; апликацијата подолу ги задржува препознаените стандардни заглавија за кеш и сите заглавија чии имиња укажуваат на информации за квота, стапка или ограничување.

Создадете проектно-локална датотека со променливи на околината и исклучете ја од контрола на верзии:

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

# .gitignore
.env
/var/
/vendor/

За локално извршување, вчитајте ја таа датотека во околината на процесот со set -a; . ./.env; set +a. Во продукција, внесете ги истите променливи преку менаџерот на процеси, платформата за контејнери или складиштето за тајни, наместо да ги вградите во слика.

Дизајнирајте ја границата пред UI

Апликацијата извршува едно синхроно снимање кога се создава обележувач. Тоа го прави овој мал проект разбирлив, додека неговите експлицитни состојби pending, ready и failed го прават подоцнежното преминување кон редица едноставно.

Границата на API има четири одговорности:

  • Да го изгради само документираното барање само со URL и да се автентицира со Bearer заглавие.
  • Да примени ограничувања за поврзување, вкупен одговор и големина на одговорот.
  • Да повторува транспортни грешки, HTTP 429 и серверски грешки со ограничено повлекување, но никогаш слепо да не повторува грешки при автентикација или валидација.
  • Да прифати одговор само кога и неговиот медиумски тип и PNG потписот се валидни.

PNG-датотеките се наоѓаат надвор од јавниот корен на документите. PHP рута ги авторизира и ги испорачува со фиксен тип на содржина, спречувајќи поставени или неочекувани бајти да станат извршна јавна содржина.

Структура на проектот и зависности

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

Извршете composer install, потоа создадете src/Screenshot.php.

Имплементирајте дефанзивен Screenshot клиент

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

Максималната големина на телото спречува неочекуван одговор да троши неограничена меморија. Клиентот ги зачувува оперативните заглавија без деловната логика да зависи од недокументирани имиња. Автентикацијата и вообичаените неуспеси на барањето запираат веднаш; нивното повторување би трошело латентност без да го промени резултатот.

Складирајте ја состојбата на обележувачот експлицитно

Создадете src/BookmarkStore.php. SQLite е доволен за фриленсер или распоредување за мал тим, додека границата на складиштето ја изолира идната промена на базата на податоци.

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

Валидирајте URL-адреси и поврзете ги рутите

Создадете public/index.php. Оваа политика прифаќа само нормални HTTP и HTTPS URL-адреси, отфрла акредитиви, локални имиња, литерали на нејавни IP-адреси и невообичаени порти. Ја намалува злоупотребата, но не е целосна SSRF одбрана: давателот на слики од екранот останува границата за мрежно преземање и мора да ги спроведува сопствените контроли за излезен сообраќај.

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

Дневниците намерно го изоставуваат сервисниот токен и целната URL-адреса. Идентификаторите на обележувачите, видовите неуспех, HTTP статусните кодови и оперативните заглавија што не се тајни се доволни за истражување на латентност, исцрпување на квота, неуспеси при автентикација и неправилно форматирани одговори од изворот, без непотребно бележење податоци за прелистување.

Тестирајте повторни обиди без повикување на мрежата

Транспортниот интерфејс ги прави тестовите детерминистички. Создадете 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);
        }
    }
}

Извршете го пакетот тестови со composer test. Илустративното заглавие за квота припаѓа само на лажниот одговор; продукциското ракување не претпоставува дека го има токму тоа име.

Распоредете и управувајте намерно

Стартувајте локален сервер за проверка со:

set -a
. ./.env
set +a

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

За распоредување, насочете вистински веб-сервер кон public/, забранете директен пристап до .env и var/, извршувајте PHP како корисник што може да пишува само во базата на податоци и директориумот за прегледи, и завршете TLS на веб-серверот или платформата. Поставете ограничувања за барања на формуларот за обележувачи и додадете CSRF заштита ако се воведат сметки или автентицирани сесии.

SQLite одговара за една инстанца на апликацијата. На повеќе реплики им се потребни споделено трајно складирање и база на податоци што поддржува истовремени запишувачи. При поголем сообраќај, преместете ги снимањата во работник во заднина, зачувувајќи ги истите премини на состојби и идемпотентното име на датотека; синхроното снимање задржува PHP работник за времетраењето на барањето до изворот.

Вообичаени неуспеси

  • HTTP 401 или 403: потврдете ги токенот ограничен на услугата, активацијата на планот и тајната за распоредување. Ако токенот бил повторно генериран, претходната вредност е поништена.
  • HTTP 429: прегледајте ги задржаните заглавија за квота или стапка, намалете ги дупликатните снимања и избегнувајте додавање повеќе повторни обиди. Треба повторно да се користат кешираните прегледи на обележувачи.
  • Транспортни неуспеси: проверете ги излезниот HTTPS, DNS, CA сертификатите и конфигурираните тајмаути.
  • Невалиден одговор: задржете ги структурираниот статус и оперативните заглавија, но никогаш не зачувувајте или испорачувајте тело што не ја поминува валидацијата на медиумскиот тип или PNG-потписот.
  • Трајни редови во pending: процесот веројатно запрел помеѓу вметнувањето и завршувањето. Закажано чистење може да ги означи старите редови во pending како failed пред повторно да се обидат.

Конечна контролна листа за проверка

  • Сметката и Free, Plus или Pro планот се активни.
  • Токенот доаѓа од панелот Service token на страницата со документација и постои само во конфигурација поткрепена со околината.
  • Клиентот ја повикува точната GET крајна точка за снимање со задолжителниот параметар url.
  • Ограничувањата за поврзување, вкупен одговор и големина на телото се активни.
  • Се повторуваат само транспортни грешки, HTTP 429 и серверски неуспеси.
  • Типот на содржината и PNG-потписот се проверуваат пред складирање.
  • Заглавијата поврзани со кешот и квотата се задржуваат за операции.
  • Датотеките за преглед остануваат надвор од јавниот корен и се испорачуваат со nosniff.
  • Тестовите поминуваат со детерминистички лажен транспорт.
  • Вистински обележувач напредува од pending до ready и го прикажува својот преглед.

Прегледот со слика изгледа како мала функција, но неговиот продукциски квалитет се определува на границите: кои URL-адреси ги прифаќате, колку долго чекате, што повторувате, на кои бајти им верувате и што бележите. Со овие одлуки донесени експлицитно, апликацијата за обележувачи добива корисен визуелен слој без да го наследи оперативниот товар од одржување на Chromium инфраструктура.

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

Mihajlo

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