PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / SetupWizard.php

SetupWizard.php in 404 Solution 4.2.0, at includes/SetupWizard.php

498 lines 22.0 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 /**
9 * Setup Wizard for first-time plugin configuration
10 * Shows a welcome modal on first visit to 404 Solution admin pages
11 *
12 * @since 3.0.5
13 */
14 class ABJ_404_Solution_SetupWizard {
15
16 /**
17 * Option name for storing setup completion date
18 */
19 const OPTION_NAME = 'abj404_setup_completed';
20
21 /**
22 * Initialize the setup wizard functionality
23 * @return void
24 */
25 public static function init(): void {
26 // Handle form submission immediately (must run before any output)
27 // This is called early during plugin load, so we check and handle here
28 if (is_admin() && isset($_POST['abj404_setup_wizard_action'])) {
29 // Use admin_init to ensure WordPress is fully loaded for nonce verification
30 add_action('admin_init', array(__CLASS__, 'handleFormSubmission'), 1);
31 }
32
33 // AJAX handler for skip/close (no page reload needed)
34 add_action('wp_ajax_abj404_dismiss_setup_wizard', array(__CLASS__, 'handleAjaxDismiss'));
35
36 // Enqueue assets and output modal on 404 Solution pages
37 add_action('admin_enqueue_scripts', array(__CLASS__, 'enqueueAssets'));
38 }
39
40 /**
41 * Handle AJAX dismiss (skip/close) - no settings changed, just mark complete
42 * @return void
43 */
44 public static function handleAjaxDismiss(): void {
45 // Verify nonce
46 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'abj404_setup_wizard')) {
47 wp_send_json_error(array('message' => __('Invalid security token', '404-solution')), 403);
48 }
49
50 // Verify user capabilities
51 if (!current_user_can('manage_options')) {
52 wp_send_json_error(array('message' => __('Insufficient permissions', '404-solution')), 403);
53 }
54
55 // Mark setup as complete
56 update_option(self::OPTION_NAME, gmdate('Y-m-d'));
57
58 // Response is intentionally minimal; the UI uses a fire-and-forget request.
59 wp_send_json_success(array('message' => ''));
60 }
61
62 /**
63 * Check if setup wizard should be shown
64 *
65 * @return bool True if wizard should display
66 */
67 private static function shouldShowWizard() {
68 // Only show if setup hasn't been completed
69 // Existing users upgrading from <3.0.7 have this set via migration in PluginLogic.php
70 $completed = get_option(self::OPTION_NAME, '');
71 return empty($completed);
72 }
73
74 /**
75 * Check if current page is a 404 Solution admin page
76 *
77 * @return bool True if on 404 Solution page
78 */
79 private static function isPluginPage() {
80 if (!is_admin()) {
81 return false;
82 }
83
84 // Check for the plugin's page parameter
85 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
86 return $page === 'abj404_solution';
87 }
88
89 /**
90 * Handle form submission for setup wizard
91 * @return void
92 */
93 public static function handleFormSubmission(): void {
94 // Check if this is our form submission
95 if (!isset($_POST['abj404_setup_wizard_action'])) {
96 return;
97 }
98
99 // Verify nonce with error feedback (Bug #10 fix)
100 if (!isset($_POST['abj404_setup_wizard_nonce']) ||
101 !wp_verify_nonce($_POST['abj404_setup_wizard_nonce'], 'abj404_setup_wizard')) {
102 wp_die(
103 esc_html__('Security check failed. Please try again.', '404-solution'),
104 esc_html__('Error', '404-solution'),
105 array('response' => 403, 'back_link' => true)
106 );
107 }
108
109 // Verify user capabilities with error feedback (Bug #10 fix)
110 if (!current_user_can('manage_options')) {
111 wp_die(
112 esc_html__('You do not have permission to access this page.', '404-solution'),
113 esc_html__('Error', '404-solution'),
114 array('response' => 403, 'back_link' => true)
115 );
116 }
117
118 $action = sanitize_text_field($_POST['abj404_setup_wizard_action']);
119
120 // All actions mark setup as complete
121 update_option(self::OPTION_NAME, gmdate('Y-m-d'));
122
123 // If user clicked "Save & Get Started", apply their settings
124 if ($action === 'save') {
125 self::applySettings();
126 }
127
128 // Determine redirect destination based on logging choice
129 $q2_answer = isset($_POST['abj404_setup_q2']) ? sanitize_text_field($_POST['abj404_setup_q2']) : 'yes';
130 $redirect_url = 'options-general.php?page=abj404_solution&setup_complete=1';
131
132 // If logging 404s, take them to Captured 404s tab; otherwise Page Redirects
133 if ($q2_answer === 'yes') {
134 $redirect_url .= '&subpage=abj404_captured';
135 }
136
137 wp_safe_redirect(admin_url($redirect_url));
138 exit;
139 }
140
141 /** Allowed values for Q1 (Bug #13 fix)
142 * @var array<int, string>
143 */
144 private static $allowedQ1Values = ['redirect', 'default'];
145
146 /** Allowed values for Q2 (Bug #13 fix)
147 * @var array<int, string>
148 */
149 private static $allowedQ2Values = ['yes', 'no'];
150
151 /** Allowed values for Q3
152 * @var array<int, string>
153 */
154 private static $allowedQ3Values = ['yes', 'no'];
155
156 /**
157 * Apply settings from wizard form
158 * @return void
159 */
160 private static function applySettings(): void {
161 $abj404logic = abj_service('plugin_logic');
162 $options = $abj404logic->getOptions();
163
164 // Question 1: What happens when page not found
165 // Validate against whitelist (Bug #13 fix)
166 $q1_answer = isset($_POST['abj404_setup_q1']) ? sanitize_text_field($_POST['abj404_setup_q1']) : 'redirect';
167 if (!in_array($q1_answer, self::$allowedQ1Values, true)) {
168 $q1_answer = 'redirect'; // Default to safe value
169 }
170
171 if ($q1_answer === 'redirect') {
172 // Automatically redirect to similar page when a match is found
173 $options['auto_redirects'] = '1';
174 $options['auto_cats'] = '1';
175 $options['auto_tags'] = '1';
176 } else {
177 // Just show the default 404 page - only use manual redirects
178 $options['auto_redirects'] = '0';
179 $options['auto_cats'] = '0';
180 $options['auto_tags'] = '0';
181 }
182 $options['dest404page'] = '0|' . ABJ404_TYPE_404_DISPLAYED;
183
184 // Question 2: Log 404s
185 // Validate against whitelist (Bug #13 fix)
186 $q2_answer = isset($_POST['abj404_setup_q2']) ? sanitize_text_field($_POST['abj404_setup_q2']) : 'yes';
187 if (!in_array($q2_answer, self::$allowedQ2Values, true)) {
188 $q2_answer = 'yes'; // Default to safe value
189 }
190
191 $options['capture_404'] = ($q2_answer === 'yes') ? '1' : '0';
192
193 // Question 3: Email alerts
194 $q3_answer = isset($_POST['abj404_setup_q3']) ? sanitize_text_field($_POST['abj404_setup_q3']) : 'yes';
195 if (!in_array($q3_answer, self::$allowedQ3Values, true)) {
196 $q3_answer = 'yes';
197 }
198
199 if ($q3_answer === 'yes') {
200 $options['admin_notification'] = '50';
201 $options['admin_notification_frequency'] = 'weekly';
202 $admin_email = get_option('admin_email');
203 $options['admin_notification_email'] = is_string($admin_email) ? $admin_email : '';
204 }
205
206 // Save options
207 $abj404logic->updateOptions($options);
208 }
209
210 /**
211 * Enqueue assets on 404 Solution admin pages
212 *
213 * @param string $hook Current admin page hook
214 * @return void
215 */
216 public static function enqueueAssets(string $hook): void {
217 // Only load on 404 Solution pages
218 if (!self::isPluginPage()) {
219 return;
220 }
221
222 // Only load if wizard should be shown
223 if (!self::shouldShowWizard()) {
224 return;
225 }
226
227 // Only for users who can manage options
228 if (!current_user_can('manage_options')) {
229 return;
230 }
231
232 // Add inline styles for the modal
233 add_action('admin_head', array(__CLASS__, 'outputStyles'));
234
235 // Output modal HTML in footer
236 add_action('admin_footer', array(__CLASS__, 'outputModalHTML'));
237
238 // Output JavaScript for dismiss functionality
239 add_action('admin_footer', array(__CLASS__, 'outputScript'), 20);
240 }
241
242 /**
243 * Output modal CSS styles
244 * @return void
245 */
246 public static function outputStyles(): void {
247 $css = ABJ_404_Solution_Functions::readFileContents(__DIR__ . '/html/setupWizardStyles.css');
248 echo '<style>' . $css . '</style>';
249 }
250
251 /**
252 * Output the modal HTML structure
253 * @return void
254 */
255 public static function outputModalHTML(): void {
256 ?>
257 <div id="abj404-setup-wizard" class="abj404-setup-overlay">
258 <div class="abj404-setup-modal">
259 <form method="post" action="">
260 <?php wp_nonce_field('abj404_setup_wizard', 'abj404_setup_wizard_nonce'); ?>
261
262 <div class="abj404-setup-header">
263 <h2><?php esc_html_e('Welcome to 404 Solution', '404-solution'); ?></h2>
264 <button type="button" id="abj404-setup-close" class="abj404-setup-close" title="<?php esc_attr_e('Close', '404-solution'); ?>">&times;</button>
265 </div>
266
267 <div class="abj404-setup-content">
268 <p class="abj404-setup-intro">
269 <?php esc_html_e('404 Solution helps you automatically handle 404 errors and broken links on your site.', '404-solution'); ?>
270 <?php esc_html_e("Let's configure how it handles missing pages. You can always change these settings later.", '404-solution'); ?>
271 </p>
272
273 <!-- Question 1: What happens when page not found -->
274 <div class="abj404-setup-question">
275 <h3><?php esc_html_e('When a page is not found, what should happen?', '404-solution'); ?></h3>
276 <div class="abj404-setup-options">
277 <label class="abj404-setup-option">
278 <input type="radio" name="abj404_setup_q1" value="redirect" checked>
279 <span class="abj404-setup-option-text">
280 <span class="abj404-setup-option-label"><?php esc_html_e('Automatically redirect to similar page (recommended)', '404-solution'); ?></span>
281 <span class="abj404-setup-option-desc"><?php esc_html_e('When a match is found, redirect visitors automatically', '404-solution'); ?></span>
282 </span>
283 </label>
284 <label class="abj404-setup-option">
285 <input type="radio" name="abj404_setup_q1" value="default">
286 <span class="abj404-setup-option-text">
287 <span class="abj404-setup-option-label"><?php esc_html_e('Just show the default 404 page', '404-solution'); ?></span>
288 <span class="abj404-setup-option-desc"><?php esc_html_e("Use WordPress's standard \"Page not found\" screen. Manual redirects still work.", '404-solution'); ?></span>
289 </span>
290 </label>
291 </div>
292 </div>
293
294 <!-- Question 2: Log 404s -->
295 <div class="abj404-setup-question">
296 <h3><?php esc_html_e('Log 404 errors for review?', '404-solution'); ?></h3>
297 <div class="abj404-setup-options">
298 <label class="abj404-setup-option">
299 <input type="radio" name="abj404_setup_q2" value="yes" checked>
300 <span class="abj404-setup-option-text">
301 <span class="abj404-setup-option-label"><?php esc_html_e('Yes, log 404 errors', '404-solution'); ?></span>
302 <span class="abj404-setup-option-desc"><?php esc_html_e('Track missing pages so you can create redirects later', '404-solution'); ?></span>
303 </span>
304 </label>
305 <label class="abj404-setup-option">
306 <input type="radio" name="abj404_setup_q2" value="no">
307 <span class="abj404-setup-option-text">
308 <span class="abj404-setup-option-label"><?php esc_html_e("No, don't log 404s", '404-solution'); ?></span>
309 <span class="abj404-setup-option-desc"><?php esc_html_e('Only handle manually created redirects', '404-solution'); ?></span>
310 </span>
311 </label>
312 </div>
313 </div>
314
315 <!-- Question 3: Email alerts -->
316 <div class="abj404-setup-question">
317 <h3><?php esc_html_e('Get email alerts about 404 problems?', '404-solution'); ?></h3>
318 <div class="abj404-setup-options">
319 <label class="abj404-setup-option">
320 <input type="radio" name="abj404_setup_q3" value="yes" checked>
321 <span class="abj404-setup-option-text">
322 <span class="abj404-setup-option-label"><?php esc_html_e('Yes, email me a weekly summary (recommended)', '404-solution'); ?></span>
323 <span class="abj404-setup-option-desc"><?php esc_html_e('Get notified when captured 404 URLs exceed 50', '404-solution'); ?></span>
324 </span>
325 </label>
326 <label class="abj404-setup-option">
327 <input type="radio" name="abj404_setup_q3" value="no">
328 <span class="abj404-setup-option-text">
329 <span class="abj404-setup-option-label"><?php esc_html_e("No, I'll check manually", '404-solution'); ?></span>
330 <span class="abj404-setup-option-desc"><?php esc_html_e('You can always enable email alerts later in Options', '404-solution'); ?></span>
331 </span>
332 </label>
333 </div>
334 </div>
335 </div>
336
337 <div class="abj404-setup-footer">
338 <button type="button" id="abj404-setup-skip" class="abj404-setup-skip">
339 <?php esc_html_e('Skip Setup', '404-solution'); ?>
340 </button>
341 <!-- Hidden input ensures action is sent even if button is disabled during submit -->
342 <input type="hidden" name="abj404_setup_wizard_action" value="save">
343 <button type="submit" class="button abj404-setup-primary">
344 <?php esc_html_e('Save & Get Started', '404-solution'); ?>
345 </button>
346 </div>
347 </form>
348 </div>
349 </div>
350 <?php
351 }
352
353 /**
354 * Output JavaScript for dismiss and save functionality
355 * @return void
356 */
357 public static function outputScript(): void {
358 ?>
359 <script>
360 (function() {
361 var overlay = document.getElementById('abj404-setup-wizard');
362 var closeBtn = document.getElementById('abj404-setup-close');
363 var skipBtn = document.getElementById('abj404-setup-skip');
364 var saveBtn = document.querySelector('.abj404-setup-primary');
365 var form = document.querySelector('#abj404-setup-wizard form');
366
367 // Bug #9 fix: Null check for nonce element
368 var nonceEl = document.getElementById('abj404_setup_wizard_nonce');
369 var nonce = nonceEl ? nonceEl.value : '';
370
371 // Bug #26 fix: Track dismiss state to prevent multiple calls
372 var isDismissing = false;
373 var isSubmitting = false;
374
375 function dismissWizard() {
376 // Bug #26 fix: Prevent multiple rapid dismissals
377 if (isDismissing) {
378 return;
379 }
380 isDismissing = true;
381
382 // Disable buttons to prevent further clicks
383 if (closeBtn) closeBtn.disabled = true;
384 if (skipBtn) skipBtn.disabled = true;
385
386 // Remove modal immediately
387 if (overlay) {
388 overlay.remove();
389 }
390
391 // Bug #9 fix: Don't send AJAX if no nonce
392 if (!nonce) {
393 showToast(<?php echo wp_json_encode(__('Could not save settings - missing security token. The wizard may appear again on next visit.', '404-solution')); ?>);
394 return;
395 }
396
397 // Fire AJAX to mark as complete with error handling
398 var xhr = new XMLHttpRequest();
399 xhr.open('POST', ajaxurl, true);
400 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
401 xhr.onload = function() {
402 if (xhr.status !== 200) {
403 showToast(<?php echo wp_json_encode(__('Could not save dismissal. The wizard may appear again on next visit.', '404-solution')); ?>);
404 return;
405 }
406 try {
407 var response = JSON.parse(xhr.responseText);
408 if (!response.success) {
409 var msg = response.data && response.data.message ? response.data.message : '';
410 showToast(<?php echo wp_json_encode(__('Could not save dismissal: ', '404-solution')); ?> + msg + <?php echo wp_json_encode(__(' The wizard may appear again on next visit.', '404-solution')); ?>);
411 }
412 } catch (e) {
413 showToast(<?php echo wp_json_encode(__('Could not save dismissal. The wizard may appear again on next visit.', '404-solution')); ?>);
414 }
415 };
416 xhr.onerror = function() {
417 showToast(<?php echo wp_json_encode(__('Network error - could not save dismissal. The wizard may appear again on next visit.', '404-solution')); ?>);
418 };
419 xhr.send('action=abj404_dismiss_setup_wizard&nonce=' + encodeURIComponent(nonce));
420 }
421
422 function showToast(message) {
423 // Remove any existing toast
424 var existingToast = document.querySelector('.abj404-toast');
425 if (existingToast) {
426 existingToast.remove();
427 }
428
429 // Create toast element using DOM methods
430 var toast = document.createElement('div');
431 toast.className = 'abj404-toast';
432 toast.setAttribute('role', 'alert');
433
434 var closeBtn = document.createElement('button');
435 closeBtn.className = 'abj404-toast-close';
436 closeBtn.setAttribute('aria-label', <?php echo wp_json_encode(__('Close', '404-solution')); ?>);
437 closeBtn.textContent = '\u00D7';
438 closeBtn.onclick = function() { toast.remove(); };
439
440 var textNode = document.createTextNode(message);
441
442 toast.appendChild(closeBtn);
443 toast.appendChild(textNode);
444 document.body.appendChild(toast);
445
446 // Auto-remove after 10 seconds
447 setTimeout(function() {
448 if (toast && toast.parentNode) {
449 toast.remove();
450 }
451 }, 10000);
452 }
453
454 function showSavingOverlay() {
455 // Bug #26 fix: Prevent multiple submissions
456 if (isSubmitting) {
457 return false;
458 }
459 isSubmitting = true;
460
461 // Disable buttons
462 if (saveBtn) saveBtn.disabled = true;
463 if (skipBtn) skipBtn.disabled = true;
464 if (closeBtn) closeBtn.disabled = true;
465
466 // Bug #19 fix: Use DOM methods instead of innerHTML
467 var loadingOverlay = document.createElement('div');
468 loadingOverlay.className = 'abj404-setup-loading';
469
470 var spinner = document.createElement('div');
471 spinner.className = 'abj404-setup-spinner';
472 loadingOverlay.appendChild(spinner);
473
474 var loadingText = document.createElement('span');
475 loadingText.textContent = <?php echo wp_json_encode(__('Saving...', '404-solution')); ?>;
476 loadingOverlay.appendChild(loadingText);
477
478 var modal = overlay ? overlay.querySelector('.abj404-setup-modal') : null;
479 if (modal) {
480 modal.appendChild(loadingOverlay);
481 }
482 }
483
484 if (closeBtn) {
485 closeBtn.addEventListener('click', dismissWizard);
486 }
487 if (skipBtn) {
488 skipBtn.addEventListener('click', dismissWizard);
489 }
490 if (form) {
491 form.addEventListener('submit', showSavingOverlay);
492 }
493 })();
494 </script>
495 <?php
496 }
497 }
498