# easy-invoice/2.4.0/includes/TemplateLoader.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.0. 392 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/TemplateLoader.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.0/raw/includes/TemplateLoader.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.0/code/includes/TemplateLoader.php#L10-L20`.

```php
<?php
/**
 * Template Loader Class
 *
 * @package Easy_Invoice
 * @subpackage Includes
 */

namespace EasyInvoice;

/**
 * Template Loader Class
 * 
 * Handles loading of custom templates for the Easy Invoice plugin.
 */
class TemplateLoader {
    
    /**
     * Initialize the template loader
     */
    public function init() {
        // Authorisation runs before anything decides which template to load, and
        // before any output. `template_redirect` is the right hook because it fires
        // for every front-end entry point into a document — the pretty permalink,
        // `?p=<id>`, feeds, embeds, and the `?auto_download_pdf=1` PDF path — so a
        // single gate covers all of them.
        add_action('template_redirect', [$this, 'enforceDocumentAccess'], 1);
        add_action('admin_post_nopriv_' . self::REFRESH_ACTION, [self::class, 'handleLinkRefreshRequest']);
        add_action('admin_post_' . self::REFRESH_ACTION, [self::class, 'handleLinkRefreshRequest']);

        // `exclude_from_search` (see EasyInvoice::registerPostTypes) keeps documents
        // out of site search and search feeds, but it does not stop an explicit
        // `?post_type=easy_invoice` query, which the theme happily rendered as an
        // archive listing every invoice title and permalink. `has_archive` is false,
        // but `publicly_queryable` has to stay true for single permalinks to resolve,
        // and that is enough for the query to run.
        add_action('pre_get_posts', [$this, 'blockDocumentArchiveQueries']);

        add_filter('single_template', [$this, 'loadSingleQuoteTemplate']);
        add_filter('single_template', [$this, 'loadSingleInvoiceTemplate']);
        add_filter('template_include', [$this, 'loadCustomTemplates']);
    }

    /**
     * Stop invoices and quotes being listed by an archive-style front-end query.
     *
     * `?post_type=easy_invoice` (and the quote equivalent, and their feeds) ran a
     * normal archive query that the active theme rendered as a post list — exposing
     * every invoice title and permalink to anonymous visitors. The single-document
     * gate did not apply because those requests are not `is_singular()`.
     *
     * Admin queries are untouched: the plugin's own list screens rely on them.
     *
     * @param \WP_Query $query
     * @return void
     */
    public function blockDocumentArchiveQueries($query) {
        if (is_admin() || !$query instanceof \WP_Query || !$query->is_main_query()) {
            return;
        }

        // Single documents are handled by enforceDocumentAccess(), which knows how to
        // authorise them. Only listing-style queries are blocked here.
        if ($query->is_singular()) {
            return;
        }

        $ours = [
            \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
            \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
        ];

        $requested = $query->get('post_type');
        if (empty($requested)) {
            return;
        }

        $requested = (array) $requested;
        if (!array_intersect($requested, $ours)) {
            return;
        }

        $remaining = array_values(array_diff($requested, $ours));

        if (!empty($remaining)) {
            // Mixed query — drop just our types and let the rest run.
            $query->set('post_type', $remaining);
            return;
        }

        // The query asked for nothing but our documents. Return no results rather
        // than an empty archive, so the response does not confirm the type exists.
        $query->set('post__in', [0]);
        $query->set('posts_per_page', 0);
    }

    /**
     * Refuse to render an invoice or quote to a visitor who is not authorised.
     *
     * Invoices and quotes are stored with post_status 'publish' regardless of their
     * workflow status (see Models\Invoice::save() and Models\Quote::save() — the
     * comment there explains it is done "to ensure proper permalinks"), and both post
     * types are registered `public` + `publicly_queryable`. Without this gate, any
     * unauthenticated visitor who guessed or discovered a URL could read the whole
     * document — customer name, email, address, line items, prices, notes and totals —
     * including invoices still in Draft. Nothing downstream checked: the single
     * templates rendered unconditionally, and the template loader keyed only on post
     * type.
     *
     * Authorisation reuses the existing helpers rather than duplicating their rules,
     * so there is one definition of "may this person see this document":
     *
     *   - a valid per-document access token (`?ik=` / `?qk=`, compared with
     *     hash_equals) — this is what emailed links carry;
     *   - an administrator;
     *   - the logged-in client the document is bound to.
     *
     * Unauthorised requests get a normal 404 rather than an "access denied" page, so
     * the response does not confirm that a given invoice number exists.
     *
     * @return void
     */
    public function enforceDocumentAccess() {
        if (is_admin() || !is_singular()) {
            return;
        }

        $post = get_queried_object();
        if (!$post instanceof \WP_Post) {
            return;
        }

        $invoice_type = \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
        $quote_type   = \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE;

        if ($post->post_type !== $invoice_type && $post->post_type !== $quote_type) {
            return;
        }

        /**
         * Allow a site to turn the gate off.
         *
         * Sites that would rather keep the old open-by-URL behaviour can return
         * false here, but they are choosing to expose customer data to anyone
         * holding or guessing a URL. A bare link to an issued document otherwise
         * shows a page offering to email a fresh keyed link (renderLinkRefreshPage).
         *
         * @param bool     $enforce Whether to require authorisation. Default true.
         * @param \WP_Post $post    The invoice or quote being requested.
         */
        if (!apply_filters('easy_invoice_require_document_authorisation', true, $post)) {
            return;
        }

        $allowed = false;

        if ($post->post_type === $invoice_type) {
            $invoice = null;
            try {
                $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($post->ID);
            } catch (\Throwable $e) {
                $invoice = null;
            }
            $allowed = \EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice((int) $post->ID, $invoice);
        } else {
            $quote = null;
            try {
                $quote = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository()->find($post->ID);
            } catch (\Throwable $e) {
                $quote = null;
            }
            $allowed = \EasyInvoice\Controllers\QuoteController::canActOnQuote((int) $post->ID, $quote);
        }

        if ($allowed) {
            return;
        }

        // A bare URL to a real, issued document: most likely a link emailed before
        // access keys existed. Show nothing of the document, but let the holder
        // ask for a fresh keyed link to the address the document was issued to.
        if (self::canOfferLinkRefresh($post)) {
            self::renderLinkRefreshPage($post);
            exit;
        }

        // Present it as "not found" rather than "forbidden" so the response does not
        // disclose that this document exists.
        global $wp_query;
        $wp_query->set_404();
        status_header(404);
        nocache_headers();
        include get_query_template('404');
        exit;
    }
    
    /**
     * Meta stamped when an email carrying the document's keyed link goes out. Kept
     * so a site can tell which documents' recipients already hold a keyed link.
     */
    const KEYED_LINK_SENT_META = '_easy_invoice_keyed_link_sent';

    /** Action (admin-post, works for anonymous visitors) behind the "send me a fresh link" button. */
    const REFRESH_ACTION = 'easy_invoice_request_document_link';

    public static function markKeyedLinkSent(int $post_id): void {
        if ($post_id > 0 && '' === (string) get_post_meta($post_id, self::KEYED_LINK_SENT_META, true)) {
            update_post_meta($post_id, self::KEYED_LINK_SENT_META, current_time('mysql', true));
        }
    }

    /**
     * Only an issued (non-draft, published) document that has an address on file
     * gets the refresh offer; anything else is a plain 404, so the page cannot be
     * used to probe which URLs exist beyond what the old behaviour already showed.
     */
    public static function canOfferLinkRefresh(\WP_Post $post): bool {
        if ('publish' !== $post->post_status) {
            return false;
        }
        $is_invoice = $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
        $status     = strtolower((string) get_post_meta($post->ID, $is_invoice ? '_easy_invoice_status' : '_easy_invoice_quote_status', true));
        if ('draft' === $status || '' === $status) {
            return false;
        }
        return '' !== self::recipientAddress($post);
    }

    /** The address the document was issued to (never shown to the visitor). */
    private static function recipientAddress(\WP_Post $post): string {
        $is_invoice = $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
        $email = (string) get_post_meta($post->ID, $is_invoice ? '_easy_invoice_customer_email' : '_easy_invoice_quote_customer_email', true);
        if ('' === $email) {
            $client_id = (int) get_post_meta($post->ID, $is_invoice ? '_easy_invoice_client_id' : '_easy_invoice_quote_client_id', true);
            $user      = $client_id > 0 ? get_user_by('id', $client_id) : null;
            $email     = $user ? (string) $user->user_email : '';
        }
        return is_email($email) ? $email : '';
    }

    /**
     * The page shown instead of the document. Deliberately standalone (no theme,
     * no document data): a title, one sentence, one button.
     */
    public static function renderLinkRefreshPage(\WP_Post $post, string $state = ''): void {
        status_header('sent' === $state ? 200 : 403);
        nocache_headers();
        header('X-Robots-Tag: noindex, nofollow');
        header('Content-Type: text/html; charset=' . get_option('blog_charset'));
        $is_invoice = $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE;
        $what       = $is_invoice ? __('invoice', 'easy-invoice') : __('quote', 'easy-invoice');
        $company    = (string) get_option('easy_invoice_company_name', get_bloginfo('name'));
        ?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php echo esc_attr(get_option('blog_charset')); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?php echo esc_html(sprintf(/* translators: %s: company name */ __('Your %s link', 'easy-invoice'), $company)); ?></title>
<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>
</head>
<body>
<div class="ei-box">
<?php if ('sent' === $state) : ?>
    <h1><?php esc_html_e('On its way', 'easy-invoice'); ?></h1>
    <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>
<?php elseif ('wait' === $state) : ?>
    <h1><?php esc_html_e('Already sent', 'easy-invoice'); ?></h1>
    <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>
<?php else : ?>
    <h1><?php echo esc_html(sprintf(/* translators: %s: invoice or quote */ __('This %s link has been retired', 'easy-invoice'), $what)); ?></h1>
    <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>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="<?php echo esc_attr(self::REFRESH_ACTION); ?>">
        <input type="hidden" name="document" value="<?php echo esc_attr((string) $post->ID); ?>">
        <input type="hidden" name="check" value="<?php echo esc_attr(self::refreshCheck($post->ID)); ?>">
        <input type="text" name="website" value="" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true">
        <button type="submit"><?php esc_html_e('Email me a fresh link', 'easy-invoice'); ?></button>
    </form>
    <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>
<?php endif; ?>
</div>
</body>
</html>
        <?php
    }

    /** Ties the form to the document id and the current day; not a session nonce (visitors are anonymous). */
    private static function refreshCheck(int $post_id): string {
        return substr(wp_hash('ei-doclink|' . $post_id . '|' . gmdate('Y-m-d')), 0, 20);
    }

    /**
     * Handle the "email me a fresh link" request (admin-post, anonymous allowed).
     * One send per document per ten minutes, twenty per visitor per hour; the
     * honeypot field must be empty. Nothing about the document is disclosed either way.
     */
    public static function handleLinkRefreshRequest(): void {
        $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.
        $check   = isset($_POST['check']) ? sanitize_text_field(wp_unslash($_POST['check'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $honey   = isset($_POST['website']) ? sanitize_text_field(wp_unslash($_POST['website'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $post    = $post_id ? get_post($post_id) : null;
        $types   = [\EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE];
        if (!$post || !in_array($post->post_type, $types, true) || '' !== $honey
            || !hash_equals(self::refreshCheck($post_id), $check) || !self::canOfferLinkRefresh($post)) {
            wp_safe_redirect(home_url('/'));
            exit;
        }
        $ip      = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
        $ip_key  = 'ei_doclink_ip_' . md5($ip);
        $ip_hits = (int) get_transient($ip_key);
        if ($ip_hits >= 20) {
            self::renderLinkRefreshPage($post, 'wait');
            exit;
        }
        set_transient($ip_key, $ip_hits + 1, HOUR_IN_SECONDS);
        if (get_transient('ei_doclink_doc_' . $post_id)) {
            self::renderLinkRefreshPage($post, 'wait');
            exit;
        }
        set_transient('ei_doclink_doc_' . $post_id, 1, 10 * MINUTE_IN_SECONDS);
        $manager = \EasyInvoice\Services\EmailManager::getInstance();
        if ($post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
            $doc = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->find($post_id);
            if ($doc) {
                $manager->sendInvoiceEmail($doc, 'new');
            }
        } else {
            $doc = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository()->find($post_id);
            if ($doc) {
                $manager->sendQuoteEmail($doc, 'new');
            }
        }
        // Same page whether or not the send succeeded: the outcome must not reveal anything.
        self::renderLinkRefreshPage($post, 'sent');
        exit;
    }

    /**
     * Load single quote template
     *
     * @param string $template The template path
     * @return string Modified template path
     */
    /**
     * The public document page, theme-overridable.
     *
     * Both document types render through templates/document/single.php; a
     * theme overrides it at {theme}/easy-invoice/document/single.php.
     *
     * @return string Absolute path, or '' when even the plugin's copy is gone.
     */
    public static function documentTemplate(): string {
        return easy_invoice_locate_template('document/single.php');
    }

    public function loadSingleQuoteTemplate($template) {
        global $post;
        if ($post && $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE) {
            $custom_template = self::documentTemplate();
            if ('' !== $custom_template) {
                return $custom_template;
            }
        }
        return $template;
    }

    public function loadSingleInvoiceTemplate($template) {
        global $post;
        if ($post && $post->post_type === \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE) {
            $custom_template = self::documentTemplate();
            if ('' !== $custom_template) {
                return $custom_template;
            }
        }
        return $template;
    }

    public function loadCustomTemplates($template) {
        if (is_singular(\EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)
            || is_singular(\EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE)) {
            $custom_template = self::documentTemplate();
            if ('' !== $custom_template) {
                return $custom_template;
            }
        }

        return $template;
    }
}

```
