PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / pdf / receipt-generator.php

receipt-generator.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/pdf/receipt-generator.php

609 lines 25.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * PDF Receipt Generator.
4 *
5 * Generates PDF donation receipts using the mPDF library.
6 *
7 * @package SureDonation
8 */
9
10 namespace SureDonation\Inc\Pdf;
11
12 use SureDonation\Inc\Database\Tables\Donations;
13 use SureDonation\Inc\Database\Tables\Donors;
14 use SureDonation\Inc\Helper;
15 use SureDonation\Inc\Payments\Payment_Helper;
16
17 // Exit if accessed directly.
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Receipt_Generator class.
24 *
25 * @since 1.0.0
26 */
27 class Receipt_Generator {
28 /**
29 * Get an existing receipt PDF or generate a new one.
30 *
31 * @param int $donation_id Donation ID.
32 * @return string|false File path on success, false on failure.
33 * @since 1.0.0
34 */
35 public static function get_or_generate( $donation_id ) {
36 $donation = Donations::get( $donation_id );
37
38 if ( ! $donation ) {
39 return false;
40 }
41
42 if ( ! self::should_generate( $donation ) ) {
43 return false;
44 }
45
46 // Check if a cached PDF exists.
47 $existing_relative = $donation['receipt_pdf_url'] ?? '';
48
49 if ( ! empty( $existing_relative ) ) {
50 $existing_path = self::relative_to_path( Helper::get_string_value( $existing_relative ) );
51
52 if ( $existing_path && file_exists( $existing_path ) ) {
53 return $existing_path;
54 }
55 }
56
57 return self::generate( $donation_id );
58 }
59
60 /**
61 * Generate a PDF receipt for a donation.
62 *
63 * @param int $donation_id Donation ID.
64 * @return string|false File path on success, false on failure.
65 * @since 1.0.0
66 */
67 public static function generate( $donation_id ) {
68 if ( ! Pdf_Utils::check_if_library_exists() || ! Pdf_Utils::is_php_compatible() ) {
69 return false;
70 }
71
72 // Load the mPDF autoloader.
73 require_once Pdf_Utils::get_library_path() . '/vendor/autoload.php';
74
75 $donation = Donations::get( $donation_id );
76
77 if ( ! $donation ) {
78 return false;
79 }
80
81 if ( ! self::should_generate( $donation ) ) {
82 return false;
83 }
84
85 $donor_id = Helper::get_integer_value( $donation['donor_id'] ?? 0 );
86 $donor = $donor_id ? Donors::get( $donor_id ) : null;
87
88 $campaign_id = Helper::get_integer_value( $donation['campaign_id'] ?? 0 );
89 $campaign_title = $campaign_id ? (string) get_the_title( $campaign_id ) : '';
90
91 // Build the receipt HTML.
92 $html = self::build_receipt_html( $donation, $donor, $campaign_title );
93
94 /**
95 * Filter the receipt HTML before PDF generation.
96 *
97 * SECURITY NOTE: The returned HTML is passed directly to mPDF's
98 * WriteHTML(). Resource fetching is blocked at the mPDF level by the
99 * empty 'whitelistStreamWrappers' in self::get_mpdf_config(), so an
100 * `<img src>` cannot reach a remote host or a local file — but that is
101 * the only guarantee. Everything else about the returned markup is
102 * trusted, so only ever return escaped content.
103 *
104 * @param string $html Receipt HTML.
105 * @param array $donation Donation data.
106 * @param array|null $donor Donor data.
107 * @since 1.0.0
108 */
109 $html = apply_filters( 'suredonation_receipt_html', $html, $donation, $donor );
110
111 // Ensure receipts directory exists.
112 Pdf_Utils::ensure_receipts_dir();
113
114 $receipts_dir = Pdf_Utils::get_receipts_dir();
115 $default_filename = self::generate_storage_filename();
116
117 $filename = self::resolve_storage_filename( $default_filename, $donation, $donor );
118
119 $filepath = $receipts_dir . '/' . $filename;
120
121 try {
122 $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config( $donation, $donor ) );
123
124 /**
125 * Fires after the mPDF instance is created, before the receipt HTML is written.
126 *
127 * Allows instance-level configuration the constructor config cannot
128 * express — e.g. SetProtection() for password-protected receipts or
129 * SetTitle()/SetAuthor() document metadata.
130 *
131 * @param \Mpdf\Mpdf $mpdf mPDF instance.
132 * @param array<string, mixed> $donation Donation data.
133 * @param array<string, mixed>|null $donor Donor data.
134 * @since 1.5.0
135 */
136 do_action( 'suredonation_receipt_mpdf_instance', $mpdf, $donation, $donor );
137
138 $mpdf->WriteHTML( $html );
139 $mpdf->Output( $filepath, \Mpdf\Output\Destination::FILE );
140 } catch ( \Exception $e ) {
141 return false;
142 }
143
144 // Store the relative path in the donation record (portable across domain changes).
145 $upload_dir = wp_upload_dir();
146 $relative_path = str_replace( $upload_dir['basedir'] . '/', '', $filepath );
147 Donations::update( $donation_id, [ 'receipt_pdf_url' => $relative_path ] );
148
149 /**
150 * Fires after a receipt PDF has been generated and stored.
151 *
152 * @param int $donation_id Donation ID.
153 * @param string $filepath Absolute path to the generated PDF.
154 * @param array<string, mixed> $donation Donation data.
155 * @since 1.5.0
156 */
157 do_action( 'suredonation_receipt_generated', $donation_id, $filepath, $donation );
158
159 return $filepath;
160 }
161
162 /**
163 * Build the opaque on-disk filename for a receipt.
164 *
165 * 128 bits of lowercase hex and nothing else. The name is sized as a
166 * capability token rather than as a collision-avoidance suffix, because on
167 * servers that ignore the receipts directory's .htaccess it is the only
168 * thing standing between a request and a document carrying the donor's
169 * name, email and amount. Lowercase keeps the entropy honest on
170 * case-insensitive filesystems (macOS, Windows), where a mixed-case name
171 * collapses to a much smaller space than it appears to occupy.
172 *
173 * @return string
174 * @since 1.5.1
175 */
176 private static function generate_storage_filename() {
177 try {
178 $token = bin2hex( random_bytes( 16 ) );
179 } catch ( \Exception $e ) {
180 // random_bytes() only throws when the platform has no CSPRNG at all.
181 // Be honest about the fallback: wp_rand() reaches for random_int()
182 // first, which draws on the same source that just failed, so it lands
183 // on its seeded md5/mt_rand stream -- salted and not trivially
184 // predictable, but not cryptographic either. A host in that state
185 // cannot keep any secret; a receipt name is the least of it.
186 $token = strtolower( wp_generate_password( 32, false ) );
187 }
188
189 return 'sd-receipt-' . $token . '.pdf';
190 }
191
192 /**
193 * Get the filename a donor sees when a receipt is delivered.
194 *
195 * Deliberately separate from the name on disk: the stored file is named
196 * for unguessability, while the delivered copy is named for the person
197 * reading it. Used for the email attachment name and for the
198 * Content-Disposition of any authenticated download.
199 *
200 * @param array<string, mixed> $donation Donation data. Carries donor_name and
201 * donor_email, so no separate donor record
202 * is needed to build a donor-facing name.
203 * @return string
204 * @since 1.5.1
205 */
206 public static function get_download_filename( $donation ) {
207 $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
208 $default_filename = $donation_id > 0
209 ? sprintf( 'donation-receipt-%d.pdf', $donation_id )
210 : 'donation-receipt.pdf';
211
212 /**
213 * Filter the filename a donor sees when a receipt is delivered.
214 *
215 * This never names anything on disk -- it is the name attached to the
216 * email and sent as Content-Disposition -- so it is free to carry
217 * donor-facing detail such as a receipt number.
218 *
219 * Deliberately NOT shaped like the storage name any more. That one
220 * collapses interior dots to guarantee a single extension, because it
221 * names a file inside a web-accessible directory; this one only ever
222 * becomes a Content-Disposition value or an email attachment key, never
223 * a path, so it keeps whatever dots the filter supplied and a filtered
224 * "x.php" stays "x.php.pdf". Nothing here touches a filesystem, and the
225 * name still ends in .pdf, so the OS treats it as one.
226 *
227 * @param string $default_filename Default download filename.
228 * @param array<string, mixed> $donation Donation data.
229 * @since 1.5.1
230 */
231 $filename = apply_filters( 'suredonation_receipt_download_filename', $default_filename, $donation );
232 $filename = sanitize_file_name( Helper::get_string_value( $filename ) );
233
234 if ( '' === $filename ) {
235 return $default_filename;
236 }
237
238 if ( 'pdf' !== strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ) ) {
239 $filename .= '.pdf';
240 }
241
242 return $filename;
243 }
244
245 /**
246 * Whether a receipt should be generated or served for a donation.
247 *
248 * @param array<string, mixed> $donation Donation data.
249 * @return bool
250 * @since 1.5.0
251 */
252 private static function should_generate( $donation ) {
253 /**
254 * Short-circuit filter to disable receipt generation for a donation.
255 *
256 * Return false to prevent generating a new receipt PDF and to stop a
257 * previously cached one from being served. Lets extensions disable
258 * receipts selectively — e.g. a per-form "disable PDF receipt"
259 * setting keyed on the donation's form_id.
260 *
261 * @param bool $should_generate Whether to generate/serve the receipt. Default true.
262 * @param array<string, mixed> $donation Donation data.
263 * @since 1.5.0
264 */
265 return (bool) apply_filters( 'suredonation_should_generate_receipt', true, $donation );
266 }
267
268 /**
269 * Delete a receipt PDF file by its stored uploads-relative path.
270 *
271 * Used by the personal-data eraser: the receipt is generated from the donor's
272 * name/email/address, so an erasure must remove the file from disk, not just
273 * the database columns.
274 *
275 * @since 1.2.0
276 * @param string $relative_path Relative path within the uploads directory.
277 * @return bool True when no file remains (deleted or never existed), false when it survived deletion.
278 */
279 public static function delete_receipt( $relative_path ) {
280 $filepath = self::relative_to_path( $relative_path );
281
282 if ( false === $filepath || ! file_exists( $filepath ) ) {
283 return true;
284 }
285
286 wp_delete_file( $filepath );
287
288 // Re-check with is_file() (not file_exists()) — wp_delete_file() has a
289 // filesystem side effect PHPStan can't see, so re-calling the already
290 // narrowed file_exists() reads as always-false to it.
291 clearstatcache( true, $filepath );
292
293 return ! is_file( $filepath );
294 }
295
296 /**
297 * Get the mPDF configuration.
298 *
299 * @param array<string, mixed> $donation Donation data.
300 * @param array<string, mixed>|null $donor Donor data.
301 * @return array<string,mixed>
302 * @since 1.0.0
303 */
304 private static function get_mpdf_config( $donation = [], $donor = null ) {
305 $config = [
306 'mode' => 'utf-8',
307 'format' => 'A4',
308 'orientation' => 'P',
309 'margin_left' => 15,
310 'margin_right' => 15,
311 'margin_top' => 15,
312 'margin_bottom' => 15,
313 'default_font' => 'dejavusans',
314 'tempDir' => Pdf_Utils::get_temp_dir(),
315 // mPDF resolves `<img src>` and CSS url() through stream wrappers,
316 // and its default whitelist is ['http', 'https', 'file'] — so out of
317 // the box a src can reach an internal host (SSRF) or read a local
318 // file into the PDF (LFI). The receipt HTML is server-templated with
319 // escaped fields, but the suredonation_receipt_html filter and the
320 // Pro receipt templates both put author-controlled HTML through
321 // WriteHTML(), and wp_kses_post() permits <img src="http(s)://…">.
322 //
323 // Emptying the whitelist is the actual control: Mpdf\File\
324 // StreamWrapperChecker then rejects every `scheme://` src before a
325 // fetch happens. Nothing legitimate needs one — the logo is
326 // embedded as a data: URI (no `://`, so the check never fires) and
327 // local font/temp paths are plain paths. A blocked <img> degrades to
328 // mPDF's own imageError() handling rather than failing the render.
329 'whitelistStreamWrappers' => [],
330 // Kept as defence in depth: if a filter callback ever re-whitelists
331 // http(s), requests still verify SSL. This is not what stops the
332 // fetch — the whitelist above is.
333 'curlAllowUnsafeSslRequests' => false,
334 ];
335
336 /**
337 * Filter the mPDF configuration used for receipt generation.
338 *
339 * SECURITY NOTE: 'whitelistStreamWrappers' is emptied deliberately.
340 * Restoring any entry lets HTML that reaches WriteHTML() fetch that
341 * scheme, which is SSRF for http(s) and LFI for file://. It is
342 * force-reset to [] after this filter runs, so callbacks cannot widen
343 * it; resolve trusted assets (e.g. a logo attachment) to a data: URI or
344 * a validated local path server-side instead.
345 *
346 * @param array<string, mixed> $config mPDF configuration.
347 * @param array<string, mixed> $donation Donation data.
348 * @param array<string, mixed>|null $donor Donor data.
349 * @since 1.5.0
350 */
351 $config = apply_filters( 'suredonation_receipt_mpdf_config', $config, $donation, $donor );
352 $config = is_array( $config ) ? $config : [];
353
354 // Enforced post-filter: an empty stream-wrapper whitelist is what gates
355 // LFI/SSRF in mPDF, so it stays empty regardless of what filter
356 // callbacks return.
357 $config['whitelistStreamWrappers'] = [];
358 $config['curlAllowUnsafeSslRequests'] = false;
359
360 return $config;
361 }
362
363 /**
364 * Build the receipt HTML template.
365 *
366 * @param array<string, mixed> $donation Donation data.
367 * @param array<string, mixed>|null $donor Donor data.
368 * @param string $campaign_title Campaign title.
369 * @return string HTML content.
370 * @since 1.0.0
371 */
372 private static function build_receipt_html( $donation, $donor, $campaign_title ) {
373 $site_name = esc_html( get_bloginfo( 'name' ) );
374 $site_url = esc_url( site_url() );
375
376 $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
377 // Prefer the name captured on this specific donation — it is the correct
378 // identity for a tax receipt and is unaffected by the donor record being
379 // set-once. Fall back to the donor record only when the donation itself
380 // carries no name.
381 $donation_donor_name = Helper::get_string_value( $donation['donor_name'] ?? '' );
382 $donor_name = esc_html( '' !== $donation_donor_name ? $donation_donor_name : Helper::get_string_value( $donor['name'] ?? '' ) );
383 $donor_email = esc_html( Helper::get_string_value( $donor['email'] ?? '' ) );
384 $payment_status = esc_html( ucfirst( Helper::get_string_value( $donation['payment_status'] ?? '' ) ) );
385 $payment_method = esc_html( ucfirst( Helper::get_string_value( $donation['gateway'] ?? '' ) ) );
386 $transaction_id = esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) );
387 $currency = Helper::get_string_value( $donation['currency'] ?? 'USD' );
388 $total = Helper::get_float_value( $donation['amount'] ?? 0 );
389 $fees_covered = Helper::get_float_value( $donation['fees_covered'] ?? 0 );
390 $amount = $total - $fees_covered;
391 $date = Helper::get_string_value( $donation['created_at'] ?? '' );
392
393 if ( ! empty( $date ) ) {
394 $date_format = Helper::get_string_value( get_option( 'date_format' ) );
395 $timestamp = strtotime( $date );
396 $formatted_date = false !== $timestamp ? wp_date( $date_format, $timestamp ) : false;
397 $date = is_string( $formatted_date ) ? $formatted_date : $date;
398 }
399
400 $campaign_title = esc_html( $campaign_title );
401
402 // Format amounts.
403 $formatted_amount = self::format_currency( $amount, $currency );
404 $formatted_fees = self::format_currency( $fees_covered, $currency );
405 $formatted_total = self::format_currency( $total, $currency );
406
407 // Build transaction ID row.
408 $transaction_row = '';
409 if ( ! empty( $transaction_id ) ) {
410 $transaction_row = sprintf(
411 '<tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">%s</td>
412 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">%s</td></tr>',
413 esc_html__( 'Transaction ID', 'suredonation' ),
414 $transaction_id
415 );
416 }
417
418 // Build fees row.
419 $fees_row = '';
420 if ( $fees_covered > 0 ) {
421 $fees_row = sprintf(
422 '<tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">%s</td>
423 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">%s</td></tr>',
424 esc_html__( 'Fees Covered', 'suredonation' ),
425 $formatted_fees
426 );
427 }
428
429 return '
430 <div style="max-width:560px;margin:0 auto;font-family:DejaVu Sans,sans-serif;color:#111827;">
431 <div style="text-align:center;margin-bottom:24px;padding-bottom:20px;border-bottom:2px solid #e5e7eb;">
432 <h1 style="font-size:20px;margin:0 0 4px;color:#111827;">' . $site_name . '</h1>
433 <p style="color:#6b7280;font-size:13px;margin:0;">' . esc_html__( 'Donation Receipt', 'suredonation' ) . '</p>
434 </div>
435
436 <p style="color:#6b7280;font-size:12px;margin:0 0 16px;text-align:right;">'
437 . esc_html__( 'Receipt', 'suredonation' ) . ' #' . $donation_id . '</p>
438
439 <table style="width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e5e7eb;border-radius:6px;">
440 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;width:40%;">'
441 . esc_html__( 'Donor Name', 'suredonation' ) . '</td>
442 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $donor_name . '</td></tr>
443 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
444 . esc_html__( 'Donor Email', 'suredonation' ) . '</td>
445 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $donor_email . '</td></tr>
446 </table>
447
448 <table style="width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e5e7eb;border-radius:6px;">
449 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;width:40%;">'
450 . esc_html__( 'Campaign Name', 'suredonation' ) . '</td>
451 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $campaign_title . '</td></tr>
452 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
453 . esc_html__( 'Payment Status', 'suredonation' ) . '</td>
454 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $payment_status . '</td></tr>
455 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
456 . esc_html__( 'Payment Method', 'suredonation' ) . '</td>
457 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $payment_method . '</td></tr>
458 ' . $transaction_row . '
459 <tr><td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">'
460 . esc_html__( 'Donation Amount', 'suredonation' ) . '</td>
461 <td style="padding:10px 12px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;">' . $formatted_amount . '</td></tr>
462 ' . $fees_row . '
463 <tr><td style="padding:10px 12px;background:#f9fafb;font-weight:bold;color:#111827;font-size:13px;">'
464 . esc_html__( 'Donation Total', 'suredonation' ) . '</td>
465 <td style="padding:10px 12px;background:#f9fafb;font-weight:bold;font-size:13px;color:#111827;">' . $formatted_total . '</td></tr>
466 </table>
467
468 <p style="color:#6b7280;font-size:12px;margin:0 0 4px;">'
469 . esc_html__( 'Date', 'suredonation' ) . ': ' . esc_html( $date ) . '</p>
470
471 <div style="margin-top:30px;padding-top:16px;border-top:1px solid #e5e7eb;text-align:center;">
472 <p style="color:#9ca3af;font-size:11px;margin:0;">'
473 . sprintf(
474 /* translators: 1: Site name, 2: Site URL. */
475 esc_html__( 'Generated by %1$s · %2$s', 'suredonation' ),
476 $site_name,
477 $site_url
478 ) . '</p>
479 </div>
480 </div>';
481 }
482
483 /**
484 * Format a monetary amount with currency symbol.
485 *
486 * @param float $amount Amount to format.
487 * @param string $currency Currency code.
488 * @return string Formatted amount.
489 * @since 1.0.0
490 */
491 private static function format_currency( $amount, $currency = 'USD' ) {
492 // Delegate to the single source of truth so the currency symbol,
493 // decimal handling and sign position match every other surface
494 // (this replaces a divergent local symbol map).
495 return Payment_Helper::format_amount( $amount, $currency );
496 }
497
498 /**
499 * Convert a relative path to an absolute file path.
500 *
501 * @param string $relative_path Relative path within the uploads directory.
502 * @return string|false Absolute file path or false.
503 * @since 1.0.0
504 */
505 private static function relative_to_path( $relative_path ) {
506 if ( empty( $relative_path ) ) {
507 return false;
508 }
509
510 $upload_dir = wp_upload_dir();
511
512 return $upload_dir['basedir'] . '/' . $relative_path;
513 }
514
515 /**
516 * Resolve the filename the receipt is stored under on disk.
517 *
518 * Extracted so the normalisation rules below are testable without
519 * rendering a PDF, which needs mPDF present.
520 *
521 * @param string $default_filename Generated opaque filename.
522 * @param array<string, mixed> $donation Donation data.
523 * @param array<string, mixed>|null $donor Donor data.
524 * @return string Filename ending in exactly one .pdf extension.
525 * @since 1.5.1
526 */
527 private static function resolve_storage_filename( $default_filename, $donation, $donor ) {
528 /**
529 * Filter the receipt PDF filename ON DISK.
530 *
531 * This is not the name anyone receives: donors get the file under
532 * {@see self::get_download_filename()}, so the stored name deliberately
533 * carries no donation id, no configured prefix and no donor data -- only
534 * randomness. The receipts directory denies direct access through
535 * .htaccess, but servers that ignore it (nginx, IIS) serve the file as a
536 * static asset, which leaves this name as the only thing gating it. Treat
537 * a filtered value as a capability token and keep it unguessable.
538 *
539 * The filtered value is passed through sanitize_file_name() -- which
540 * strips path separators and collapses '..' -- and forced to a .pdf
541 * extension; an empty result falls back to the default.
542 *
543 * @param string $default_filename Generated filename.
544 * @param array<string, mixed> $donation Donation data.
545 * @param array<string, mixed>|null $donor Donor data.
546 * @since 1.5.0
547 * @since 1.5.1 Names only the file on disk. To name the copy a donor
548 * receives, use {@see 'suredonation_receipt_download_filename'}.
549 */
550 $filename = apply_filters( 'suredonation_receipt_filename', $default_filename, $donation, $donor );
551
552 // This is the call that makes the value safe: it strips path separators
553 // and null bytes and collapses '..', so everything after it works on a
554 // bare filename. It also normalises before the two tests below, which is
555 // what keeps a filter's intended stem — "receipt.pdf " still ends in
556 // .pdf once trimmed, and so resolves to receipt.pdf rather than
557 // receipt-pdf.pdf.
558 //
559 // There is a second sanitize_file_name() inside the else. Measured
560 // against 35 filtered values, including traversal, null bytes and bare
561 // extension words, it changes no outcome's safety — the single-extension
562 // guarantee comes from this call plus the dot collapse plus the appended
563 // .pdf. What it does change is the name: it is why a stem left as a bare
564 // extension word comes back as unnamed-file-exe.pdf instead of exe.pdf.
565 // It is kept as defence in depth on a name that lands in a
566 // web-accessible directory; the tests pin both effects.
567 $filename = sanitize_file_name( Helper::get_string_value( $filename ) );
568
569 if ( '' === $filename ) {
570 $filename = $default_filename;
571 } else {
572 // Force exactly one extension, and make it .pdf. Appending to the
573 // filtered value produced a double extension — a filter returning
574 // "x.php" landed on disk as "x.php.pdf", which sanitize_file_name()
575 // does not underscore (it early-returns for a two-part name) and
576 // which some Apache configurations still hand to the PHP handler on
577 // the strength of the inner extension. Stripping only the last
578 // segment is not enough either: "x.php.pdf" would survive intact.
579 //
580 // So a trailing .pdf is dropped first — that is the normal case, and
581 // the currently-released Pro's filter returns exactly that shape —
582 // then every remaining dot is removed and one .pdf added back. That
583 // is safe for this value specifically: it names the file on disk
584 // only, is
585 // documented as a capability token rather than anything a donor
586 // sees, and the default is already dot-free (sd-receipt-<hex>). The
587 // donor-facing name comes from get_download_filename() and is
588 // untouched by this.
589 // Order matters, and getting it wrong is what the first attempt at
590 // this did. sanitize_file_name() *re-inserts* an extension when the
591 // name it is given has none — it runs
592 // wp_check_filetype( 'test.' . $filename ), so a bare 'exe' comes
593 // back as 'unnamed-file.exe'. Collapsing the dots first therefore
594 // handed core a token it turned back into a two-part name, and
595 // 'exe.pdf' landed as 'unnamed-file.exe.pdf': two extensions, from
596 // the very code meant to guarantee one.
597 //
598 // So sanitize first, collapse whatever dots that leaves, and make
599 // .pdf the genuinely last operation on a dot-free token.
600 $filename = (string) preg_replace( '/\.pdf$/i', '', $filename );
601 $filename = sanitize_file_name( $filename );
602 $filename = str_replace( '.', '-', $filename );
603 $filename = '' === $filename ? $default_filename : $filename . '.pdf';
604 }
605
606 return $filename;
607 }
608 }
609