From 00e513343c0be8e798223298f3f89efdabc6c4a1 Mon Sep 17 00:00:00 2001 From: jacobd Date: Tue, 28 Oct 2025 18:53:35 +0000 Subject: [PATCH 1/2] Fixed bug with bunny CDN where you could enable both CDN and FSD under certain conditions. Added missing check for total CDN to prevent enabling both CDN and FSD. --- Cdn_TotalCdn_FsdEnablePopup.js | 151 --------------- Generic_AdminActions_Default.php | 34 ++++ Generic_Plugin_Admin.php | 15 +- pub/js/options.js | 311 +++++++++++++++++++++++++++---- 4 files changed, 318 insertions(+), 193 deletions(-) delete mode 100644 Cdn_TotalCdn_FsdEnablePopup.js diff --git a/Cdn_TotalCdn_FsdEnablePopup.js b/Cdn_TotalCdn_FsdEnablePopup.js deleted file mode 100644 index dfb46b7ab..000000000 --- a/Cdn_TotalCdn_FsdEnablePopup.js +++ /dev/null @@ -1,151 +0,0 @@ -/** - * File: Cdn_TotalCdn_FsdEnablePopup.js - * - * @package W3TC - */ - -var w3tcTotalcdnFsdModalOpen = false; - -/** - * Modal for Total CDN Full Site Delivery enablement reminder. - * - * @return void - */ -function w3tc_show_totalcdn_fsd_enable_notice( - previousEngine, - triggeredByEngineChange, -) { - var closeHandler = null; - - function revertSelection() { - if ( triggeredByEngineChange ) { - if ( previousEngine !== null && typeof previousEngine !== 'undefined' ) { - jQuery( '#cdnfsd__engine' ).val( previousEngine ); - } - } else { - jQuery( '#cdnfsd__enabled' ).prop( 'checked', false ); - } - } - - W3tc_Lightbox.open( - { - id: 'w3tc-overlay', - close: '', - width: 800, - height: 360, - url: ajaxurl + '?action=w3tc_ajax&_wpnonce=' + w3tc_nonce + '&w3tc_action=cdn_totalcdn_fsd_enable_notice', - callback: function ( lightbox ) { - function cleanup() { - jQuery( document ).off( 'keyup.w3tc_totalcdn_fsd_notice' ); - closeHandler = null; - w3tcTotalcdnFsdModalOpen = false; - lightbox.close(); - } - - jQuery( '.btn-primary', lightbox.container ).on( - 'click', - function () { - cleanup(); - } - ); - - closeHandler = function () { - revertSelection(); - cleanup(); - }; - - jQuery( - '.btn-secondary, .lightbox-close', - lightbox.container - ).on( - 'click', - closeHandler, - ); - - jQuery( document ).on( - 'keyup.w3tc_totalcdn_fsd_notice', - function ( event ) { - if ( 'Escape' === event.key && closeHandler ) { - closeHandler(); - } - } - ); - - lightbox.resize(); - }, - } - ); -} - -jQuery( - function ( $ ) { - var previousEngine = null; - - function maybeShowNotice( triggeredByEngineChange ) { - if ( w3tcTotalcdnFsdModalOpen ) { - return; - } - - var enabled = $( '#cdnfsd__enabled' ).is( ':checked' ); - var engine = $( '#cdnfsd__engine' ).val(); - - if ( ! enabled || 'totalcdn' !== engine ) { - return; - } - - w3tcTotalcdnFsdModalOpen = true; - - var originalPrevious = triggeredByEngineChange ? previousEngine : null; - - w3tc_show_totalcdn_fsd_enable_notice( - originalPrevious, - triggeredByEngineChange, - ); - } - - $( '#cdnfsd__enabled' ).on( - 'click', - function () { - var isChecked = $( this ).is( ':checked' ); - - if ( ! isChecked ) { - return; - } - - maybeShowNotice( false ); - } - ); - - $( '#cdnfsd__engine' ).on( - 'focus', - function () { - previousEngine = this.value; - } - ).on( - 'change', - function () { - var engine = $( this ).val(); - var enabled = $( '#cdnfsd__enabled' ).is( ':checked' ); - - if ( ! enabled ) { - previousEngine = engine; - return; - } - - if ( 'totalcdn' === engine ) { - maybeShowNotice( true ); - } else { - previousEngine = engine; - } - } - ); - - // Ensure the flag resets if the lightbox script triggers a custom close event. - $( document ).on( - 'w3tc_lightbox_closed', - function () { - w3tcTotalcdnFsdModalOpen = false; - } - ); - } -); \ No newline at end of file diff --git a/Generic_AdminActions_Default.php b/Generic_AdminActions_Default.php index 6ac840eec..71bb257c8 100644 --- a/Generic_AdminActions_Default.php +++ b/Generic_AdminActions_Default.php @@ -614,6 +614,40 @@ private function _w3tc_save_options_process() { do_action( 'w3tc_config_ui_save', $config, $this->_config ); do_action( "w3tc_config_ui_save-{$this->_page}", $config, $this->_config ); + $conflicting_engines = array( 'bunnycdn', 'totalcdn' ); + + if ( $config->get_boolean( 'cdn.enabled' ) && $config->get_boolean( 'cdnfsd.enabled' ) ) { + $cdn_engine = $config->get_string( 'cdn.engine' ); + $cdnfsd_engine = $config->get_string( 'cdnfsd.engine' ); + + if ( $cdn_engine === $cdnfsd_engine && in_array( $cdn_engine, $conflicting_engines, true ) ) { + $data['response_errors'][] = 'cdn_fsd_conflict_' . $cdn_engine; + $data['response_notes'] = array(); + + return array( + 'query_string' => $data['response_query_string'], + 'actions' => $data['response_actions'], + 'errors' => $data['response_errors'], + 'notes' => $data['response_notes'], + ); + } + + if ( + in_array( $cdn_engine, $conflicting_engines, true ) && + in_array( $cdnfsd_engine, $conflicting_engines, true ) + ) { + $data['response_errors'][] = 'cdn_fsd_conflict_mixed'; + $data['response_notes'] = array(); + + return array( + 'query_string' => $data['response_query_string'], + 'actions' => $data['response_actions'], + 'errors' => $data['response_errors'], + 'notes' => $data['response_notes'], + ); + } + } + Util_Admin::config_save( $this->_config, $config ); if ( 'w3tc_cdn' === $this->_page ) { diff --git a/Generic_Plugin_Admin.php b/Generic_Plugin_Admin.php index 73bcc2403..3f957b968 100644 --- a/Generic_Plugin_Admin.php +++ b/Generic_Plugin_Admin.php @@ -764,7 +764,15 @@ public function admin_print_scripts() { ) ), 'bunnyCdnWarning' => esc_html__( - 'Bunny CDN should only be enabled as either a CDN for objects or full-site delivery, not both at the same time. The CDN settings have been reverted.', + 'Bunny CDN cannot be enabled for both CDN and Full Site Delivery.', + 'w3-total-cache' + ), + 'totalCdnWarning' => esc_html__( + 'Total CDN cannot be enabled for both CDN and Full Site Delivery', + 'w3-total-cache' + ), + 'mixedCdnWarning' => esc_html__( + 'Bunny CDN and Total CDN cannot be enabled across CDN and Full Site Delivery at the same time.', 'w3-total-cache' ), ) @@ -1151,6 +1159,9 @@ public function admin_notices() { 'require_once(ABSPATH . \'wp-settings.php\');' ), 'pull_zone' => __( 'Pull Zone could not be automatically created.', 'w3-total-cache' ), + 'cdn_fsd_conflict_bunnycdn' => esc_html__( 'Bunny CDN cannot be enabled for both CDN and Full Site Delivery. Please disable one and save again.', 'w3-total-cache' ), + 'cdn_fsd_conflict_totalcdn' => esc_html__( 'Total CDN cannot be enabled for both CDN and Full Site Delivery. Please disable one and save again.', 'w3-total-cache' ), + 'cdn_fsd_conflict_mixed' => esc_html__( 'Bunny CDN and Total CDN cannot be enabled across CDN and Full Site Delivery at the same time. Please disable one and save again.', 'w3-total-cache' ), 'flush_cdn_failed' => sprintf( // translators: 1 HTML acronym for CDN (content delivery network). __( @@ -1160,7 +1171,7 @@ public function admin_notices() { '' . esc_html__( 'CDN', 'w3-total-cache' ) . '' ), 'updated_pullzone_url' => __( 'Pull Zone URL could not be automatically updated. Please contact support for assistance.', 'w3-total-cache' ), - 'cdn_totalcdn_fsd_origin_update_failed' => __( 'Unable to update the Total CDN origin for Full Site Delivery. Please contact support for assistance.', 'w3-total-cache' ), + 'cdn_totalcdn_fsd_origin_update_failed' => __( 'Unable to update the Total CDN origin for Full Site Delivery. Please contact support for assistance.', 'w3-total-cache' ), 'cdn_totalcdn_fsd_custom_hostname_remove_failed' => Cdn_TotalCdn_CustomHostname::removal_failure_message(), 'cdn_totalcdn_fsd_custom_hostname_failed' => Cdn_TotalCdn_CustomHostname::failure_message(), ); diff --git a/pub/js/options.js b/pub/js/options.js index 2ebebb70b..57f6f65a3 100644 --- a/pub/js/options.js +++ b/pub/js/options.js @@ -320,48 +320,232 @@ function w3tc_csp_reference() { }); } +// Tracks the last known good CDN/CDNFSD combination so we can revert when conflicts are blocked. +var cdnConflictState = null; +// Ensures we don't create an infinite loop while restoring the previous state. +var cdnConflictRestoring = false; + /** - * Bunny CDN check. + * Creates a shallow copy of a CDN conflict state object. + * + * @since X.X.X * - * Prevent enabling Bunny CDN ("bunnycdn" engine) for both CDN and CDNFSD. + * @param {Object} state State to clone. + * @returns {Object} Cloned state. + */ +function cdn_conflict_clone_state(state) { + var defaults = { + cdn_enabled: false, + cdn_engine: '', + cdnfsd_enabled: false, + cdnfsd_engine: '' + }; + + state = state || defaults; + + return { + cdn_enabled: !!state.cdn_enabled, + cdn_engine: state.cdn_engine || '', + cdnfsd_enabled: !!state.cdnfsd_enabled, + cdnfsd_engine: state.cdnfsd_engine || '' + }; +} + +/** + * Reads the current CDN/CDNFSD state from the UI. * - * @since 2.6.0 + * @since X.X.X * - * @returns null + * @returns {Object} Current state snapshot. */ -function cdn_bunnycdn_check() { +function cdn_conflict_read_state() { + return { + cdn_enabled: jQuery('#cdn__enabled').prop('checked'), + cdn_engine: jQuery('#cdn__engine').val() || '', + cdnfsd_enabled: jQuery('#cdnfsd__enabled').prop('checked'), + cdnfsd_engine: jQuery('#cdnfsd__engine').val() || '' + }; +} + +/** + * Attach a capturing listener that checks for CDN/FSD conflicts before other handlers run. + * + * @since X.X.X + * + * @param {string} selector Element selector. + * @param {string} eventName Event name to listen for. + * @param {string} changedField Field identifier passed to cdn_conflict_check(). + * @returns {void} + */ +/** + * Register a capture-phase event handler so we can veto conflicting changes + * before the TotalCDN FSD modal (or any other bubble-phase listener) executes. + */ +function cdn_conflict_bind_capture(selector, eventName, changedField) { + var element = jQuery(selector).get(0); + + if (!element || typeof element.addEventListener !== 'function') { + return; + } + + element.addEventListener(eventName, function(nativeEvent) { + // Predict the post-change state so we can evaluate conflicts using the new value. + var predictedState = cdn_conflict_clone_state(cdn_conflict_read_state()); + + if (nativeEvent && nativeEvent.target) { + switch (changedField) { + case 'cdn_enabled': + predictedState.cdn_enabled = !!nativeEvent.target.checked; + break; + case 'cdn_engine': + predictedState.cdn_engine = nativeEvent.target.value || ''; + break; + case 'cdnfsd_enabled': + predictedState.cdnfsd_enabled = !!nativeEvent.target.checked; + break; + case 'cdnfsd_engine': + predictedState.cdnfsd_engine = nativeEvent.target.value || ''; + break; + } + } + + cdn_conflict_check(changedField, nativeEvent, predictedState); + }, true); +} + +/** + * Determines whether the current admin screen is the CDN settings page. + * + * @since X.X.X + * + * @returns {boolean} True when viewing the general settings page. + */ +function cdn_conflict_is_general_settings_page() { + if (!document || !document.body || !document.body.classList) { + return false; + } + + return document.body.classList.contains('performance_page_w3tc_general'); +} + +/** + * CDN conflict check. + * + * Prevent enabling Bunny CDN ("bunnycdn" engine), Total CDN ("totalcdn" engine), or any combination of either for both CDN and CDNFSD. + * If a conflict is detected, revert only the last changed value and show a warning. + * + * @since X.X.X + * + * @param {string} [changed] - Which field was changed: 'cdn_enabled', 'cdn_engine', 'cdnfsd_enabled', or 'cdnfsd_engine'. + * @param {Event} [event] - Optional event associated with the change. + * @returns {boolean} True when a conflict was handled. + */ +/** + * Prevents Bunny/Total CDN conflicts between CDN and CDN FSD. + * Reverts the field that triggered the conflict and surfaces the warning message. + */ +function cdn_conflict_check(changed, event, nextState) { // Prevents JS error for non W3TC pages. if (typeof w3tcData === 'undefined') { - return; + return false; + } + + // Ignore recursive triggers while we are reverting to the last good state. + if (cdnConflictRestoring) { + return false; } - var $cdn_enabled = jQuery('#cdn__enabled'), - $cdn_engine = jQuery('#cdn__engine'), - $cdnfsd_enabled = jQuery('#cdnfsd__enabled'), - $cdnfsd_engine = jQuery('#cdnfsd__engine'), - cdn_enabled = $cdn_enabled.is(':checked'), - cdn_engine = $cdn_engine.find(':selected').val(), - cdnfsd_enabled = $cdnfsd_enabled.is(':checked'), - cdnfsd_engine = $cdnfsd_engine.find(':selected').val(), - $cdn_inside = jQuery('#cdn .inside'); - - if (cdn_enabled && cdnfsd_enabled && 'bunnycdn' === cdn_engine && cdnfsd_engine === cdn_engine ) { - // Reset to what was last saved. - $cdn_enabled.prop('checked', w3tcData.cdnEnabled); - $cdn_engine.val(w3tcData.cdnEngine).change(); - $cdnfsd_enabled.prop('checked', w3tcData.cdnfsdEnabled); - $cdnfsd_engine.val(w3tcData.cdnfsdEngine).change(); + var cdn_enabled = jQuery('#cdn__enabled'), + cdn_engine = jQuery('#cdn__engine'), + cdnfsd_enabled = jQuery('#cdnfsd__enabled'), + cdnfsd_engine = jQuery('#cdnfsd__engine'), + cdn_inside = jQuery('#cdn .inside'), + state = nextState ? cdn_conflict_clone_state(nextState) : cdn_conflict_read_state(); + + var cdn_enabled_value = state.cdn_enabled, + cdn_engine_value = state.cdn_engine, + cdnfsd_enabled_value = state.cdnfsd_enabled, + cdnfsd_engine_value = state.cdnfsd_engine; + + // Remove any previous warning. + jQuery('#w3tc-cdn-conflict-warning').remove(); + + // Check for any conflict between CDN and FSD using bunnycdn/totalcdn in any combination. + var cdn_is_restricted = cdn_enabled_value && ( cdn_engine_value === 'bunnycdn' || cdn_engine_value === 'totalcdn' ); + var fsd_is_restricted = cdnfsd_enabled_value && ( cdnfsd_engine_value === 'bunnycdn' || cdnfsd_engine_value === 'totalcdn' ); + + if (cdn_is_restricted && fsd_is_restricted) { + var warningMessage = ''; + + if (event) { + // Stop any remaining handlers (including the TotalCDN modal) from firing. + if (typeof event.stopImmediatePropagation === 'function') { + event.stopImmediatePropagation(); + } + if (typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + if (typeof event.preventDefault === 'function') { + event.preventDefault(); + } + } + + if (cdn_engine_value === 'totalcdn' && cdnfsd_engine_value === 'totalcdn') { + warningMessage = w3tcData.totalCdnWarning; + } else if (cdn_engine_value === 'bunnycdn' && cdnfsd_engine_value === 'bunnycdn') { + warningMessage = w3tcData.bunnyCdnWarning; + } else if ( + ( cdn_engine_value === 'totalcdn' && cdnfsd_engine_value === 'bunnycdn' ) + || ( cdn_engine_value === 'bunnycdn' && cdnfsd_engine_value === 'totalcdn' ) + ) { + warningMessage = w3tcData.mixedCdnWarning; + } + + cdnConflictRestoring = true; + + // Roll the UI back to the last safe selection for the control that changed. + switch (changed) { + case 'cdn_enabled': + cdn_enabled.prop('checked', cdnConflictState.cdn_enabled).trigger('change'); + break; + case 'cdn_engine': + cdn_engine.val(cdnConflictState.cdn_engine).trigger('change'); + break; + case 'cdnfsd_enabled': + cdnfsd_enabled.prop('checked', cdnConflictState.cdnfsd_enabled).trigger('change'); + break; + case 'cdnfsd_engine': + cdnfsd_engine.val(cdnConflictState.cdnfsd_engine).trigger('change'); + break; + default: + cdn_enabled.prop('checked', cdnConflictState.cdn_enabled).trigger('change'); + cdn_engine.val(cdnConflictState.cdn_engine).trigger('change'); + cdnfsd_enabled.prop('checked', cdnConflictState.cdnfsd_enabled).trigger('change'); + cdnfsd_engine.val(cdnConflictState.cdnfsd_engine).trigger('change'); + break; + } + + cdnConflictRestoring = false; // Display a warning. - jQuery('
', { - class: 'notice notice-warning', - id: 'w3tc-bunnycdn-warning', - text: w3tcData.bunnyCdnWarning - }).prependTo($cdn_inside); + if (warningMessage) { + jQuery('
', { + class: 'notice notice-warning', + id: 'w3tc-cdn-conflict-warning', + text: warningMessage + }).prependTo(cdn_inside); + } + + return true; + } + + if (nextState) { + cdnConflictState = cdn_conflict_clone_state(nextState); } else { - // Remove the warning. - jQuery('#w3tc-bunnycdn-warning').remove(); + cdnConflictState = cdn_conflict_clone_state(cdn_conflict_read_state()); } + + return false; } /** @@ -511,9 +695,62 @@ function toggle_fragmentcache_notice() { // On document ready. jQuery(function() { - // Global vars. - var $cdn_enabled = jQuery('#cdn__enabled'), - $cdn_engine = jQuery('#cdn__engine'); + if (cdn_conflict_is_general_settings_page()) { + // Seed the initial snapshot and register capture listeners before other scripts bind bubble handlers. + cdnConflictState = cdn_conflict_clone_state(cdn_conflict_read_state()); + + cdn_conflict_bind_capture('#cdn__enabled', 'click', 'cdn_enabled'); + cdn_conflict_bind_capture('#cdn__engine', 'change', 'cdn_engine'); + cdn_conflict_bind_capture('#cdnfsd__enabled', 'click', 'cdnfsd_enabled'); + cdn_conflict_bind_capture('#cdnfsd__engine', 'change', 'cdnfsd_engine'); + + if (typeof w3tcData !== 'undefined') { + // Catch an existing conflict immediately so the admin sees the warning on load. + cdn_conflict_check(); + } + + // Prevent enabling Bunny/Total CDN for both CDN and CDNFSD. + jQuery('#cdn__enabled').on('click', function(event) { + // Only handle synthetic events triggered via jQuery; user input is already intercepted in the capture handler. + if (!event || !event.isTrigger) { + return; + } + + if (cdn_conflict_check('cdn_enabled', event)) { + return false; + } + }); + jQuery('#cdn__engine').on('change', function(event) { + // Only handle synthetic events triggered via jQuery; user input is already intercepted in the capture handler. + if (!event || !event.isTrigger) { + return; + } + + if (cdn_conflict_check('cdn_engine', event)) { + return false; + } + }); + jQuery('#cdnfsd__enabled').on('click', function(event) { + // Only handle synthetic events triggered via jQuery; user input is already intercepted in the capture handler. + if (!event || !event.isTrigger) { + return; + } + + if (cdn_conflict_check('cdnfsd_enabled', event)) { + return false; + } + }); + jQuery('#cdnfsd__engine').on('change', function(event) { + // Only handle synthetic events triggered via jQuery; user input is already intercepted in the capture handler. + if (!event || !event.isTrigger) { + return; + } + + if (cdn_conflict_check('cdnfsd_engine', event)) { + return false; + } + }); + } // Database cache disk usage warning. toggle_dbcache_notice(); @@ -621,16 +858,10 @@ jQuery(function() { }); }); - // Prevent enabling Bunny CDN for both CDN and CDNFSD. - $cdn_enabled.on('click', cdn_bunnycdn_check); - $cdn_engine.on('change', cdn_bunnycdn_check); - jQuery('#cdnfsd__enabled').on('click', cdn_bunnycdn_check); - jQuery('#cdnfsd__engine').on('change', cdn_bunnycdn_check); - // When CDN is enabled as "cf" or "cf2", then display a notice about possible charges. cdn_cf_check(); - $cdn_enabled.on('click', cdn_cf_check); - $cdn_engine.on('change', cdn_cf_check); + jQuery('#cdn__enabled').on('click', cdn_cf_check); + jQuery('#cdn__engine').on('change', cdn_cf_check); /** * CDN page. From ec06320acba8a5d93e94eaf99779bba121187b59 Mon Sep 17 00:00:00 2001 From: jacobd Date: Tue, 4 Nov 2025 16:31:44 +0000 Subject: [PATCH 2/2] Addressed copilot feedback. --- Generic_Plugin_Admin.php | 2 +- pub/js/options.js | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Generic_Plugin_Admin.php b/Generic_Plugin_Admin.php index 3f957b968..641545fd1 100644 --- a/Generic_Plugin_Admin.php +++ b/Generic_Plugin_Admin.php @@ -768,7 +768,7 @@ public function admin_print_scripts() { 'w3-total-cache' ), 'totalCdnWarning' => esc_html__( - 'Total CDN cannot be enabled for both CDN and Full Site Delivery', + 'Total CDN cannot be enabled for both CDN and Full Site Delivery.', 'w3-total-cache' ), 'mixedCdnWarning' => esc_html__( diff --git a/pub/js/options.js b/pub/js/options.js index 57f6f65a3..1fce3ffea 100644 --- a/pub/js/options.js +++ b/pub/js/options.js @@ -414,7 +414,7 @@ function cdn_conflict_bind_capture(selector, eventName, changedField) { } /** - * Determines whether the current admin screen is the CDN settings page. + * Determines whether the current admin screen is the general settings page. * * @since X.X.X * @@ -438,12 +438,9 @@ function cdn_conflict_is_general_settings_page() { * * @param {string} [changed] - Which field was changed: 'cdn_enabled', 'cdn_engine', 'cdnfsd_enabled', or 'cdnfsd_engine'. * @param {Event} [event] - Optional event associated with the change. + * @param {Object} [nextState] - The predicted state after the change, used to evaluate conflicts. * @returns {boolean} True when a conflict was handled. */ -/** - * Prevents Bunny/Total CDN conflicts between CDN and CDN FSD. - * Reverts the field that triggered the conflict and surfaces the warning message. - */ function cdn_conflict_check(changed, event, nextState) { // Prevents JS error for non W3TC pages. if (typeof w3tcData === 'undefined') {