Przejdź do głównej zawartości
getnextpdf.com

Dostarczanie wygenerowanego PDF przez podpisany, wygasający URL

Generujesz plik Portable Document Format (PDF) i musisz przekazać go klientowi. Najprostsza ścieżka przesyła bajty wprost przez kontroler, ale to wiąże robotnika aplikacji na czas całego pobrania, prowadzi ruch przez twoje serwery i udostępnia plik każdemu, kto dotrze do trasy. Wzorzec dostarczania na tej stronie robi odwrotnie: wygeneruj PDF, zapisz bajty w magazynie obiektów i zwróć krótkotrwały podpisany Uniform Resource Locator (URL), który klient pobiera bezpośrednio z magazynu. Twoja aplikacja oddaje małą ładunek JavaScript Object Notation (JSON) z URL-em; magazyn serwuje bajty.

Strona NextPDF to jedno wywołanie: getPdfData() na dokumencie zwraca surowy plik binarny PDF jako łańcuch znaków. Wszystko po tym — umieszczenie obiektu i wybicie ograniczonego czasowo podpisanego linku — to zadanie twojego frameworka lub dostawcy chmury. Prymitywy podpisywania to prawdziwe, udokumentowane API: Laravel Storage::temporaryUrl() oraz URL::temporarySignedRoute(), Symfony UriSigner oraz operacje wstępnie podpisanych URL-i Amazon Simple Storage Service (S3) lub Google Cloud Storage (GCS) w ich zestawach do tworzenia oprogramowania (SDK). NextPDF nie definiuje własnego pomocnika URL; nie szukaj go.

Sprawdź najpierw te elementy:

  • Rdzeń NextPDF jest zainstalowany i potrafisz zbudować dokument.
  • Masz magazyn obiektów, dla którego framework może podpisywać: bucket S3 lub zgodny z S3, bucket GCS lub dysk Laravel, którego sterownik obsługuje URL-e tymczasowe.
  • Poświadczenia żyją w zmiennych środowiskowych lub menedżerze sekretów, nigdy w zatwierdzonej konfiguracji.

To poradnik how-to. Zakłada, że potrafisz już skierować żądanie do kontrolera. Aby zamiast tego zwracać bajty bezpośrednio, zobacz Zwracanie wygenerowanego PDF z kontrolera.

Wzorzec ma trzy kroki, a tylko pierwszy dotyka NextPDF:

  1. Generuj. Zbuduj dokument i wywołaj getPdfData(), aby uzyskać bajty.
  2. Zapisz. Zapisz te bajty pod kluczem magazynu obiektów (reports/2026/r-42.pdf).
  3. Podpisz. Poproś framework lub SDK chmury o podpisany URL do tego klucza, z wygaśnięciem, i zwróć URL klientowi.

Dlaczego zapisywać i podpisywać zamiast przepuszczać bajty:

  • Odciąż przepustowość. Magazyn obiektów (lub jego brzeg sieci dostarczania treści) serwuje pobranie. Twój robotnik aplikacji zwraca kilkaset bajtów JSON i jest natychmiast wolny, zamiast być trzymany przez czas transferu o wielkości wielu megabajtów.
  • Ogranicz dostęp. Podpisany URL nadaje dostęp do jednego obiektu na ograniczone okno. Sam bucket pozostaje prywatny. Nie ma publicznej trasy do ataku siłowego ani szerokiego nadania odczytu bucketa.
  • Wygaśnięcie. Podpis osadza znacznik czasu wygaśnięcia. Po jego upływie link jest martwy. Wyciekły URL sam przestaje działać, co ogranicza promień rażenia przypadkowego udostępnienia.

Istnieją dwa odrębne modele podpisywania i różnią się one tym, co jest podpisywane:

  • Wstępnie podpisane URL-e magazynu obiektów (S3, GCS lub temporaryUrl() Laravel na dysku S3/GCS) wskazują bezpośrednio na obiekt magazynu. Pobranie nigdy nie dociera do twojej aplikacji.
  • Podpisane trasy aplikacji (Laravel URL::temporarySignedRoute(), Symfony UriSigner) wskazują na twoją własną trasę. Żądanie wciąż trafia do twojej aplikacji, która weryfikuje podpis, a następnie strumieniuje lub przekierowuje do obiektu. Użyj ich, gdy musisz uruchomić autoryzację, logowanie lub rozliczanie przy każdym pobraniu albo gdy twój magazyn nie potrafi wstępnie podpisywać.
ZagadnienieNextPDFLaravelSymfony
Pobierz bajty PDFNextPDF\Core\Document::getPdfData(): stringtak samotak samo
Zapisz bajtyStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) lub Flysystem write()
Wstępnie podpisany URL magazynuStorage::disk($d)->temporaryUrl($key, $expiresAt)presigner AWS/GCS SDK (poniżej)
Podpisana trasa aplikacjiURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Zweryfikuj podpisaną trasę aplikacjimiddleware trasy signed / $request->hasValidSignature()UriSigner::check() / checkRequest()

Jedynym wywołaniem silnika NextPDF, którego wymaga ten wzorzec dostarczania, jest getPdfData(); sam dokument jest budowany tak, jak twoja aplikacja już buduje dokumenty (np. wstrzyknięty DocumentFactoryInterface / Symfony PdfFactory). getPdfData() jest zadeklarowane w cesze HasOutput na NextPDF\Core\Document. Wywołuje writer raz i zwraca cały PDF jako łańcuch znaków. Jego bliźniacze save(string $path): void zapisuje te same bajty na dysk przez atomowy writer; używaj go tylko wtedy, gdy twój magazyn to rzeczywista ścieżka lokalnego systemu plików. Dla magazynu obiektów preferuj getPdfData() i pozwól, by SDK magazynu zarządzał transferem.

Dokument jest budowany, gdy wywołujesz getPdfData() (lub save()), a budowa nie jest idempotentna. Wywołaj ją raz na dokument, przechwyć łańcuch znaków i wykorzystaj ten łańcuch ponownie zarówno do przesłania, jak i do każdego rozmiaru czy sumy kontrolnej, którą wyliczasz.

Abstrakcja systemu plików Laravel podpisuje za ciebie. Na dysku S3 (lub zgodnym z S3) Storage::temporaryUrl() zwraca wstępnie podpisany URL prosto do obiektu. Klient pobiera z magazynu; twoja akcja zwraca tylko 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);
}
}
}

Dysk musi być takim, którego sterownik obsługuje URL-e tymczasowe — dołączony sterownik s3 to potrafi. Wywołanie temporaryUrl() na sterowniku local rzuca wyjątek, chyba że zarejestrujesz dla niego generator, bo dysk lokalny nie ma czego wstępnie podpisać.

Gdy wolisz utrzymać pobranie na własnej trasie — aby uruchomić autoryzację przy każdym żądaniu lub zalogować każdy dostęp — podpisz zamiast tego trasę za pomocą URL::temporarySignedRoute(). Middleware signed trasy odrzuca zmanipulowany lub wygasły link, zanim uruchomi się twoja akcja.

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 nie ma fasady magazynu w stylu Laravel, więc podpisujesz własną trasę za pomocą frameworkowego Symfony\Component\HttpFoundation\UriSigner, a następnie sprawiasz, by ta trasa przekierowała do wstępnie podpisanego URL-a magazynu (lub strumieniowała obiekt). UriSigner::sign() dopisuje hasz z kluczem; checkRequest() odrzuca zmanipulowany link. Aby utrzymać przykład przenośnym między wersjami Symfony, osadź własny parametr zapytania expires (znacznik czasu Uniksa kilka minut później) przed podpisaniem, a następnie sam zwaliduj ten parametr w trasie download po pomyślnej weryfikacji podpisu. Działa to na każdej wersji Symfony, bo UriSigner::sign(string $uri) przyjmuje tylko 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 jest konstruowany z sekretem (Symfony autowiruje go z parametru %kernel.secret% / APP_SECRET). Powyższy przykład to ścieżka przenośna: UriSigner::sign(string $uri) podpisuje tylko URL i istnieje na każdej wersji Symfony, więc wygaśnięcie podróżuje jako twój własny parametr zapytania expires. Podpis obejmuje ten parametr, więc nie da się go zmanipulować — a po pomyślnej weryfikacji checkRequest() trasa download egzekwuje go, porównując znacznik czasu z bieżącym czasem i zwracając 410 Gone, gdy jest już w przeszłości.

Na wersjach Symfony, których UriSigner::sign() przyjmuje argument wygaśnięcia DateTimeInterface, możesz przekazać wygaśnięcie bezpośrednio — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — i pozwolić, by checkRequest() odrzucał wygasłe linki za ciebie, rezygnując z ręcznego parametru expires i jego sprawdzenia. Potwierdź sygnaturę UriSigner::sign() w zainstalowanej Symfony, zanim na niej polegniesz; przenośny wzorzec powyżej działa niezależnie.

Jeśli podpisujesz bezpośrednio SDK chmury, a nie przez dysk frameworka, kształt jest taki sam: umieść obiekt, a następnie poproś SDK o wstępne podpisanie GET dla niego. To zwykły S3 (przepływ GCS odzwierciedla go: pobierz obiekt przez $bucket->object($key) i wywołaj $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();

Dla GCS zbuduj bajty tak samo za pomocą getPdfData(), prześlij obiekt klientem Cloud Storage, a następnie pobierz obiekt magazynu przez $bucket->object($key) i wywołaj $object->signedUrl($expiresAt, [...]) z wygaśnięciem Carbon/DateTime, aby wybić równoważny link. Wygaśnięcie podpisanego URL-a u obu dostawców jest ograniczone typem poświadczenia; sprawdź dokumentację dostawcy po maksymalny czas życia, na jaki pozwalają twoje poświadczenia.

  • Buduj dokument dokładnie raz. getPdfData() wyzwala budowę, a budowa nie jest idempotentna. Wywołaj ją raz, przytrzymaj łańcuch znaków i wykorzystaj go ponownie zarówno do przesłania, jak i do każdego Content-Length, sumy kontrolnej czy ETag, który wyliczasz. Nie wywołuj jej ponownie, by „ponownie odczytać” bajty.
  • temporaryUrl() wymaga sterownika potrafiącego wstępnie podpisywać. Sterownik s3 Laravel wstępnie podpisuje; sterownik local rzuca wyjątek na temporaryUrl(), chyba że zarejestrujesz niestandardowy generator za pomocą Storage::disk('local')->buildTemporaryUrlsUsing(...). Wybierz dysk, który potrafi podpisywać, albo podpisz zamiast tego trasę aplikacji.
  • Ustaw typ zawartości obiektu. Zapisz z Content-Type: application/pdf (opcja przesyłania ContentType lub metadane dysku), aby przeglądarka otwarła wstępnie podpisany link jako PDF zamiast pobierać octet-stream.
  • Krótkie wygaśnięcie może przeżyć powolnego klienta. Jeśli użytkownik klika link dużo po jego wybiciu, 60-sekundowe okno może już być martwe. Dobierz wygaśnięcie do realistycznej luki między wybiciem a pierwszym bajtem — minuty, a nie sekundy — i wybijaj ponownie na żądanie, zamiast rozciągać je do godzin.
  • Podpisany URL to dostęp na okaziciela. Każdy, kto trzyma URL przed jego wygaśnięciem, może pobrać obiekt. Utrzymuj krótkie wygaśnięcia, preferuj zakres jednego obiektu i nigdy nie loguj pełnego podpisanego URL-a — podpis jest w praktyce tokenem.
  • Nie osadzaj wejścia użytkownika w kluczu obiektu bez sanityzacji. Buduj klucze z wartości, które kontrolujesz, plus losowych bajtów (bin2hex(random_bytes(16))). Przewidywalny klucz zaprasza do enumeracji, gdy bucket jest choćby częściowo odsłonięty.

Ten wzorzec wymienia jeden synchroniczny transfer na jedno przesłanie plus malutką odpowiedź JSON. Robotnik aplikacji jest trzymany tylko na czas budowy PDF i przesłania do magazynu, a nie na pełne pobranie klienta. Samo pobranie odbywa się między klientem a magazynem (lub jego brzegiem), więc w ogóle nie zużywa robotnika aplikacji.

Budowa wciąż jest synchroniczna i wciąż dominuje dla dużych lub wielostronicowych dokumentów — getPdfData() realizuje cały PDF w pamięci, zanim możesz go przesłać. Dla ciężkich dokumentów przenieś generowanie i przesyłanie do zadania w kolejce i dostarcz podpisany URL poza pasmem (na przykład powiadamiając klienta, gdy obiekt jest gotowy). Zobacz Generowanie PDF w zadaniu w kolejce.

  • Trzymaj bucket prywatny; pozwól podpisowi nadawać dostęp. Nigdy nie czyń obiektu publicznie czytelnym, by „uprościć” dostarczanie. Całym sensem jest to, że dostęp płynie wyłącznie przez krótkotrwały podpis.
  • Krótkie, zawężone wygaśnięcie. Podpisuj na najmniejsze okno, które pasuje do twojego przepływu, i zawężaj każdy URL do jednego obiektu. Wyciekły link wtedy sam wygasa i nie odsłania niczego więcej.
  • Sekrety ze środowiska. Poświadczenia S3/GCS oraz APP_SECRET Symfony, który zasila UriSigner, pochodzą ze zmiennych środowiskowych lub menedżera sekretów, nigdy z zatwierdzonej konfiguracji. Rotacja sekretu podpisującego natychmiast unieważnia każdą zaległą podpisaną trasę.
  • Weryfikuj przed serwowaniem na trasach podpisanych przez aplikację. Gdy pobranie przechodzi przez twoją aplikację (Laravel middleware signed, Symfony UriSigner::checkRequest()), zweryfikuj podpis przed jakimkolwiek dostępem do magazynu lub autoryzacją. Odrzuć zmanipulowany lub wygasły link z określonym statusem.
  • Nigdy nie loguj pełnego podpisanego URL-a. Podpis to poświadczenie na okaziciela. Loguj klucz obiektu i identyfikator korelacji, a nie podpisany URL, i loguj klasę wyjątku przy awarii — nigdy wiadomości ani śladu stosu.
  • Brak pustego catch. Każdy przykład loguje klasę awarii i zwraca określoną odpowiedź błędu.

Ten przewodnik nie formułuje normatywnego twierdzenia o standardach. Jedynym wywołaniem silnika NextPDF, którego wymaga ten wzorzec dostarczania, jest NextPDF\Core\Document::getPdfData(), zweryfikowana publiczna metoda, która zwraca surowy plik binarny PDF; sam dokument jest budowany tak, jak twoja aplikacja już buduje dokumenty (np. wstrzyknięty DocumentFactoryInterface / Symfony PdfFactory). Prymitywy podpisywania to udokumentowane API frameworków i chmury — Laravel Storage::temporaryUrl() oraz URL::temporarySignedRoute(), Symfony UriSigner oraz operacje SDK wstępnie podpisanych URL-i S3/GCS — a ich dokładne sygnatury, obsługiwane sterowniki i maksymalne okna wygaśnięcia są regulowane przez te projekty nadrzędne. Sprawdź ich dokumentację po miarodajny kontrakt dla każdej platformy.