Salta ai contenuti
getnextpdf.com

Consegnare un PDF generato tramite un URL firmato e a scadenza

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.

Il pattern ha tre passaggi, e solo il primo tocca NextPDF:

  1. Generare. Costruire il documento e chiamare getPdfData() per ottenere i byte.
  2. Memorizzare. Scrivere quei byte su una chiave di object storage (reports/2026/r-42.pdf).
  3. 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(), Symfony UriSigner) 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.
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()

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() (o save()), 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.

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.

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);
}
}
}

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.

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 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.

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 è 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 argomento DateTimeInterface di scadenza, si può passare la scadenza direttamente — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — e lasciare che checkRequest() rifiuti i link scaduti al posto vostro, eliminando il parametro manuale expires e il suo controllo. Confermare la firma di UriSigner::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, [...])).

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();

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.

  • 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 qualsiasi Content-Length, checksum o ETag che si calcola. Non chiamarla di nuovo per «rileggere» i byte.
  • temporaryUrl() necessita di un driver in grado di fare il presigning. Il driver s3 di Laravel fa il presigning; il driver local solleva un’eccezione su temporaryUrl() a meno che non si registri un generatore personalizzato con Storage::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 upload ContentType, o i metadati del disk) così che il browser apra il link presigned come un PDF anziché scaricare un octet-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.

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.

  • 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_SECRET di Symfony che sta dietro UriSigner provengono 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 signed di 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 catch vuoto. Ogni esempio registra la classe del fallimento e restituisce una risposta di errore definita.

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.