PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 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.1, at inc/pdf/receipt-generator.php

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