透過一個有簽章、會過期的 URL 遞送已產生的 PDF
快速概覽
標題為「快速概覽」的區段你產生一份可攜式文件格式(PDF)檔,並需要把它交給一個用戶端。最簡單的路徑是把位元組直接透過控制器串流,但那會把一個應用程式 worker 綁住整段下載、把流量跑過你的伺服器,並把該檔案暴露給任何能抵達該路由的人。 本頁的遞送模式做相反的事:產生 PDF、把位元組存進物件儲存,並回傳一個短壽命的 有簽章的統一資源定位符(URL),讓用戶端直接從儲存抓取。你的 app 交回一個帶有 URL 的小型 JavaScript 物件表示法(JSON)酬載;儲存服務位元組。
NextPDF 端就一個呼叫:文件上的 getPdfData() 回傳原始
PDF 二進位作為字串。那之後的一切——放上物件並鑄造一個有時限的有簽章連結——是你框架或雲端供應商的工作。簽章原語都是真實、有記錄的 API:Laravel Storage::temporaryUrl()
與 URL::temporarySignedRoute()、Symfony UriSigner,以及 Amazon Simple
Storage Service(S3)或 Google Cloud Storage(GCS)在其軟體開發套件(SDK)中的預簽 URL 操作。NextPDF 不定義自己的 URL helper;
不要去找一個。
請先確認這些部分:
- NextPDF core 已安裝,且你能建構一份文件。
- 你擁有框架能簽章的物件儲存:一個 S3 或 S3 相容的 bucket、一個 GCS bucket,或一個其驅動程式支援臨時 URL 的 Laravel disk。
- 憑證放在環境變數或祕密管理器中,絕不放在已提交的設定裡。
這是一篇 how-to。它假設你已經知道如何把請求路由到一個控制器。若要改為直接回傳位元組,見 從控制器回傳已產生的 PDF。
概念總覽
標題為「概念總覽」的區段這個模式有三個步驟,只有第一個碰到 NextPDF:
- 產生。 建構文件並呼叫
getPdfData()取得位元組。 - 儲存。 把那些位元組寫到一個物件儲存 key(
reports/2026/r-42.pdf)。 - 簽章。 向框架或雲端 SDK 索取一個指向該 key 的有簽章 URL,帶一個過期時間,並把該 URL 回傳給用戶端。
為什麼要儲存並簽章,而不是代理位元組:
- 卸載頻寬。 物件儲存(或它的內容遞送網路邊緣) 服務下載。你的應用程式 worker 回傳幾百位元組的 JSON 後立刻空出來,而不是被一個數 MB 的傳輸綁住整段時間。
- 限縮存取範圍。 一個有簽章的 URL 在一個 有界時窗 內,授予對 一個物件 的存取。bucket 本身保持私有。沒有公開路由可暴力破解,也沒有廣泛的 bucket 讀取授權。
- 過期。 簽章嵌入一個過期時間戳記。在它過去之後,連結就失效了。一個洩漏的 URL 會自行停止運作,這就界定了一次意外分享的爆炸半徑。
有兩種截然不同的簽章模型,它們的差別在於 什麼 被簽章:
- 物件儲存預簽 URL(S3、GCS,或在 S3/GCS disk 上的
Laravel
temporaryUrl())直接指向儲存物件。下載永遠不抵達你的 app。 - 應用程式有簽章路由(Laravel
URL::temporarySignedRoute()、SymfonyUriSigner)指向 你自己的路由。請求仍然打到你的 app,由它驗證簽章,再串流或重新導向到該物件。當你需要在每次下載上執行授權、記錄或計帳,或當你的儲存無法預簽時,使用這些。
API 介面
標題為「API 介面」的區段| Concern | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Get PDF bytes | NextPDF\Core\Document::getPdfData(): string | same | same |
| Store bytes | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) or Flysystem write() |
| Presigned storage URL | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK presigner (below) |
| Signed app route | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Verify a signed app route | — | signed route middleware / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
這個遞送模式唯一需要的 NextPDF 引擎呼叫是 getPdfData();
文件本身怎麼建構,就照你的 app 平常建構文件的方式(例如注入的
DocumentFactoryInterface / Symfony PdfFactory)。getPdfData()
宣告在 NextPDF\Core\Document 上的 HasOutput trait 中。它呼叫寫入器一次,並回傳整份
PDF 作為字串。它的手足 save(string $path): void 透過一個原子寫入器把同樣的位元組寫到磁碟;只在你的儲存是一個真實本機檔案系統路徑時才用它。對於物件儲存,
請優先用 getPdfData() 並讓儲存 SDK 主導傳輸。
文件在你呼叫
getPdfData()(或save())時被建構,且該建構不是冪等的。每份文件 只 呼叫一次,捕捉該字串,並把那個字串重用於上傳與任何你計算的大小或 checksum。
程式碼範例 — Laravel 臨時 URL
標題為「程式碼範例 — Laravel 臨時 URL」的區段Laravel 的檔案系統抽象層會替你簽章。在一個 S3(或 S3 相容)
disk 上,Storage::temporaryUrl() 回傳一個直達物件的預簽 URL。
用戶端從儲存下載;你的 action 只回傳 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 必須是其驅動程式支援臨時 URL 的那一種——所附的 s3
驅動程式支援。在 local 驅動程式上呼叫 temporaryUrl() 會擲出例外,除非你為它註冊一個產生器,因為本機 disk 沒有東西可預簽。
當你寧可把下載保留在你自己的路由上——為了執行逐請求授權或記錄每次存取——就改用
URL::temporarySignedRoute() 簽章一個路由。該路由的 signed middleware 會在你的
action 執行之前,拒絕被竄改或已過期的連結。
<?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 UriSigner
標題為「程式碼範例 — Symfony UriSigner」的區段Symfony 沒有 Laravel 那種儲存 facade,所以你用框架的
Symfony\Component\HttpFoundation\UriSigner 簽章 你自己的路由,再讓那個路由重新導向到一個預簽儲存 URL(或串流該物件)。UriSigner::sign()
附上一個帶 key 的雜湊;checkRequest() 拒絕被竄改的連結。為了讓範例可跨 Symfony 版本攜帶,在簽章 之前 嵌入你自己的 expires query
參數(一個數分鐘後的 Unix 時間戳記),再在簽章驗證通過後,於
download 路由中自己驗證那個參數。這在每一個 Symfony 版本上都有效,
因為 UriSigner::sign(string $uri) 只接受 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 以一個祕密建構(Symfony 從
%kernel.secret% / APP_SECRET 參數自動接線注入它)。上面的範例是可攜的路徑:UriSigner::sign(string $uri) 只簽章 URL,且存在於每一個
Symfony 版本,所以過期時間以你自己的 expires query 參數隨行。
簽章涵蓋那個參數,所以它無法被竄改——而在
checkRequest() 通過後,download 路由透過把時間戳記與目前時間比較,並在它已過去時回傳 410 Gone 來執行它。
在那些
UriSigner::sign()接受一個過期DateTimeInterface引數的 Symfony 版本上,你可以直接傳入過期時間——$signer->sign($url, new \DateTimeImmutable('+10 minutes'))——並讓checkRequest()替你拒絕已過期的連結,省掉手動的expires參數及其檢查。在依賴它之前,先在你安裝的 Symfony 中確認UriSigner::sign()的簽名;上面的可攜模式無論如何都有效。
程式碼範例 — Cloud SDK 預簽 URL
標題為「程式碼範例 — Cloud SDK 預簽 URL」的區段如果你直接用一個雲端 SDK 簽章,而不是透過框架 disk,那麼形狀是一樣的:放上物件,再請 SDK 為它預簽一個 GET。
這是純粹的 S3(GCS 流程與它相仿:用 $bucket->object($key) 取得物件,並呼叫 $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();對於 GCS,用 getPdfData() 以同樣方式建構位元組,用
Cloud Storage 用戶端上傳物件,再用 $bucket->object($key) 取得儲存物件並呼叫 $object->signedUrl($expiresAt, [...]),搭配一個
Carbon/DateTime 過期時間來鑄造對等的連結。兩個供應商上的有簽章 URL 過期時間都受憑證型別所界定;請查閱供應商的文件,以了解你的憑證所允許的最大壽命。
邊界情況與陷阱
標題為「邊界情況與陷阱」的區段- 每份文件只建構一次。
getPdfData()觸發建構,且該建構不是冪等的。呼叫一次,握住該字串,並把它重用於上傳與任何你計算的Content-Length、checksum 或ETag。不要再呼叫它一次來「重讀」位元組。 temporaryUrl()需要一個能預簽的驅動程式。 Laravel 的s3驅動程式會預簽;local驅動程式在temporaryUrl()上會擲出例外,除非你用Storage::disk('local')->buildTemporaryUrlsUsing(...)註冊一個自訂產生器。 選一個能簽章的 disk,或改為簽章一個 app 路由。- 設定物件的內容型別。 以
Content-Type: application/pdf儲存(ContentType上傳選項,或 disk metadata),讓瀏覽器把預簽連結當成 PDF 開啟,而不是下載成一個octet-stream。 - 一個短過期可能撐不過慢用戶端。 如果使用者在你鑄造連結很久之後才點,一個 60 秒的時窗可能早已失效。把過期時間調校到鑄造與第一個位元組之間的實際間隔——是分鐘,不是秒—— 並按需重新鑄造,而不是把它拉長到數小時。
- 一個有簽章的 URL 就是 bearer 存取。 任何在它過期前持有該 URL 的人都能下載該物件。讓過期時間保持短、優先用單一物件範圍,並絕不記錄完整的有簽章 URL——簽章實質上就是一個 token。
- 不要把未經消毒的使用者輸入嵌入物件 key。 用你掌控的值加上隨機位元組來建構 key(
bin2hex(random_bytes(16)))。一個可預測的 key 在 bucket 哪怕只暴露一部分時都會招來列舉。
這個模式把一個同步傳輸換成一個上傳加一個小型 JSON 回應。應用程式 worker 只在 PDF 建構與上傳到儲存期間被綁住,而不是整段用戶端完整下載。下載本身在用戶端與儲存(或它的邊緣)之間進行,所以它完全不消耗一個 app worker。
對於大型或多頁文件,建構仍然是同步的、仍然主導——
getPdfData() 在你能上傳之前,把整份 PDF 在記憶體中實現。
對於繁重的文件,把產生與上傳移進一個 queue 的工作,並把有簽章 URL 帶外遞送(例如,在物件就緒時通知用戶端)。見
在 queue 工作中產生 PDF。
安全性說明
標題為「安全性說明」的區段- 讓 bucket 保持私有;讓簽章授予存取。 絕不為了「簡化」 遞送而把物件設為公開可讀。整個重點在於存取只透過一個短壽命的簽章流動。
- 短而限縮的過期。 簽章一個能配合你流程的最小時窗,並把每個 URL 限縮到單一物件。一個洩漏的連結就會自行過期,且不暴露其他任何東西。
- 祕密來自環境。 S3/GCS 憑證與支撐
UriSigner的 SymfonyAPP_SECRET來自環境變數或祕密管理器,絕不是已提交的設定。立刻輪替簽章祕密會作廢每一個尚未過期的有簽章路由。 - 在 app 簽章路由上服務前先驗證。 當下載穿越你的
app 時(Laravel
signedmiddleware、SymfonyUriSigner::checkRequest()),在任何儲存存取或授權 之前 驗證簽章。以一個已定義的狀態拒絕被竄改或已過期的連結。 - 絕不記錄完整的有簽章 URL。 簽章是一個 bearer 憑證。記錄物件 key 與一個關聯識別碼,而非有簽章 URL,並在失敗時記錄例外類別——絕不記錄訊息或堆疊追蹤。
- 不要有空的
catch。 每個範例都記錄失敗類別並回傳一個已定義的錯誤回應。
符合性
標題為「符合性」的區段本指南未提出任何規範性標準主張。這個遞送模式唯一需要的
NextPDF 引擎呼叫是 NextPDF\Core\Document::getPdfData(),這個已驗證的公開方法回傳原始 PDF 二進位;文件本身怎麼建構,就照你的
app 平常建構文件的方式(例如注入的
DocumentFactoryInterface / Symfony PdfFactory)。簽章原語是有記錄的框架與雲端 API——
Laravel Storage::temporaryUrl() 與 URL::temporarySignedRoute()、Symfony
UriSigner,以及 S3/GCS 預簽 URL 的 SDK 操作——而它們確切的簽名、支援的驅動程式與最大過期時窗,由那些上游專案治理。請查閱它們的文件,以了解每個平台上權威的合約。
另請參閱
標題為「另請參閱」的區段- 從控制器回傳已產生的 PDF——當你不想讓物件儲存進入流程時,直接串流位元組。
- 把大型已產生 PDF 以 HTTP 回應串流——
getPdfData()背後緩衝對串流的記憶體模型。 - 在邊緣以 Cloudflare 繪製——這個模式的 R2 專屬有簽章 URL 與邊緣繪製變體。
- 在 queue 工作中產生 PDF——把建構與上傳移出請求執行緒。