跳转到内容
getnextpdf.com

通过签名的、会过期的 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:

  1. 生成。 构建文档并调用 getPdfData() 取得字节。
  2. 存储。 把那些字节写到一个对象存储 key(reports/2026/r-42.pdf)。
  3. 签名。 向框架或云 SDK 索取一个指向该 key 的签名 URL,带一个过期时间,并把该 URL 返回给客户端。

为什么要存储并签名,而不是代理字节:

  • 卸载带宽。 由对象存储(或其内容分发网络边缘)来提供下载。你的应用 worker 返回几百字节的 JSON 后立即获释,而不是被占住直到一次数兆字节的传输结束。
  • 限定访问范围。 一个签名 URL 在一个有界窗口内授予对一个对象的访问权。bucket 本身保持私有。没有可供暴力破解的公共路由,也没有宽泛的 bucket 读取授权。
  • 过期。 签名内嵌一个过期时间戳。它过去之后,链接即失效。一个泄漏的 URL 会自行停止工作,这把一次意外分享的爆炸半径限制了下来。

有两种截然不同的签名模型,它们的差异在于什么被签名:

  • 对象存储预签名 URL(S3、GCS,或在 S3/GCS disk 上的 Laravel temporaryUrl()直接指向存储对象。下载根本不抵达你的应用。
  • 应用签名路由(Laravel URL::temporarySignedRoute()、Symfony UriSigner)指向你自己的路由。请求仍会命中你的应用,由它校验签名,然后流式返回或重定向到对象。当你需要对每次下载运行授权、日志记录或计量时,或当你的存储无法预签名时,请使用它们。
关注点NextPDFLaravelSymfony
取得 PDF 字节NextPDF\Core\Document::getPdfData(): stringsamesame
存储字节Storage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) or Flysystem write()
预签名存储 URLStorage::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 的文件系统抽象会替你签名。在一个 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 中间件会在你的 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() 追加一个带密钥的哈希;checkRequest() 会拒绝一个被篡改的链接。为了让示例在各 Symfony 版本间保持可移植,请在签名之前内嵌你自己的 expires 查询参数(一个若干分钟之后的 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 查询参数随行。签名覆盖了该参数,所以它无法被篡改——而在 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、校验和或 ETag。不要再次调用它来“重读”字节。
  • temporaryUrl() 需要一个能预签名的驱动。 Laravel 的 s3 驱动能预签名;local 驱动在 temporaryUrl() 上会抛出异常,除非你用 Storage::disk('local')->buildTemporaryUrlsUsing(...) 注册一个自定义生成器。请挑一个能签名的 disk,或改为签名一个应用路由。
  • 设置对象内容类型。Content-Type: application/pdfContentType 上传选项,或 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 的 Symfony APP_SECRET,都来自环境变量或密钥管理器,绝不在已提交的配置里。轮换签名密钥会立即使每一个尚未过期的签名路由失效。
  • 在应用签名路由上,先校验再提供服务。 当下载穿越你的应用时(Laravel signed 中间件、Symfony UriSigner::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 操作——它们确切的签名、所支持的驱动,以及最大过期窗口,由那些上游项目治理。关于每个平台上的权威契约,请查阅它们的文档。