`, 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')); ?> > <?php echo esc_html(sprintf(/* translators: %s: company name */ __('Your %s link', 'easy-invoice'), $company)); ?>

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; } }