PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
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 / TemplateLoader.php

TemplateLoader.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at includes/TemplateLoader.php

392 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Template Loader Class
4 *
5 * @package Easy_Invoice
6 * @subpackage Includes
7 */
8
9 namespace EasyInvoice;
10
11 /**
12 * Template Loader Class
13 *
14 * Handles loading of custom templates for the Easy Invoice plugin.
15 */
16 class TemplateLoader {
17
18 /**
19 * Initialize the template loader
20 */
21 public function init() {
22 // Authorisation runs before anything decides which template to load, and
23 // before any output. `template_redirect` is the right hook because it fires
24 // for every front-end entry point into a document — the pretty permalink,
25 // `?p=<id>`, feeds, embeds, and the `?auto_download_pdf=1` PDF path — so a
26 // single gate covers all of them.
27 add_action('template_redirect', [$this, 'enforceDocumentAccess'], 1);
28 add_action('admin_post_nopriv_' . self::REFRESH_ACTION, [self::class, 'handleLinkRefreshRequest']);
29 add_action('admin_post_' . self::REFRESH_ACTION, [self::class, 'handleLinkRefreshRequest']);
30
31 // `exclude_from_search` (see EasyInvoice::registerPostTypes) keeps documents
32 // out of site search and search feeds, but it does not stop an explicit
33 // `?post_type=easy_invoice` query, which the theme happily rendered as an
34 // archive listing every invoice title and permalink. `has_archive` is false,
35 // but `publicly_queryable` has to stay true for single permalinks to resolve,
36 // and that is enough for the query to run.
37 add_action('pre_get_posts', [$this, 'blockDocumentArchiveQueries']);
38
39 add_filter('single_template', [$this, 'loadSingleQuoteTemplate']);
40 add_filter('single_template', [$this, 'loadSingleInvoiceTemplate']);
41 add_filter('template_include', [$this, 'loadCustomTemplates']);
42 }
43
44 /**
45 * Stop invoices and quotes being listed by an archive-style front-end query.
46 *
47 * `?post_type=easy_invoice` (and the quote equivalent, and their feeds) ran a
48 * normal archive query that the active theme rendered as a post list — exposing
49 * every invoice title and permalink to anonymous visitors. The single-document
50 * gate did not apply because those requests are not `is_singular()`.
51 *
52 * Admin queries are untouched: the plugin's own list screens rely on them.
53 *
54 * @param \WP_Query $query
55 * @return void
56 */
57 public function blockDocumentArchiveQueries($query) {
58 if (is_admin() || !$query instanceof \WP_Query || !$query->is_main_query()) {
59 return;
60 }
61
62 // Single documents are handled by enforceDocumentAccess(), which knows how to
63 // authorise them. Only listing-style queries are blocked here.
64 if ($query->is_singular()) {
65 return;
66 }
67
68 $ours = [
69 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
70 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
71 ];
72
73 $requested = $query->get('post_type');
74 if (empty($requested)) {
75 return;
76 }
77
78 $requested = (array) $requested;
79 if (!array_intersect($requested, $ours)) {
80 return;
81 }
82
83 $remaining = array_values(array_diff($requested, $ours));
84
85 if (!empty($remaining)) {
86 // Mixed query — drop just our types and let the rest run.
87 $query->set('post_type', $remaining);
88 return;
89 }
90
91 // The query asked for nothing but our documents. Return no results rather
92 // than an empty archive, so the response does not confirm the type exists.
93 $query->set('post__in', [0]);
94 $query->set('posts_per_page', 0);
95 }
96
97 /**
98 * Refuse to render an invoice or quote to a visitor who is not authorised.
99 *
100 * Invoices and quotes are stored with post_status 'publish' regardless of their
101 * workflow status (see Models\Invoice::save() and Models\Quote::save() — the
102 * comment there explains it is done "to ensure proper permalinks"), and both post
103 * types are registered `public` + `publicly_queryable`. Without this gate, any
104 * unauthenticated visitor who guessed or discovered a URL could read the whole
105 * document — customer name, email, address, line items, prices, notes and totals —
106 * including invoices still in Draft. Nothing downstream checked: the single
107 * templates rendered unconditionally, and the template loader keyed only on post
108 * type.
109 *
110 * Authorisation reuses the existing helpers rather than duplicating their rules,
111 * so there is one definition of "may this person see this document":
112 *
113 * - a valid per-document access token (`?ik=` / `?qk=`, compared with
114 * hash_equals) — this is what emailed links carry;
115 * - an administrator;
116 * - the logged-in client the document is bound to.
117 *
118 * Unauthorised requests get a normal 404 rather than an "access denied" page, so
119 * the response does not confirm that a given invoice number exists.
120 *
121 * @return void
122 */
123 public function enforceDocumentAccess() {
124 if (is_admin() || !is_singular()) {
125 return;
126 }
127
128 $post = get_queried_object();
129 if (!$post instanceof \WP_Post) {
130 return;
131 }
132
133 $invoice_type = \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
134 $quote_type = \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE;
135
136 if ($post->post_type !== $invoice_type && $post->post_type !== $quote_type) {
137 return;
138 }
139
140 /**
141 * Allow a site to turn the gate off.
142 *
143 * Sites that would rather keep the old open-by-URL behaviour can return
144 * false here, but they are choosing to expose customer data to anyone
145 * holding or guessing a URL. A bare link to an issued document otherwise
146 * shows a page offering to email a fresh keyed link (renderLinkRefreshPage).
147 *
148 * @param bool $enforce Whether to require authorisation. Default true.
149 * @param \WP_Post $post The invoice or quote being requested.
150 */
151 if (!apply_filters('easy_invoice_require_document_authorisation', true, $post)) {
152 return;
153 }
154
155 $allowed = false;
156
157 if ($post->post_type === $invoice_type) {
158 $invoice = null;
159 try {
160 $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($post->ID);
161 } catch (\Throwable $e) {
162 $invoice = null;
163 }
164 $allowed = \EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice((int) $post->ID, $invoice);
165 } else {
166 $quote = null;
167 try {
168 $quote = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository()->find($post->ID);
169 } catch (\Throwable $e) {
170 $quote = null;
171 }
172 $allowed = \EasyInvoice\Controllers\QuoteController::canActOnQuote((int) $post->ID, $quote);
173 }
174
175 if ($allowed) {
176 return;
177 }
178
179 // A bare URL to a real, issued document: most likely a link emailed before
180 // access keys existed. Show nothing of the document, but let the holder
181 // ask for a fresh keyed link to the address the document was issued to.
182 if (self::canOfferLinkRefresh($post)) {
183 self::renderLinkRefreshPage($post);
184 exit;
185 }
186
187 // Present it as "not found" rather than "forbidden" so the response does not
188 // disclose that this document exists.
189 global $wp_query;
190 $wp_query->set_404();
191 status_header(404);
192 nocache_headers();
193 include get_query_template('404');
194 exit;
195 }
196
197 /**
198 * Meta stamped when an email carrying the document's keyed link goes out. Kept
199 * so a site can tell which documents' recipients already hold a keyed link.
200 */
201 const KEYED_LINK_SENT_META = '_easy_invoice_keyed_link_sent';
202
203 /** Action (admin-post, works for anonymous visitors) behind the "send me a fresh link" button. */
204 const REFRESH_ACTION = 'easy_invoice_request_document_link';
205
206 public static function markKeyedLinkSent(int $post_id): void {
207 if ($post_id > 0 && '' === (string) get_post_meta($post_id, self::KEYED_LINK_SENT_META, true)) {
208 update_post_meta($post_id, self::KEYED_LINK_SENT_META, current_time('mysql', true));
209 }
210 }
211
212 /**
213 * Only an issued (non-draft, published) document that has an address on file
214 * gets the refresh offer; anything else is a plain 404, so the page cannot be
215 * used to probe which URLs exist beyond what the old behaviour already showed.
216 */
217 public static function canOfferLinkRefresh(\WP_Post $post): bool {
218 if ('publish' !== $post->post_status) {
219 return false;
220 }
221 $is_invoice = $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
222 $status = strtolower((string) get_post_meta($post->ID, $is_invoice ? '_easy_invoice_status' : '_easy_invoice_quote_status', true));
223 if ('draft' === $status || '' === $status) {
224 return false;
225 }
226 return '' !== self::recipientAddress($post);
227 }
228
229 /** The address the document was issued to (never shown to the visitor). */
230 private static function recipientAddress(\WP_Post $post): string {
231 $is_invoice = $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
232 $email = (string) get_post_meta($post->ID, $is_invoice ? '_easy_invoice_customer_email' : '_easy_invoice_quote_customer_email', true);
233 if ('' === $email) {
234 $client_id = (int) get_post_meta($post->ID, $is_invoice ? '_easy_invoice_client_id' : '_easy_invoice_quote_client_id', true);
235 $user = $client_id > 0 ? get_user_by('id', $client_id) : null;
236 $email = $user ? (string) $user->user_email : '';
237 }
238 return is_email($email) ? $email : '';
239 }
240
241 /**
242 * The page shown instead of the document. Deliberately standalone (no theme,
243 * no document data): a title, one sentence, one button.
244 */
245 public static function renderLinkRefreshPage(\WP_Post $post, string $state = ''): void {
246 status_header('sent' === $state ? 200 : 403);
247 nocache_headers();
248 header('X-Robots-Tag: noindex, nofollow');
249 header('Content-Type: text/html; charset=' . get_option('blog_charset'));
250 $is_invoice = $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
251 $what = $is_invoice ? __('invoice', 'easy-invoice') : __('quote', 'easy-invoice');
252 $company = (string) get_option('easy_invoice_company_name', get_bloginfo('name'));
253 ?>
254 <!DOCTYPE html>
255 <html <?php language_attributes(); ?>>
256 <head>
257 <meta charset="<?php echo esc_attr(get_option('blog_charset')); ?>">
258 <meta name="viewport" content="width=device-width, initial-scale=1">
259 <meta name="robots" content="noindex, nofollow">
260 <title><?php echo esc_html(sprintf(/* translators: %s: company name */ __('Your %s link', 'easy-invoice'), $company)); ?></title>
261 <style>body{margin:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1f2937}.ei-box{max-width:480px;margin:12vh auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}h1{font-size:20px;margin:0 0 12px}p{line-height:1.6;margin:0 0 16px;color:#4b5563}button{background:#4f46e5;color:#fff;border:0;border-radius:8px;padding:12px 20px;font-size:15px;cursor:pointer}button:hover{background:#4338ca}.ok{color:#065f46;background:#ecfdf5;border:1px solid #a7f3d0;border-radius:8px;padding:12px}.muted{font-size:13px;color:#6b7280}</style>
262 </head>
263 <body>
264 <div class="ei-box">
265 <?php if ('sent' === $state) : ?>
266 <h1><?php esc_html_e('On its way', 'easy-invoice'); ?></h1>
267 <p class="ok"><?php echo esc_html(sprintf(/* translators: %s: invoice or quote */ __('A fresh link to your %s has been emailed to the address it was issued to. Please check your inbox (and spam folder).', 'easy-invoice'), $what)); ?></p>
268 <?php elseif ('wait' === $state) : ?>
269 <h1><?php esc_html_e('Already sent', 'easy-invoice'); ?></h1>
270 <p><?php esc_html_e('A fresh link was emailed a few minutes ago. Please check your inbox (and spam folder) before requesting another.', 'easy-invoice'); ?></p>
271 <?php else : ?>
272 <h1><?php echo esc_html(sprintf(/* translators: %s: invoice or quote */ __('This %s link has been retired', 'easy-invoice'), $what)); ?></h1>
273 <p><?php echo esc_html(sprintf(/* translators: 1: company name, 2: invoice or quote */ __('%1$s now protects each %2$s with a private link. We can email a new one to the address this %2$s was issued to.', 'easy-invoice'), $company, $what)); ?></p>
274 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
275 <input type="hidden" name="action" value="<?php echo esc_attr(self::REFRESH_ACTION); ?>">
276 <input type="hidden" name="document" value="<?php echo esc_attr((string) $post->ID); ?>">
277 <input type="hidden" name="check" value="<?php echo esc_attr(self::refreshCheck($post->ID)); ?>">
278 <input type="text" name="website" value="" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true">
279 <button type="submit"><?php esc_html_e('Email me a fresh link', 'easy-invoice'); ?></button>
280 </form>
281 <p class="muted" style="margin-top:16px"><?php esc_html_e('The address is not shown here and cannot be changed from this page.', 'easy-invoice'); ?></p>
282 <?php endif; ?>
283 </div>
284 </body>
285 </html>
286 <?php
287 }
288
289 /** Ties the form to the document id and the current day; not a session nonce (visitors are anonymous). */
290 private static function refreshCheck(int $post_id): string {
291 return substr(wp_hash('ei-doclink|' . $post_id . '|' . gmdate('Y-m-d')), 0, 20);
292 }
293
294 /**
295 * Handle the "email me a fresh link" request (admin-post, anonymous allowed).
296 * One send per document per ten minutes, twenty per visitor per hour; the
297 * honeypot field must be empty. Nothing about the document is disclosed either way.
298 */
299 public static function handleLinkRefreshRequest(): void {
300 $post_id = isset($_POST['document']) ? absint($_POST['document']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- anonymous visitors; keyed by a daily hash + honeypot + rate limits.
301 $check = isset($_POST['check']) ? sanitize_text_field(wp_unslash($_POST['check'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
302 $honey = isset($_POST['website']) ? sanitize_text_field(wp_unslash($_POST['website'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
303 $post = $post_id ? get_post($post_id) : null;
304 $types = [\EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE];
305 if (!$post || !in_array($post->post_type, $types, true) || '' !== $honey
306 || !hash_equals(self::refreshCheck($post_id), $check) || !self::canOfferLinkRefresh($post)) {
307 wp_safe_redirect(home_url('/'));
308 exit;
309 }
310 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
311 $ip_key = 'ei_doclink_ip_' . md5($ip);
312 $ip_hits = (int) get_transient($ip_key);
313 if ($ip_hits >= 20) {
314 self::renderLinkRefreshPage($post, 'wait');
315 exit;
316 }
317 set_transient($ip_key, $ip_hits + 1, HOUR_IN_SECONDS);
318 if (get_transient('ei_doclink_doc_' . $post_id)) {
319 self::renderLinkRefreshPage($post, 'wait');
320 exit;
321 }
322 set_transient('ei_doclink_doc_' . $post_id, 1, 10 * MINUTE_IN_SECONDS);
323 $manager = \EasyInvoice\Services\EmailManager::getInstance();
324 if ($post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
325 $doc = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($post_id);
326 if ($doc) {
327 $manager->sendInvoiceEmail($doc, 'new');
328 }
329 } else {
330 $doc = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository()->find($post_id);
331 if ($doc) {
332 $manager->sendQuoteEmail($doc, 'new');
333 }
334 }
335 // Same page whether or not the send succeeded: the outcome must not reveal anything.
336 self::renderLinkRefreshPage($post, 'sent');
337 exit;
338 }
339
340 /**
341 * Load single quote template
342 *
343 * @param string $template The template path
344 * @return string Modified template path
345 */
346 /**
347 * The public document page, theme-overridable.
348 *
349 * Both document types render through templates/document/single.php; a
350 * theme overrides it at {theme}/easy-invoice/document/single.php.
351 *
352 * @return string Absolute path, or '' when even the plugin's copy is gone.
353 */
354 public static function documentTemplate(): string {
355 return easy_invoice_locate_template('document/single.php');
356 }
357
358 public function loadSingleQuoteTemplate($template) {
359 global $post;
360 if ($post && $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) {
361 $custom_template = self::documentTemplate();
362 if ('' !== $custom_template) {
363 return $custom_template;
364 }
365 }
366 return $template;
367 }
368
369 public function loadSingleInvoiceTemplate($template) {
370 global $post;
371 if ($post && $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
372 $custom_template = self::documentTemplate();
373 if ('' !== $custom_template) {
374 return $custom_template;
375 }
376 }
377 return $template;
378 }
379
380 public function loadCustomTemplates($template) {
381 if (is_singular(\EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)
382 || is_singular(\EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE)) {
383 $custom_template = self::documentTemplate();
384 if ('' !== $custom_template) {
385 return $custom_template;
386 }
387 }
388
389 return $template;
390 }
391 }
392