PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Services / PdfDownload.php

PdfDownload.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at includes/Services/PdfDownload.php

199 lines 7.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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