Bỏ qua để đến nội dung

Kết xuất ở biên với Cloudflare, kèm dự phòng cục bộ

Cầu nối Cloudflare gửi HTML của bạn đến một điểm cuối kết xuất Cloudflare Worker và trả về PDF. Việc kết xuất chạy ở biên, nên bạn không cần vận hành một tiến trình trình duyệt chạy lâu dài. Bạn tạo một cấu hình chỉ dùng HTTPS, kết nối một client PHP Standards Recommendation (PSR)-18 và các factory PSR-17, gọi render(), rồi có thể thêm một bộ kết xuất cục bộ cho các Worker không truy cập được. Hướng dẫn này trình bày lệnh gọi kết xuất, đường dẫn dự phòng, và các biện pháp kiểm soát chống Server-Side Request Forgery (SSRF), chống tấn công đổi địa chỉ Domain Name System (DNS), và ghim khóa công khai Transport Layer Security (TLS) mà cầu nối thực thi trước khi bất kỳ yêu cầu nào rời khỏi tiến trình.

Trước hết, cần chuẩn bị:

  • Đã cài đặt NextPDF core và nextpdf/cloudflare.
  • Một điểm cuối Worker phục vụ giao kèo kết xuất qua HTTPS và chấp nhận bearer token. Cầu nối từ chối URL Worker không phải HTTPS trước khi gửi bất cứ thứ gì.
  • Có sẵn một client PSR-18 (ví dụ Guzzle 7) cùng các factory yêu cầu và luồng PSR-17. Đối với transport cURL có ghim khóa, hãy cung cấp thêm một response factory PSR-17 và ext-curl.
  • Đối với dự phòng cục bộ, có sẵn nextpdf/artisan (hoặc một bộ kết xuất cục bộ khác).

Đây là hướng dẫn thực hành. Để có lần kết xuất chạy được đầu tiên, hãy bắt đầu với phần khởi động nhanh Cloudflare.

Cài đặt cầu nối, một client PSR-18 và các factory PSR-17.

Terminal window
composer require nextpdf/cloudflare guzzlehttp/guzzle

Đối với dự phòng cục bộ, hãy cài đặt một bộ kết xuất cục bộ mà cầu nối có thể gọi.

Terminal window
composer require nextpdf/artisan

Hãy nạp bearer token của Worker và mọi thông tin xác thực R2 từ biến môi trường hoặc trình quản lý bí mật. Đừng bao giờ commit chúng.

CloudflareHtmlRenderer::render() xác thực HTML và đích đến, gửi một POST đã xác thực đến Worker, rồi phân tích phản hồi. Worker trả về các byte PDF thô (Content-Type: application/pdf) hoặc một thân JSON có trường pdf dạng base64. Bộ kết xuất ánh xạ phản hồi thành một final readonly CloudflareRenderResult chứa các byte, chiều rộng yêu cầu, chiều cao, vị trí kết xuất (suy ra từ header CF-Ray), và thời gian kết xuất.

Cầu nối tách các lỗi thành hai lớp rõ ràng:

  • CloudflareRenderException — Worker đã trả lời nhưng quá trình kết xuất thất bại (một lỗi HTTP hoặc một thân không bắt đầu bằng %PDF). Đây là lỗi kết xuất và không bao giờ được thử lại bằng dự phòng.
  • CloudflareNotAvailableException — không thể truy cập được biên và không có dự phòng dùng được nào khả dụng.

Dự phòng cục bộ xử lý trường hợp thứ hai. Khi không thể truy cập được Worker và fallbackToLocaltrue, cầu nối gọi LocalRendererFactoryInterface mà bạn cung cấp. Việc này diễn ra theo kiểu khởi tạo lười: create() của factory chỉ chạy trên đường dẫn dự phòng. Trong một lần kết xuất dự phòng, renderLocation của kết quả là chuỗi ký tự nguyên văn local.

Cầu nối bảo vệ ranh giới mạng trước khi bất kỳ yêu cầu nào rời khỏi PHP. Nó từ chối URL Worker không phải HTTPS. Nó từ chối một host Worker phân giải về không gian địa chỉ riêng tư hoặc dành riêng, đồng thời kiểm tra tất cả bản ghi A và AAAA, không chỉ bản ghi đầu tiên. Nó cũng phân giải lại host ngay trước khi kết nối, nhờ đó khép lại cửa sổ time-of-check/time-of-use (TOCTOU) trước tấn công đổi địa chỉ DNS. Khi bạn cung cấp một response factory PSR-17 cùng với một tập IP đã phân giải hoặc các pin Subject Public Key Info (SPKI), cầu nối sẽ dùng một transport cURL có ghim khóa. Transport đó ràng buộc kết nối với các IP đã được kiểm duyệt (CURLOPT_RESOLVE), thực thi ghim khóa công khai TLS (CURLOPT_PINNEDPUBLICKEY), xác minh peer và host, và không đi theo chuyển hướng.

// Configuration (final readonly):
new CloudflareRendererConfig(
string $workerUrl, // required, must be HTTPS
string $apiToken, // required, #[SensitiveParameter]
int $renderTimeout = 30,
string $defaultCss = '',
int $maxHtmlSize = 5_000_000,
?string $r2FontBucket = null,
bool $fallbackToLocal = true,
list<string> $pinnedPublicKeys = [], // sha256/<base64>
list<string> $backupPublicKeys = [],
)
CloudflareRendererConfig::fromArray(array $config): self
// The renderer:
new CloudflareHtmlRenderer(
CloudflareRendererConfig $config,
ClientInterface $httpClient, // PSR-18
RequestFactoryInterface $requestFactory, // PSR-17
StreamFactoryInterface $streamFactory, // PSR-17
?LoggerInterface $logger = null, // PSR-3
?LocalRendererFactoryInterface $localRendererFactory = null,
?HtmlSecurityPolicyInterface $htmlSecurityPolicy = null,
?ResponseFactoryInterface $responseFactory = null, // enables pinned transport
)
CloudflareHtmlRenderer::render(string $html, float $widthPt = 595.28, float $heightPt = 0.0, list<string> $fontFiles = []): CloudflareRenderResult
CloudflareHtmlRenderer::isAvailable(): bool

render() mặc định dùng chiều rộng A4 (595.28 điểm) và chiều cao tự phát hiện (heightPt: 0). Để xem tham chiếu đầy đủ về các trường và ánh xạ khóa fromArray(), hãy xem trang cấu hình Cloudflare trong phần Xem thêm.

Tạo cấu hình, dựng bộ kết xuất, kết xuất, và ghi các byte.

edge-quickstart.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
use NextPDF\Cloudflare\CloudflareRendererConfig;
use NextPDF\Cloudflare\Exception\CloudflareNotAvailableException;
use NextPDF\Cloudflare\Exception\CloudflareRenderException;
$config = new CloudflareRendererConfig(
workerUrl: 'https://pdf-renderer.example.workers.dev/render',
apiToken: getenv('CF_PDF_TOKEN') ?: throw new RuntimeException('CF_PDF_TOKEN not set'),
);
$httpFactory = new HttpFactory();
$renderer = new CloudflareHtmlRenderer(
config: $config,
httpClient: new Client(),
requestFactory: $httpFactory,
streamFactory: $httpFactory,
responseFactory: $httpFactory, // enables the pinned cURL transport
);
try {
$result = $renderer->render('<h1>Hello from the edge</h1>');
if (!$result->isValid()) {
throw new RuntimeException('Worker did not return a valid PDF');
}
file_put_contents('output.pdf', $result->pdfData);
} catch (CloudflareRenderException $exception) {
// Worker answered but the render failed. Not retried with fallback.
fwrite(STDERR, 'Render failed: ' . $exception->getMessage() . PHP_EOL);
exit(1);
} catch (CloudflareNotAvailableException $exception) {
// Edge unreachable and no usable fallback.
fwrite(STDERR, 'Edge unavailable: ' . $exception->getMessage() . PHP_EOL);
exit(2);
}

Token lấy từ môi trường và không bao giờ được mã hóa cứng. workerUrl phải dùng HTTPS; cầu nối từ chối một URL http:// trước khi gửi bất kỳ yêu cầu nào.

Trong môi trường production, hãy kết nối một local renderer factory để khi Worker không truy cập được, yêu cầu sẽ chuyển sang dự phòng thay vì thất bại. Hãy cấu hình các pin TLS kèm một pin dự phòng. create() của factory chỉ chạy trên đường dẫn dự phòng.

ArtisanLocalRendererFactory.php
<?php
declare(strict_types=1);
use NextPDF\Artisan\ChromeHtmlRenderer;
use NextPDF\Cloudflare\Contract\LocalRendererFactoryInterface;
use NextPDF\Cloudflare\Contract\LocalRendererInterface;
final readonly class ArtisanLocalRendererFactory implements LocalRendererFactoryInterface
{
public function __construct(private ChromeHtmlRenderer $chrome) {}
public function create(): LocalRendererInterface
{
return new readonly class($this->chrome) implements LocalRendererInterface {
public function __construct(private ChromeHtmlRenderer $chrome) {}
/** @param array<string, mixed> $options */
public function render(string $html, array $options = []): string
{
$widthPt = (float) ($options['widthPt'] ?? 595.28); // A4 width
$heightPt = (float) ($options['heightPt'] ?? 0.0); // 0 = auto-fit
return $this->chrome->render($html, $widthPt, $heightPt)->getPdfData();
}
};
}
}

Kết nối factory và các pin vào bộ kết xuất.

build the production renderer
<?php
declare(strict_types=1);
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
use NextPDF\Cloudflare\CloudflareRendererConfig;
$config = CloudflareRendererConfig::fromArray([
'worker_url' => getenv('CF_WORKER_URL') ?: '',
'api_token' => getenv('CF_PDF_TOKEN') ?: '',
'render_timeout' => 60,
'fallback_to_local' => true,
'pinned_public_keys' => ['sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg='],
'backup_public_keys' => ['sha256/Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys='],
]);
$renderer = new CloudflareHtmlRenderer(
config: $config,
httpClient: $httpClient,
requestFactory: $httpFactory,
streamFactory: $httpFactory,
logger: $logger,
localRendererFactory: new ArtisanLocalRendererFactory($chrome),
responseFactory: $httpFactory,
);

Khi dự phòng chạy, renderLocation của kết quả là localheightPt0.0. Cầu nối ghi log dự phòng ở mức warning, rồi info. Hãy luôn cấu hình một pin dự phòng trước khi xoay vòng chứng chỉ, để một lần xoay vòng theo kế hoạch không khóa cầu nối khỏi điểm cuối.

Trường hợp đặc biệt & điểm cần lưu ý

Phần tiêu đề “Trường hợp đặc biệt & điểm cần lưu ý”
  • Lỗi Worker không phải là lỗi không truy cập được. Một Worker trả về lỗi HTTP hoặc thân không đúng định dạng sẽ phát sinh CloudflareRenderException và không bao giờ được thử lại bằng dự phòng. Chỉ khi biên không truy cập được thì mới chuyển sang dự phòng. Hãy giữ hai nhánh catch tách biệt.
  • Dự phòng cần cả cờ lẫn một factory. Với fallbackToLocal: true nhưng không có factory nào được kết nối, một Worker không truy cập được sẽ phát sinh CloudflareNotAvailableException và nêu tên factory bị thiếu. Hãy kết nối factory.
  • isAvailable() là một gợi ý, không phải một bảo đảm. Nó gửi một HEAD đã xác thực và trả về true cho một trạng thái dưới 500; lệnh POST kế tiếp vẫn có thể thất bại. Đừng coi nó là một giao kèo.
  • Ghim khóa là tùy chọn bật. Một tập pin rỗng sẽ tắt ghim khóa. Chỉ dùng tập rỗng với một chuỗi chứng chỉ ổn định, đã biết, và hãy giữ một pin dự phòng một khi bạn đã ghim khóa.
  • fontFiles cần một bucket R2. Đối số fontFiles chỉ có ý nghĩa khi cấu hình đặt r2FontBucket; nếu không, nó không có tác dụng.
  • Cầu nối không ký. Nó trả về các byte PDF. Hãy kết xuất ở biên, rồi ký trong tiến trình của riêng bạn, để khóa ký không bao giờ vượt qua ranh giới biên.

Kết xuất ở biên chuyển chi phí trình duyệt ra khỏi các host của bạn. Bạn vẫn phải trả cho một vòng đi-về HTTPS đến Worker cộng với thời gian kết xuất của Worker, được kết quả báo cáo dưới dạng renderTimeMs. Cầu nối áp dụng thời gian chờ đã cấu hình thông qua transport có ghim khóa. Hãy đặt thời gian chờ dựa trên độ trễ Worker đã đo được, kèm dư địa, và giữ nó dưới mọi thời gian chờ của gateway thượng nguồn. Gói chỉ nêu các giới hạn mà chính nó thực thi. Nó không đưa ra tuyên bố nào về trần CPU, bộ nhớ hay thân yêu cầu của nền tảng Cloudflare. Đối với các giới hạn đó, hãy tham khảo tài liệu của Cloudflare và Worker của bạn.

  • Đích đến được xác thực trước khi yêu cầu rời khỏi PHP. Các URL không phải HTTPS bị từ chối. Một host phân giải về không gian địa chỉ riêng tư hoặc dành riêng sẽ bị từ chối trên tất cả bản ghi A và AAAA. Host được phân giải lại ngay trước khi kết nối để phòng thủ chống tấn công đổi địa chỉ DNS.
  • Transport có ghim khóa ràng buộc DNS và TLS. Với một response factory và các pin đã cấu hình, cầu nối ràng buộc kết nối với các IP đã được kiểm duyệt, thực thi ghim khóa SPKI, xác minh peer và host, và từ chối đi theo chuyển hướng đến một host chưa được kiểm duyệt.
  • Đầu vào được giới hạn. HTML vượt quá maxHtmlSize (mặc định 5 MB), data URI base64 quá khổ, và mọi thẻ <meta http-equiv="refresh"> đều bị từ chối trước khi yêu cầu được gửi.
  • Bí mật được che giấu và bất biến. apiToken và các khóa R2 mang #[SensitiveParameter], nên stack trace sẽ che giấu chúng, và các đối tượng cấu hình là final readonly. Hãy nạp bí mật từ môi trường hoặc một trình quản lý bí mật; đừng bao giờ commit chúng.
  • Đừng bao giờ viết một khối catch rỗng. Mỗi ví dụ bắt đúng loại ngoại lệ cụ thể và ghi log hoặc thoát với một mã đã định.

Mô hình bảo mật đầy đủ nằm trên trang security-and-operations của Cloudflare trong phần Xem thêm. Trang đó bao quát phòng thủ SSRF và chống tấn công đổi địa chỉ DNS, thao tác ghim khóa, xử lý bí mật, và các điều khoản OWASP và RFC 7469 liên quan.

Hướng dẫn này không tự đưa ra tuyên bố tiêu chuẩn quy phạm nào. Trên các trang security-and-operations và cấu hình thượng nguồn của Cloudflare, việc phân giải DNS toàn bộ bản ghi và kiểm tra lại TOCTOU của cầu nối ánh xạ tới hướng dẫn phòng chống SSRF của OWASP, còn việc ghim khóa công khai TLS và phục hồi bằng pin dự phòng của nó ánh xạ tới RFC 7469. Trang cookbook này chỉ trình bày lại cách sử dụng và để các trích dẫn đó cho các trang kia. Cầu nối không thực hiện việc ký và không đưa ra tuyên bố tuân thủ về chữ ký.