跳到內容
getnextpdf.com

透過一個有簽章、會過期的 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:

  1. 產生。 建構文件並呼叫 getPdfData() 取得位元組。
  2. 儲存。 把那些位元組寫到一個物件儲存 key(reports/2026/r-42.pdf)。
  3. 簽章。 向框架或雲端 SDK 索取一個指向該 key 的有簽章 URL,帶一個過期時間,並把該 URL 回傳給用戶端。

為什麼要儲存並簽章,而不是代理位元組:

  • 卸載頻寬。 物件儲存(或它的內容遞送網路邊緣) 服務下載。你的應用程式 worker 回傳幾百位元組的 JSON 後立刻空出來,而不是被一個數 MB 的傳輸綁住整段時間。
  • 限縮存取範圍。 一個有簽章的 URL 在一個 有界時窗 內,授予對 一個物件 的存取。bucket 本身保持私有。沒有公開路由可暴力破解,也沒有廣泛的 bucket 讀取授權。
  • 過期。 簽章嵌入一個過期時間戳記。在它過去之後,連結就失效了。一個洩漏的 URL 會自行停止運作,這就界定了一次意外分享的爆炸半徑。

有兩種截然不同的簽章模型,它們的差別在於 什麼 被簽章:

  • 物件儲存預簽 URL(S3、GCS,或在 S3/GCS disk 上的 Laravel temporaryUrl()直接指向儲存物件。下載永遠不抵達你的 app。
  • 應用程式有簽章路由(Laravel URL::temporarySignedRoute()、Symfony UriSigner)指向 你自己的路由。請求仍然打到你的 app,由它驗證簽章,再串流或重新導向到該物件。當你需要在每次下載上執行授權、記錄或計帳,或當你的儲存無法預簽時,使用這些。
ConcernNextPDFLaravelSymfony
Get PDF bytesNextPDF\Core\Document::getPdfData(): stringsamesame
Store bytesStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) or Flysystem write()
Presigned storage URLStorage::disk($d)->temporaryUrl($key, $expiresAt)AWS/GCS SDK presigner (below)
Signed app routeURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Verify a signed app routesigned 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 的檔案系統抽象層會替你簽章。在一個 S3(或 S3 相容) disk 上,Storage::temporaryUrl() 回傳一個直達物件的預簽 URL。 用戶端從儲存下載;你的 action 只回傳 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 必須是其驅動程式支援臨時 URL 的那一種——所附的 s3 驅動程式支援。在 local 驅動程式上呼叫 temporaryUrl() 會擲出例外,除非你為它註冊一個產生器,因為本機 disk 沒有東西可預簽。

當你寧可把下載保留在你自己的路由上——為了執行逐請求授權或記錄每次存取——就改用 URL::temporarySignedRoute() 簽章一個路由。該路由的 signed middleware 會在你的 action 執行之前,拒絕被竄改或已過期的連結。

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 沒有 Laravel 那種儲存 facade,所以你用框架的 Symfony\Component\HttpFoundation\UriSigner 簽章 你自己的路由,再讓那個路由重新導向到一個預簽儲存 URL(或串流該物件)。UriSigner::sign() 附上一個帶 key 的雜湊;checkRequest() 拒絕被竄改的連結。為了讓範例可跨 Symfony 版本攜帶,在簽章 之前 嵌入你自己的 expires query 參數(一個數分鐘後的 Unix 時間戳記),再在簽章驗證通過後,於 download 路由中自己驗證那個參數。這在每一個 Symfony 版本上都有效, 因為 UriSigner::sign(string $uri) 只接受 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 以一個祕密建構(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() 的簽名;上面的可攜模式無論如何都有效。

如果你直接用一個雲端 SDK 簽章,而不是透過框架 disk,那麼形狀是一樣的:放上物件,再請 SDK 為它預簽一個 GET。 這是純粹的 S3(GCS 流程與它相仿:用 $bucket->object($key) 取得物件,並呼叫 $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();

對於 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 的 Symfony APP_SECRET 來自環境變數或祕密管理器,絕不是已提交的設定。立刻輪替簽章祕密會作廢每一個尚未過期的有簽章路由。
  • 在 app 簽章路由上服務前先驗證。 當下載穿越你的 app 時(Laravel signed middleware、Symfony UriSigner::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 操作——而它們確切的簽名、支援的驅動程式與最大過期時窗,由那些上游專案治理。請查閱它們的文件,以了解每個平台上權威的合約。