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
← All changes | inc/pdf/receipt-generator.php +409 -33 1.4.0 → 1.6.1 View file →
@@ -38,8 +38,12 @@
38 38 if ( ! $donation ) {
39 39 return false;
40 40 }
41 41
42 + if ( ! self::should_generate( $donation ) ) {
43 + return false;
44 + }
45 +
42 46 // Check if a cached PDF exists.
43 47 $existing_relative = $donation['receipt_pdf_url'] ?? '';
44 48
45 49 if ( ! empty( $existing_relative ) ) {
@@ -44,9 +48,10 @@
44 48
45 49 if ( ! empty( $existing_relative ) ) {
46 50 $existing_path = self::relative_to_path( Helper::get_string_value( $existing_relative ) );
47 51
48 - if ( $existing_path && file_exists( $existing_path ) ) {
52 + // is_file() so a directory can never be served as a cached receipt.
53 + if ( $existing_path && is_file( $existing_path ) ) {
49 54 return $existing_path;
50 55 }
51 56 }
52 57
@@ -73,8 +78,12 @@
73 78 if ( ! $donation ) {
74 79 return false;
75 80 }
76 81
82 + if ( ! self::should_generate( $donation ) ) {
83 + return false;
84 + }
85 +
77 86 $donor_id = Helper::get_integer_value( $donation['donor_id'] ?? 0 );
78 87 $donor = $donor_id ? Donors::get( $donor_id ) : null;
79 88
80 89 $campaign_id = Helper::get_integer_value( $donation['campaign_id'] ?? 0 );
@@ -85,11 +94,14 @@
85 94
86 95 /**
87 96 * Filter the receipt HTML before PDF generation.
88 97 *
89 - * SECURITY NOTE: The returned HTML is passed directly to mPDF's WriteHTML().
90 - * mPDF can process <img> tags with file:// URIs and load external resources.
91 - * Ensure any modifications only use trusted, escaped content.
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.
92 104 *
93 105 * @param string $html Receipt HTML.
94 106 * @param array $donation Donation data.
95 107 * @param array|null $donor Donor data.
@@ -99,14 +111,32 @@
99 111
100 112 // Ensure receipts directory exists.
101 113 Pdf_Utils::ensure_receipts_dir();
102 114
103 - $receipts_dir = Pdf_Utils::get_receipts_dir();
104 - $filename = sprintf( 'suredonation-receipt-%d-%s.pdf', $donation_id, wp_generate_password( 8, false ) );
105 - $filepath = $receipts_dir . '/' . $filename;
115 + $receipts_dir = Pdf_Utils::get_receipts_dir();
116 + $default_filename = self::generate_storage_filename();
106 117
118 + $filename = self::resolve_storage_filename( $default_filename, $donation, $donor );
119 +
120 + $filepath = $receipts_dir . '/' . $filename;
121 +
107 122 try {
108 - $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config() );
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 +
109 139 $mpdf->WriteHTML( $html );
110 140 $mpdf->Output( $filepath, \Mpdf\Output\Destination::FILE );
111 141 } catch ( \Exception $e ) {
112 142 return false;
@@ -116,12 +146,128 @@
116 146 $upload_dir = wp_upload_dir();
117 147 $relative_path = str_replace( $upload_dir['basedir'] . '/', '', $filepath );
118 148 Donations::update( $donation_id, [ 'receipt_pdf_url' => $relative_path ] );
119 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 +
120 160 return $filepath;
121 161 }
122 162
123 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 + /**
124 270 * Delete a receipt PDF file by its stored uploads-relative path.
125 271 *
126 272 * Used by the personal-data eraser: the receipt is generated from the donor's
127 273 * name/email/address, so an erasure must remove the file from disk, not just
@@ -126,15 +272,25 @@
126 272 * Used by the personal-data eraser: the receipt is generated from the donor's
127 273 * name/email/address, so an erasure must remove the file from disk, not just
128 274 * the database columns.
129 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 + *
130 281 * @since 1.2.0
131 282 * @param string $relative_path Relative path within the uploads directory.
132 - * @return bool True when no file remains (deleted or never existed), false when it survived deletion.
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.
133 284 */
134 285 public static function delete_receipt( $relative_path ) {
135 286 $filepath = self::relative_to_path( $relative_path );
136 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.
137 293 if ( false === $filepath || ! file_exists( $filepath ) ) {
138 294 return true;
139 295 }
140 296
@@ -150,33 +306,70 @@
150 306
151 307 /**
152 308 * Get the mPDF configuration.
153 309 *
310 + * @param array<string, mixed> $donation Donation data.
311 + * @param array<string, mixed>|null $donor Donor data.
154 312 * @return array<string,mixed>
155 313 * @since 1.0.0
156 314 */
157 - private static function get_mpdf_config() {
158 - return [
159 - 'mode' => 'utf-8',
160 - 'format' => 'A4',
161 - 'orientation' => 'P',
162 - 'margin_left' => 15,
163 - 'margin_right' => 15,
164 - 'margin_top' => 15,
165 - 'margin_bottom' => 15,
166 - 'default_font' => 'dejavusans',
167 - 'tempDir' => Pdf_Utils::get_temp_dir(),
168 - // mPDF defaults allow <img src="file:///..."> and remote http(s)
169 - // resource fetching. The receipt HTML is server-templated with
170 - // escaped fields, but the suredonation_receipt_html filter (and
171 - // any future ucfirst-only gateway label) would still surface
172 - // donor / gateway data into HTML that mPDF processes. Disable
173 - // the dangerous resource-loading defaults so a malicious string
174 - // in any rendered field can't become SSRF (remote fetch) or
175 - // LFI (local file read into the PDF) regardless of the source.
176 - 'allow_remote_dir_in_links_filesystem' => false,
177 - 'curlAllowUnsafeSslRequests' => false,
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,
178 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;
179 372 }
180 373
181 374 /**
182 375 * Build the receipt HTML template.
@@ -315,10 +508,14 @@
315 508
316 509 /**
317 510 * Convert a relative path to an absolute file path.
318 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 + *
319 516 * @param string $relative_path Relative path within the uploads directory.
320 - * @return string|false Absolute file path or false.
517 + * @return string|false Absolute file path inside the receipts directory, or false.
321 518 * @since 1.0.0
322 519 */
323 520 private static function relative_to_path( $relative_path ) {
324 521 if ( empty( $relative_path ) ) {
@@ -324,9 +521,188 @@
324 521 if ( empty( $relative_path ) ) {
325 522 return false;
326 523 }
327 524
328 - $upload_dir = wp_upload_dir();
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 );
329 534
330 - return $upload_dir['basedir'] . '/' . $relative_path;
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;
331 707 }
332 708 }