Bỏ qua để đến nội dung
getnextpdf.com

Giao một PDF được tạo qua một URL có chữ ký, hết hạn

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()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.

Mẫu này có ba bước, và chỉ bước đầu chạm tới NextPDF:

  1. Tạo. Dựng tài liệu và gọi getPdfData() để lấy các byte.
  2. Lưu. Ghi các byte đó vào một khóa lưu trữ đối tượng (reports/2026/r-42.pdf).
  3. 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(), Symfony UriSigner) 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.
Mối quan tâmNextPDFLaravelSymfony
Lấy các byte PDFNextPDF\Core\Document::getPdfData(): stringsamesame
Lưu các byteStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) or Flysystem write()
URL lưu trữ presignedStorage::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ặc save()), 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.

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.

app/Http/Controllers/ReportDeliveryController.php
<?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.

routes/web.php
<?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');

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.

src/Controller/ReportDeliveryController.php
<?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ạn DateTimeInterface, 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ố expires thủ công và phần kiểm tra của nó. Hãy xác nhận chữ ký của UriSigner::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ể.

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, [...])).

store-and-presign.php
<?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.

  • 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, hay ETag nà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. Driver s3 của Laravel presign được; driver local ném lỗi trên temporaryUrl() trừ khi bạn đăng ký một generator tùy chỉnh với Storage::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ên ContentType, hoặc metadata disk) để trình duyệt mở liên kết presigned như một PDF thay vì tải về một octet-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.

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.

  • 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_SECRET chống lưng UriSigner đế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, Symfony UriSigner::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ó catch rỗng. Mọi ví dụ đều ghi log lớp thất bại và trả về một response lỗi đã định.

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()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.