Kirim PDF yang dihasilkan melalui URL bertanda tangan yang dibatasi waktu
Sekilas pandang
Bagian berjudul “Sekilas pandang”Anda menghasilkan sebuah file Portable Document Format (PDF) dan perlu menyerahkannya kepada klien. Jalur paling sederhana men-stream byte-nya langsung melalui sebuah controller, tetapi itu menyandera seorang worker aplikasi selama seluruh unduhan, mengalirkan trafik melalui server Anda, dan mengekspos file kepada siapa pun yang dapat mencapai route-nya. Pola pengiriman di halaman ini melakukan kebalikannya: hasilkan PDF, simpan byte-nya di object storage, dan kembalikan sebuah Uniform Resource Locator (URL) bertanda tangan berumur pendek yang diambil klien langsung dari storage. Aplikasi Anda mengembalikan payload JavaScript Object Notation (JSON) kecil berisi sebuah URL; storage melayani byte-nya.
Sisi NextPDF-nya adalah satu pemanggilan: getPdfData() pada dokumen
mengembalikan biner PDF mentah sebagai string. Segala hal setelah itu — menaruh
objek dan mencetak tautan bertanda tangan yang dibatasi waktu — adalah tugas
framework atau penyedia cloud Anda. Primitif penandatanganannya nyata, API
terdokumentasi: Laravel Storage::temporaryUrl() dan
URL::temporarySignedRoute(), Symfony UriSigner, serta operasi URL presigned
Amazon Simple Storage Service (S3) atau Google Cloud Storage (GCS) di software
development kit (SDK) mereka. NextPDF tidak mendefinisikan helper URL sendiri;
jangan mencarinya.
Periksa komponen-komponen ini lebih dulu:
- NextPDF core sudah terpasang dan Anda dapat membangun sebuah dokumen.
- Anda memiliki object storage yang dapat ditandatangani framework: sebuah bucket S3 atau yang kompatibel-S3, sebuah bucket GCS, atau sebuah disk Laravel yang driver-nya mendukung URL sementara.
- Kredensial berada di variabel lingkungan atau secret manager, tidak pernah di config yang ter-commit.
Ini adalah sebuah how-to. Ia mengasumsikan Anda sudah tahu cara me-route sebuah permintaan ke controller. Untuk mengembalikan byte secara langsung sebagai gantinya, lihat Kembalikan PDF yang dihasilkan dari sebuah controller.
Tinjauan konseptual
Bagian berjudul “Tinjauan konseptual”Pola ini memiliki tiga langkah, dan hanya yang pertama menyentuh NextPDF:
- Generate. Bangun dokumen dan panggil
getPdfData()untuk mendapatkan byte-nya. - Store. Tulis byte itu ke sebuah key object-storage (
reports/2026/r-42.pdf). - Sign. Minta framework atau SDK cloud untuk URL bertanda tangan ke key itu, dengan masa berlaku, dan kembalikan URL-nya kepada klien.
Mengapa menyimpan dan menandatangani alih-alih mem-proxy byte:
- Lepas beban bandwidth. Object storage (atau edge content-delivery-network-nya) melayani unduhan. Worker aplikasi Anda mengembalikan beberapa ratus byte JSON dan langsung bebas, alih-alih ditahan selama transfer multi-megabyte.
- Batasi akses. Sebuah URL bertanda tangan memberikan akses ke satu objek untuk jendela waktu terbatas. Bucket-nya sendiri tetap privat. Tidak ada route publik untuk di-brute force dan tidak ada pemberian baca-bucket yang luas.
- Kedaluwarsa. Tanda tangan menanamkan timestamp kedaluwarsa. Setelah lewat, tautan itu mati. Sebuah URL yang bocor berhenti bekerja dengan sendirinya, yang membatasi radius ledakan dari berbagi yang tidak disengaja.
Ada dua model penandatanganan yang berbeda, dan keduanya berbeda dalam apa yang ditandatangani:
- URL presigned object-storage (S3, GCS, atau
temporaryUrl()Laravel di atas sebuah disk S3/GCS) menunjuk langsung ke objek storage. Unduhan sama sekali tidak mencapai aplikasi Anda. - Route aplikasi bertanda tangan (Laravel
URL::temporarySignedRoute(), SymfonyUriSigner) menunjuk ke route Anda sendiri. Permintaan tetap mengenai aplikasi Anda, yang memverifikasi tanda tangan, lalu men-stream atau meredirect ke objek. Gunakan ini ketika Anda perlu menjalankan otorisasi, logging, atau accounting pada setiap unduhan, atau ketika storage Anda tidak dapat presign.
Permukaan API
Bagian berjudul “Permukaan API”| Kepedulian | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Ambil byte PDF | NextPDF\Core\Document::getPdfData(): string | sama | sama |
| Simpan byte | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) atau Flysystem write() |
| URL storage presigned | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | presigner SDK AWS/GCS (di bawah) |
| Route app bertanda tangan | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Verifikasi route app bertanda tangan | — | middleware route signed / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
Satu-satunya PEMANGGILAN engine NextPDF yang dibutuhkan pola pengiriman ini adalah
getPdfData(); dokumennya sendiri dibangun seperti cara aplikasi Anda sudah
membangun dokumen (mis. DocumentFactoryInterface yang di-inject / PdfFactory
Symfony). getPdfData() dideklarasikan di trait HasOutput pada
NextPDF\Core\Document. Ia memanggil writer sekali dan mengembalikan seluruh PDF
sebagai string. Saudaranya save(string $path): void menulis byte yang sama ke
disk melalui writer atomik; gunakan hanya ketika storage Anda adalah path
filesystem lokal yang sebenarnya. Untuk object storage, utamakan getPdfData()
dan biarkan SDK storage memiliki transfernya.
Dokumen dibangun saat Anda memanggil
getPdfData()(atausave()), dan build ini tidak idempoten. Panggil ia sekali per dokumen, tangkap string-nya, dan gunakan ulang string itu untuk unggahan maupun ukuran atau checksum apa pun yang Anda hitung.
Contoh kode — URL sementara Laravel
Bagian berjudul “Contoh kode — URL sementara Laravel”Abstraksi filesystem Laravel menandatangani untuk Anda. Pada sebuah disk S3 (atau
yang kompatibel-S3), Storage::temporaryUrl() mengembalikan URL presigned
langsung ke objek. Klien mengunduh dari storage; action Anda hanya mengembalikan
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); } }}Disk-nya harus berupa disk yang driver-nya mendukung URL sementara — driver s3
yang dibundel mendukungnya. Memanggil temporaryUrl() pada driver local akan
melempar exception kecuali Anda mendaftarkan sebuah generator untuknya, karena
disk lokal tidak punya apa-apa untuk di-presign.
Ketika Anda lebih suka mempertahankan unduhan di route Anda sendiri — untuk
menjalankan otorisasi per-permintaan atau mencatat setiap akses — tandatangani
sebuah route saja dengan URL::temporarySignedRoute(). Middleware signed route
itu menolak tautan yang dirusak atau kedaluwarsa sebelum action Anda berjalan.
<?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');Contoh kode — UriSigner Symfony
Bagian berjudul “Contoh kode — UriSigner Symfony”Symfony tidak memiliki facade storage bergaya Laravel, jadi Anda menandatangani
route Anda sendiri dengan Symfony\Component\HttpFoundation\UriSigner dari
framework, lalu membuat route itu meredirect ke sebuah URL storage presigned (atau
men-stream objeknya). UriSigner::sign() menambahkan sebuah hash berkunci;
checkRequest() menolak tautan yang dirusak. Untuk menjaga contohnya portabel
lintas versi Symfony, tanamkan parameter query expires Anda sendiri (sebuah Unix
timestamp beberapa menit ke depan) sebelum menandatangani, lalu validasi
parameter itu sendiri di route download setelah tanda tangan terverifikasi. Ini
bekerja pada setiap versi Symfony, karena UriSigner::sign(string $uri) hanya
menerima 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 dibangun dengan sebuah secret (Symfony meng-autowire-nya dari
parameter %kernel.secret% / APP_SECRET). Contoh di atas adalah jalur portabel:
UriSigner::sign(string $uri) menandatangani hanya URL-nya dan ada pada setiap
versi Symfony, sehingga masa berlaku berjalan sebagai parameter query expires
Anda sendiri. Tanda tangan mencakup parameter itu, jadi ia tidak dapat dirusak —
dan setelah checkRequest() lolos, route download menegakkannya dengan
membandingkan timestamp terhadap waktu saat ini dan mengembalikan 410 Gone
begitu ia berada di masa lampau.
Pada versi Symfony yang
UriSigner::sign()-nya menerima argumen masa berlakuDateTimeInterface, Anda dapat memberikan masa berlaku secara langsung —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— dan membiarkancheckRequest()menolak tautan yang kedaluwarsa untuk Anda, menjatuhkan parameterexpiresmanual dan pemeriksaannya. Konfirmasi tanda tanganUriSigner::sign()di Symfony Anda yang terpasang sebelum mengandalkannya; pola portabel di atas bekerja terlepas dari itu.
Contoh kode — URL presigned Cloud SDK
Bagian berjudul “Contoh kode — URL presigned Cloud SDK”Jika Anda menandatangani dengan sebuah SDK cloud secara langsung alih-alih melalui
disk framework, bentuknya sama: taruh objeknya, lalu minta SDK untuk men-presign
sebuah GET untuknya. Ini adalah S3 polos (alur GCS mencerminkannya: ambil objek
dengan $bucket->object($key) dan panggil $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();Untuk GCS, bangun byte-nya dengan cara yang sama dengan getPdfData(), unggah
objeknya dengan client Cloud Storage, lalu ambil objek storage dengan
$bucket->object($key) dan panggil $object->signedUrl($expiresAt, [...]) dengan
masa berlaku Carbon/DateTime untuk mencetak tautan yang setara. Masa berlaku
URL bertanda tangan pada kedua penyedia dibatasi oleh tipe kredensial; konsultasi
dokumen penyedia untuk masa hidup maksimum yang diizinkan kredensial Anda.
Kasus tepi & jebakan
Bagian berjudul “Kasus tepi & jebakan”- Bangun dokumen tepat sekali.
getPdfData()memicu build, dan build ini tidak idempoten. Panggil ia sekali, tahan string-nya, dan gunakan ulang untuk unggahan maupunContent-Length, checksum, atauETagapa pun yang Anda hitung. Jangan memanggilnya lagi untuk “membaca ulang” byte-nya. temporaryUrl()membutuhkan driver yang mampu presign. Drivers3Laravel men-presign; driverlocalmelempar exception padatemporaryUrl()kecuali Anda mendaftarkan generator kustom denganStorage::disk('local')->buildTemporaryUrlsUsing(...). Pilih disk yang dapat menandatangani, atau tandatangani sebuah route app sebagai gantinya.- Atur content type objek. Simpan dengan
Content-Type: application/pdf(opsi unggahContentType, atau metadata disk) agar peramban membuka tautan presigned sebagai PDF alih-alih mengunduh sebuahoctet-stream. - Masa berlaku yang pendek dapat lebih cepat habis daripada klien yang lambat. Jika pengguna mengeklik tautan jauh setelah Anda mencetaknya, jendela 60 detik mungkin sudah mati. Sesuaikan masa berlaku dengan jeda realistis antara pencetakan dan byte pertama — menit, bukan detik — dan cetak ulang sesuai permintaan alih-alih merentangkannya menjadi berjam-jam.
- Sebuah URL bertanda tangan adalah akses pembawa. Siapa pun yang memegang URL sebelum kedaluwarsa dapat mengunduh objeknya. Jaga masa berlaku tetap pendek, utamakan lingkup satu-objek, dan jangan pernah mencatat URL bertanda tangan lengkap — tanda tangan itu pada dasarnya sebuah token.
- Jangan menanamkan input pengguna ke key objek tanpa sanitasi. Bangun key
dari nilai yang Anda kendalikan ditambah byte acak (
bin2hex(random_bytes(16))). Sebuah key yang dapat ditebak mengundang enumerasi begitu bucket-nya bahkan terekspos sebagian.
Performa
Bagian berjudul “Performa”Pola ini menukar satu transfer sinkron dengan satu unggahan ditambah satu respons JSON kecil. Worker aplikasi ditahan hanya untuk build PDF dan unggahan ke storage, bukan untuk seluruh unduhan klien. Unduhan itu sendiri berjalan antara klien dan storage (atau edge-nya), sehingga ia sama sekali tidak mengonsumsi worker aplikasi.
Build-nya masih sinkron dan masih mendominasi untuk dokumen besar atau
banyak-halaman — getPdfData() mewujudkan seluruh PDF di memori sebelum Anda dapat
mengunggahnya. Untuk dokumen berat, pindahkan generasi dan unggahan ke dalam
sebuah job yang di-queue dan kirimkan URL bertanda tangan secara terpisah
(misalnya dengan memberi tahu klien saat objeknya siap). Lihat
Hasilkan PDF dalam sebuah job yang di-queue.
Catatan keamanan
Bagian berjudul “Catatan keamanan”- Jaga bucket tetap privat; biarkan tanda tangan memberikan akses. Jangan pernah membuat objek dapat dibaca publik untuk “menyederhanakan” pengiriman. Seluruh intinya adalah bahwa akses mengalir hanya melalui tanda tangan berumur pendek.
- Masa berlaku pendek dan terbatas lingkup. Tandatangani untuk jendela terkecil yang cocok dengan alur Anda, dan batasi setiap URL ke satu objek. Sebuah tautan yang bocor kemudian kedaluwarsa dengan sendirinya dan tidak mengekspos apa pun yang lain.
- Secret dari lingkungan. Kredensial S3/GCS dan
APP_SECRETSymfony yang mendukungUriSignerberasal dari variabel lingkungan atau secret manager, tidak pernah config yang ter-commit. Memutar secret penandatanganan segera membatalkan setiap route bertanda tangan yang masih beredar. - Verifikasi sebelum melayani pada route app-signed. Ketika unduhan melintasi
aplikasi Anda (middleware
signedLaravel,UriSigner::checkRequest()Symfony), verifikasi tanda tangan sebelum akses storage atau otorisasi apa pun. Tolak tautan yang dirusak atau kedaluwarsa dengan status yang terdefinisi. - Jangan pernah mencatat URL bertanda tangan lengkap. Tanda tangan adalah kredensial pembawa. Catat key objek dan sebuah pengidentifikasi korelasi, bukan URL bertanda tangan, dan catat kelas exception saat gagal — bukan pesan atau stack trace.
- Tanpa
catchkosong. Setiap contoh mencatat kelas kegagalan dan mengembalikan respons error yang terdefinisi.
Kesesuaian
Bagian berjudul “Kesesuaian”Panduan ini tidak membuat klaim standar normatif. Satu-satunya PEMANGGILAN engine
NextPDF yang dibutuhkan pola pengiriman ini adalah
NextPDF\Core\Document::getPdfData(), metode publik terverifikasi yang
mengembalikan biner PDF mentah; dokumennya sendiri dibangun seperti cara aplikasi
Anda sudah membangun dokumen (mis. DocumentFactoryInterface yang di-inject /
PdfFactory Symfony). Primitif penandatanganannya adalah API framework dan cloud
terdokumentasi — Laravel Storage::temporaryUrl() dan
URL::temporarySignedRoute(), Symfony UriSigner, serta operasi SDK URL presigned
S3/GCS — dan tanda tangan persisnya, driver yang didukung, serta jendela masa
berlaku maksimum diatur oleh proyek-proyek upstream tersebut. Konsultasi
dokumentasinya untuk kontrak otoritatif pada setiap platform.
Lihat juga
Bagian berjudul “Lihat juga”- Kembalikan PDF yang dihasilkan dari sebuah controller — stream byte secara langsung ketika Anda tidak ingin object storage ada dalam alur.
- Stream PDF besar yang dihasilkan sebagai respons HTTP — model memori buffered-vs-streamed di balik
getPdfData(). - Render di edge dengan Cloudflare — varian URL bertanda tangan khusus R2 dan edge-render dari pola ini.
- Hasilkan PDF dalam sebuah job yang di-queue — pindahkan build dan unggahan keluar dari thread permintaan.