Giao một PDF được tạo qua một URL có chữ ký, hết hạn
Tổng quan nhanh
Phần tiêu đề “Tổng quan nhanh”Bạn tạo một tệp Portable Document Format (PDF) và cần trao nó cho một client. Lối đơn giản nhất là stream các byte thẳng qua một controller, nhưng điều đó chiếm dụng một worker ứng dụng suốt cả lần tải về, đẩy lưu lượng qua các máy chủ của bạn, và phơi tệp ra cho bất kỳ ai có thể tiếp cận route. Mẫu giao hàng trên trang này làm điều ngược lại: tạo PDF, lưu các byte vào lưu trữ đối tượng, và trả về một Uniform Resource Locator (URL) có chữ ký sống ngắn để client lấy trực tiếp từ lưu trữ. Ứng dụng của bạn trả lại một payload JavaScript Object Notation (JSON) nhỏ chứa một URL; lưu trữ phục vụ các byte.
Phía NextPDF chỉ là một lệnh gọi: getPdfData() trên tài liệu trả về nhị phân
PDF thô dưới dạng một chuỗi. Mọi thứ sau đó — đặt đối tượng và đúc một liên kết có
chữ ký, giới hạn thời gian — là việc của framework hoặc nhà cung cấp cloud của
bạn. Các nguyên thủy ký là những API thực sự, có tài liệu: Laravel
Storage::temporaryUrl() và URL::temporarySignedRoute(), Symfony UriSigner,
và các thao tác presigned-URL của Amazon Simple Storage Service (S3) hoặc Google
Cloud Storage (GCS) trong các bộ phát triển phần mềm (SDK) của chúng. NextPDF
không định nghĩa helper URL nào của riêng nó; đừng tìm một cái.
Hãy kiểm tra những phần này trước:
- NextPDF core đã được cài và bạn có thể dựng một tài liệu.
- Bạn có lưu trữ đối tượng mà framework có thể ký cho: một bucket S3 hoặc tương thích-S3, một bucket GCS, hoặc một disk Laravel có driver hỗ trợ temporary URL.
- Thông tin xác thực nằm trong biến môi trường hoặc một trình quản lý bí mật, không bao giờ trong cấu hình được commit.
Đây là một how-to. Nó giả định bạn đã biết cách định tuyến một request tới một controller. Để trả về các byte trực tiếp thay vì vậy, xem Trả về một PDF được tạo từ một controller.
Tổng quan khái niệm
Phần tiêu đề “Tổng quan khái niệm”Mẫu này có ba bước, và chỉ bước đầu chạm tới NextPDF:
- Tạo. Dựng tài liệu và gọi
getPdfData()để lấy các byte. - Lưu. Ghi các byte đó vào một khóa lưu trữ đối tượng (
reports/2026/r-42.pdf). - Ký. Yêu cầu framework hoặc cloud SDK một URL có chữ ký tới khóa đó, kèm một thời điểm hết hạn, và trả URL về cho client.
Vì sao lưu và ký thay vì proxy các byte:
- Giảm tải băng thông. Lưu trữ đối tượng (hoặc biên content-delivery-network của nó) phục vụ lần tải về. Worker ứng dụng của bạn trả về vài trăm byte JSON và rảnh ngay lập tức, thay vì bị giữ suốt độ dài của một lần truyền nhiều megabyte.
- Giới hạn quyền truy cập. Một URL có chữ ký cấp quyền truy cập tới một đối tượng trong một cửa sổ có giới hạn. Bản thân bucket vẫn riêng tư. Không có route công khai để brute force và không có quyền đọc-bucket rộng.
- Hết hạn. Chữ ký nhúng một dấu thời gian hết hạn. Sau khi nó qua, liên kết chết. Một URL bị rò rỉ tự ngừng hoạt động, điều này giới hạn bán kính ảnh hưởng của một lần chia sẻ vô tình.
Có hai mô hình ký riêng biệt, và chúng khác nhau ở cái gì được ký:
- Các presigned URL lưu trữ đối tượng (S3, GCS, hoặc
temporaryUrl()của Laravel trên một disk S3/GCS) trỏ trực tiếp tới đối tượng lưu trữ. Lần tải về không bao giờ chạm tới ứng dụng của bạn. - Các route ứng dụng có chữ ký (Laravel
URL::temporarySignedRoute(), SymfonyUriSigner) trỏ tới route của chính bạn. Request vẫn chạm ứng dụng của bạn, ứng dụng xác minh chữ ký, rồi stream hoặc redirect tới đối tượng. Hãy dùng chúng khi bạn cần chạy phân quyền, ghi log, hoặc tính toán trên mỗi lần tải về, hoặc khi lưu trữ của bạn không thể presign.
Bề mặt API
Phần tiêu đề “Bề mặt API”| Mối quan tâm | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Lấy các byte PDF | NextPDF\Core\Document::getPdfData(): string | same | same |
| Lưu các byte | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) or Flysystem write() |
| URL lưu trữ presigned | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK presigner (below) |
| Route ứng dụng có chữ ký | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Xác minh một route ứng dụng có chữ ký | — | signed route middleware / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
Lệnh gọi engine NextPDF duy nhất mà mẫu giao hàng này cần là getPdfData();
bản thân tài liệu được dựng theo cách ứng dụng của bạn vốn dựng tài liệu (ví dụ
DocumentFactoryInterface được tiêm / PdfFactory của Symfony). getPdfData()
được khai báo trong trait HasOutput trên NextPDF\Core\Document. Nó gọi trình ghi một lần và trả về toàn bộ
PDF dưới dạng một chuỗi. Anh em của nó save(string $path): void ghi cùng các byte
xuống đĩa qua một trình ghi nguyên tử; chỉ dùng nó khi lưu trữ của bạn là một
đường dẫn hệ thống tệp cục bộ thực sự. Với lưu trữ đối tượng, hãy ưu tiên
getPdfData() và để storage SDK sở hữu việc truyền.
Tài liệu được dựng khi bạn gọi
getPdfData()(hoặcsave()), và bản dựng không idempotent. Hãy gọi nó một lần cho mỗi tài liệu, bắt chuỗi, và tái sử dụng chuỗi đó cho cả việc tải lên lẫn bất kỳ kích thước hay checksum nào bạn tính.
Mẫu mã — Laravel temporary URL
Phần tiêu đề “Mẫu mã — Laravel temporary URL”Lớp trừu tượng hệ thống tệp của Laravel ký giúp bạn. Trên một disk S3 (hoặc tương
thích-S3), Storage::temporaryUrl() trả về một presigned URL thẳng tới đối tượng.
Client tải về từ lưu trữ; action của bạn chỉ trả về JSON.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;use Illuminate\Support\Facades\Storage;use NextPDF\Contracts\DocumentFactoryInterface;use Psr\Log\LoggerInterface;use Throwable;
final class ReportDeliveryController extends Controller{ public function __construct( private readonly DocumentFactoryInterface $documents, private readonly LoggerInterface $logger, ) {}
public function store(int $reportId): JsonResponse { try { // 1. Generate. Build once; getPdfData() returns the raw bytes. $document = $this->documents->create(); $document->addPage(); $document->cell(0, 10, "Report #{$reportId}", newLine: true); $bytes = $document->getPdfData();
// 2. Store under a non-guessable key on a private disk. $key = sprintf('reports/%d/%s.pdf', $reportId, bin2hex(random_bytes(16))); Storage::disk('s3')->put($key, $bytes, ['visibility' => 'private']);
// 3. Sign. A presigned URL straight to the object, valid 10 minutes. $url = Storage::disk('s3')->temporaryUrl($key, now()->addMinutes(10));
return new JsonResponse(['download_url' => $url], 201); } catch (Throwable $exception) { // Log the class, never the message or trace, so detail does not leak. $this->logger->error('Report PDF delivery failed', [ 'report_id' => $reportId, 'exception' => $exception::class, ]);
return new JsonResponse(['error' => 'Could not prepare the report.'], 500); } }}Disk phải là một disk có driver hỗ trợ temporary URL — driver s3 đi kèm thì có.
Gọi temporaryUrl() trên driver local sẽ ném lỗi trừ khi bạn đăng ký một
generator cho nó, vì một disk cục bộ không có gì để presign.
Khi bạn muốn giữ lần tải về trên route của chính mình — để chạy phân quyền theo
từng request hoặc để ghi log mỗi lần truy cập — hãy ký một route thay vì vậy với
URL::temporarySignedRoute(). Middleware signed của route từ chối một liên kết
bị giả mạo hoặc hết hạn trước khi action của bạn chạy.
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// Mint the link elsewhere:// URL::temporarySignedRoute('reports.download', now()->addMinutes(10),// ['report' => $reportId]);Route::get('/reports/{report}/download', DownloadReportController::class) ->name('reports.download') ->middleware('signed');Mẫu mã — Symfony UriSigner
Phần tiêu đề “Mẫu mã — Symfony UriSigner”Symfony không có facade lưu trữ kiểu Laravel, nên bạn ký route của chính mình
với Symfony\Component\HttpFoundation\UriSigner của framework, rồi cho route đó
redirect tới một presigned URL lưu trữ (hoặc stream đối tượng). UriSigner::sign()
nối thêm một hash có khóa; checkRequest() từ chối một liên kết bị giả mạo. Để giữ
ví dụ di động được qua các phiên bản Symfony, hãy nhúng tham số truy vấn expires
của riêng bạn (một dấu thời gian Unix vài phút sau) trước khi ký, rồi tự xác
thực tham số đó trong route download sau khi chữ ký được kiểm tra. Cách này hoạt
động trên mọi phiên bản Symfony, vì UriSigner::sign(string $uri) chỉ nhận URL.
<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Service\PdfFactory;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\UriSigner;use Symfony\Component\Routing\Attribute\Route;use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ReportDeliveryController{ // 1 + 2 + sign: build, store, and return a signed URL to our own route. #[Route('/reports/{reportId}', name: 'report_prepare', methods: ['POST'])] public function prepare( int $reportId, PdfFactory $pdf, UriSigner $signer, UrlGeneratorInterface $urls, ReportStorage $storage, // your storage adapter ): JsonResponse { $document = $pdf->create(); $document->addPage(); $document->cell(0, 10, "Report #{$reportId}", newLine: true);
$key = $storage->put($reportId, $document->getPdfData());
$url = $urls->generate( 'report_download', ['reportId' => $reportId, 'key' => $key], UrlGeneratorInterface::ABSOLUTE_URL, );
// Embed our own expiry (a Unix timestamp 10 minutes out), then sign the // URL only. UriSigner::sign(string $uri) is portable across all versions. $url .= (str_contains($url, '?') ? '&' : '?') . 'expires=' . ((new \DateTimeImmutable('+10 minutes'))->getTimestamp());
return new JsonResponse(['download_url' => $signer->sign($url)]); }
// verify: the signed route. checkRequest() rejects a tampered link; then we // enforce the embedded expiry ourselves. #[Route('/reports/{reportId}/download', name: 'report_download', methods: ['GET'])] public function download( Request $request, UriSigner $signer, ReportStorage $storage, ): Response { if (!$signer->checkRequest($request)) { return new Response('Link invalid.', 403); }
// Enforce the embedded expiry: reject once the timestamp is in the past. $expires = (int) $request->query->get('expires'); if ($expires < time()) { return new Response('Link expired.', 410); }
// Redirect to a presigned storage URL, or stream the object here. return new Response('', 302, ['Location' => $storage->presign( (string) $request->query->get('key'), )]); }}UriSigner được khởi tạo với một bí mật (Symfony tự đấu nối nó từ tham số
%kernel.secret% / APP_SECRET). Ví dụ ở trên là lối di động được:
UriSigner::sign(string $uri) chỉ ký URL và tồn tại trên mọi phiên bản Symfony,
nên thời điểm hết hạn đi cùng dưới dạng tham số truy vấn expires của riêng bạn.
Chữ ký phủ tham số đó, nên nó không thể bị giả mạo — và sau khi
checkRequest() qua, route download thực thi nó bằng cách so sánh dấu thời gian
với thời gian hiện tại và trả về 410 Gone một khi nó đã qua.
Trên các phiên bản Symfony mà
UriSigner::sign()chấp nhận một đối số hết hạnDateTimeInterface, bạn có thể truyền thời điểm hết hạn trực tiếp —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— và đểcheckRequest()từ chối các liên kết hết hạn giúp bạn, bỏ đi tham sốexpiresthủ công và phần kiểm tra của nó. Hãy xác nhận chữ ký củaUriSigner::sign()trong Symfony bạn đã cài trước khi dựa vào nó; mẫu di động ở trên hoạt động bất kể.
Mẫu mã — presigned URL bằng Cloud SDK
Phần tiêu đề “Mẫu mã — presigned URL bằng Cloud SDK”Nếu bạn ký bằng một cloud SDK trực tiếp thay vì qua một disk framework, hình dạng
là như nhau: đặt đối tượng, rồi yêu cầu SDK presign một GET cho nó.
Đây là S3 thuần (luồng GCS phản chiếu nó: lấy đối tượng với $bucket->object($key) và gọi $object->signedUrl($expiresAt, [...])).
<?php
declare(strict_types=1);
use Aws\S3\S3Client;use NextPDF\Core\Document;
/** @var Document $document Already built by your generation code. */$bytes = $document->getPdfData(); // NextPDF: the only engine call.
$s3 = new S3Client(['region' => 'eu-central-1', 'version' => 'latest']);$key = 'reports/' . bin2hex(random_bytes(16)) . '.pdf';
// Store the object privately.$s3->putObject([ 'Bucket' => 'my-private-reports', 'Key' => $key, 'Body' => $bytes, 'ContentType' => 'application/pdf',]);
// Presign a GET valid for 10 minutes. The returned URI is the signed URL.$command = $s3->getCommand('GetObject', [ 'Bucket' => 'my-private-reports', 'Key' => $key,]);$signedUrl = (string) $s3->createPresignedRequest($command, '+10 minutes')->getUri();Với GCS, dựng các byte theo cùng cách với getPdfData(), tải đối tượng lên với
client Cloud Storage, rồi lấy đối tượng lưu trữ với $bucket->object($key) và gọi $object->signedUrl($expiresAt, [...]) với một
thời điểm hết hạn Carbon/DateTime để đúc liên kết tương đương. Thời điểm hết
hạn signed-URL trên cả hai nhà cung cấp bị giới hạn bởi loại thông tin xác thực;
hãy tham khảo tài liệu của nhà cung cấp để biết thời gian sống tối đa mà thông tin
xác thực của bạn cho phép.
Trường hợp đặc biệt & lưu ý
Phần tiêu đề “Trường hợp đặc biệt & lưu ý”- Dựng tài liệu đúng một lần.
getPdfData()kích hoạt bản dựng, và bản dựng không idempotent. Gọi nó một lần, giữ chuỗi, và tái sử dụng nó cho cả việc tải lên lẫn bất kỳContent-Length, checksum, hayETagnào bạn tính. Đừng gọi nó lại để “đọc lại” các byte. temporaryUrl()cần một driver có khả năng presign. Drivers3của Laravel presign được; driverlocalném lỗi trêntemporaryUrl()trừ khi bạn đăng ký một generator tùy chỉnh vớiStorage::disk('local')->buildTemporaryUrlsUsing(...). Hãy chọn một disk có thể ký, hoặc ký một route ứng dụng thay vì vậy.- Đặt content type của đối tượng. Lưu với
Content-Type: application/pdf(tùy chọn tải lênContentType, hoặc metadata disk) để trình duyệt mở liên kết presigned như một PDF thay vì tải về mộtoctet-stream. - Một thời điểm hết hạn ngắn có thể sống ngắn hơn một client chậm. Nếu người dùng nhấp liên kết khá lâu sau khi bạn đúc nó, một cửa sổ 60 giây có thể đã chết. Hãy định cỡ thời điểm hết hạn theo khoảng cách thực tế giữa lúc đúc và byte đầu tiên — phút, không phải giây — và đúc lại theo nhu cầu thay vì kéo dài nó tới hàng giờ.
- Một URL có chữ ký là quyền truy cập bearer. Bất kỳ ai giữ URL trước khi nó hết hạn đều có thể tải đối tượng. Hãy giữ thời điểm hết hạn ngắn, ưu tiên phạm vi một-đối-tượng, và đừng bao giờ ghi log toàn bộ URL có chữ ký — chữ ký thực chất là một token.
- Đừng nhúng đầu vào người dùng vào khóa đối tượng mà không khử trùng. Dựng các
khóa từ những giá trị bạn kiểm soát cộng với các byte ngẫu nhiên (
bin2hex(random_bytes(16))). Một khóa đoán được mời gọi việc liệt kê một khi bucket dù chỉ bị phơi một phần.
Hiệu năng
Phần tiêu đề “Hiệu năng”Mẫu này đánh đổi một lần truyền đồng bộ lấy một lần tải lên cộng với một response JSON nhỏ xíu. Worker ứng dụng chỉ bị giữ cho việc dựng PDF và lần tải lên lưu trữ, không phải cho lần tải về đầy đủ của client. Bản thân lần tải về chạy giữa client và lưu trữ (hoặc biên của nó), nên nó không tiêu thụ một worker ứng dụng nào cả.
Bản dựng vẫn đồng bộ và vẫn chiếm phần lớn với các tài liệu lớn hoặc nhiều trang —
getPdfData() hiện thực hóa toàn bộ PDF trong bộ nhớ trước khi bạn có thể tải nó
lên. Với các tài liệu nặng, hãy chuyển việc tạo và tải lên vào một job được xếp
hàng và giao URL có chữ ký ngoài luồng (ví dụ bằng cách thông báo cho client khi
đối tượng đã sẵn sàng). Xem
Tạo một PDF trong một job được xếp hàng.
Ghi chú bảo mật
Phần tiêu đề “Ghi chú bảo mật”- Giữ bucket riêng tư; để chữ ký cấp quyền truy cập. Đừng bao giờ làm đối tượng đọc được công khai để “đơn giản hóa” việc giao hàng. Toàn bộ điểm mấu chốt là quyền truy cập chỉ chảy qua một chữ ký sống ngắn.
- Thời điểm hết hạn ngắn, có phạm vi. Ký cho cửa sổ nhỏ nhất vừa với luồng của bạn, và giới hạn mỗi URL vào một đối tượng duy nhất. Một liên kết bị rò rỉ khi đó tự hết hạn và không phơi bày gì khác.
- Bí mật từ môi trường. Thông tin xác thực S3/GCS và bí mật Symfony
APP_SECRETchống lưngUriSignerđến từ biến môi trường hoặc một trình quản lý bí mật, không bao giờ là cấu hình được commit. Việc xoay bí mật ký ngay lập tức vô hiệu hóa mọi route có chữ ký còn hiệu lực. - Xác minh trước khi phục vụ trên các route ứng dụng có chữ ký. Khi lần tải về
băng qua ứng dụng của bạn (Laravel middleware
signed, SymfonyUriSigner::checkRequest()), hãy xác minh chữ ký trước bất kỳ truy cập lưu trữ hoặc phân quyền nào. Từ chối một liên kết bị giả mạo hoặc hết hạn với một trạng thái đã định. - Đừng bao giờ ghi log toàn bộ URL có chữ ký. Chữ ký là một thông tin xác thực bearer. Hãy ghi log khóa đối tượng và một định danh tương quan, không phải URL có chữ ký, và ghi log lớp ngoại lệ khi thất bại — không bao giờ ghi thông điệp hay một stack trace.
- Không có
catchrỗng. Mọi ví dụ đều ghi log lớp thất bại và trả về một response lỗi đã định.
Tuân thủ
Phần tiêu đề “Tuân thủ”Hướng dẫn này không đưa ra tuyên bố tiêu chuẩn quy phạm nào. Lệnh gọi engine
NextPDF duy nhất mà mẫu giao hàng này cần là
NextPDF\Core\Document::getPdfData(), phương thức công khai đã xác minh trả về
nhị phân PDF thô; bản thân tài liệu được dựng theo cách ứng dụng của bạn vốn dựng
tài liệu (ví dụ DocumentFactoryInterface được tiêm /
PdfFactory của Symfony). Các nguyên thủy ký là các API framework và cloud có tài liệu —
Laravel Storage::temporaryUrl() và URL::temporarySignedRoute(), Symfony
UriSigner, và các thao tác SDK presigned-URL S3/GCS — và chữ ký chính xác,
driver được hỗ trợ, cùng cửa sổ hết hạn tối đa của chúng được quản trị bởi các dự
án thượng nguồn đó. Hãy tham khảo tài liệu của chúng để biết hợp đồng có thẩm quyền
trên từng nền tảng.
Xem thêm
Phần tiêu đề “Xem thêm”- Trả về một PDF được tạo từ một controller — stream các byte trực tiếp khi bạn không muốn lưu trữ đối tượng trong vòng lặp.
- Stream một PDF lớn được tạo dưới dạng một HTTP response — mô hình bộ nhớ buffered-so-với-streamed phía sau
getPdfData(). - Kết xuất tại biên với Cloudflare — biến thể signed-URL và kết xuất tại biên đặc thù R2 của mẫu này.
- Tạo một PDF trong một job được xếp hàng — chuyển việc dựng và tải lên ra khỏi luồng request.