تسليم ملف PDF مُولَّد عبر رابط موقَّع منتهي الصلاحية
لمحة سريعة
قسم بعنوان «لمحة سريعة»تولّد ملفًّا بصيغة المستند المحمول (PDF) وتحتاج إلى تسليمه إلى عميل. أبسط مسارٍ يدفّق البايتات مباشرةً عبر متحكّم، لكنّ ذلك يشغل عامل تطبيقٍ طوال التنزيل، ويمرّر حركة المرور عبر خوادمك، ويعرّض الملفّ لأيّ من يصل إلى المسار. ويفعل نمط التسليم في هذه الصفحة العكس: ولّد ملف PDF، وخزّن البايتات في تخزين الكائنات، وأرجِع رابطًا موقَّعًا قصير العمر (Uniform Resource Locator، URL) يجلبه العميل مباشرةً من التخزين. يُرجِع تطبيقك حمولةً صغيرة بصيغة ترميز كائنات JavaScript (JSON) بها رابط؛ ويخدم التخزين البايتات.
جانب NextPDF استدعاءٌ واحد: يُرجِع getPdfData() على المستند ثنائيّ PDF الخام
سلسلةً. وكلّ ما بعد ذلك — وضع الكائن وسكّ رابطٍ موقَّعٍ محدود المدّة — مهمّة إطارك أو
مزوّد سحابتك. وبدائيّات التوقيع واجهات API حقيقية موثَّقة: Laravel
Storage::temporaryUrl() وURL::temporarySignedRoute()، وSymfony UriSigner،
وعمليّات الروابط المُوقَّعة مسبقًا لخدمة Amazon Simple Storage Service (S3) أو
Google Cloud Storage (GCS) في حزم تطويرها البرمجية (SDKs). ولا يعرّف NextPDF
أيّ مساعد روابط خاصٍّ به؛ لا تبحث عن واحد.
تحقّق من هذه العناصر أوّلًا:
- نواة NextPDF مثبَّتة ويمكنك بناء مستند.
- لديك تخزين كائناتٍ يستطيع الإطار التوقيع له: دلو S3 أو متوافقٍ مع S3، أو دلو GCS، أو قرص Laravel يدعم مشغّله الروابط المؤقّتة.
- تعيش بيانات الاعتماد في متغيّرات البيئة أو في مدير أسرار، لا أبدًا في إعدادٍ مُودَع.
هذا دليلٌ عمليّ تشغيلي. يفترض أنّك تعرف بالفعل كيفية توجيه طلبٍ إلى متحكّم. ولإرجاع البايتات مباشرةً بدلًا من ذلك، انظر أرجِع ملف PDF مولَّدًا من متحكّم.
نظرة مفاهيمية عامّة
قسم بعنوان «نظرة مفاهيمية عامّة»للنمط ثلاث خطوات، ولا تمسّ NextPDF إلّا الأولى:
- ولّد. ابنِ المستند واستدعِ
getPdfData()لأخذ البايتات. - خزّن. اكتب تلك البايتات إلى مفتاح تخزين كائنات (
reports/2026/r-42.pdf). - وقّع. اطلب من الإطار أو حزمة تطوير السحابة رابطًا موقَّعًا لذلك المفتاح، مع انتهاء صلاحية، وأرجِع الرابط إلى العميل.
لماذا تخزّن وتوقّع بدلًا من تمرير البايتات:
- حمّل عرض النطاق على غيرك. يخدم تخزين الكائنات (أو حافّة شبكة توصيل المحتوى لديه) التنزيل. ويُرجِع عامل تطبيقك بضع مئاتٍ من بايتات JSON ويتحرّر فورًا، بدلًا من احتجازه طوال نقلٍ بحجم ميغابايتاتٍ عدّة.
- حدّد نطاق الوصول. يمنح الرابط الموقَّع وصولًا إلى كائنٍ واحد ضمن نافذةٍ محدودة. ويبقى الدلو نفسه خاصًّا. فلا مسار عامٌّ لاختراقه بالقوّة ولا منحُ قراءةٍ واسعٌ للدلو.
- انتهاء الصلاحية. يضمّن التوقيع طابع زمن انتهاء صلاحية. وبعد مروره، يموت الرابط. فرابطٌ مسرَّب يتوقّف عن العمل من تلقائه، وهو ما يحدّ نطاق ضرر مشاركةٍ عَرَضية.
ثمّة نموذجا توقيعٍ متمايزان، ويختلفان في ما يُوقَّع:
- روابط تخزين الكائنات المُوقَّعة مسبقًا (S3، GCS، أو
temporaryUrl()في Laravel على قرص S3/GCS) تشير مباشرةً إلى كائن التخزين. ولا يصل التنزيل إلى تطبيقك إطلاقًا. - مسارات التطبيق الموقَّعة (Laravel
URL::temporarySignedRoute()، Symfony UriSigner) تشير إلى مسارك أنت. فيظلّ الطلب يصل إلى تطبيقك، الذي يتحقّق من التوقيع، ثمّ يدفّق إلى الكائن أو يعيد التوجيه إليه. استخدمها حين تحتاج إلى تشغيل تخويلٍ أو تسجيلٍ أو محاسبةٍ على كلّ تنزيل، أو حين يتعذّر على تخزينك التوقيع المسبق.
سطح واجهة API
قسم بعنوان «سطح واجهة API»| الشأن | 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() |
استدعاء محرّك NextPDF الوحيد الذي يتطلّبه نمط التسليم هذا هو getPdfData()؛
ويُبنى المستند نفسه كما يبني تطبيقك المستندات أصلًا (مثلًا
DocumentFactoryInterface المحقون / Symfony PdfFactory). وgetPdfData()
مُصرَّحٌ به في السمة HasOutput على NextPDF\Core\Document. يستدعي الكاتب مرّةً
ويُرجِع ملف PDF كاملًا سلسلةً. وقرينه save(string $path): void يكتب البايتات
نفسها إلى القرص عبر كاتبٍ ذرّي؛ استخدمه فقط حين يكون تخزينك مسار نظام ملفّاتٍ محلّيًّا
حقيقيًّا. ولتخزين الكائنات، فضّل getPdfData() ودَع حزمة تطوير التخزين تملك النقل.
يُبنى المستند حين تستدعي
getPdfData()(أوsave())، والبناء ليس مُتعاكِسًا. استدعِه مرّةً لكلّ مستند، والتقط السلسلة، وأعد استخدام تلك السلسلة للرفع وأيّ حجمٍ أو مجموع تحقّقٍ تحسبه.
عيّنة شيفرة — رابط Laravel المؤقّت
قسم بعنوان «عيّنة شيفرة — رابط Laravel المؤقّت»يوقّع تجريد نظام ملفّات Laravel نيابةً عنك. وعلى قرص S3 (أو متوافقٍ مع S3)،
يُرجِع Storage::temporaryUrl() رابطًا موقَّعًا مسبقًا مباشرةً إلى الكائن. ويُنزِّل
العميل من التخزين؛ ولا يُرجِع إجراؤك إلّا 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); } }}يجب أن يكون القرص ممّا يدعم مشغّله الروابط المؤقّتة — والمشغّل s3 المجمَّع يفعل.
ويرمي استدعاء temporaryUrl() على مشغّل local ما لم تسجّل مولِّدًا له، لأنّ
قرصًا محلّيًّا لا شيء فيه يُوقَّع مسبقًا.
حين تفضّل إبقاء التنزيل على مسارك أنت — لتشغيل تخويلٍ لكلّ طلبٍ أو لتسجيل كلّ وصول —
وقّع مسارًا بدلًا من ذلك بـURL::temporarySignedRoute(). وترفض وسيطة signed
الخاصّة بالمسار رابطًا مُتلاعَبًا به أو منتهيًا قبل تشغيل إجراءك.
<?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 UriSigner
قسم بعنوان «عيّنة شيفرة — Symfony UriSigner»ليس لـSymfony واجهة تخزينٍ على نمط Laravel، فتوقّع مسارك أنت بـUriSigner
الخاصّ بالإطار Symfony\Component\HttpFoundation\UriSigner، ثمّ تجعل ذلك المسار
يعيد التوجيه إلى رابط تخزينٍ موقَّعٍ مسبقًا (أو يدفّق الكائن). يُلحِق UriSigner::sign()
تجزئةً ذات مفتاح؛ ويرفض checkRequest() رابطًا مُتلاعَبًا به. ولإبقاء المثال
محمولًا عبر إصدارات Symfony، ضمّن مُعامِل استعلام expires خاصًّا بك (طابع زمن
Unix بعد دقائق) قبل التوقيع، ثمّ تحقّق من ذلك المُعامِل بنفسك في مسار
download بعد أن يصحّ التوقيع. ويعمل هذا على كلّ إصدار Symfony، لأنّ
UriSigner::sign(string $uri) لا يأخذ إلّا الرابط.
<?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 بسرٍّ (يوصّله Symfony تلقائيًّا من مُعامِل
%kernel.secret% / APP_SECRET). والمثال أعلاه هو المسار المحمول:
UriSigner::sign(string $uri) يوقّع الرابط فقط وموجودٌ على كلّ إصدار Symfony،
فيسافر انتهاء الصلاحية بوصفه مُعامِل استعلام expires خاصًّا بك. ويغطّي التوقيع
ذلك المُعامِل، فلا يمكن التلاعب به — وبعد أن ينجح checkRequest()، يفرضه مسار
download بمقارنة الطابع الزمني بالوقت الحالي وإرجاع 410 Gone بمجرّد مروره.
على إصدارات Symfony التي يقبل فيها
UriSigner::sign()وسيطة انتهاء صلاحية DateTimeInterface، يمكنك تمرير انتهاء الصلاحية مباشرةً — $signer->sign($url, new \DateTimeImmutable('+10 minutes'))— وترك checkRequest()يرفض الروابط المنتهية نيابةً عنك، مُسقِطًا مُعامِلexpiresاليدوي وفحصه. أكّد توقيعUriSigner::sign()في Symfony المثبَّت لديك قبل الاعتماد عليه؛ والنمط المحمول أعلاه يعمل بصرف النظر.
عيّنة شيفرة — رابط حزمة السحابة المُوقَّع مسبقًا
قسم بعنوان «عيّنة شيفرة — رابط حزمة السحابة المُوقَّع مسبقًا»إن وقّعت بحزمة تطوير سحابةٍ مباشرةً بدلًا من قرص إطار، فالشكل نفسه: ضع الكائن، ثمّ
اطلب من الحزمة توقيع GET مسبقًا له. هذا S3 صرف (ويعكسه تدفّق GCS: احصل على
الكائن بـ$bucket->object($key) واستدعِ $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();ولـGCS، ابنِ البايتات بالطريقة نفسها بـgetPdfData()، وارفع الكائن بعميل
Cloud Storage، ثمّ احصل على كائن التخزين بـ$bucket->object($key) واستدعِ
$object->signedUrl($expiresAt, [...]) بانتهاء صلاحية Carbon/DateTime لسكّ
الرابط المكافئ. وانتهاء صلاحية الرابط الموقَّع لدى كلا المزوّدين محدودٌ بنوع بيانات
الاعتماد؛ راجع وثائق المزوّد لأقصى عمرٍ تسمح به بيانات اعتمادك.
الحالات الحدّية والمزالق
قسم بعنوان «الحالات الحدّية والمزالق»- ابنِ المستند مرّةً واحدةً بالضبط. يطلق
getPdfData()البناء، والبناء ليس مُتعاكِسًا. استدعِه مرّةً، واحتفظ بالسلسلة، وأعد استخدامها للرفع وأيّ Content-Lengthأو مجموع تحقّقٍ أوETagتحسبه. لا تستدعِه مجدّدًا لـ”إعادة قراءة” البايتات. - يحتاج
temporaryUrl()إلى مشغّلٍ قادرٍ على التوقيع المسبق. فمشغّل Laravel s3يوقّع مسبقًا؛ ويرمي مشغّلlocalعلىtemporaryUrl()ما لم تسجّل مولِّدًا مخصّصًا بـStorage::disk('local')->buildTemporaryUrlsUsing(...). اختر قرصًا يستطيع التوقيع، أو وقّع مسار تطبيقٍ بدلًا من ذلك. - اضبط نوع محتوى الكائن. خزّن بـ
Content-Type: application/pdf(خيار رفع ContentType، أو بيانات وصف القرص) كي يفتح المتصفّح الرابط الموقَّع مسبقًا بوصفه PDF بدلًا من تنزيلoctet-stream. - انتهاء صلاحية قصيرٌ قد يفوق عميلًا بطيئًا. إن نقر المستخدم الرابط بعد سكّك إيّاه بوقتٍ طويل، فقد تكون نافذةٌ من 60 ثانية ميتةً أصلًا. قِس انتهاء الصلاحية بحسب الفجوة الواقعية بين السكّ وأوّل بايت — دقائق، لا ثوانٍ — وأعد السكّ عند الطلب بدلًا من مدّه إلى ساعات.
- الرابط الموقَّع وصولٌ بحاملٍ. فأيّ من يحمل الرابط قبل انتهائه يمكنه تنزيل الكائن. أبقِ انتهاءات الصلاحية قصيرة، وفضّل نطاق كائنٍ واحد، ولا تسجّل أبدًا الرابط الموقَّع كاملًا — فالتوقيع رمزٌ فعليًّا.
- لا تضمّن مدخل المستخدم في مفتاح الكائن دون تطهير. ابنِ المفاتيح من قيمٍ
تتحكّم بها زائد بايتاتٍ عشوائية (
bin2hex(random_bytes(16))). فمفتاحٌ متوقَّعٌ يدعو إلى التعداد بمجرّد أن يكون الدلو معرّضًا ولو جزئيًّا.
الأداء
قسم بعنوان «الأداء»يبادل هذا النمط نقلًا متزامنًا واحدًا برفعٍ واحدٍ زائد استجابة JSON صغيرة. ويُحتجَز عامل التطبيق فقط لبناء ملف PDF والرفع إلى التخزين، لا للتنزيل الكامل للعميل. ويجري التنزيل نفسه بين العميل والتخزين (أو حافّته)، فلا يستهلك عامل تطبيقٍ إطلاقًا.
ويظلّ البناء متزامنًا ويظلّ يهيمن للمستندات الكبيرة أو متعدّدة الصفحات —
getPdfData() يحقّق ملف PDF كاملًا في الذاكرة قبل أن تتمكّن من رفعه. وللمستندات
الثقيلة، انقل التوليد والرفع إلى مهمّةٍ في طابور وسلّم الرابط الموقَّع خارج النطاق
(مثلًا بإبلاغ العميل حين يكون الكائن جاهزًا). انظر
ولّد ملف PDF في مهمّةٍ مُدرَجة في طابور.
ملاحظات أمنية
قسم بعنوان «ملاحظات أمنية»- أبقِ الدلو خاصًّا؛ ودَع التوقيع يمنح الوصول. لا تجعل الكائن قابلًا للقراءة علنًا أبدًا لـ”تبسيط” التسليم. فالمقصد كلّه أن يتدفّق الوصول فقط عبر توقيعٍ قصير العمر.
- انتهاء صلاحية قصير ومحدود النطاق. وقّع لأصغر نافذةٍ تلائم تدفّقك، وحدّد نطاق كلّ رابطٍ بكائنٍ واحد. فرابطٌ مسرَّب ينتهي بعدها من تلقائه ولا يعرّض شيئًا آخر.
- الأسرار من البيئة. تأتي بيانات اعتماد S3/GCS و
APP_SECRETالخاصّ بـSymfony الذي يَسنُدUriSignerمن متغيّرات البيئة أو من مدير أسرار، لا من إعدادٍ مُودَع أبدًا. وتدوير سرّ التوقيع يُبطِل فورًا كلّ مسارٍ موقَّعٍ قائم. - تحقّق قبل الخدمة على مسارات التطبيق الموقَّعة. حين يعبر التنزيل تطبيقك
(وسيطة Laravel
signed، SymfonyUriSigner::checkRequest())، تحقّق من التوقيع قبل أيّ وصولٍ للتخزين أو تخويل. ارفض رابطًا مُتلاعَبًا به أو منتهيًا بحالةٍ محدَّدة. - لا تسجّل أبدًا الرابط الموقَّع كاملًا. فالتوقيع بيان اعتمادٍ بحامل. سجّل مفتاح الكائن ومعرّف ربطٍ، لا الرابط الموقَّع، وسجّل صنف الاستثناء عند الفشل — لا الرسالة ولا أثر المكدّس.
- لا
catchفارغ. فكلّ مثالٍ يسجّل صنف الفشل ويُرجِع استجابة خطأٍ محدَّدة.
المطابقة
قسم بعنوان «المطابقة»لا يدّعي هذا الدليل أيّ ادّعاءٍ معياريّ. استدعاء محرّك NextPDF الوحيد الذي يتطلّبه
نمط التسليم هذا هو NextPDF\Core\Document::getPdfData()، التابع العام المُتحقَّق
الذي يُرجِع ثنائيّ PDF الخام؛ ويُبنى المستند نفسه كما يبني تطبيقك المستندات أصلًا
(مثلًا DocumentFactoryInterface المحقون / Symfony PdfFactory). وبدائيّات
التوقيع واجهات أطرٍ وسحابةٍ موثَّقة — Laravel Storage::temporaryUrl()
وURL::temporarySignedRoute()، وSymfony UriSigner، وعمليات حزمة تطوير الروابط
المُوقَّعة مسبقًا لـS3/GCS — وتوقيعاتها الدقيقة، ومشغّلاتها المدعومة، ونوافذ انتهاء
الصلاحية القصوى تحكمها تلك المشاريع الأعلى منبعًا. راجع وثائقها للعقد المرجعيّ على
كلّ منصّة.
انظر أيضًا
قسم بعنوان «انظر أيضًا»- أرجِع ملف PDF مولَّدًا من متحكّم — دفّق البايتات مباشرةً حين لا تريد تخزين الكائنات في الحلقة.
- دفّق ملف PDF كبيرًا مولَّدًا بوصفه استجابة HTTP — نموذج الذاكرة المُخزَّن مقابل المتدفّق خلف
getPdfData(). - اعرض عند الحافّة بـCloudflare — صيغة الرابط الموقَّع والعرض عند الحافّة الخاصّة بـR2 من هذا النمط.
- ولّد ملف PDF في مهمّةٍ مُدرَجة في طابور — انقل البناء والرفع خارج خيط الطلب.