تخطَّ إلى المحتوى
getnextpdf.com

تسليم ملف ⁨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⁩ إلّا الأولى:

  1. ولّد. ابنِ المستند واستدعِ getPdfData() لأخذ البايتات.
  2. خزّن. اكتب تلك البايتات إلى مفتاح تخزين كائنات (‏reports/2026/r-42.pdf).
  3. وقّع. اطلب من الإطار أو حزمة تطوير السحابة رابطًا موقَّعًا لذلك المفتاح، مع انتهاء صلاحية، وأرجِع الرابط إلى العميل.

لماذا تخزّن وتوقّع بدلًا من تمرير البايتات:

  • حمّل عرض النطاق على غيرك. يخدم تخزين الكائنات (أو حافّة شبكة توصيل المحتوى لديه) التنزيل. ويُرجِع عامل تطبيقك بضع مئاتٍ من بايتات ⁨JSON⁩ ويتحرّر فورًا، بدلًا من احتجازه طوال نقلٍ بحجم ميغابايتاتٍ عدّة.
  • حدّد نطاق الوصول. يمنح الرابط الموقَّع وصولًا إلى كائنٍ واحد ضمن نافذةٍ محدودة. ويبقى الدلو نفسه خاصًّا. فلا مسار عامٌّ لاختراقه بالقوّة ولا منحُ قراءةٍ واسعٌ للدلو.
  • انتهاء الصلاحية. يضمّن التوقيع طابع زمن انتهاء صلاحية. وبعد مروره، يموت الرابط. فرابطٌ مسرَّب يتوقّف عن العمل من تلقائه، وهو ما يحدّ نطاق ضرر مشاركةٍ عَرَضية.

ثمّة نموذجا توقيعٍ متمايزان، ويختلفان في ما يُوقَّع:

  • روابط تخزين الكائنات المُوقَّعة مسبقًا (‏S3، GCS، أو temporaryUrl() في ⁨Laravel⁩ على قرص ⁨S3⁩/⁨GCS⁩) تشير مباشرةً إلى كائن التخزين. ولا يصل التنزيل إلى تطبيقك إطلاقًا.
  • مسارات التطبيق الموقَّعة (‏⁨Laravel⁩ URL::temporarySignedRoute()، ⁨Symfony⁩ ‏UriSigner) تشير إلى مسارك أنت. فيظلّ الطلب يصل إلى تطبيقك، الذي يتحقّق من التوقيع، ثمّ يدفّق إلى الكائن أو يعيد التوجيه إليه. استخدمها حين تحتاج إلى تشغيل تخويلٍ أو تسجيلٍ أو محاسبةٍ على كلّ تنزيل، أو حين يتعذّر على تخزينك التوقيع المسبق.
الشأنNextPDFLaravelSymfony
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()

استدعاء محرّك ⁨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⁩.

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

يجب أن يكون القرص ممّا يدعم مشغّله الروابط المؤقّتة — والمشغّل s3 المجمَّع يفعل. ويرمي استدعاء temporaryUrl() على مشغّل local ما لم تسجّل مولِّدًا له، لأنّ قرصًا محلّيًّا لا شيء فيه يُوقَّع مسبقًا.

حين تفضّل إبقاء التنزيل على مسارك أنت — لتشغيل تخويلٍ لكلّ طلبٍ أو لتسجيل كلّ وصول — وقّع مسارًا بدلًا من ذلك بـURL::temporarySignedRoute(). وترفض وسيطة signed الخاصّة بالمسار رابطًا مُتلاعَبًا به أو منتهيًا قبل تشغيل إجراءك.

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 UriSigner⁩

قسم بعنوان «عيّنة شيفرة — ⁨Symfony UriSigner⁩»

ليس لـ⁨Symfony⁩ واجهة تخزينٍ على نمط ⁨Laravel⁩، فتوقّع مسارك أنت بـUriSigner الخاصّ بالإطار Symfony\Component\HttpFoundation\UriSigner، ثمّ تجعل ذلك المسار يعيد التوجيه إلى رابط تخزينٍ موقَّعٍ مسبقًا (أو يدفّق الكائن). يُلحِق UriSigner::sign() تجزئةً ذات مفتاح؛ ويرفض checkRequest() رابطًا مُتلاعَبًا به. ولإبقاء المثال محمولًا عبر إصدارات ⁨Symfony⁩، ضمّن مُعامِل استعلام expires خاصًّا بك (طابع زمن ⁨Unix⁩ بعد دقائق) قبل التوقيع، ثمّ تحقّق من ذلك المُعامِل بنفسك في مسار ‏download بعد أن يصحّ التوقيع. ويعمل هذا على كلّ إصدار ⁨Symfony⁩، لأنّ ‏UriSigner::sign(string $uri) لا يأخذ إلّا الرابط.

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 بسرٍّ (يوصّله ⁨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, [...])).

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

ولـ⁨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، ⁨Symfony⁩ UriSigner::checkRequest())، تحقّق من التوقيع قبل أيّ وصولٍ للتخزين أو تخويل. ارفض رابطًا مُتلاعَبًا به أو منتهيًا بحالةٍ محدَّدة.
  • لا تسجّل أبدًا الرابط الموقَّع كاملًا. فالتوقيع بيان اعتمادٍ بحامل. سجّل مفتاح الكائن ومعرّف ربطٍ، لا الرابط الموقَّع، وسجّل صنف الاستثناء عند الفشل — لا الرسالة ولا أثر المكدّس.
  • لا catch فارغ. فكلّ مثالٍ يسجّل صنف الفشل ويُرجِع استجابة خطأٍ محدَّدة.

لا يدّعي هذا الدليل أيّ ادّعاءٍ معياريّ. استدعاء محرّك ⁨NextPDF⁩ الوحيد الذي يتطلّبه نمط التسليم هذا هو NextPDF\Core\Document::getPdfData()، التابع العام المُتحقَّق الذي يُرجِع ثنائيّ ⁨PDF⁩ الخام؛ ويُبنى المستند نفسه كما يبني تطبيقك المستندات أصلًا (مثلًا DocumentFactoryInterface المحقون / ⁨Symfony⁩ PdfFactory). وبدائيّات التوقيع واجهات أطرٍ وسحابةٍ موثَّقة — ‏⁨Laravel⁩ Storage::temporaryUrl() وURL::temporarySignedRoute()، و⁨Symfony⁩ UriSigner، وعمليات حزمة تطوير الروابط المُوقَّعة مسبقًا لـ⁨S3⁩/⁨GCS⁩ — وتوقيعاتها الدقيقة، ومشغّلاتها المدعومة، ونوافذ انتهاء الصلاحية القصوى تحكمها تلك المشاريع الأعلى منبعًا. راجع وثائقها للعقد المرجعيّ على كلّ منصّة.