通过签名的、会过期的 URL 交付生成的 PDF
你生成了一个可移植文档格式(PDF)文件,并需要把它交给客户端。最简单的路径是把字节直接通过控制器流式返回,但那会在整个下载过程中占用一个应用 worker、让流量穿过你的服务器,并把文件暴露给任何能访问该路由的人。本页的交付模式做的恰恰相反:生成 PDF,把字节存入对象存储,并返回一个短寿命的签名统一资源定位符(URL),由客户端直接从存储获取。你的应用交回一小段 JavaScript 对象表示法(JSON)载荷,其中带有一个 URL;存储则负责提供字节。
NextPDF 这一侧只是一次调用:文档上的 getPdfData() 返回原始的 PDF 二进制内容作为一个字符串。其后的一切——放置对象并铸造一个有时限的签名链接——都是你的框架或你的云服务商的工作。这些签名原语是真实、有文档记载的 API:Laravel Storage::temporaryUrl() 与 URL::temporarySignedRoute()、Symfony UriSigner,以及 Amazon Simple Storage Service(S3)或 Google Cloud Storage(GCS)软件开发工具包(SDK)中的预签名 URL 操作。NextPDF 没有定义自己的任何 URL 辅助工具;不要去找一个。
先检查这些部件:
- NextPDF 核心已安装,并且你能构建一份文档。
- 你拥有框架能为之签名的对象存储:一个 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 后立即获释,而不是被占住直到一次数兆字节的传输结束。
- 限定访问范围。 一个签名 URL 在一个有界窗口内授予对一个对象的访问权。bucket 本身保持私有。没有可供暴力破解的公共路由,也没有宽泛的 bucket 读取授权。
- 过期。 签名内嵌一个过期时间戳。它过去之后,链接即失效。一个泄漏的 URL 会自行停止工作,这把一次意外分享的爆炸半径限制了下来。
有两种截然不同的签名模型,它们的差异在于什么被签名:
- 对象存储预签名 URL(S3、GCS,或在 S3/GCS disk 上的 Laravel
temporaryUrl())直接指向存储对象。下载根本不抵达你的应用。 - 应用签名路由(Laravel
URL::temporarySignedRoute()、SymfonyUriSigner)指向你自己的路由。请求仍会命中你的应用,由它校验签名,然后流式返回或重定向到对象。当你需要对每次下载运行授权、日志记录或计量时,或当你的存储无法预签名时,请使用它们。
API 接口
标题为“API 接口”的章节| 关注点 | NextPDF | Laravel | Symfony |
|---|---|---|---|
| 取得 PDF 字节 | NextPDF\Core\Document::getPdfData(): string | same | same |
| 存储字节 | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) or Flysystem write() |
| 预签名存储 URL | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK presigner (below) |
| 签名应用路由 | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| 校验一个签名应用路由 | — | signed route middleware / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
这套交付模式所需的唯一一个 NextPDF 引擎调用是 getPdfData();文档本身则按你的应用既有的方式构建(例如,注入的 DocumentFactoryInterface / Symfony 的 PdfFactory)。getPdfData() 声明在 NextPDF\Core\Document 上的 HasOutput trait 中。它调用写入器一次,并把整份 PDF 作为一个字符串返回。其同胞 save(string $path): void 通过一个原子写入器把相同的字节写到磁盘;只有当你的存储是一个真实的本地文件系统路径时才使用它。对于对象存储,请优先使用 getPdfData(),并让存储 SDK 拥有那次传输。
文档是在你调用
getPdfData()(或save())时构建的,而该构建并非幂等。每份文档只调用它一次,捕获那个字符串,并把那个字符串同时用于上传以及你计算的任何大小或校验和。
代码示例 — 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 中间件会在你的 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() 追加一个带密钥的哈希;checkRequest() 会拒绝一个被篡改的链接。为了让示例在各 Symfony 版本间保持可移植,请在签名之前内嵌你自己的 expires 查询参数(一个若干分钟之后的 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 查询参数随行。签名覆盖了该参数,所以它无法被篡改——而在 checkRequest() 通过之后,download 路由会把时间戳与当前时间比较来强制执行它,并在它过去之后返回 410 Gone。
在那些
UriSigner::sign()接受一个过期时间DateTimeInterface参数的 Symfony 版本上,你可以直接传入过期时间——$signer->sign($url, new \DateTimeImmutable('+10 minutes'))——并让checkRequest()替你拒绝过期链接,从而省去手动的expires参数及其检查。在依赖它之前,请在你已安装的 Symfony 中确认UriSigner::sign()的签名;上面的可移植模式无论如何都有效。
代码示例 — 云 SDK 预签名 URL
标题为“代码示例 — 云 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、校验和或ETag。不要再次调用它来“重读”字节。 temporaryUrl()需要一个能预签名的驱动。 Laravel 的s3驱动能预签名;local驱动在temporaryUrl()上会抛出异常,除非你用Storage::disk('local')->buildTemporaryUrlsUsing(...)注册一个自定义生成器。请挑一个能签名的 disk,或改为签名一个应用路由。- 设置对象内容类型。 用
Content-Type: application/pdf(ContentType上传选项,或 disk 元数据)存储,以便浏览器把预签名链接当作一个 PDF 打开,而不是下载成一个octet-stream。 - 过短的过期时间可能熬不过一个慢客户端。 如果用户在你铸造链接很久之后才点击它,一个 60 秒的窗口可能已经失效。请把过期时间设定到铸造与首字节之间的实际间隔——以分钟计,而非以秒计——并按需重新铸造,而不是把它拉长到几小时。
- 签名 URL 是持票(bearer)访问。 任何在它过期前持有该 URL 的人都能下载对象。请保持过期时间短、优先采用单对象范围,并绝不记录完整的签名 URL——签名实际上就是一个令牌。
- 不要在对象 key 中内嵌未经清理的用户输入。 用你可控的值加上随机字节(
bin2hex(random_bytes(16)))来构建 key。一个可预测的 key 一旦 bucket 哪怕被部分暴露,就会招致枚举。
这种模式用一次上传加一个微小的 JSON 响应,换取一次同步传输。应用 worker 只在 PDF 构建与向存储上传期间被占用,而不是在客户端的完整下载期间。下载本身在客户端与存储(或其边缘)之间进行,因此它根本不占用一个应用 worker。
构建仍是同步的,并且对于大型或多页文档仍占主导——getPdfData() 会在你能上传之前把整份 PDF 在内存中实体化。对于重型文档,请把生成与上传移入一个队列工作(queued job),并以带外(out of band)方式交付签名 URL(例如,在对象就绪时通知客户端)。参见 在队列工作中生成 PDF。
安全注意事项
标题为“安全注意事项”的章节- 保持 bucket 私有;让签名授予访问权。 绝不要为“简化”交付而把对象设为公开可读。整个要点就在于访问只通过一个短寿命签名流转。
- 短而限定范围的过期时间。 按适合你流程的最小窗口签名,并把每个 URL 限定到单个对象。一个泄漏的链接随后会自行过期,且不会暴露其他任何东西。
- 来自环境的密钥。 S3/GCS 凭据以及支撑
UriSigner的 SymfonyAPP_SECRET,都来自环境变量或密钥管理器,绝不在已提交的配置里。轮换签名密钥会立即使每一个尚未过期的签名路由失效。 - 在应用签名路由上,先校验再提供服务。 当下载穿越你的应用时(Laravel
signed中间件、SymfonyUriSigner::checkRequest()),请在任何存储访问或授权之前校验签名。用一个已定义的状态拒绝一个被篡改或已过期的链接。 - 绝不记录完整的签名 URL。 签名是一个持票凭据。请记录对象 key 与一个关联标识符,而不是签名 URL,并在失败时记录异常类——绝不记录消息或堆栈跟踪。
- 没有空
catch。 每个示例都会记录失败类并返回一个已定义的错误响应。
合规性
标题为“合规性”的章节本指南不提出任何规范性标准主张。这套交付模式所需的唯一一个 NextPDF 引擎调用是 NextPDF\Core\Document::getPdfData(),这个已验证的公共方法返回原始的 PDF 二进制内容;文档本身则按你的应用既有的方式构建(例如,注入的 DocumentFactoryInterface / Symfony 的 PdfFactory)。签名原语是有文档记载的框架与云 API——Laravel Storage::temporaryUrl() 与 URL::temporarySignedRoute()、Symfony UriSigner,以及 S3/GCS 预签名 URL 的 SDK 操作——它们确切的签名、所支持的驱动,以及最大过期窗口,由那些上游项目治理。关于每个平台上的权威契约,请查阅它们的文档。
另请参阅
标题为“另请参阅”的章节- 从控制器返回生成的 PDF——当你不想让对象存储参与其中时,直接流式返回字节。
- 将生成的大型 PDF 作为 HTTP 响应流式返回——
getPdfData()背后缓冲式与流式的内存模型。 - 使用 Cloudflare 在边缘渲染——这种模式针对 R2 的签名 URL 与边缘渲染变体。
- 在队列工作中生成 PDF——把构建与上传移出请求线程。