PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / WordPress_Connector.php

WordPress_Connector.php in 404 Solution 4.1.19, at includes/WordPress_Connector.php

1,492 lines 65.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
9
10 class ABJ_404_Solution_WordPress_Connector {
11
12 /** @var self|null */
13 private static $instance = null;
14 private const REVIEW_INITIAL_DELAY_DAYS = 30;
15 private const REVIEW_ASK_LATER_DELAY_DAYS = 7;
16 private const REVIEW_CLOSE_X_SNOOZE_DAYS = 14;
17
18 /** Set to true by handleReviewResponseRedirects() when feedback POST is processed,
19 * so maybeShowReviewRequest() can display the thank-you notice instead of the form.
20 * @var bool */
21 private static $feedbackSubmitted = false;
22
23 /** @var array<int, string> */
24 private static $adminRuntimeErrors = array();
25
26 /** @var ABJ_404_Solution_PluginLogic */
27 private $logic;
28
29 /** @var ABJ_404_Solution_DataAccess */
30 private $dao;
31
32 /** @var ABJ_404_Solution_Logging */
33 private $logger;
34
35 /** @var ABJ_404_Solution_Functions */
36 private $f;
37
38 /** @var ABJ_404_Solution_SpellChecker */
39 private $spellChecker;
40
41 /** @var ABJ_404_Solution_FrontendRequestPipeline|null */
42 private $frontendPipeline = null;
43
44 /**
45 * Constructor with dependency injection.
46 *
47 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
48 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer
49 * @param ABJ_404_Solution_Logging|null $logging Logging service
50 * @param ABJ_404_Solution_Functions|null $functions String utilities
51 * @param ABJ_404_Solution_SpellChecker|null $spellChecker Spell checker service
52 */
53 public function __construct($pluginLogic = null, $dataAccess = null, $logging = null, $functions = null, $spellChecker = null) {
54 // Use injected dependencies or fall back to getInstance() for backward compatibility
55 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
56 $this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
57 $this->logger = $logging !== null ? $logging : abj_service('logging');
58 $this->f = $functions !== null ? $functions : abj_service('functions');
59 $this->spellChecker = $spellChecker !== null ? $spellChecker : abj_service('spell_checker');
60 }
61
62 /** @return ABJ_404_Solution_FrontendRequestPipeline */
63 private function getFrontendPipeline() {
64 if ($this->frontendPipeline !== null) {
65 return $this->frontendPipeline;
66 }
67
68 if (!class_exists('ABJ_404_Solution_FrontendRequestPipeline')) {
69 require_once dirname(__FILE__) . '/FrontendRequestPipeline.php';
70 }
71
72 $matchingEngines = [];
73 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
74 $engines = ABJ_404_Solution_ServiceContainer::safeGet('matching_engines');
75 if (is_array($engines)) {
76 $matchingEngines = $engines;
77 }
78 }
79
80 $this->frontendPipeline = new ABJ_404_Solution_FrontendRequestPipeline(
81 $this->logic,
82 $this->dao,
83 $this->logger,
84 $this->f,
85 $this->spellChecker,
86 $matchingEngines
87 );
88 return $this->frontendPipeline;
89 }
90
91 /** @return self */
92 public static function getInstance() {
93 if (self::$instance !== null) {
94 return self::$instance;
95 }
96
97 // If the DI container is initialized, prefer it.
98 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
99 $svc = ABJ_404_Solution_ServiceContainer::safeGet('wordpress_connector');
100 if ($svc instanceof self) {
101 self::$instance = $svc;
102 return self::$instance;
103 }
104 }
105
106 self::$instance = new ABJ_404_Solution_WordPress_Connector();
107
108 return self::$instance;
109 }
110
111 /**
112 * Persist and queue an admin runtime error so users see a notice instead of a blank page.
113 *
114 * @param string $hookName
115 * @param Throwable $e
116 * @return void
117 */
118 private static function reportAdminRuntimeError(string $hookName, Throwable $e): void {
119 $summary = sprintf('[%s] %s', $hookName, $e->getMessage());
120 self::$adminRuntimeErrors[] = $summary;
121
122 try {
123 $logger = abj_service('logging');
124 $logger->errorMessage('Admin runtime exception in ' . $hookName . ': ' . $e->getMessage());
125 } catch (Throwable $ignored) {
126 // Last-resort logging fallback.
127 @error_log('404 Solution admin runtime exception in ' . $hookName . ': ' . $e->getMessage());
128 }
129
130 if (function_exists('set_transient')) {
131 set_transient('abj404_admin_runtime_error', $summary, 300);
132 }
133 }
134
135 /**
136 * Echo one-time admin runtime errors captured from earlier hooks in this request (or previous request).
137 *
138 * @return void
139 */
140 private static function echoAdminRuntimeErrorNotice(): void {
141 $errors = self::$adminRuntimeErrors;
142 self::$adminRuntimeErrors = array();
143
144 if (function_exists('get_transient')) {
145 $saved = get_transient('abj404_admin_runtime_error');
146 if (is_string($saved) && $saved !== '') {
147 $errors[] = $saved;
148 delete_transient('abj404_admin_runtime_error');
149 }
150 }
151
152 if (empty($errors)) {
153 return;
154 }
155
156 $message = implode("\n", array_unique(array_filter($errors)));
157 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ';
158 echo esc_html__('An internal error occurred while loading this admin page.', '404-solution');
159 echo '</p><details><summary>' . esc_html__('Show details', '404-solution') . '</summary><pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">';
160 echo esc_html($message);
161 echo '</pre></details></div>';
162 }
163
164 /** Setup.
165 * @return void
166 */
167 static function init() {
168 self::registerLifecycleHooks();
169 self::registerAdminHooks();
170 self::registerAsyncSuggestionHooks();
171 ABJ_404_Solution_PluginLogic::doRegisterCrons();
172 }
173
174 /** @return void */
175 private static function registerLifecycleHooks() {
176 if (!is_admin()) {
177 return;
178 }
179
180 register_deactivation_hook(ABJ404_NAME, 'ABJ_404_Solution_PluginLogic::runOnPluginDeactivation');
181 register_activation_hook(ABJ404_NAME, 'ABJ_404_Solution_PluginLogic::runOnPluginActivation');
182
183 if (is_multisite()) {
184 add_action('wpmu_new_blog', 'ABJ_404_Solution_PluginLogic::activateNewSite', 10, 6);
185 add_action('wp_initialize_site', 'ABJ_404_Solution_PluginLogic::activateNewSiteModern', 10, 2);
186 add_action('delete_blog', 'ABJ_404_Solution_PluginLogic::deleteBlogData', 10, 2);
187 }
188 }
189
190 /** @return void */
191 private static function registerAdminHooks() {
192 if (!is_admin()) {
193 return;
194 }
195
196 add_filter("plugin_action_links_" . ABJ404_NAME,
197 'ABJ_404_Solution_WordPress_Connector::addSettingsLinkToPluginPage');
198 add_filter('plugin_row_meta',
199 'ABJ_404_Solution_WordPress_Connector::addPluginRowMeta', 10, 2);
200 add_action('admin_notices',
201 'ABJ_404_Solution_WordPress_Connector::echoDashboardNotification');
202 add_action('admin_init',
203 'ABJ_404_Solution_WordPress_Connector::handleReviewResponseRedirects');
204 add_action('admin_menu',
205 'ABJ_404_Solution_WordPress_Connector::addMainSettingsPageLink');
206 add_action('admin_enqueue_scripts',
207 'ABJ_404_Solution_WordPress_Connector::add_scripts', 11);
208 add_action('admin_enqueue_scripts',
209 'ABJ_404_Solution_WordPress_Connector::enqueueSupportRequestAssetsOnPluginsPage', 11);
210 add_action('admin_head',
211 'ABJ_404_Solution_WordPress_Connector::outputCriticalThemeCSS', 1);
212
213 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_echoViewLogsFor', 'ABJ_404_Solution_Ajax_Php::echoViewLogsFor');
214 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_trashLink', 'ABJ_404_Solution_Ajax_TrashLink::trashAction');
215 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_echoRedirectToPages', 'ABJ_404_Solution_Ajax_Php::echoRedirectToPages');
216 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_updateOptions', 'ABJ_404_Solution_Ajax_Php::updateOptions');
217 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_load_gsc_section', 'ABJ_404_Solution_Ajax_Php::loadGscSection');
218 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404getTrendData', 'ABJ_404_Solution_Ajax_TrendData::echoTrendData');
219 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_crossPluginPreview', 'ABJ_404_Solution_Ajax_CrossPluginImporter::handlePreview');
220 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_gsc_oauth_callback', 'ABJ_404_Solution_WordPress_Connector::handleGscOauthCallback');
221 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_gsc_revoke', 'ABJ_404_Solution_WordPress_Connector::handleGscRevoke');
222
223 ABJ_404_Solution_Ajax_EngineProfiles::registerActions();
224 ABJ_404_Solution_Ajax_SettingsModeToggle::init();
225 ABJ_404_Solution_Ajax_RestoreDefaults::init();
226 ABJ_404_Solution_Ajax_SupportRequest::init();
227 ABJ_404_Solution_Ajax_SupportRequestPreview::init();
228 ABJ_404_Solution_UninstallModal::init();
229 ABJ_404_Solution_SetupWizard::init();
230 if (class_exists('ABJ_404_Solution_Privacy')) {
231 ABJ_404_Solution_Privacy::init();
232 }
233 }
234
235 /** @return void */
236 private static function registerAsyncSuggestionHooks() {
237 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_compute_suggestions', 'ABJ_404_Solution_Ajax_SuggestionCompute::computeSuggestions');
238 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_nopriv_abj404_compute_suggestions', 'ABJ_404_Solution_Ajax_SuggestionCompute::computeSuggestions');
239 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_abj404_poll_suggestions', 'ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions');
240 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_nopriv_abj404_poll_suggestions', 'ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions');
241 }
242
243 /** Include things necessary for ajax.
244 * @param string $hook
245 * @return void
246 */
247 static function add_scripts($hook) {
248 // only load this stuff for this plugin.
249 // thanks to https://pippinsplugins.com/loading-scripts-correctly-in-the-wordpress-admin/
250 if (!array_key_exists('abj404_settingsPageName', $GLOBALS) ||
251 $hook != $GLOBALS['abj404_settingsPageName']) {
252 return;
253 }
254
255 try {
256 $subpage = '';
257 if (array_key_exists('subpage', $_GET)) {
258 $subpage = sanitize_text_field(self::normalizeRequestScalar($_GET['subpage']));
259 }
260 // Default plugin landing is redirects when subpage is not specified.
261 if ($subpage === '') {
262 $subpage = 'abj404_redirects';
263 }
264
265 $isOptionsPage = ($subpage === 'abj404_options');
266 $isStatsPage = ($subpage === 'abj404_stats');
267 $isToolsPage = ($subpage === 'abj404_tools');
268 $isCardAccordionPage = in_array($subpage, array('abj404_options', 'abj404_tools', 'abj404_stats'), true);
269 $isLogsPage = ($subpage === 'abj404_logs');
270 $isListPage = in_array($subpage, array('abj404_redirects', 'abj404_captured', 'abj404_logs'), true);
271 $isEditPage = ($subpage === 'abj404_edit');
272 $needsDestinationAutocomplete = in_array($subpage, array('abj404_redirects', 'abj404_captured', 'abj404_options', 'abj404_edit'), true);
273
274 // remove the "thank you for creating with wordpress" message
275 add_filter('admin_footer_text',
276 'ABJ_404_Solution_WordPress_Connector::remove_admin_footer_text');
277 // remove the version number message
278 add_filter('update_footer',
279 'ABJ_404_Solution_WordPress_Connector::remove_admin_footer_text', 11);
280
281 // jquery is used for the searchable dropdown list of pages for adding a redirect and other things.
282 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery');
283 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-ui-autocomplete');
284 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-effects-core');
285 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-effects-highlight');
286 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('jquery-color');
287
288 wp_register_script('abj404-redirect_to_ajax', plugin_dir_url(__FILE__) . 'ajax/redirect_to_ajax.js',
289 array('jquery', 'jquery-ui-autocomplete'));
290 wp_register_script('abj404-exclude_pages_ajax', plugin_dir_url(__FILE__) . 'ajax/exclude_pages_ajax.js',
291 array('jquery', 'jquery-ui-autocomplete', 'abj404-redirect_to_ajax'));
292 // Localize the script with new data
293 $translation_array = array(
294 'type_a_page_name' => __('(Type a page name or an external URL)', '404-solution'),
295 'a_page_has_been_selected' => __('(A page has been selected.)', '404-solution'),
296 'an_external_url_will_be_used' => __('(An external URL will be used.)', '404-solution')
297 );
298 wp_localize_script('abj404-redirect_to_ajax', 'abj404localization', $translation_array );
299 if ($needsDestinationAutocomplete) {
300 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-redirect_to_ajax');
301 wp_localize_script('abj404-exclude_pages_ajax', 'abj404localization', $translation_array );
302 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-exclude_pages_ajax');
303 }
304
305 // make sure the "apply" button is only enabled if at least one checkbox is selected
306 wp_register_script('abj404-enable_disable_apply_button_js',
307 ABJ404_URL . 'includes/js/enableDisableApplyButton.js');
308 $translation_array = array('{altText}' => __('Choose at least one URL', '404-solution'));
309 wp_localize_script('abj404-enable_disable_apply_button_js', 'abj404localization', $translation_array);
310 if ($isListPage) {
311 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-enable_disable_apply_button_js');
312 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-trash_link_ajax', plugin_dir_url(__FILE__) . 'ajax/trash_link_ajax.js',
313 array('jquery'));
314 }
315 // tableInteractions.js provides abj404ToggleRegexInfo() used on both list pages
316 // and the Edit Redirect page (subpage=abj404_edit).
317 if ($isListPage || $isEditPage) {
318 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-table-interactions', plugin_dir_url(__FILE__) . 'js/tableInteractions.js',
319 array('jquery'));
320
321 // Localized strings for time-ago display
322 wp_localize_script('abj404-table-interactions', 'abj404_time_ago', array(
323 'second' => __('second', '404-solution'),
324 'seconds' => __('seconds', '404-solution'),
325 'minute' => __('minute', '404-solution'),
326 'minutes' => __('minutes', '404-solution'),
327 'hour' => __('hour', '404-solution'),
328 'hours' => __('hours', '404-solution'),
329 'day' => __('day', '404-solution'),
330 'days' => __('days', '404-solution'),
331 'ago' => __('ago', '404-solution'),
332 ));
333 }
334
335 if ($isListPage || $isStatsPage) {
336 self::enqueueViewUpdaterModules(plugin_dir_url(__FILE__) . 'ajax/');
337 }
338
339 if ($isLogsPage) {
340 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-search_logs_ajax', plugin_dir_url(__FILE__) . 'ajax/search_logs_ajax.js',
341 array('jquery', 'jquery-ui-autocomplete'));
342 }
343
344 if ($isOptionsPage) {
345 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-general-js', plugin_dir_url(__FILE__) . 'js/general.js',
346 array('jquery'));
347
348 // Localize general.js strings for translation
349 wp_localize_script('abj404-general-js', 'abj404General', array(
350 'savingSettings' => __('Saving settings...', '404-solution'),
351 ));
352
353 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-theme-preview', plugin_dir_url(__FILE__) . 'js/themePreview.js',
354 array('jquery'));
355
356 // Settings mode toggle (Simple/Advanced)
357 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-settings-mode-toggle', plugin_dir_url(__FILE__) . 'ajax/SettingsModeToggle.js',
358 array('jquery'));
359
360 // Restore defaults (sticky save bar)
361 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-restore-defaults', plugin_dir_url(__FILE__) . 'ajax/RestoreDefaults.js',
362 array('jquery'));
363
364 // Behavior tiles (404 destination selector)
365 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-behavior-tiles', ABJ404_URL . 'includes/js/behaviorTiles.js',
366 array());
367 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-settings-deferred', ABJ404_URL . 'includes/js/settingsDeferred.js',
368 array('jquery'));
369 }
370
371 if ($isCardAccordionPage) {
372 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-options-accordion', plugin_dir_url(__FILE__) . 'js/optionsAccordion.js',
373 array('jquery'));
374
375 // Localize accordion strings for translation
376 wp_localize_script('abj404-options-accordion', 'abj404Accordion', array(
377 'expandAll' => __('Expand All', '404-solution'),
378 'collapseAll' => __('Collapse All', '404-solution'),
379 ));
380 }
381
382 if ($isOptionsPage) {
383 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-engine-profiles',
384 plugin_dir_url(__FILE__) . 'ajax/ajax-engine-profiles.js',
385 array('jquery'));
386 wp_localize_script('abj404-engine-profiles', 'abj404EngineProfiles', array(
387 'nonce' => wp_create_nonce('abj404_engine_profiles_nonce'),
388 'ajaxUrl' => admin_url('admin-ajax.php'),
389 'i18n' => array(
390 'edit' => __('Edit', '404-solution'),
391 'delete' => __('Delete', '404-solution'),
392 'addProfile' => __('Add Engine Profile', '404-solution'),
393 'editProfile' => __('Edit Engine Profile', '404-solution'),
394 'nameRequired' => __('Profile name is required.', '404-solution'),
395 'patternRequired' => __('URL pattern is required.', '404-solution'),
396 'saved' => __('Profile saved.', '404-solution'),
397 'saveFailed' => __('Failed to save profile.', '404-solution'),
398 'confirmDelete' => __('Delete this engine profile?', '404-solution'),
399 ),
400 ));
401 }
402
403 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt(
404 'abj404-review-feedback',
405 plugin_dir_url(__FILE__) . 'js/reviewFeedback.js',
406 array()
407 );
408
409 self::registerSupportRequestAssets();
410
411 ABJ_404_Solution_WPUtils::my_wp_enq_style('abj404solution-styles', ABJ404_URL . 'includes/html/404solutionStyles.css',
412 array());
413 ABJ_404_Solution_WPUtils::my_wp_enq_style('abj404solution-themes', ABJ404_URL . 'includes/html/adminThemes.css',
414 array());
415
416 // Load RTL styles for Arabic, Hebrew, and other right-to-left languages
417 if (is_rtl()) {
418 ABJ_404_Solution_WPUtils::my_wp_enq_style('abj404solution-rtl', ABJ404_URL . 'includes/html/404solutionStyles-rtl.css',
419 array('abj404solution-styles'));
420 }
421 } catch (Throwable $e) {
422 self::reportAdminRuntimeError('admin_enqueue_scripts', $e);
423 }
424 }
425
426 /**
427 * Enqueue the reusable support-request button assets. Loaded on
428 * every plugin admin page so any screen can drop a
429 * SupportRequestButton::render() mount-point without a per-screen
430 * enqueue checklist that drifts as new mount points are added.
431 *
432 * The inline bootstrap exposes window.ABJ404.ajaxurl plus
433 * window.ABJ404.nonces.{support_request, support_request_preview}
434 * so the JS client and the modal component can both reach the
435 * nonces without wp_localize_script's per-handle binding.
436 *
437 * @return void
438 */
439 /**
440 * Enqueue the support-request button assets specifically for the
441 * wp-admin/plugins.php screen so the row-meta link added by
442 * `addPluginRowMeta()` can open its consent modal in-place. The
443 * plugin's main `add_scripts()` enqueue is gated to the plugin's
444 * settings page and would skip plugins.php otherwise.
445 *
446 * Scope: this hook runs on every admin page but no-ops unless the
447 * current screen is plugins.php, keeping the asset footprint tight.
448 *
449 * @param string $hook the admin page slug WP passes to admin_enqueue_scripts
450 * @return void
451 */
452 static function enqueueSupportRequestAssetsOnPluginsPage($hook) {
453 if ($hook !== 'plugins.php') {
454 return;
455 }
456 try {
457 self::registerSupportRequestAssets();
458 } catch (Throwable $e) {
459 self::reportAdminRuntimeError('admin_enqueue_scripts:plugins.php', $e);
460 }
461 }
462
463 /**
464 * Enqueue the view-updater module bundle (the AJAX-driven admin table
465 * orchestration). Split out of add_scripts() to keep that function under
466 * the ModularityTest body-line cap. Enqueue order matters: every file
467 * below uses globals defined by the modules listed before it; the
468 * bootstrap (view_updater.js) declares the jQuery.ready entry point and
469 * must load LAST so the helpers are defined when ready fires.
470 * WordPress's $deps array enforces this ordering on the emitted
471 * <script> tags. The B20 nonce-refresh helper exposes
472 * abj404AjaxWithNonceRetry which every sibling uses via a soft typeof
473 * reference, so its enqueue must precede them.
474 *
475 * @param string $vuBase URL prefix for the ajax/ assets directory.
476 * @return void
477 */
478 private static function enqueueViewUpdaterModules(string $vuBase): void {
479 $enq = array('ABJ_404_Solution_WPUtils', 'my_wp_enq_scrpt');
480 $enq('abj404-view-updater-nonce-refresh',
481 $vuBase . 'view_updater_nonce_refresh.js', array('jquery'));
482 $enq('abj404-view-updater-stage-diagnostics',
483 $vuBase . 'view_updater_stage_diagnostics.js', array('jquery'));
484 $enq('abj404-view-updater-compare',
485 $vuBase . 'view_updater_compare.js', array('jquery'));
486 $enq('abj404-view-updater-toast',
487 $vuBase . 'view_updater_toast.js', array('jquery'));
488 $enq('abj404-view-updater-stats', $vuBase . 'view_updater_stats.js',
489 array('jquery', 'abj404-view-updater-toast', 'abj404-view-updater-nonce-refresh'));
490 $enq('abj404-view-updater-build-advance', $vuBase . 'view_updater_build_advance.js',
491 array('jquery', 'abj404-view-updater-stage-diagnostics', 'abj404-view-updater-nonce-refresh'));
492 $enq('abj404-view-updater-table-init', $vuBase . 'view_updater_table_init.js',
493 array('jquery', 'abj404-view-updater-toast', 'abj404-view-updater-stats',
494 'abj404-view-updater-nonce-refresh'));
495 $enq('abj404-view-updater-table-warmup', $vuBase . 'view_updater_table_warmup.js',
496 array('jquery', 'abj404-view-updater-stage-diagnostics',
497 'abj404-view-updater-build-advance', 'abj404-view-updater-table-init',
498 'abj404-view-updater-nonce-refresh'));
499 $enq('abj404-view-updater-pagination', $vuBase . 'view_updater_pagination.js',
500 array('jquery', 'abj404-view-updater-compare', 'abj404-view-updater-stage-diagnostics',
501 'abj404-view-updater-build-advance', 'abj404-view-updater-table-init',
502 'abj404-view-updater-table-warmup', 'abj404-view-updater-toast',
503 'abj404-view-updater-nonce-refresh'));
504 $enq('abj404-view-updater', $vuBase . 'view_updater.js',
505 array('jquery', 'jquery-ui-autocomplete',
506 'abj404-view-updater-stage-diagnostics', 'abj404-view-updater-compare',
507 'abj404-view-updater-toast', 'abj404-view-updater-stats',
508 'abj404-view-updater-build-advance', 'abj404-view-updater-table-init',
509 'abj404-view-updater-table-warmup', 'abj404-view-updater-pagination',
510 'abj404-view-updater-nonce-refresh'));
511 }
512
513 private static function registerSupportRequestAssets(): void {
514 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-support-request-client',
515 plugin_dir_url(__FILE__) . 'ajax/SupportRequest.js', array());
516 ABJ_404_Solution_WPUtils::my_wp_enq_scrpt('abj404-support-request-button',
517 ABJ404_URL . 'includes/js/support-request-button.js',
518 array('abj404-support-request-client'));
519 if (!function_exists('wp_add_inline_script')) {
520 return;
521 }
522 $supportNonce = wp_create_nonce(ABJ_404_Solution_Ajax_SupportRequest::NONCE_ACTION);
523 $previewNonce = wp_create_nonce(ABJ_404_Solution_Ajax_SupportRequestPreview::NONCE_ACTION);
524 $ajaxUrl = function_exists('admin_url') ? admin_url('admin-ajax.php') : '/wp-admin/admin-ajax.php';
525 $payload = wp_json_encode(array(
526 'ajaxurl' => $ajaxUrl,
527 'nonces' => array(
528 'support_request' => $supportNonce,
529 'support_request_preview' => $previewNonce,
530 ),
531 ));
532 $bootstrap = 'window.ABJ404=window.ABJ404||{};Object.assign(window.ABJ404,'
533 . (is_string($payload) ? $payload : '{}') . ');';
534 wp_add_inline_script('abj404-support-request-client', $bootstrap, 'before');
535 }
536
537 /** Detect if dark mode is enabled from various sources.
538 * Checks WordPress admin color scheme, dark mode plugins, and browser preference.
539 *
540 * @return bool True if dark mode is detected, false otherwise
541 */
542 static function isDarkModeDetected() {
543 // Check WordPress admin color scheme
544 $current_user_id = get_current_user_id();
545 if ($current_user_id) {
546 $admin_color = get_user_meta($current_user_id, 'admin_color', true);
547 // WordPress dark color schemes: midnight, ectoplasm, coffee
548 $dark_schemes = array('midnight', 'ectoplasm', 'coffee');
549 if (in_array($admin_color, $dark_schemes)) {
550 return true;
551 }
552 }
553
554 // Check for popular dark mode plugins
555 // WP Dark Mode plugin
556 if (get_option('wp_dark_mode_enabled')) {
557 return true;
558 }
559
560 // Dark Mode for WP Dashboard plugin
561 if (get_option('dark_mode_for_wp_dashboard_enabled')) {
562 return true;
563 }
564
565 // Check if any dark mode plugin class exists
566 if (class_exists('WP_Dark_Mode') || class_exists('Dark_Mode_For_WP_Dashboard')) {
567 return true;
568 }
569
570 // Browser/OS preference will be checked via JavaScript
571 return false;
572 }
573
574 /** Get the auto-selected theme based on dark mode detection.
575 *
576 * @return string The theme to use ('obsidian' for dark mode, 'default' otherwise)
577 */
578 static function getAutoSelectedTheme() {
579 if (self::isDarkModeDetected()) {
580 // Default to obsidian for dark mode (can be changed to 'neon' if preferred)
581 return 'obsidian';
582 }
583 return 'default';
584 }
585
586 /** Output critical theme CSS inline to prevent FOUC (Flash of Unstyled Content).
587 * This outputs the CSS variables for the selected theme directly in the <head>
588 * before any external CSS files load, eliminating the flash when a custom theme is selected.
589 *
590 * Additionally, this sets the data-theme attribute on both HTML and body elements
591 * via a synchronous script, ensuring the attribute exists before CSS is parsed.
592 */
593 /** @return void */
594 static function outputCriticalThemeCSS() {
595 try {
596 // Only run on our plugin pages
597 if (!array_key_exists('abj404_settingsPageName', $GLOBALS) ||
598 !array_key_exists('page', $_GET) ||
599 $_GET['page'] != ABJ404_PP) {
600 return;
601 }
602
603 $logic = abj_service('plugin_logic');
604 $options = $logic->getOptions();
605 $theme = (isset($options['admin_theme']) && is_string($options['admin_theme'])) ? $options['admin_theme'] : 'default';
606
607 // Check if auto dark mode detection is enabled (default: enabled)
608 $auto_dark_mode = !isset($options['disable_auto_dark_mode']) || $options['disable_auto_dark_mode'] != '1';
609
610 // If theme is 'default' and auto dark mode is enabled, check for dark mode
611 if ($theme === 'default' && $auto_dark_mode) {
612 $theme = self::getAutoSelectedTheme();
613 }
614
615 // Sanitize theme value - only allow specific values
616 $allowed_themes = array('default', 'calm', 'mono', 'neon', 'obsidian');
617 if (!in_array($theme, $allowed_themes)) {
618 $theme = 'default';
619 }
620
621 // For 'default' theme, don't set data-theme attribute
622 // This respects WordPress admin color scheme (default/Fresh is light)
623 // and avoids overriding it with browser dark mode preference
624 if ($theme === 'default') {
625 // No theme CSS needed for default - use WordPress default styling
626 // Ensure no data-theme attribute is set
627 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/themeRemoverScript.html");
628 echo $html;
629 return;
630 }
631
632 // Output synchronous script to set data-theme attributes immediately
633 // This MUST run before CSS is parsed to prevent flash
634 // Setting on html immediately, and body as soon as it's available
635 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/themeSetterScript.html");
636 $f = abj_service('functions');
637 $html = $f->str_replace('{theme}', esc_js($theme), $html);
638 echo $html;
639
640 // Define CSS variables for each theme
641 $themeVariables = array(
642 'mono' => array(
643 '--abj404-bg' => '#F8FAFC',
644 '--abj404-bg-muted' => '#F5F7FA',
645 '--abj404-surface' => '#ffffff',
646 '--abj404-surface-muted' => '#F1F5F9',
647 '--abj404-text' => '#111827',
648 '--abj404-text-muted' => '#6B7280',
649 '--abj404-border' => '#E5E7EB',
650 '--abj404-primary' => '#374151',
651 '--abj404-accent' => '#2563EB',
652 '--abj404-info' => '#3B82F6',
653 '--abj404-success' => '#10B981',
654 '--abj404-warning' => '#F59E0B',
655 '--abj404-danger' => '#EF4444',
656 '--abj404-focus' => '#93C5FD',
657 '--abj404-table-header' => '#F1F5F9',
658 '--abj404-row-hover' => '#F5F7FA',
659 '--abj404-row-selected' => '#DBEAFE',
660 '--abj404-badge-bg' => '#EFF1F5',
661 '--abj404-badge-text' => '#374151',
662 ),
663 'calm' => array(
664 '--abj404-bg' => '#F7FAFD',
665 '--abj404-bg-muted' => '#F1F6FE',
666 '--abj404-surface' => '#ffffff',
667 '--abj404-surface-muted' => '#E9F0FB',
668 '--abj404-text' => '#17223B',
669 '--abj404-text-muted' => '#5A6B86',
670 '--abj404-border' => '#E1E8F5',
671 '--abj404-primary' => '#1E6BD6',
672 '--abj404-accent' => '#00A27A',
673 '--abj404-info' => '#2B8AE2',
674 '--abj404-success' => '#20B67A',
675 '--abj404-warning' => '#F6A700',
676 '--abj404-danger' => '#D53F3F',
677 '--abj404-focus' => '#5AA2FF',
678 '--abj404-table-header' => '#E9F0FB',
679 '--abj404-row-hover' => '#F1F6FE',
680 '--abj404-row-selected' => '#D7E8FF',
681 '--abj404-badge-bg' => '#EEF2F8',
682 '--abj404-badge-text' => '#3E546E',
683 ),
684 'neon' => array(
685 '--abj404-bg' => '#0C0F13',
686 '--abj404-bg-muted' => '#11151A',
687 '--abj404-surface' => '#151A21',
688 '--abj404-surface-muted' => '#1B222B',
689 '--abj404-text' => '#E5EAF2',
690 '--abj404-text-muted' => '#A6B0C3',
691 '--abj404-border' => '#273141',
692 '--abj404-primary' => '#7C3AED',
693 '--abj404-accent' => '#22D3EE',
694 '--abj404-info' => '#60A5FA',
695 '--abj404-success' => '#34D399',
696 '--abj404-warning' => '#F59E0B',
697 '--abj404-danger' => '#F87171',
698 '--abj404-focus' => '#38BDF8',
699 '--abj404-table-header' => '#1F2732',
700 '--abj404-row-hover' => '#192028',
701 '--abj404-row-selected' => '#0E2936',
702 '--abj404-badge-bg' => '#202734',
703 '--abj404-badge-text' => '#CFD8E6',
704 ),
705 'obsidian' => array(
706 '--abj404-bg' => '#0A0F1A',
707 '--abj404-bg-muted' => '#0E1522',
708 '--abj404-surface' => '#121826',
709 '--abj404-surface-muted' => '#172032',
710 '--abj404-text' => '#E6ECF7',
711 '--abj404-text-muted' => '#A9B7CC',
712 '--abj404-border' => '#223149',
713 '--abj404-primary' => '#1D4ED8',
714 '--abj404-accent' => '#A78BFA',
715 '--abj404-info' => '#60A5FA',
716 '--abj404-success' => '#22C55E',
717 '--abj404-warning' => '#F59E0B',
718 '--abj404-danger' => '#EF4444',
719 '--abj404-focus' => '#93C5FD',
720 '--abj404-table-header' => '#1B253A',
721 '--abj404-row-hover' => '#141C2C',
722 '--abj404-row-selected' => '#1A2A46',
723 '--abj404-badge-bg' => '#1A2438',
724 '--abj404-badge-text' => '#DCE6F7',
725 ),
726 );
727
728 // Output inline critical CSS if theme is selected
729 /** @var string $themeKey */
730 $themeKey = $theme;
731 if (isset($themeVariables[$themeKey])) {
732 // Build CSS variables string
733 $cssVars = '';
734 foreach ($themeVariables[$themeKey] as $var => $value) {
735 $cssVars .= esc_html($var) . ':' . esc_html($value) . ';';
736 }
737
738 // Load template and replace placeholder
739 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/criticalThemeCSS.html");
740 $f = abj_service('functions');
741 $html = $f->str_replace('{css_variables}', $cssVars, $html);
742 echo $html;
743 }
744 } catch (Throwable $e) {
745 self::reportAdminRuntimeError('admin_head', $e);
746 }
747 }
748
749 /**
750 * @param string $content
751 * @return string
752 */
753 static function remove_admin_footer_text($content) {
754 return '';
755 }
756
757 /** Add the "Settings" link to the WordPress plugins page (next to activate/deactivate and edit).
758 * @param array<int|string, string> $links
759 * @return array<int|string, string>
760 */
761 static function addSettingsLinkToPluginPage($links) {
762 $instance = self::getInstance();
763
764 if (!is_array($links)) {
765 $instance->logger->infoMessage("The settings links variable was not an array. " .
766 "Please verify the validity of other plugins. " . print_r($links, true));
767 $links = array();
768 }
769
770 if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
771 $instance->logger->logUserCapabilities("addSettingsLinkToPluginPage");
772
773 return $links;
774 }
775
776 $settings_link = '<a href="options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options">' .
777 __('Settings', '404-solution') . '</a>';
778 array_unshift($links, $settings_link);
779
780 $debugExplanation = __('Debug Log', '404-solution');
781 $debugLogLink = $instance->logic->getDebugLogFileLink();
782 $debugExplanation = '<a href="options-general.php' . $debugLogLink . '" target="_blank" >'
783 . $debugExplanation . '</a>';
784 array_push($links, $debugExplanation);
785
786 return $links;
787 }
788
789 /**
790 * Adds a "Send debug log to developer" link to the plugin row on
791 * the Plugins page. The link opens the support-request consent
792 * modal in-place on wp-admin/plugins.php (handled by
793 * support-request-button.js, which attaches to elements matching
794 * .abj404-support-request-link). Opening in-place is deliberate:
795 * the Plugins listing is the screen an admin reaches when the
796 * plugin's own Settings page is broken, so the modal must not
797 * depend on Settings rendering correctly.
798 *
799 * The href falls back to the same-page anchor `#abj404-support-request`
800 * so the link is still well-formed if support-request-button.js
801 * fails to load. The modal itself is the only path that transmits
802 * the support-request payload; clicking the link never POSTs.
803 *
804 * @param array<int|string, string> $links
805 * @param string $file
806 * @return array<int|string, string>
807 */
808 static function addPluginRowMeta($links, $file) {
809 if ($file !== ABJ404_NAME) {
810 return $links;
811 }
812 $links[] = '<a href="#abj404-support-request"'
813 . ' class="abj404-support-request-link"'
814 . ' data-triggered-from="plugins_row_action">'
815 . esc_html__('Send debug log to developer', '404-solution') . '</a>';
816 return $links;
817 }
818
819 /** This is called directly by php code inserted into the page by the user.
820 * Code: <?php if (!empty($abj404connector)) {$abj404connector->suggestions(); } ?>
821 * @global type $abj404shortCode
822 */
823 /** @return void */
824 function suggestions() {
825 $abj404shortCode = abj_service('shortcode');
826
827 if (is_404()) {
828 $content = $abj404shortCode->shortcodePageSuggestions(array());
829
830 echo $content;
831 }
832 }
833
834 /** @return void */
835 function processRedirectAllRequests() {
836 $this->getFrontendPipeline()->processRedirectAllRequests();
837 }
838 /**
839 * Process the 404s
840 */
841 /** @return void */
842 function process404() {
843 $this->getFrontendPipeline()->process404();
844 }
845
846 /**
847 * @param array<string, mixed> $options
848 * @param string $requestedURL
849 * @return bool true if the user is sent to the default 404 page.
850 */
851 function tryRegexRedirect($options, $requestedURL) {
852 return $this->getFrontendPipeline()->tryRegexRedirect($options, $requestedURL);
853 }
854
855 /**
856 * @param array<string, mixed> $options
857 * @param string $requestedURL
858 * @param array<string, mixed> $redirect
859 * @return void
860 */
861 function logAReallyLongDebugMessage($options, $requestedURL, $redirect) {
862 $this->getFrontendPipeline()->logAReallyLongDebugMessage($options, $requestedURL, $redirect);
863 }
864
865 /** Redirect to the page specified.
866 * @param string $requestedURL
867 * @param array<string, mixed> $redirect
868 * @param string $matchReason
869 * @return bool true if the user is sent to the default 404 page.
870 */
871 function processRedirect($requestedURL, $redirect, $matchReason) {
872 return $this->getFrontendPipeline()->processRedirect($requestedURL, $redirect, $matchReason);
873 }
874
875 /** Display an admin dashboard notification.
876 * e.g. There are 29 captured 404 URLs to be processed.
877 * @global type $pagenow
878 * @global type $abj404dao
879 * @global type $abj404logic
880 * @global type $abj404view
881 */
882 /** @return void */
883 static function echoDashboardNotification() {
884 $instance = self::getInstance();
885
886 if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
887 $instance->logger->logUserCapabilities("echoDashboardNotification");
888 return;
889 }
890
891 self::echoAdminRuntimeErrorNotice();
892
893 global $pagenow;
894 global $abj404view;
895
896 $isPluginPage = array_key_exists('page', $_GET) && $_GET['page'] == ABJ404_PP;
897 $isDashboard = $pagenow == 'index.php' && !isset($_GET['page']);
898
899 // Display infrastructure notices (DB errors, stale cache, etc.) only on
900 // the plugin's own admin pages — not on the dashboard or other screens.
901 // This hook runs early in the request; rendering here ensures a notice
902 // set by a failed repair attempt is visible before later queries might
903 // auto-clear stale transients.
904 if ($isPluginPage) {
905 $dbNotice = get_transient('abj404_plugin_db_notice');
906 if (is_array($dbNotice) && isset($dbNotice['message']) && is_string($dbNotice['message'])) {
907 $type = isset($dbNotice['type']) && is_string($dbNotice['type']) ? $dbNotice['type'] : 'warning';
908 // Per owner directive: collation issues must NEVER surface as user notices.
909 // The plugin auto-recovers by running correctCollations() at query time.
910 // Skip rendering even if a stale 'collation' transient exists from a
911 // previous plugin version.
912 if ($type === 'collation') {
913 // intentionally do not render
914 } else {
915 // Map internal type names to WP notice CSS classes. Per the
916 // defensive-coding philosophy, infrastructure issues the plugin
917 // can degrade past (read-only DB, disk full, quota exceeded,
918 // stale cache, generic warning) render as notice-warning. Only
919 // failures that leave the plugin unable to function (e.g.
920 // missing tables that can't be repaired) escalate to
921 // notice-error.
922 $warningTypes = array(
923 'stale_permalink_cache',
924 'warning',
925 'read_only',
926 'disk_full',
927 'query_quota',
928 );
929 $cssClass = in_array($type, $warningTypes, true) ? 'notice-warning' : 'notice-error';
930 echo '<div class="notice ' . esc_attr($cssClass) . '"><p>' .
931 esc_html($dbNotice['message']) . '</p></div>';
932 }
933 }
934 }
935
936 if ($isPluginPage || $isDashboard) {
937 $captured404Count = $instance->dao->getCapturedCountForNotification();
938 if ($instance->logic->shouldNotifyAboutCaptured404s($captured404Count)) {
939 $msg = $abj404view->getDashboardNotificationCaptured($captured404Count);
940 echo $msg;
941 }
942
943 // Show review request after 7 days of use
944 self::maybeShowReviewRequest();
945 }
946 }
947
948 /** Handle review GET redirects and feedback POST submission on admin_init (before output).
949 *
950 * Called via the admin_init hook so wp_safe_redirect() + exit can be used safely.
951 * For feedback POST (no redirect), sets the static $feedbackSubmitted flag so
952 * maybeShowReviewRequest() can render the thank-you notice in the admin_notices hook.
953 *
954 * @return void
955 */
956 static function handleReviewResponseRedirects() {
957 if (!is_admin()) {
958 return;
959 }
960 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
961 return;
962 }
963
964 // Handle user responses to qualification question
965 if (isset($_GET['abj404_review_response'])) {
966 $rawResponseNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
967 $responseNonce = sanitize_text_field(self::normalizeRequestScalar($rawResponseNonce));
968 if ($responseNonce === '' || !wp_verify_nonce($responseNonce, 'abj404_review_response')) {
969 return;
970 }
971
972 $response = sanitize_text_field(self::normalizeRequestScalar($_GET['abj404_review_response']));
973 $allowedResponses = array('yes', 'not_yet', 'ask_later', 'close_x', 'never');
974 if (!in_array($response, $allowedResponses, true)) {
975 return;
976 }
977
978 if ($response === 'yes') {
979 // User thinks it deserves 5 stars - show review link
980 update_user_meta(get_current_user_id(), 'abj404_review_step', 'show_review_link');
981 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
982 } elseif ($response === 'not_yet') {
983 // User doesn't think it deserves 5 stars - show feedback form
984 update_user_meta(get_current_user_id(), 'abj404_review_step', 'show_feedback');
985 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
986 } elseif ($response === 'ask_later') {
987 // User wants to be reminded in 7 days
988 update_user_meta(get_current_user_id(), 'abj404_review_remind_later', time() + (self::REVIEW_ASK_LATER_DELAY_DAYS * 86400));
989 delete_user_meta(get_current_user_id(), 'abj404_review_step');
990 } elseif ($response === 'close_x') {
991 // Close button snoozes this prompt for at least two weeks.
992 update_user_meta(get_current_user_id(), 'abj404_review_remind_later', time() + (self::REVIEW_CLOSE_X_SNOOZE_DAYS * 86400));
993 delete_user_meta(get_current_user_id(), 'abj404_review_step');
994 } elseif ($response === 'never') {
995 // User never wants to see this - PERMANENT dismissal
996 update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
997 delete_user_meta(get_current_user_id(), 'abj404_review_step');
998 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
999 }
1000
1001 // Redirect to remove query parameter and show the appropriate notice
1002 wp_safe_redirect(remove_query_arg(array('abj404_review_response', '_wpnonce')));
1003 exit;
1004 }
1005
1006 // Handle "Going to review now" button click - PERMANENT dismissal
1007 if (isset($_GET['abj404_leaving_review'])) {
1008 $rawLeavingReviewNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
1009 $leavingReviewNonce = sanitize_text_field(self::normalizeRequestScalar($rawLeavingReviewNonce));
1010 if ($leavingReviewNonce !== '' && wp_verify_nonce($leavingReviewNonce, 'abj404_leaving_review')) {
1011 update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
1012 delete_user_meta(get_current_user_id(), 'abj404_review_step');
1013 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
1014
1015 // Open review page in new tab and redirect current page to clean URL
1016 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewRedirectScript.html");
1017 $f = abj_service('functions');
1018 $html = $f->str_replace('{review_url}', esc_js('https://wordpress.org/support/plugin/404-solution/reviews/#new-post'), $html);
1019 echo $html;
1020 wp_safe_redirect(remove_query_arg(array('abj404_leaving_review', '_wpnonce')));
1021 exit;
1022 }
1023 }
1024
1025 // Handle feedback POST submission - PERMANENT dismissal
1026 $rawFeedbackNonce = isset($_POST['abj404_feedback_nonce']) ? $_POST['abj404_feedback_nonce'] : '';
1027 $feedbackNonce = sanitize_text_field(self::normalizeRequestScalar($rawFeedbackNonce));
1028 if (isset($_POST['abj404_submit_feedback']) &&
1029 $feedbackNonce !== '' &&
1030 wp_verify_nonce($feedbackNonce, 'abj404_submit_feedback')) {
1031
1032 // Get selected issues (checkboxes) and normalize malformed inputs safely.
1033 $issuesRaw = isset($_POST['feedback_issues']) ? $_POST['feedback_issues'] : array();
1034 $issues = self::sanitizeFeedbackIssues($issuesRaw);
1035
1036 $feedbackDetailsRaw = isset($_POST['feedback_details']) ? $_POST['feedback_details'] : '';
1037 $feedback_details = sanitize_textarea_field(self::normalizeRequestScalar($feedbackDetailsRaw));
1038
1039 // Prepare feedback data
1040 $feedback_data = array(
1041 'timestamp' => current_time('mysql'),
1042 'user_id' => get_current_user_id(),
1043 'site_url' => get_site_url(),
1044 'issues' => $issues,
1045 'details' => $feedback_details,
1046 'wp_version' => get_bloginfo('version'),
1047 'plugin_version' => ABJ404_VERSION,
1048 'php_version' => PHP_VERSION
1049 );
1050
1051 // Store feedback in database
1052 $all_feedback_raw = get_option('abj404_user_feedback', array());
1053 $all_feedback = is_array($all_feedback_raw) ? $all_feedback_raw : array();
1054 $all_feedback[] = $feedback_data;
1055 update_option('abj404_user_feedback', $all_feedback);
1056
1057 // Email feedback to plugin author
1058 self::emailFeedback($feedback_data);
1059
1060 // PERMANENT dismissal - never show again
1061 update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
1062 delete_user_meta(get_current_user_id(), 'abj404_review_step');
1063 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
1064
1065 // Signal to maybeShowReviewRequest() to render the thank-you notice
1066 self::$feedbackSubmitted = true;
1067 }
1068 }
1069
1070 /** Display a review request notification after a sustained period of plugin use.
1071 * Uses a qualification question to ensure only satisfied users are directed to leave reviews.
1072 * Unhappy users are directed to provide feedback instead.
1073 *
1074 * Guarantees:
1075 * - Never shows again after user clicks "Never ask again"
1076 * - Never shows again after user clicks review link button
1077 * - Never shows again after user submits feedback
1078 * - Shows again in 7 days after "Ask again later"
1079 * - Shows again in 14 days after close "X"
1080 */
1081 /** @return void */
1082 static function maybeShowReviewRequest() {
1083 // Only show on 404 Solution plugin pages
1084 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
1085 return;
1086 }
1087
1088 // If feedback was submitted this request (processed early by handleReviewResponseRedirects),
1089 // show the thank-you notice immediately — before any other checks, since those checks
1090 // would bail because handleReviewResponseRedirects already set dismissed=permanent.
1091 if (self::$feedbackSubmitted) {
1092 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/feedbackSuccessNotice.html");
1093 echo $html;
1094 return;
1095 }
1096
1097 // Check if user permanently dismissed this
1098 $dismissed = get_user_meta(get_current_user_id(), 'abj404_review_dismissed', true);
1099 if ($dismissed === 'permanent') {
1100 return;
1101 }
1102
1103 // Check if user asked to be reminded later
1104 $remind_later = get_user_meta(get_current_user_id(), 'abj404_review_remind_later', true);
1105 if ($remind_later && time() < $remind_later) {
1106 // Not time yet to remind
1107 return;
1108 }
1109
1110 // Get plugin installation/activation time
1111 $installed_time = get_option('abj404_installed_time');
1112 if (!$installed_time) {
1113 // First time - record installation time
1114 $installed_time = time();
1115 update_option('abj404_installed_time', $installed_time);
1116 return;
1117 }
1118
1119 // Show review request after enough real usage time has passed.
1120 $days_installed = (time() - $installed_time) / 86400;
1121 if ($days_installed < self::REVIEW_INITIAL_DELAY_DAYS) {
1122 return;
1123 }
1124
1125 // Check what step we're on
1126 $review_step = get_user_meta(get_current_user_id(), 'abj404_review_step', true);
1127
1128 if ($review_step === 'show_review_link') {
1129 // Step 2a: User said YES - show review link
1130 self::showReviewLinkNotice();
1131 } elseif ($review_step === 'show_feedback') {
1132 // Step 2b: User said NOT YET - show feedback form
1133 self::showFeedbackFormNotice();
1134 } else {
1135 // Step 1: Initial qualification question
1136 self::showQualificationQuestion();
1137 }
1138 }
1139
1140 /** Email feedback to plugin author.
1141 * @param array<string, mixed> $feedback_data
1142 * @return void
1143 */
1144 private static function emailFeedback($feedback_data) {
1145 $to = '404solution@ajexperience.com';
1146 $subject = '404 Solution Feedback from ' . get_bloginfo('name');
1147
1148 $message = "New feedback received from 404 Solution plugin\n\n";
1149 $message .= "Site: " . $feedback_data['site_url'] . "\n";
1150 $message .= "Date: " . $feedback_data['timestamp'] . "\n";
1151 $message .= "WordPress Version: " . $feedback_data['wp_version'] . "\n";
1152 $message .= "Plugin Version: " . $feedback_data['plugin_version'] . "\n";
1153 $message .= "PHP Version: " . $feedback_data['php_version'] . "\n\n";
1154
1155 $message .= "Issues Selected:\n";
1156 $feedbackIssues = isset($feedback_data['issues']) && is_array($feedback_data['issues']) ? $feedback_data['issues'] : array();
1157 if (!empty($feedbackIssues)) {
1158 foreach ($feedbackIssues as $issue) {
1159 $issueStr = is_string($issue) ? $issue : (string)$issue;
1160 $message .= " - " . ucfirst(str_replace('_', ' ', $issueStr)) . "\n";
1161 }
1162 } else {
1163 $message .= " None selected\n";
1164 }
1165
1166 $message .= "\nAdditional Details:\n";
1167 $message .= $feedback_data['details'] ? $feedback_data['details'] : "(No additional details provided)\n";
1168
1169 $headers = array('Content-Type: text/plain; charset=UTF-8');
1170
1171 wp_mail($to, $subject, $message, $headers);
1172 }
1173
1174 /** Step 1: Show the initial qualification question.
1175 * @return void
1176 */
1177 private static function showQualificationQuestion() {
1178 $yes_url = wp_nonce_url(
1179 add_query_arg('abj404_review_response', 'yes'),
1180 'abj404_review_response'
1181 );
1182 $not_yet_url = wp_nonce_url(
1183 add_query_arg('abj404_review_response', 'not_yet'),
1184 'abj404_review_response'
1185 );
1186 $ask_later_url = wp_nonce_url(
1187 add_query_arg('abj404_review_response', 'ask_later'),
1188 'abj404_review_response'
1189 );
1190 $never_url = wp_nonce_url(
1191 add_query_arg('abj404_review_response', 'never'),
1192 'abj404_review_response'
1193 );
1194 $close_url = wp_nonce_url(
1195 add_query_arg('abj404_review_response', 'close_x'),
1196 'abj404_review_response'
1197 );
1198
1199 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewQualificationQuestion.html");
1200 $f = abj_service('functions');
1201 $html = $f->str_replace('{yes_url}', esc_attr($yes_url), $html);
1202 $html = $f->str_replace('{not_yet_url}', esc_attr($not_yet_url), $html);
1203 $html = $f->str_replace('{ask_later_url}', esc_attr($ask_later_url), $html);
1204 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
1205 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
1206 echo $html;
1207 }
1208
1209 /** Step 2a: User said YES - show review link and thank you.
1210 * @return void
1211 */
1212 private static function showReviewLinkNotice() {
1213 // URL that marks as done when they click to go leave review
1214 $review_link_url = wp_nonce_url(
1215 add_query_arg('abj404_leaving_review', '1'),
1216 'abj404_leaving_review'
1217 );
1218
1219 $never_url = wp_nonce_url(
1220 add_query_arg('abj404_review_response', 'never'),
1221 'abj404_review_response'
1222 );
1223 $close_url = wp_nonce_url(
1224 add_query_arg('abj404_review_response', 'close_x'),
1225 'abj404_review_response'
1226 );
1227
1228 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewLinkNotice.html");
1229 $f = abj_service('functions');
1230 $html = $f->str_replace('{review_link_url}', esc_attr($review_link_url), $html);
1231 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
1232 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
1233 echo $html;
1234 }
1235
1236 /** Step 2b: User said NOT YET - show feedback form.
1237 * @return void
1238 */
1239 private static function showFeedbackFormNotice() {
1240 $never_url = wp_nonce_url(
1241 add_query_arg('abj404_review_response', 'never'),
1242 'abj404_review_response'
1243 );
1244 $close_url = wp_nonce_url(
1245 add_query_arg('abj404_review_response', 'close_x'),
1246 'abj404_review_response'
1247 );
1248
1249 // Get nonce field HTML
1250 ob_start();
1251 wp_nonce_field('abj404_submit_feedback', 'abj404_feedback_nonce');
1252 $nonce_field = ob_get_clean();
1253 if ($nonce_field === false) { $nonce_field = ''; }
1254
1255 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/feedbackFormNotice.html");
1256 $f = abj_service('functions');
1257 $html = $f->str_replace('{nonce_field}', $nonce_field, $html);
1258 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
1259 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
1260 echo $html;
1261 }
1262
1263 /**
1264 * Safely unslash request data when wp_unslash exists and is callable.
1265 * Some test environments report wp_unslash as existing but throw when called.
1266 *
1267 * @param mixed $value
1268 * @return mixed
1269 */
1270 private static function safeWpUnslash($value) {
1271 if (!function_exists('wp_unslash')) {
1272 return $value;
1273 }
1274
1275 try {
1276 return wp_unslash($value);
1277 } catch (Throwable $e) {
1278 return $value;
1279 }
1280 }
1281
1282 /**
1283 * Normalize request input to a scalar string to avoid warnings when arrays/objects are passed.
1284 *
1285 * @param mixed $value
1286 * @return string
1287 */
1288 private static function normalizeRequestScalar($value) {
1289 $value = self::safeWpUnslash($value);
1290 if (!is_scalar($value)) {
1291 return '';
1292 }
1293 return (string)$value;
1294 }
1295
1296 /**
1297 * Normalize and sanitize feedback issue selections from request data.
1298 *
1299 * @param mixed $issuesRaw
1300 * @return array<int, string>
1301 */
1302 private static function sanitizeFeedbackIssues($issuesRaw) {
1303 $issuesRaw = self::safeWpUnslash($issuesRaw);
1304 if (!is_array($issuesRaw)) {
1305 $issuesRaw = array($issuesRaw);
1306 }
1307
1308 $issues = array();
1309 foreach ($issuesRaw as $issue) {
1310 if (is_array($issue) || is_object($issue)) {
1311 continue;
1312 }
1313 $clean = sanitize_text_field((string)$issue);
1314 if ($clean !== '') {
1315 $issues[] = $clean;
1316 }
1317 }
1318 return $issues;
1319 }
1320
1321 /** Adds a link under the "Settings" link to the plugin page.
1322 * @global string $menu
1323 * @global type $abj404dao
1324 * @global type $abj404logic
1325 * @global type $abj404logging
1326 */
1327 /** @return void */
1328 static function addMainSettingsPageLink() {
1329 global $menu;
1330
1331 // The menu must ALWAYS be registered so the admin page is accessible.
1332 // Wrap all pre-registration logic in try/catch — if anything fails
1333 // (missing tables, broken service container, etc.), fall through to
1334 // register the menu with safe defaults.
1335 $pageName = "404 Solution";
1336 $menuLocation = '';
1337
1338 try {
1339 $instance = self::getInstance();
1340
1341 if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
1342 $instance->logger->logUserCapabilities("addMainSettingsPageLink");
1343 return;
1344 }
1345
1346 // Use skip_db_check=true so menu registration never triggers
1347 // updateToNewVersion() — that can hang on slow database upgrades
1348 // and block the entire admin page from rendering.
1349 $options = $instance->logic->getOptions(true);
1350 $menuLocation = isset($options['menuLocation']) ? $options['menuLocation'] : '';
1351
1352 // Admin notice badge
1353 if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
1354 $captured = $instance->dao->getCapturedCountForNotification();
1355 if ($captured >= $options['admin_notification']) {
1356 $pageName .= " <span class='update-plugins count-1'><span class='update-count'>" . esc_html((string)$captured) . "</span></span>";
1357 if (isset($menu[80][0])) {
1358 $pos = $instance->f->strpos($menu[80][0], 'update-plugins');
1359 if ($pos === false) {
1360 $menu[80][0] = $menu[80][0] . " <span class='update-plugins count-1'><span class='update-count'>1</span></span>";
1361 }
1362 }
1363 }
1364 }
1365 } catch (\Throwable $e) {
1366 // Something failed before menu registration. Continue with defaults
1367 // so the admin page is still accessible for debugging. Surface the
1368 // failure to PHP's error log so it isn't completely invisible.
1369 error_log('404 Solution: addMainSettingsPageLink pre-registration failed: ' . $e->getMessage());
1370 }
1371
1372 if ($menuLocation === 'settingsLevel') {
1373 // this adds the settings link at the same level as the "Tools" and "Settings" menu items.
1374 $GLOBALS['abj404_settingsPageName'] = add_menu_page(PLUGIN_NAME, PLUGIN_NAME, 'manage_options', 'abj404_solution',
1375 'abj404_admin_page_callback');
1376
1377 } else {
1378 // this adds the settings link at Settings->404 Solution.
1379 $GLOBALS['abj404_settingsPageName'] = add_submenu_page('options-general.php', PLUGIN_NAME, $pageName, 'manage_options', ABJ404_PP,
1380 'abj404_admin_page_callback');
1381 }
1382 }
1383
1384 /**
1385 * AJAX handler: OAuth callback from Google (custom mode) or from the
1386 * centralized Worker (centralized mode).
1387 *
1388 * In centralized mode the Worker has already exchanged the authorization
1389 * code for tokens, so the callback URL contains access_token, refresh_token,
1390 * and expires_in as query parameters. The `abj404_gsc_centralized` flag
1391 * distinguishes the two flows.
1392 *
1393 * @return void
1394 */
1395 public static function handleGscOauthCallback() {
1396 if (!current_user_can('manage_options')) {
1397 wp_die(__('Insufficient permissions.', '404-solution'), 403);
1398 }
1399
1400 $logger = abj_service('logging');
1401 $gsc = new ABJ_404_Solution_GoogleSearchConsole($logger);
1402
1403 $isCentralized = isset($_GET['abj404_gsc_centralized']) && $_GET['abj404_gsc_centralized'] === '1';
1404
1405 if ($isCentralized) {
1406 self::handleCentralizedGscCallback($gsc);
1407 return;
1408 }
1409
1410 // --- Custom-credentials flow (original behavior) ---
1411 $code = isset($_GET['code']) ? sanitize_text_field((string)$_GET['code']) : '';
1412 $state = isset($_GET['state']) ? sanitize_text_field((string)$_GET['state']) : '';
1413
1414 // Verify state nonce to prevent CSRF.
1415 if (!wp_verify_nonce($state, 'abj404_gsc_oauth')) {
1416 wp_die(__('Security check failed.', '404-solution'), 403);
1417 }
1418
1419 if ($code === '') {
1420 // User denied access or error occurred.
1421 $gsc->setLastOAuthError(__('Authorization was denied or cancelled.', '404-solution'));
1422 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
1423 exit;
1424 }
1425
1426 $error = $gsc->exchangeCodeForToken($code);
1427
1428 if ($error !== '') {
1429 $gsc->setLastOAuthError($error);
1430 }
1431 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
1432 exit;
1433 }
1434
1435 /**
1436 * Handle the centralized OAuth callback. Tokens arrive as URL parameters
1437 * from the Worker, so no code exchange is needed.
1438 *
1439 * @param ABJ_404_Solution_GoogleSearchConsole $gsc
1440 * @return void
1441 */
1442 private static function handleCentralizedGscCallback(ABJ_404_Solution_GoogleSearchConsole $gsc): void {
1443 $nonce = isset($_GET['nonce']) ? sanitize_text_field((string)$_GET['nonce']) : '';
1444
1445 // Verify round-tripped nonce for CSRF protection.
1446 if (!wp_verify_nonce($nonce, 'abj404_gsc_oauth')) {
1447 wp_die(__('Security check failed.', '404-solution'), 403);
1448 }
1449
1450 // Check for error from the Worker.
1451 $error = isset($_GET['abj404_gsc_error']) ? sanitize_text_field((string)$_GET['abj404_gsc_error']) : '';
1452 if ($error !== '') {
1453 $gsc->setLastOAuthError($error);
1454 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
1455 exit;
1456 }
1457
1458 $accessToken = isset($_GET['access_token']) ? sanitize_text_field((string)$_GET['access_token']) : '';
1459 $refreshToken = isset($_GET['refresh_token']) ? sanitize_text_field((string)$_GET['refresh_token']) : '';
1460 $expiresIn = isset($_GET['expires_in']) ? (int)$_GET['expires_in'] : 3600;
1461
1462 if ($accessToken === '') {
1463 $gsc->setLastOAuthError(__('No access token received from authorization.', '404-solution'));
1464 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
1465 exit;
1466 }
1467
1468 $gsc->storeCentralizedTokens($accessToken, $refreshToken, $expiresIn);
1469
1470 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
1471 exit;
1472 }
1473
1474 /**
1475 * AJAX handler: revoke GSC authorization.
1476 * @return void
1477 */
1478 public static function handleGscRevoke() {
1479 if (!current_user_can('manage_options') || !check_admin_referer('abj404_gsc_revoke')) {
1480 wp_die(__('Security check failed.', '404-solution'), 403);
1481 }
1482
1483 $logger = abj_service('logging');
1484 $gsc = new ABJ_404_Solution_GoogleSearchConsole($logger);
1485 $gsc->revokeAuthorization();
1486
1487 wp_safe_redirect(admin_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'));
1488 exit;
1489 }
1490
1491 }
1492