| 1 |
<?php |
| 2 |
/** |
| 3 |
* Serves the PDF download from the server when it can. |
| 4 |
* |
| 5 |
* @package Easy_Invoice |
| 6 |
* @subpackage Services |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace EasyInvoice\Services; |
| 10 |
|
| 11 |
if ( ! defined( 'ABSPATH' ) ) { |
| 12 |
exit; |
| 13 |
} |
| 14 |
|
| 15 |
/** |
| 16 |
* Upgrades `?auto_download_pdf=1` to a real, server-rendered PDF. |
| 17 |
* |
| 18 |
* Why intercept the existing URL |
| 19 |
* ------------------------------ |
| 20 |
* That query argument is already the download link everywhere — the invoice and |
| 21 |
* quote listings, the single-document page, and the links inside emails that |
| 22 |
* have already been sent. Introducing a second URL would leave every one of |
| 23 |
* those on the old path, and the ones already in customers' inboxes could never |
| 24 |
* be changed at all. Taking over the existing route upgrades all of them at |
| 25 |
* once, including links that predate this code. |
| 26 |
* |
| 27 |
* What changes for the reader |
| 28 |
* --------------------------- |
| 29 |
* Until now a "PDF" from this plugin was a screenshot. The browser rasterised |
| 30 |
* the page with html2canvas and wrapped the image in a PDF, so the result had |
| 31 |
* no selectable text, no searchable content, nothing a screen reader could |
| 32 |
* announce, and a file size measured in megabytes. It also varied by browser, |
| 33 |
* which is why PDF rendering bugs kept recurring across releases. |
| 34 |
* |
| 35 |
* The server-rendered document is real text. |
| 36 |
* |
| 37 |
* Why the old path stays |
| 38 |
* ---------------------- |
| 39 |
* dompdf is a Composer dependency, and sites deployed by copying files around |
| 40 |
* can arrive without `vendor/`. If the renderer is missing, or rendering fails |
| 41 |
* for this particular document, this hook simply returns and the page loads and |
| 42 |
* does what it always did. A merchant never sees a download stop working |
| 43 |
* because the better implementation was unavailable — the worst case is the |
| 44 |
* result they were already getting. |
| 45 |
* |
| 46 |
* Access control |
| 47 |
* -------------- |
| 48 |
* None here, deliberately. `TemplateLoader::enforceDocumentAccess` runs on the |
| 49 |
* same hook at priority 1 and has already decided whether this visitor may see |
| 50 |
* this document; anything still executing at priority 5 is past that gate. |
| 51 |
* Repeating the check here would mean two places to keep in agreement, and the |
| 52 |
* quieter failure is the one that forgets to deny. |
| 53 |
*/ |
| 54 |
class PdfDownload { |
| 55 |
|
| 56 |
/** Query argument that asks for the PDF. */ |
| 57 |
const TRIGGER = 'auto_download_pdf'; |
| 58 |
|
| 59 |
/** |
| 60 |
* Hook the interceptor. |
| 61 |
* |
| 62 |
* @return void |
| 63 |
*/ |
| 64 |
public static function init(): void { |
| 65 |
// Priority 5: after enforceDocumentAccess (1), before anything renders. |
| 66 |
add_action( 'template_redirect', [ __CLASS__, 'maybeServe' ], 5 ); |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Serve the PDF if this request is asking for one and we can produce it. |
| 71 |
* |
| 72 |
* @return void |
| 73 |
*/ |
| 74 |
public static function maybeServe(): void { |
| 75 |
if ( ! isset( $_GET[ self::TRIGGER ] ) || '1' !== (string) $_GET[ self::TRIGGER ] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a read-only download of a document the access gate has already cleared. |
| 76 |
return; |
| 77 |
} |
| 78 |
|
| 79 |
if ( ! is_singular( [ 'easy_invoice', 'easy_invoice_quote' ] ) ) { |
| 80 |
return; |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Filter whether the server renders the PDF for this request. |
| 85 |
* |
| 86 |
* Returning false falls back to the browser-side renderer, which is the |
| 87 |
* behaviour every version before this one had. |
| 88 |
* |
| 89 |
* @param bool $enabled Whether to render server-side. |
| 90 |
*/ |
| 91 |
if ( ! apply_filters( 'easy_invoice_server_pdf_enabled', true ) ) { |
| 92 |
return; |
| 93 |
} |
| 94 |
|
| 95 |
// The site chooses how the download button makes its PDF. "Browser" |
| 96 |
// captures the page exactly as it is drawn — every design, custom |
| 97 |
// template and watermark included — which is what the button did |
| 98 |
// before 2.4.0 and what it does by default. "Server" renders the |
| 99 |
// document with dompdf: real text and a small file. Emails, the REST |
| 100 |
// API and e-invoicing always use the server, since they have no browser. |
| 101 |
if ( 'server' !== self::downloadMethod() ) { |
| 102 |
return; |
| 103 |
} |
| 104 |
|
| 105 |
if ( ! PdfRenderer::isAvailable() ) { |
| 106 |
return; |
| 107 |
} |
| 108 |
|
| 109 |
$post = get_queried_object(); |
| 110 |
if ( ! $post instanceof \WP_Post ) { |
| 111 |
return; |
| 112 |
} |
| 113 |
|
| 114 |
$is_quote = 'easy_invoice_quote' === $post->post_type; |
| 115 |
|
| 116 |
try { |
| 117 |
$document = $is_quote |
| 118 |
? new \EasyInvoice\Models\Quote( $post ) |
| 119 |
: new \EasyInvoice\Models\Invoice( $post ); |
| 120 |
|
| 121 |
$pdf = $is_quote |
| 122 |
? PdfRenderer::renderQuote( $document ) |
| 123 |
: PdfRenderer::renderInvoice( $document ); |
| 124 |
} catch ( \Throwable $e ) { |
| 125 |
error_log( 'Easy Invoice: server-side PDF failed, falling back to the browser — ' . $e->getMessage() ); |
| 126 |
return; |
| 127 |
} |
| 128 |
|
| 129 |
if ( is_wp_error( $pdf ) || ! is_string( $pdf ) || '' === $pdf ) { |
| 130 |
if ( is_wp_error( $pdf ) ) { |
| 131 |
error_log( 'Easy Invoice: server-side PDF failed, falling back to the browser — ' . $pdf->get_error_message() ); |
| 132 |
} |
| 133 |
return; |
| 134 |
} |
| 135 |
|
| 136 |
self::stream( $pdf, self::filename( $document, $is_quote ) ); |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* How the download button produces its PDF: 'browser' or 'server'. |
| 141 |
* |
| 142 |
* @return string |
| 143 |
*/ |
| 144 |
public static function downloadMethod(): string { |
| 145 |
$method = (string) get_option( 'easy_invoice_pdf_download_method', 'browser' ); |
| 146 |
/** |
| 147 |
* Filter how the Download as PDF button produces its file. |
| 148 |
* |
| 149 |
* @param string $method 'browser' (capture the page as shown) or 'server' (dompdf). |
| 150 |
*/ |
| 151 |
$method = (string) apply_filters( 'easy_invoice_pdf_download_method', $method ); |
| 152 |
return 'server' === $method ? 'server' : 'browser'; |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Send the bytes as a download and stop. |
| 157 |
* |
| 158 |
* @param string $pdf PDF bytes. |
| 159 |
* @param string $filename Download filename. |
| 160 |
* @return void |
| 161 |
*/ |
| 162 |
private static function stream( string $pdf, string $filename ): void { |
| 163 |
// Any stray output — a notice, a plugin's whitespace — would corrupt the |
| 164 |
// file, and a corrupt PDF is worse than a slow one. |
| 165 |
if ( ob_get_length() ) { |
| 166 |
@ob_end_clean(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 167 |
} |
| 168 |
|
| 169 |
nocache_headers(); |
| 170 |
header( 'Content-Type: application/pdf' ); |
| 171 |
header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); |
| 172 |
header( 'Content-Length: ' . strlen( $pdf ) ); |
| 173 |
header( 'X-Content-Type-Options: nosniff' ); |
| 174 |
|
| 175 |
echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput -- binary document. |
| 176 |
exit; |
| 177 |
} |
| 178 |
|
| 179 |
/** |
| 180 |
* Download filename for a document. |
| 181 |
* |
| 182 |
* @param object $document Invoice or Quote model. |
| 183 |
* @param bool $is_quote Whether this is a quote. |
| 184 |
* @return string |
| 185 |
*/ |
| 186 |
private static function filename( $document, bool $is_quote ): string { |
| 187 |
$number = ''; |
| 188 |
if ( is_callable( [ $document, 'getNumber' ] ) ) { |
| 189 |
$number = trim( (string) $document->getNumber() ); |
| 190 |
} |
| 191 |
|
| 192 |
if ( '' === $number ) { |
| 193 |
$number = $is_quote ? 'quote' : 'invoice'; |
| 194 |
} |
| 195 |
|
| 196 |
return sanitize_file_name( $number . '.pdf' ); |
| 197 |
} |
| 198 |
} |
| 199 |
|