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
← All changes | includes/TemplateLoader.php +337 -51 2.3.32.4.0 View file →
@@ -18,88 +18,374 @@
18 18 /**
19 19 * Initialize the template loader
20 20 */
21 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 +
22 39 add_filter('single_template', [$this, 'loadSingleQuoteTemplate']);
23 40 add_filter('single_template', [$this, 'loadSingleInvoiceTemplate']);
24 41 add_filter('template_include', [$this, 'loadCustomTemplates']);
25 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 + }
26 196
27 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 + /**
28 341 * Load single quote template
29 342 *
30 343 * @param string $template The template path
31 344 * @return string Modified template path
32 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 +
33 358 public function loadSingleQuoteTemplate($template) {
34 359 global $post;
35 -
36 360 if ($post && $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) {
37 - $custom_template = EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/single.php';
38 -
39 - if (file_exists($custom_template)) {
361 + $custom_template = self::documentTemplate();
362 + if ('' !== $custom_template) {
40 363 return $custom_template;
41 - } else {
42 - // Single quote template not found
43 364 }
44 365 }
45 -
46 366 return $template;
47 367 }
48 -
49 - /**
50 - * Load single invoice template
51 - *
52 - * @param string $template The template path
53 - * @return string Modified template path
54 - */
368 +
55 369 public function loadSingleInvoiceTemplate($template) {
56 370 global $post;
57 -
58 371 if ($post && $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
59 - $custom_template = EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/single.php';
60 -
61 - if (file_exists($custom_template)) {
372 + $custom_template = self::documentTemplate();
373 + if ('' !== $custom_template) {
62 374 return $custom_template;
63 - } else {
64 - // Single invoice template not found
65 375 }
66 376 }
67 -
68 377 return $template;
69 378 }
70 -
71 - /**
72 - * Load custom templates for Easy Invoice pages
73 - *
74 - * @param string $template The template path
75 - * @return string Modified template path
76 - */
379 +
77 380 public function loadCustomTemplates($template) {
78 - global $post;
79 -
80 - // Handle single quote pages
81 - if (is_singular(\EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
82 - $custom_template = EASY_INVOICE_PLUGIN_DIR . 'templates/quotes/single.php';
83 -
84 -
85 - if (file_exists($custom_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) {
86 385 return $custom_template;
87 - } else {
88 - // Single quote template not found
89 386 }
90 387 }
91 -
92 - // Handle single invoice pages
93 - if (is_singular(\EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE)) {
94 - $custom_template = EASY_INVOICE_PLUGIN_DIR . 'templates/invoices/single.php';
95 -
96 - if (file_exists($custom_template)) {
97 - return $custom_template;
98 - } else {
99 - // Single invoice template not found
100 - }
101 - }
102 -
388 +
103 389 return $template;
104 390 }
105 -}
391 +}