| 1 |
<?php |
| 2 |
/** |
| 3 |
* One-time upgrade notice for the document access-key change. |
| 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 |
* Tells site owners, once, that invoice and quote links now require an access key. |
| 17 |
* |
| 18 |
* Why this exists |
| 19 |
* --------------- |
| 20 |
* From 2.4.0 an invoice or quote is only rendered to someone holding a valid |
| 21 |
* per-document access key, an administrator, or the signed-in client the document |
| 22 |
* is bound to (see TemplateLoader::enforceDocumentAccess). That closes a real |
| 23 |
* exposure — previously anyone with, or guessing, a URL could read a customer's |
| 24 |
* name, address, line items and totals, including on draft invoices. |
| 25 |
* |
| 26 |
* Documents emailed before per-document keys existed carry no `?ik=` / `?qk=`. |
| 27 |
* Such a link now lands on a page (TemplateLoader::renderLinkRefreshPage) that |
| 28 |
* shows nothing of the document and offers to email a fresh keyed link to the |
| 29 |
* address it was issued to — so the customer is not stuck and the merchant need |
| 30 |
* not act. The notice tells the merchant this is what their customers will see. |
| 31 |
* |
| 32 |
* Only shown where it is useful: |
| 33 |
* - sites that already have documents (a fresh install has no old links to break); |
| 34 |
* - once, until dismissed. |
| 35 |
* |
| 36 |
* It deliberately hooks plain `admin_notices` rather than trying to survive |
| 37 |
* EasyInvoice::disableAdminNoticesOnEasyInvoicePages(), which clears that hook on |
| 38 |
* Easy Invoice's own screens. The Dashboard and Plugins screens are where someone |
| 39 |
* lands after updating, and those are unaffected. |
| 40 |
*/ |
| 41 |
class DocumentAccessNoticeService { |
| 42 |
|
| 43 |
/** Option holding the notice state: absent | 'show' | 'dismissed'. */ |
| 44 |
const OPTION = 'easy_invoice_doc_access_notice'; |
| 45 |
|
| 46 |
/** AJAX action used by the dismiss button. */ |
| 47 |
const AJAX_DISMISS = 'easy_invoice_dismiss_doc_access_notice'; |
| 48 |
|
| 49 |
/** |
| 50 |
* Wire up the notice. |
| 51 |
* |
| 52 |
* @return void |
| 53 |
*/ |
| 54 |
public static function init() { |
| 55 |
add_action( 'admin_init', [ __CLASS__, 'maybeFlagUpgrade' ] ); |
| 56 |
add_action( 'admin_notices', [ __CLASS__, 'render' ] ); |
| 57 |
add_action( 'wp_ajax_' . self::AJAX_DISMISS, [ __CLASS__, 'ajaxDismiss' ] ); |
| 58 |
} |
| 59 |
|
| 60 |
/** |
| 61 |
* Decide, once, whether this site needs the notice. |
| 62 |
* |
| 63 |
* @return void |
| 64 |
*/ |
| 65 |
public static function maybeFlagUpgrade() { |
| 66 |
if ( get_option( self::OPTION, '' ) !== '' ) { |
| 67 |
return; |
| 68 |
} |
| 69 |
|
| 70 |
// A site with no documents has no previously-sent links to break, so there is |
| 71 |
// nothing to warn about. Stamp it dismissed so this never runs again. |
| 72 |
update_option( self::OPTION, self::hasExistingDocuments() ? 'show' : 'dismissed', false ); |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Are there any invoices or quotes on this site? |
| 77 |
* |
| 78 |
* @return bool |
| 79 |
*/ |
| 80 |
private static function hasExistingDocuments(): bool { |
| 81 |
$found = get_posts( [ |
| 82 |
'post_type' => [ |
| 83 |
\EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE, |
| 84 |
\EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE, |
| 85 |
], |
| 86 |
'post_status' => 'any', |
| 87 |
'numberposts' => 1, |
| 88 |
'fields' => 'ids', |
| 89 |
'suppress_filters' => true, |
| 90 |
'no_found_rows' => true, |
| 91 |
] ); |
| 92 |
|
| 93 |
return ! empty( $found ); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Output the notice. |
| 98 |
* |
| 99 |
* @return void |
| 100 |
*/ |
| 101 |
public static function render() { |
| 102 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 103 |
return; |
| 104 |
} |
| 105 |
if ( get_option( self::OPTION, '' ) !== 'show' ) { |
| 106 |
return; |
| 107 |
} |
| 108 |
|
| 109 |
$nonce = wp_create_nonce( self::AJAX_DISMISS ); |
| 110 |
?> |
| 111 |
<div class="notice notice-warning is-dismissible" id="easy-invoice-doc-access-notice"> |
| 112 |
<p> |
| 113 |
<strong><?php esc_html_e( 'Easy Invoice: invoice and quote links are now protected by an access key.', 'easy-invoice' ); ?></strong> |
| 114 |
</p> |
| 115 |
<p> |
| 116 |
<?php esc_html_e( 'Invoices and quotes used to be readable by anyone who had, or guessed, the URL — including drafts. They are now shown only to you, to the signed-in client the document belongs to, or to someone opening the link that was emailed to them.', 'easy-invoice' ); ?> |
| 117 |
</p> |
| 118 |
<p> |
| 119 |
<?php esc_html_e( 'Links you sent before this update carry no key. Anyone opening one sees no document data — just a page offering to email a fresh link to the address the document was issued to, one click, no action needed from you. Anything sent from now on is protected automatically.', 'easy-invoice' ); ?> |
| 120 |
</p> |
| 121 |
<?php // phpcs:ignore -- inline script keeps the notice self-contained. ?> |
| 122 |
<script> |
| 123 |
(function () { |
| 124 |
var el = document.getElementById('easy-invoice-doc-access-notice'); |
| 125 |
if (!el) { return; } |
| 126 |
el.addEventListener('click', function (e) { |
| 127 |
if (!e.target.classList.contains('notice-dismiss')) { return; } |
| 128 |
var body = new FormData(); |
| 129 |
body.append('action', <?php echo wp_json_encode( self::AJAX_DISMISS ); ?>); |
| 130 |
body.append('nonce', <?php echo wp_json_encode( $nonce ); ?>); |
| 131 |
fetch(<?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, { |
| 132 |
method: 'POST', body: body, credentials: 'same-origin' |
| 133 |
}); |
| 134 |
}); |
| 135 |
})(); |
| 136 |
</script> |
| 137 |
</div> |
| 138 |
<?php |
| 139 |
} |
| 140 |
|
| 141 |
/** |
| 142 |
* Persist the dismissal. |
| 143 |
* |
| 144 |
* @return void |
| 145 |
*/ |
| 146 |
public static function ajaxDismiss() { |
| 147 |
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; |
| 148 |
if ( ! wp_verify_nonce( $nonce, self::AJAX_DISMISS ) ) { |
| 149 |
wp_send_json_error( [ 'message' => __( 'Security check failed', 'easy-invoice' ) ] ); |
| 150 |
} |
| 151 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 152 |
wp_send_json_error( [ 'message' => __( 'You do not have permission to perform this action', 'easy-invoice' ) ] ); |
| 153 |
} |
| 154 |
|
| 155 |
update_option( self::OPTION, 'dismissed', false ); |
| 156 |
wp_send_json_success(); |
| 157 |
} |
| 158 |
} |
| 159 |
|