Consegnare un PDF generato tramite un URL firmato e a scadenza
In sintesi
Sezione intitolata “In sintesi”Si genera un file Portable Document Format (PDF) e occorre consegnarlo a un client. Il percorso più semplice trasmette i byte direttamente attraverso un controller, ma questo tiene occupato un worker dell’applicazione per l’intero download, fa transitare il traffico attraverso i propri server ed espone il file a chiunque possa raggiungere la rotta. Il pattern di consegna di questa pagina fa l’opposto: generare il PDF, memorizzare i byte nell’object storage e restituire un Uniform Resource Locator (URL) firmato di breve durata che il client recupera direttamente dallo storage. La propria app restituisce un piccolo payload JavaScript Object Notation (JSON) con un URL; lo storage serve i byte.
Il lato NextPDF è una sola chiamata: getPdfData() sul documento restituisce il
binario PDF grezzo come stringa. Tutto ciò che segue — mettere l’oggetto e coniare
un link firmato a tempo limitato — è compito del proprio framework o del proprio
fornitore cloud. Le primitive di firma sono API reali e documentate: Laravel
Storage::temporaryUrl() e URL::temporarySignedRoute(), Symfony UriSigner, e
le operazioni di URL presigned di Amazon Simple Storage Service (S3) o Google
Cloud Storage (GCS) nei loro software development kit (SDK). NextPDF non definisce
alcun helper di URL proprio; non cercarne uno.
Controllare prima questi elementi:
- Il core di NextPDF è installato e si è in grado di costruire un documento.
- Si dispone di un object storage per cui il framework può firmare: un bucket S3 o compatibile con S3, un bucket GCS, o un disk Laravel il cui driver supporta gli URL temporanei.
- Le credenziali vivono in variabili d’ambiente o in un secrets manager, mai in config committata.
Questa è una guida pratica. Presuppone che si sappia già instradare una richiesta a un controller. Per restituire i byte direttamente invece, vedere Restituire un PDF generato da un controller.
Panoramica concettuale
Sezione intitolata “Panoramica concettuale”Il pattern ha tre passaggi, e solo il primo tocca NextPDF:
- Generare. Costruire il documento e chiamare
getPdfData()per ottenere i byte. - Memorizzare. Scrivere quei byte su una chiave di object storage
(
reports/2026/r-42.pdf). - Firmare. Chiedere al framework o all’SDK cloud un URL firmato per quella chiave, con una scadenza, e restituire l’URL al client.
Perché memorizzare e firmare anziché far transitare i byte:
- Scaricare la banda. L’object storage (o il suo edge content-delivery-network) serve il download. Il proprio worker dell’applicazione restituisce qualche centinaio di byte di JSON ed è libero immediatamente, anziché essere trattenuto per la durata di un trasferimento di più megabyte.
- Limitare l’accesso. Un URL firmato concede l’accesso a un oggetto per una finestra limitata. Il bucket stesso resta privato. Non c’è alcuna rotta pubblica da forzare a forza bruta e nessuna concessione ampia di lettura sul bucket.
- Scadenza. La firma incorpora un timestamp di scadenza. Dopo che è passato, il link è morto. Un URL trapelato smette di funzionare da solo, il che limita il raggio d’azione di una condivisione accidentale.
Ci sono due modelli di firma distinti, e differiscono in cosa viene firmato:
- Gli URL presigned dell’object storage (S3, GCS, o
temporaryUrl()di Laravel su un disk S3/GCS) puntano direttamente all’oggetto di storage. Il download non raggiunge mai la propria app. - Le rotte firmate dell’applicazione (Laravel
URL::temporarySignedRoute(), SymfonyUriSigner) puntano alla propria rotta. La richiesta colpisce comunque la propria app, che verifica la firma, poi trasmette in streaming o reindirizza all’oggetto. Usarle quando occorre eseguire autorizzazione, logging o accounting a ogni download, oppure quando il proprio storage non può fare il presigning.
Superficie API
Sezione intitolata “Superficie 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() |
L’unica CHIAMATA al motore NextPDF che questo pattern di consegna richiede è
getPdfData(); il documento stesso è costruito come la propria app già
costruisce i documenti (ad es. il DocumentFactoryInterface iniettato / il
PdfFactory di Symfony). getPdfData() è dichiarato nel trait HasOutput su
NextPDF\Core\Document. Chiama il writer una volta e restituisce l’intero PDF
come stringa. La sua controparte save(string $path): void scrive gli stessi
byte su disco tramite un writer atomico; usarla solo quando il proprio storage è
un percorso di filesystem locale reale. Per l’object storage, preferire
getPdfData() e lasciare che l’SDK di storage si occupi del trasferimento.
Il documento è costruito quando si chiama
getPdfData()(osave()), e la build non è idempotente. Chiamarla una volta per documento, catturare la stringa e riutilizzare quella stringa sia per l’upload sia per qualsiasi dimensione o checksum che si calcola.
Esempio di codice — URL temporaneo di Laravel
Sezione intitolata “Esempio di codice — URL temporaneo di Laravel”L’astrazione del filesystem di Laravel firma al posto vostro. Su un disk S3 (o
compatibile con S3), Storage::temporaryUrl() restituisce un URL presigned
direttamente all’oggetto. Il client scarica dallo storage; la propria azione
restituisce solo 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); } }}Il disk deve essere uno il cui driver supporti gli URL temporanei — il driver
s3 incluso lo fa. Chiamare temporaryUrl() sul driver local solleva
un’eccezione a meno che non si registri un generatore per esso, perché un disk
locale non ha nulla da fare presigning.
Quando si preferisce mantenere il download sulla propria rotta — per eseguire
autorizzazione per richiesta o per registrare ogni accesso — firmare invece una
rotta con URL::temporarySignedRoute(). Il middleware signed della rotta
rifiuta un link manomesso o scaduto prima che la propria azione venga eseguita.
<?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');Esempio di codice — UriSigner di Symfony
Sezione intitolata “Esempio di codice — UriSigner di Symfony”Symfony non ha alcuna facade di storage in stile Laravel, quindi si firma la
propria rotta con lo Symfony\Component\HttpFoundation\UriSigner del
framework, poi si fa reindirizzare quella rotta a un URL di storage presigned (o
si trasmette in streaming l’oggetto). UriSigner::sign() aggiunge un hash con
chiave; checkRequest() rifiuta un link manomesso. Per mantenere l’esempio
portabile tra le versioni di Symfony, incorporare il proprio parametro di query
expires (un timestamp Unix di qualche minuto avanti) prima di firmare, poi
validare quel parametro da soli nella rotta download dopo che la firma è
risultata corretta. Questo funziona su ogni versione di Symfony, perché
UriSigner::sign(string $uri) accetta solo l’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 è costruito con un segreto (Symfony lo autowira dal parametro
%kernel.secret% / APP_SECRET). L’esempio sopra è il percorso portabile:
UriSigner::sign(string $uri) firma solo l’URL ed esiste su ogni versione di
Symfony, quindi la scadenza viaggia come il proprio parametro di query expires.
La firma copre quel parametro, quindi non può essere manomesso — e dopo che
checkRequest() passa, la rotta download lo applica confrontando il timestamp
con l’ora corrente e restituendo 410 Gone una volta che è nel passato.
Sulle versioni di Symfony il cui
UriSigner::sign()accetta un argomentoDateTimeInterfacedi scadenza, si può passare la scadenza direttamente —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— e lasciare checheckRequest()rifiuti i link scaduti al posto vostro, eliminando il parametro manualeexpirese il suo controllo. Confermare la firma diUriSigner::sign()nel proprio Symfony installato prima di farvi affidamento; il pattern portabile sopra funziona indipendentemente.
Esempio di codice — URL presigned tramite SDK cloud
Sezione intitolata “Esempio di codice — URL presigned tramite SDK cloud”Se si firma con un SDK cloud direttamente anziché tramite un disk del framework,
la forma è la stessa: mettere l’oggetto, poi chiedere all’SDK di fare il
presigning di un GET per esso. Questo è S3 puro (il flusso GCS lo rispecchia:
ottenere l’oggetto con $bucket->object($key) e chiamare $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();Per GCS, costruire i byte allo stesso modo con getPdfData(), caricare l’oggetto
con il client di Cloud Storage, poi ottenere l’oggetto di storage con
$bucket->object($key) e chiamare $object->signedUrl($expiresAt, [...]) con una
scadenza Carbon/DateTime per coniare il link equivalente. La scadenza dell’URL
firmato su entrambi i fornitori è limitata dal tipo di credenziale; consultare la
documentazione del fornitore per la durata massima che le proprie credenziali
consentono.
Casi limite e insidie
Sezione intitolata “Casi limite e insidie”- Costruire il documento esattamente una volta.
getPdfData()innesca la build, e la build non è idempotente. Chiamarla una volta, mantenere la stringa e riutilizzarla sia per l’upload sia per qualsiasiContent-Length, checksum oETagche si calcola. Non chiamarla di nuovo per «rileggere» i byte. temporaryUrl()necessita di un driver in grado di fare il presigning. Il drivers3di Laravel fa il presigning; il driverlocalsolleva un’eccezione sutemporaryUrl()a meno che non si registri un generatore personalizzato conStorage::disk('local')->buildTemporaryUrlsUsing(...). Scegliere un disk in grado di firmare, o firmare invece una rotta dell’app.- Impostare il content type dell’oggetto. Memorizzare con
Content-Type: application/pdf(l’opzione di uploadContentType, o i metadati del disk) così che il browser apra il link presigned come un PDF anziché scaricare unoctet-stream. - Una scadenza breve può sopravvivere a un client lento. Se l’utente clicca il link molto dopo che lo si è coniato, una finestra di 60 secondi potrebbe essere già morta. Dimensionare la scadenza al divario realistico tra il conio e il primo byte — minuti, non secondi — e ri-coniare su richiesta anziché allungarla a ore.
- Un URL firmato è accesso bearer. Chiunque detenga l’URL prima che scada può scaricare l’oggetto. Tenere le scadenze brevi, preferire l’ambito a un solo oggetto e non registrare mai l’URL firmato completo — la firma è di fatto un token.
- Non incorporare input utente nella chiave dell’oggetto senza sanificazione.
Costruire le chiavi da valori che si controllano più byte casuali
(
bin2hex(random_bytes(16))). Una chiave prevedibile invita all’enumerazione una volta che il bucket è anche solo parzialmente esposto.
Prestazioni
Sezione intitolata “Prestazioni”Questo pattern scambia un trasferimento sincrono con un upload più una piccola risposta JSON. Il worker dell’applicazione è trattenuto solo per la build del PDF e l’upload allo storage, non per l’intero download del client. Il download stesso si svolge tra il client e lo storage (o il suo edge), quindi non consuma affatto un worker dell’app.
La build è comunque sincrona e domina comunque per documenti grandi o di più
pagine — getPdfData() realizza l’intero PDF in memoria prima che lo si possa
caricare. Per documenti pesanti, spostare la generazione e l’upload in un job in
coda e consegnare l’URL firmato fuori banda (per esempio notificando il client
quando l’oggetto è pronto). Vedere
Generare un PDF in un job in coda.
Note di sicurezza
Sezione intitolata “Note di sicurezza”- Mantenere il bucket privato; lasciare che la firma conceda l’accesso. Non rendere mai l’oggetto pubblicamente leggibile per «semplificare» la consegna. Il punto centrale è che l’accesso fluisca solo attraverso una firma di breve durata.
- Scadenza breve e limitata. Firmare per la finestra più piccola che si adatta al proprio flusso, e limitare ogni URL a un singolo oggetto. Un link trapelato scade allora da solo e non espone nient’altro.
- Segreti dall’ambiente. Le credenziali S3/GCS e l’
APP_SECRETdi Symfony che sta dietroUriSignerprovengono da variabili d’ambiente o da un secrets manager, mai da config committata. Ruotare il segreto di firma invalida immediatamente ogni rotta firmata in sospeso. - Verificare prima di servire sulle rotte firmate dall’app. Quando il download
attraversa la propria app (middleware
signeddi Laravel,UriSigner::checkRequest()di Symfony), verificare la firma prima di qualsiasi accesso allo storage o autorizzazione. Rifiutare un link manomesso o scaduto con uno stato definito. - Non registrare mai l’URL firmato completo. La firma è una credenziale bearer. Registrare la chiave dell’oggetto e un identificatore di correlazione, non l’URL firmato, e registrare la classe dell’eccezione in caso di fallimento — mai il messaggio o uno stack trace.
- Nessun
catchvuoto. Ogni esempio registra la classe del fallimento e restituisce una risposta di errore definita.
Conformità
Sezione intitolata “Conformità”Questa guida non avanza alcuna pretesa normativa rispetto agli standard. L’unica
CHIAMATA al motore NextPDF che questo pattern di consegna richiede è
NextPDF\Core\Document::getPdfData(), il metodo pubblico verificato che
restituisce il binario PDF grezzo; il documento stesso è costruito come la propria
app già costruisce i documenti (ad es. il DocumentFactoryInterface iniettato / il
PdfFactory di Symfony). Le primitive di firma sono API documentate di framework e
cloud — Laravel Storage::temporaryUrl() e URL::temporarySignedRoute(), Symfony
UriSigner, e le operazioni SDK di URL presigned S3/GCS — e le loro firme esatte,
i driver supportati e le finestre di scadenza massime sono governati da quei
progetti upstream. Consultare la loro documentazione per il contratto autorevole
su ciascuna piattaforma.
Vedere anche
Sezione intitolata “Vedere anche”- Restituire un PDF generato da un controller — trasmettere i byte direttamente quando non si vuole l’object storage nel ciclo.
- Trasmettere in streaming un PDF generato di grandi dimensioni come risposta HTTP — il modello di memoria buffered-vs-streamed dietro
getPdfData(). - Rendering all’edge con Cloudflare — la variante di URL firmato e rendering all’edge specifica per R2 di questo pattern.
- Generare un PDF in un job in coda — spostare la build e l’upload fuori dal thread della richiesta.