Lewati ke konten
getnextpdf.com

Kirim PDF yang dihasilkan melalui URL bertanda tangan yang dibatasi waktu

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.

Pola ini memiliki tiga langkah, dan hanya yang pertama menyentuh NextPDF:

  1. Generate. Bangun dokumen dan panggil getPdfData() untuk mendapatkan byte-nya.
  2. Store. Tulis byte itu ke sebuah key object-storage (reports/2026/r-42.pdf).
  3. 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(), Symfony UriSigner) 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.
KepedulianNextPDFLaravelSymfony
Ambil byte PDFNextPDF\Core\Document::getPdfData(): stringsamasama
Simpan byteStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) atau Flysystem write()
URL storage presignedStorage::disk($d)->temporaryUrl($key, $expiresAt)presigner SDK AWS/GCS (di bawah)
Route app bertanda tanganURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Verifikasi route app bertanda tanganmiddleware 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() (atau save()), 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.

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.

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

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.

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

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 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 berlaku DateTimeInterface, Anda dapat memberikan masa berlaku secara langsung — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — dan membiarkan checkRequest() menolak tautan yang kedaluwarsa untuk Anda, menjatuhkan parameter expires manual dan pemeriksaannya. Konfirmasi tanda tangan UriSigner::sign() di Symfony Anda yang terpasang sebelum mengandalkannya; pola portabel di atas bekerja terlepas dari itu.

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, [...])).

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

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.

  • Bangun dokumen tepat sekali. getPdfData() memicu build, dan build ini tidak idempoten. Panggil ia sekali, tahan string-nya, dan gunakan ulang untuk unggahan maupun Content-Length, checksum, atau ETag apa pun yang Anda hitung. Jangan memanggilnya lagi untuk “membaca ulang” byte-nya.
  • temporaryUrl() membutuhkan driver yang mampu presign. Driver s3 Laravel men-presign; driver local melempar exception pada temporaryUrl() kecuali Anda mendaftarkan generator kustom dengan Storage::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 unggah ContentType, atau metadata disk) agar peramban membuka tautan presigned sebagai PDF alih-alih mengunduh sebuah octet-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.

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.

  • 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_SECRET Symfony yang mendukung UriSigner berasal 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 signed Laravel, 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 catch kosong. Setiap contoh mencatat kelas kegagalan dan mengembalikan respons error yang terdefinisi.

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.