Доставка сгенерированного PDF через подписанный URL с истечением срока
Вы генерируете файл Portable Document Format (PDF) и вам нужно выдать его клиенту. Простейший путь — потоково отдать байты прямо через контроллер, но это занимает воркер приложения на всю загрузку, пропускает трафик через ваши серверы и открывает файл всем, кто может достучаться до маршрута. Паттерн доставки на этой странице делает противоположное: сгенерировать PDF, сохранить байты в объектном хранилище и вернуть короткоживущий подписанный единый указатель ресурса (URL), который клиент получает напрямую из хранилища. Ваше приложение возвращает небольшую полезную нагрузку JavaScript Object Notation (JSON) с URL; хранилище отдаёт байты.
Со стороны NextPDF это один вызов: getPdfData() на документе возвращает сырые
бинарные данные PDF как строку. Всё после этого — размещение объекта и создание
ограниченной по времени подписанной ссылки — это работа вашего фреймворка или вашего
облачного провайдера. Примитивы подписи — это реальные, документированные API: Laravel
Storage::temporaryUrl() и URL::temporarySignedRoute(), Symfony UriSigner, а также
операции предподписанных URL Amazon Simple Storage Service (S3) или Google Cloud Storage
(GCS) в их наборах средств разработки (SDK). NextPDF не определяет собственного помощника
для URL; не ищите его.
Проверьте сначала эти части:
- NextPDF core установлен, и вы можете построить документ.
- У вас есть объектное хранилище, для которого фреймворк может подписать: bucket S3 или S3-совместимый, bucket GCS или диск Laravel, чей драйвер поддерживает временные URL.
- Учётные данные живут в переменных окружения или менеджере секретов, никогда в закоммиченной конфигурации.
Это операционное руководство. Оно предполагает, что вы уже знаете, как направить запрос к контроллеру. Для возврата байтов напрямую вместо этого см. Возврат сгенерированного PDF из контроллера.
Концептуальный обзор
Заголовок раздела «Концептуальный обзор»Паттерн состоит из трёх шагов, и только первый касается NextPDF:
- Сгенерировать. Постройте документ и вызовите
getPdfData(), чтобы получить байты. - Сохранить. Запишите эти байты по ключу объектного хранилища (
reports/2026/r-42.pdf). - Подписать. Попросите у фреймворка или облачного SDK подписанный URL к этому ключу, с истечением срока, и верните URL клиенту.
Почему сохранять и подписывать вместо проксирования байтов:
- Разгрузка пропускной способности. Объектное хранилище (или его edge сети доставки контента) отдаёт загрузку. Воркер вашего приложения возвращает несколько сотен байт JSON и освобождается немедленно, вместо того чтобы быть занятым на длину многомегабайтной передачи.
- Ограничение доступа. Подписанный URL даёт доступ к одному объекту на ограниченное окно. Сам bucket остаётся приватным. Нет публичного маршрута для перебора и нет широкого гранта на чтение bucket.
- Истечение срока. Подпись встраивает отметку времени истечения. После того как она проходит, ссылка мертва. Утёкший URL перестаёт работать сам по себе, что ограничивает радиус поражения от случайной передачи.
Есть две различные модели подписи, и они отличаются тем, что подписывается:
- Предподписанные URL объектного хранилища (S3, GCS или Laravel
temporaryUrl()поверх диска S3/GCS) указывают прямо на объект хранилища. Загрузка вообще не достигает вашего приложения. - Подписанные маршруты приложения (Laravel
URL::temporarySignedRoute(), SymfonyUriSigner) указывают на ваш собственный маршрут. Запрос всё ещё попадает в ваше приложение, которое проверяет подпись, затем потоково отдаёт или перенаправляет к объекту. Используйте их, когда нужно выполнять авторизацию, журналирование или учёт на каждой загрузке, или когда ваше хранилище не может предподписывать.
Поверхность API
Заголовок раздела «Поверхность API»| Задача | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Получить байты PDF | NextPDF\Core\Document::getPdfData(): string | то же | то же |
| Сохранить байты | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) или Flysystem write() |
| Предподписанный URL хранилища | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | предподписыватель AWS/GCS SDK (ниже) |
| Подписанный маршрут приложения | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Проверить подписанный маршрут приложения | — | middleware маршрута signed / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
Единственный ВЫЗОВ движка NextPDF, который требует этот паттерн доставки, — это getPdfData();
сам документ строится так, как ваше приложение уже строит документы (например, внедрённый
DocumentFactoryInterface / Symfony PdfFactory). getPdfData()
объявлен в трейте HasOutput на NextPDF\Core\Document. Он вызывает writer один раз и возвращает весь
PDF как строку. Его собрат save(string $path): void записывает те же байты на
диск через атомарный writer; используйте его только когда ваше хранилище — реальный локальный
путь файловой системы. Для объектного хранилища предпочитайте getPdfData() и дайте SDK
хранилища владеть передачей.
Документ строится, когда вы вызываете
getPdfData()(илиsave()), и сборка не идемпотентна. Вызывайте её один раз на документ, захватите строку и переиспользуйте эту строку и для загрузки, и для любого размера или контрольной суммы, которые вы вычисляете.
Пример кода — временный URL Laravel
Заголовок раздела «Пример кода — временный URL Laravel»Абстракция файловой системы Laravel подписывает за вас. На диске S3 (или S3-совместимом),
Storage::temporaryUrl() возвращает предподписанный URL прямо к объекту.
Клиент загружает из хранилища; ваше действие возвращает только 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); } }}Диск должен быть таким, чей драйвер поддерживает временные URL — встроенный драйвер s3
поддерживает. Вызов temporaryUrl() на драйвере local бросает исключение, пока вы не
зарегистрируете для него генератор, потому что у локального диска нечего предподписывать.
Когда вы предпочли бы держать загрузку на собственном маршруте — чтобы выполнять
авторизацию на каждый запрос или журналировать каждый доступ — вместо этого подпишите
маршрут с помощью URL::temporarySignedRoute(). Middleware signed маршрута отклоняет
подделанную или истёкшую ссылку до того, как ваше действие выполнится.
<?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, поэтому вы подписываете ваш собственный
маршрут через Symfony\Component\HttpFoundation\UriSigner фреймворка, а затем этот
маршрут перенаправляет на предподписанный URL хранилища (или потоково отдаёт объект).
UriSigner::sign() добавляет ключевой хеш; checkRequest() отклоняет подделанную ссылку.
Чтобы пример оставался переносимым между версиями Symfony, встройте свой собственный
query-параметр 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, поэтому истечение срока путешествует как ваш собственный query-параметр expires.
Подпись покрывает этот параметр, поэтому его нельзя подделать — и после того, как
checkRequest() проходит, маршрут download обеспечивает его, сравнивая
отметку времени с текущим временем и возвращая 410 Gone, как только она в
прошлом.
На версиях Symfony, чей
UriSigner::sign()принимает аргумент истечения срокаDateTimeInterface, вы можете передать истечение напрямую —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— и датьcheckRequest()отклонять истёкшие ссылки за вас, отбросив ручной параметрexpiresи его проверку. Подтвердите сигнатуруUriSigner::sign()в вашей установленной Symfony, прежде чем полагаться на неё; переносимый паттерн выше работает независимо.
Пример кода — предподписанный URL облачного SDK
Заголовок раздела «Пример кода — предподписанный URL облачного SDK»Если вы подписываете напрямую облачным SDK, а не через диск фреймворка,
форма та же: разместить объект, затем попросить 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()нуждается в драйвере с поддержкой предподписи. Драйверs3Laravel предподписывает; драйверlocalбросает наtemporaryUrl(), пока вы не зарегистрируете кастомный генератор черезStorage::disk('local')->buildTemporaryUrlsUsing(...). Выберите диск, который может подписывать, или вместо этого подпишите маршрут приложения.- Задайте тип содержимого объекта. Сохраняйте с
Content-Type: application/pdf(опция загрузкиContentTypeили метаданные диска), чтобы браузер открывал предподписанную ссылку как PDF, а не загружалoctet-stream. - Короткое истечение может пережить медленного клиента. Если пользователь нажимает ссылку значительно позже того, как вы её создали, окно в 60 секунд может уже быть мёртвым. Подбирайте истечение под реалистичный разрыв между созданием и первым байтом — минуты, а не секунды — и создавайте заново по требованию, а не растягивайте до часов.
- Подписанный URL — это доступ предъявителя. Любой, держащий URL до его истечения, может загрузить объект. Держите истечения короткими, предпочитайте охват одного объекта и никогда не журналируйте полный подписанный URL — подпись фактически является токеном.
- Не встраивайте ввод пользователя в ключ объекта несанированным. Стройте ключи из
значений, которые вы контролируете, плюс случайные байты (
bin2hex(random_bytes(16))). Предсказуемый ключ приглашает к перебору, как только bucket хотя бы частично открыт.
Производительность
Заголовок раздела «Производительность»Этот паттерн меняет одну синхронную передачу на одну загрузку плюс крошечный ответ JSON. Воркер приложения занят только на сборку PDF и загрузку в хранилище, а не на полную загрузку клиента. Сама загрузка идёт между клиентом и хранилищем (или его edge), поэтому она вообще не потребляет воркер приложения.
Сборка по-прежнему синхронна и по-прежнему доминирует для больших или многостраничных
документов — getPdfData() реализует весь PDF в памяти до того, как вы сможете загрузить
его. Для тяжёлых документов перенесите генерацию и загрузку в задачу очереди и доставьте
подписанный URL вне основного потока (например, уведомив клиента, когда объект
готов). См.
Генерация PDF в задаче очереди.
Примечания по безопасности
Заголовок раздела «Примечания по безопасности»- Держите bucket приватным; пусть подпись даёт доступ. Никогда не делайте объект публично читаемым, чтобы “упростить” доставку. Весь смысл в том, что доступ идёт только через короткоживущую подпись.
- Короткое, ограниченное истечение. Подписывайте на наименьшее окно, которое подходит вашему потоку, и ограничивайте каждый URL одним объектом. Утёкшая ссылка тогда истечёт сама по себе и не откроет ничего другого.
- Секреты из окружения. Учётные данные S3/GCS и Symfony
APP_SECRET, который стоит заUriSigner, берутся из переменных окружения или менеджера секретов, никогда из закоммиченной конфигурации. Ротация секрета подписи немедленно делает недействительным каждый невыполненный подписанный маршрут. - Проверяйте до обслуживания на маршрутах, подписанных приложением. Когда загрузка
пересекает ваше приложение (Laravel middleware
signed, SymfonyUriSigner::checkRequest()), проверяйте подпись до любого доступа к хранилищу или авторизации. Отклоняйте подделанную или истёкшую ссылку с определённым статусом. - Никогда не журналируйте полный подписанный URL. Подпись — это учётные данные предъявителя. Журналируйте ключ объекта и идентификатор корреляции, а не подписанный URL, и журналируйте класс исключения при сбое — никогда сообщение или трассировку стека.
- Никаких пустых
catch. Каждый пример журналирует класс сбоя и возвращает определённый ответ об ошибке.
Соответствие
Заголовок раздела «Соответствие»Это руководство не делает нормативного заявления о стандартах. Единственный ВЫЗОВ движка
NextPDF, который требует этот паттерн доставки, — это NextPDF\Core\Document::getPdfData(),
проверенный публичный метод, который возвращает сырые бинарные данные PDF; сам документ
строится так, как ваше приложение уже строит документы (например, внедрённый
DocumentFactoryInterface / Symfony PdfFactory). Примитивы подписи — это документированные
API фреймворка и облака —
Laravel Storage::temporaryUrl() и URL::temporarySignedRoute(), Symfony
UriSigner и операции SDK предподписанных URL S3/GCS — и их точные
сигнатуры, поддерживаемые драйверы и максимальные окна истечения управляются этими
вышестоящими проектами. Сверьтесь с их документацией на предмет авторитетного контракта на
каждой платформе.
См. также
Заголовок раздела «См. также»- Возврат сгенерированного PDF из контроллера — потоково отдавайте байты напрямую, когда не хотите объектного хранилища в цепочке.
- Потоковая передача большого сгенерированного PDF как ответ HTTP — модель памяти буферизованной-против-потоковой за
getPdfData(). - Отрисовка на edge с Cloudflare — специфичный для R2 вариант этого паттерна с подписанным URL и отрисовкой на edge.
- Генерация PDF в задаче очереди — перенесите сборку и загрузку вне потока запроса.