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 / PluginLogicTrait_SettingsUpdate.php

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

916 lines 42.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Settings update helpers: table options, POST sanitization, options-from-POST pipeline.
9 * Used by ABJ_404_Solution_PluginLogic via `use`.
10 */
11 trait ABJ_404_Solution_PluginLogicTrait_SettingsUpdate {
12 /**
13 * Read a scalar query parameter directly from REQUEST_URI.
14 * This is a defensive fallback for environments where superglobals can
15 * miss or mangle specific keys (for example repeated keys becoming arrays).
16 *
17 * @param string $name
18 * @return string
19 */
20 private function getQueryParamFromRequestUri($name) {
21 if (!is_string($name) || $name === '') {
22 return '';
23 }
24 $requestUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
25 if ($requestUri === '') {
26 return '';
27 }
28 $queryString = parse_url($requestUri, PHP_URL_QUERY);
29 if (!is_string($queryString) || $queryString === '') {
30 return '';
31 }
32 $query = array();
33 parse_str($queryString, $query);
34 if (!array_key_exists($name, $query) || !is_scalar($query[$name])) {
35 return '';
36 }
37 return sanitize_text_field((string)$query[$name]);
38 }
39
40 /**
41 * @param string $pageBeingViewed
42 * @return array<string, mixed>
43 */
44 function getTableOptions(string $pageBeingViewed): array {
45 $tableOptions = array();
46 $options = $this->getOptions(true);
47
48 $translationArray = array(
49 '{ABJ404_STATUS_MANUAL_text}' => __('Man', '404-solution'),
50 '{ABJ404_STATUS_AUTO_text}' => __('Auto', '404-solution'),
51 '{ABJ404_STATUS_REGEX_text}' => __('RegEx', '404-solution'),
52 '{ABJ404_TYPE_EXTERNAL_text}' => __('External', '404-solution'),
53 '{ABJ404_TYPE_CAT_text}' => __('Category', '404-solution'),
54 '{ABJ404_TYPE_TAG_text}' => __('Tag', '404-solution'),
55 '{ABJ404_TYPE_HOME_text}' => __('Home Page', '404-solution'),
56 '{ABJ404_TYPE_404_DISPLAYED_text}' => __('(Default 404 Page)', '404-solution'),
57 '{ABJ404_TYPE_SPECIAL_text}' => __('(Special)', '404-solution'),
58 );
59
60 $tableOptions['translations'] = $translationArray;
61
62 $rawFilter = $this->dao->getPostOrGetSanitize("filter", "");
63 if ($rawFilter === "") {
64 if ($this->dao->getPostOrGetSanitize('subpage') == 'abj404_captured') {
65 $tableOptions['filter'] = ABJ404_STATUS_CAPTURED;
66 } else {
67 $tableOptions['filter'] = 0;
68 }
69 } else {
70 $tableOptions['filter'] = intval($rawFilter);
71 }
72
73 $tableOptions['filterText'] = trim($this->dao->getPostOrGetSanitize("filterText", ""));
74 // Remove comment markers early to prevent filterText from breaking SQL comments.
75 $tableOptions['filterText'] = $this->f->str_replace(array('*', '/', '$'), '', $tableOptions['filterText']);
76
77 $orderbyInput = $this->dao->getPostOrGetSanitize('orderby', "");
78 if ($orderbyInput != "" && in_array($orderbyInput, self::$allowedOrderbyColumns, true)) {
79 $tableOptions['orderby'] = $orderbyInput;
80
81 if ($pageBeingViewed == 'abj404_redirects') {
82 $options['page_redirects_order_by'] = $tableOptions['orderby'];
83 $this->updateOptions($options);
84
85 } else if ($pageBeingViewed == 'abj404_captured') {
86 $options['captured_order_by'] = $tableOptions['orderby'];
87 $this->updateOptions($options);
88 }
89
90 } else if ($pageBeingViewed == "abj404_logs") {
91 $tableOptions['orderby'] = "timestamp";
92 } else if ($pageBeingViewed == 'abj404_redirects') {
93 $savedRedirectsOrderBy = isset($options['page_redirects_order_by']) && is_scalar($options['page_redirects_order_by'])
94 ? (string)$options['page_redirects_order_by'] : 'url';
95 $tableOptions['orderby'] = in_array($savedRedirectsOrderBy, self::$allowedOrderbyColumns, true)
96 ? $savedRedirectsOrderBy : 'url';
97 } else if ($pageBeingViewed == 'abj404_captured') {
98 $savedCapturedOrderBy = isset($options['captured_order_by']) && is_scalar($options['captured_order_by'])
99 ? (string)$options['captured_order_by'] : 'timestamp';
100 $tableOptions['orderby'] = in_array($savedCapturedOrderBy, self::$allowedOrderbyColumns, true)
101 ? $savedCapturedOrderBy : 'timestamp';
102 } else {
103 $tableOptions['orderby'] = 'url';
104 }
105
106 $orderInput = strtoupper($this->dao->getPostOrGetSanitize('order', ''));
107 if ($orderInput != '' && in_array($orderInput, self::$allowedOrderValues, true)) {
108 $tableOptions['order'] = $orderInput;
109
110 if ($pageBeingViewed == 'abj404_redirects') {
111 $options['page_redirects_order'] = $tableOptions['order'];
112 $this->updateOptions($options);
113
114 } else if ($pageBeingViewed == 'abj404_captured') {
115 $options['captured_order'] = $tableOptions['order'];
116 $this->updateOptions($options);
117 }
118
119 } else if ($tableOptions['orderby'] == "created" || $tableOptions['orderby'] == "lastused" || $tableOptions['orderby'] == "timestamp") {
120 $tableOptions['order'] = "DESC";
121
122 } else if ($pageBeingViewed == 'abj404_redirects') {
123 $savedRedirectsOrder = isset($options['page_redirects_order']) && is_scalar($options['page_redirects_order'])
124 ? strtoupper((string)$options['page_redirects_order']) : 'ASC';
125 $tableOptions['order'] = in_array($savedRedirectsOrder, self::$allowedOrderValues, true)
126 ? $savedRedirectsOrder : 'ASC';
127
128 } else if ($pageBeingViewed == 'abj404_captured') {
129 $savedCapturedOrder = isset($options['captured_order']) && is_scalar($options['captured_order'])
130 ? strtoupper((string)$options['captured_order']) : 'DESC';
131 $tableOptions['order'] = in_array($savedCapturedOrder, self::$allowedOrderValues, true)
132 ? $savedCapturedOrder : 'DESC';
133
134 } else {
135 $tableOptions['order'] = "ASC";
136 }
137
138 // Prefer DAO helper (GET/POST), but fall back to REQUEST_URI query parsing for
139 // environments where 'paged' may not survive as a scalar in superglobals.
140 $paged = $this->dao->getPostOrGetSanitize("paged", '');
141 if ($paged === '') {
142 $paged = $this->getQueryParamFromRequestUri('paged');
143 }
144 $tableOptions['paged'] = ($paged === '') ? '1' : $paged;
145
146 $perPageOption = ABJ404_OPTION_DEFAULT_PERPAGE;
147 if (isset($options['perpage'])) {
148 $perPageOption = max(absint(is_scalar($options['perpage']) ? $options['perpage'] : 0), ABJ404_OPTION_MIN_PERPAGE);
149 }
150 $tableOptions['perpage'] = $this->dao->getPostOrGetSanitize("perpage", (string)$perPageOption);
151
152 $tableOptions['logsid'] = 0;
153 if ($this->dao->getPostOrGetSanitize('subpage') == "abj404_logs") {
154 $logId = (string)$this->dao->getPostOrGetSanitize('id', '');
155 if ($this->f->regexMatch('[0-9]+', $logId)) {
156 $tableOptions['logsid'] = absint($logId);
157
158 } else {
159 $redirectToDataFieldId = (string)$this->dao->getPostOrGetSanitize('redirect_to_data_field_id', '');
160 if ($this->f->regexMatch('[0-9]+', $redirectToDataFieldId)) {
161 $tableOptions['logsid'] = absint($redirectToDataFieldId);
162 }
163 }
164 }
165
166 // Score range filter (high / medium / low / manual / all).
167 $rawScoreRange = (string)$this->dao->getPostOrGetSanitize('score_range', 'all');
168 $allowedScoreRanges = array('all', 'high', 'medium', 'low', 'manual');
169 $tableOptions['score_range'] = in_array($rawScoreRange, $allowedScoreRanges, true) ? $rawScoreRange : 'all';
170
171 // Developer/admin diagnostic: force a fresh staged view_done rebuild
172 // for the current AJAX table load. This is intentionally hidden behind
173 // an explicit request flag rather than a normal option.
174 $forceViewRebuild = (string)$this->dao->getPostOrGetSanitize('forceViewRebuild', '');
175 if ($forceViewRebuild === '') {
176 $forceViewRebuild = (string)$this->dao->getPostOrGetSanitize('abj404_force_view_rebuild', '');
177 }
178 if ($forceViewRebuild === '1') {
179 $tableOptions['_abj404_force_view_rebuild'] = '1';
180 }
181
182 // sanitize all values.
183 $sanitizedTableOptions = $this->sanitizePostData($tableOptions);
184
185 return $sanitizedTableOptions;
186 }
187
188 /**
189 * @param array<string, mixed> $postData
190 * @param bool $restoreNewlines
191 * @return array<string, mixed>
192 */
193 function sanitizePostData(array $postData, bool $restoreNewlines = false): array {
194 $newData = array();
195 foreach ($postData as $key => $value) {
196 $key = wp_kses_post($key);
197 if (is_array($value)) {
198 $newData[$key] = $this->sanitizePostData($value, $restoreNewlines);
199 } else {
200 // Handle null values (PHP 8.1+ deprecation fix)
201 if ($value === null) {
202 $newData[$key] = '';
203 } else {
204 $valueStr = is_string($value) ? $value : (is_scalar($value) ? (string)$value : '');
205 $newData[$key] = wp_kses_post($valueStr);
206 $newData[$key] = esc_sql($newData[$key]);
207 if ($restoreNewlines) {
208 $newData[$key] = str_replace('\n', "\n", $newData[$key]);
209 }
210 }
211 }
212 }
213 return $newData;
214 }
215
216 /** Remove non a-zA-Z0-9 or _ characters.
217 * @param string $str
218 * @return string
219 */
220 function sanitizeForSQL($str) {
221 if ($str == null || $str == '') {
222 return '';
223 }
224 $re = '/[^\w_]/';
225
226 $result = preg_replace($re, '', $str);
227 return is_string($result) ? $result : $str;
228 }
229
230 /**
231 * @return array<string, mixed>
232 */
233 function updateOptionsFromPOST() {
234 $message = "";
235 $options = $this->getOptions();
236
237 // to return after handling the ajax call.
238 $returnData = array();
239 $returnData['newURL'] = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options';
240
241 // get the submitted settings
242 if (!isset($_POST['encodedData'])) {
243 $this->logger->errorMessage('Missing encodedData in POST');
244 return array(
245 'success' => false,
246 'status' => 400,
247 'message' => 'Missing form data',
248 );
249 }
250
251 $encodedData = $_POST['encodedData'];
252 $postData = $this->f->decodeComplicatedData($encodedData);
253 if (!is_array($postData)) {
254 $this->logger->errorMessage('Invalid JSON encodedData in POST');
255 return array(
256 'success' => false,
257 'status' => 400,
258 'message' => 'Missing form data',
259 );
260 }
261
262 // verify nonce (defense-in-depth; Ajax_Php already verifies for admin-ajax calls)
263 $nonce = isset($postData['nonce']) ? $postData['nonce'] : '';
264 if (!wp_verify_nonce($nonce, 'abj404UpdateOptions') || !is_admin()) {
265 return array(
266 'success' => false,
267 'status' => 403,
268 'message' => 'Invalid security token',
269 );
270 }
271
272 $_POST = $postData;
273
274 // delete the debug file if requested.
275 if (array_key_exists('deleteDebugFile', $_POST) && $_POST['deleteDebugFile'] == true) {
276 $sub = '';
277 $returnData['error'] = '';
278 $returnData['message'] = $this->handlePluginAction('updateOptions', $sub);
279
280 } else {
281 // save all options - grouped by related functionality
282 $message .= $this->updateRedirectSettings($options, $_POST);
283 $message .= $this->updateWordPressSettings($options, $_POST);
284 $message .= $this->updateNotificationSettings($options, $_POST);
285 $message .= $this->updateDeletionSettings($options, $_POST);
286 $message .= $this->updateSuggestionSettings($options, $_POST);
287 $message .= $this->updateBooleanToggles($options, $_POST);
288 $message .= $this->updateSuggestionHTMLOptions($options, $_POST);
289 $message .= $this->updateRegexPatternSettings($options, $_POST);
290 $message .= $this->updateAdminUsers($options, $_POST);
291 $message .= $this->updateExcludedPages($options, $_POST);
292
293 // save this for later to sanitize it ourselves.
294 $excludedPages = $options['excludePages[]'];
295
296 /** Sanitize all data. */
297 $new_options = array();
298 // when sanitizing data we keep the newlines (\n) because some data
299 // is entered that way and it shouldn't allow any kind of sql
300 // injection or any other security issues that I foresee at this point.
301 $new_options = $this->sanitizePostData($options, true);
302
303 // only some characters in the string.
304 $excludedPages = $excludedPages == null ? '' : trim($excludedPages);
305 $excludedPages = preg_replace('/[^\[\",\]a-zA-Z\d\|\\\\ ]/', '', $excludedPages);
306 $new_options['excludePages[]'] = $excludedPages;
307
308 $this->updateOptions($new_options);
309
310 // update the permalink cache because the post types included may have changed.
311 $permalinkCache = abj_service('permalink_cache');
312 $permalinkCache->updatePermalinkCache(2);
313
314 $returnData['error'] = $message;
315 if ($message == "") {
316 $returnData['message'] = __('Options Saved Successfully!', '404-solution');
317 } else {
318 $returnData['message'] = __('Some options were not saved successfully.', '404-solution') .
319 ' ' . $message;
320 }
321 }
322
323 return array(
324 'success' => true,
325 'status' => 200,
326 'data' => $returnData,
327 );
328 }
329
330 /** Update redirect-related settings.
331 * @param array<string, mixed> $options The options array to update
332 * @param array<string, mixed> $postData The POST data
333 * @return string Any error messages
334 */
335 private function updateRedirectSettings(array &$options, array $postData): string {
336 $message = "";
337
338 if (isset($postData['default_redirect'])) {
339 $validDefaultCodes = array('301', '302', '307', '308');
340 if (in_array((string)(is_scalar($postData['default_redirect']) ? $postData['default_redirect'] : ''), $validDefaultCodes, true)) {
341 $options['default_redirect'] = is_scalar($postData['default_redirect']) ? intval($postData['default_redirect']) : 301;
342 } else {
343 $message .= __('Error: Invalid value specified for default redirect type', '404-solution') . ".<BR/>";
344 }
345 }
346
347 // Handle behavior tile selection
348 if (isset($postData['dest404_behavior'])) {
349 $validBehaviors = array('suggest', 'homepage', 'custom', 'theme_default');
350 $behavior = sanitize_text_field(is_string($postData['dest404_behavior']) ? $postData['dest404_behavior'] : '');
351 if (in_array($behavior, $validBehaviors, true)) {
352 $options['dest404_behavior'] = $behavior;
353 $message .= $this->applyBehaviorToDest404Page($options, $behavior, $postData);
354 } else {
355 $message .= __('Error: Invalid 404 behavior selected', '404-solution') . ".<BR/>";
356 }
357 } else {
358 // Legacy: handle direct redirect_to_data_field_id (for backward compat)
359 $candidateUrlLegacy = null;
360 if (isset($postData['redirect_to_data_field_title'])) {
361 $candidateUrlLegacy = sanitize_text_field(is_string($postData['redirect_to_data_field_title']) ? $postData['redirect_to_data_field_title'] : '');
362 if (strlen($candidateUrlLegacy) > ABJ404_MAX_URL_LENGTH) {
363 $message .= sprintf(__('Error: 404 destination URL exceeds the maximum length of %d characters', '404-solution'), ABJ404_MAX_URL_LENGTH) . ".<BR/>";
364 $candidateUrlLegacy = null;
365 }
366 }
367 if ($candidateUrlLegacy !== null) {
368 if (isset($postData['redirect_to_data_field_id'])) {
369 $options['dest404page'] = sanitize_text_field(is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : '');
370 }
371 $options['dest404pageURL'] = $candidateUrlLegacy;
372 if ($options['dest404page'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
373 $options['dest404page'] = $options['dest404pageURL'] . '|' . ABJ404_TYPE_EXTERNAL;
374 }
375 } else if (isset($postData['redirect_to_data_field_id']) && !isset($postData['redirect_to_data_field_title'])) {
376 $options['dest404page'] = sanitize_text_field(is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : '');
377 }
378 }
379
380 if (isset($postData['template_redirect_priority'])) {
381 if (is_numeric($postData['template_redirect_priority']) && $postData['template_redirect_priority'] >= 0 && $postData['template_redirect_priority'] <= 999) {
382 $options['template_redirect_priority'] = absint($postData['template_redirect_priority']);
383 } else {
384 $message .= __('Error: Template redirect priority value must be a number between 0 and 999', '404-solution') . ".<BR/>";
385 }
386 }
387
388 return $message;
389 }
390
391 /**
392 * Apply the selected behavior tile to the dest404page option.
393 *
394 * @param array<string, mixed> $options The options array to update (by reference)
395 * @param string $behavior The selected behavior: suggest, homepage, custom, theme_default
396 * @param array<string, mixed> $postData The POST data
397 * @return string Any error messages
398 */
399 private function applyBehaviorToDest404Page(array &$options, string $behavior, array $postData): string {
400 switch ($behavior) {
401 case 'suggest':
402 // Create or find the system page
403 $systemPage = ABJ_404_Solution_SystemPage::getInstance();
404 $pageId = $systemPage->getOrCreateSystemPage();
405 if ($pageId > 0) {
406 $options['dest404page'] = $pageId . '|' . ABJ404_TYPE_POST;
407 } else {
408 return __('Error: Could not create the suggestion page', '404-solution') . ".<BR/>";
409 }
410 break;
411
412 case 'homepage':
413 $options['dest404page'] = '0|' . ABJ404_TYPE_HOME;
414 break;
415
416 case 'custom':
417 if (isset($postData['redirect_to_data_field_title'])) {
418 $candidateUrl = sanitize_text_field(
419 is_string($postData['redirect_to_data_field_title']) ? $postData['redirect_to_data_field_title'] : ''
420 );
421 if (strlen($candidateUrl) > ABJ404_MAX_URL_LENGTH) {
422 return sprintf(__('Error: 404 destination URL exceeds the maximum length of %d characters', '404-solution'), ABJ404_MAX_URL_LENGTH) . ".<BR/>";
423 }
424 if (isset($postData['redirect_to_data_field_id'])) {
425 $options['dest404page'] = sanitize_text_field(
426 is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : ''
427 );
428 }
429 $options['dest404pageURL'] = $candidateUrl;
430 if ($options['dest404page'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
431 $options['dest404page'] = $options['dest404pageURL'] . '|' . ABJ404_TYPE_EXTERNAL;
432 }
433 } else if (isset($postData['redirect_to_data_field_id'])) {
434 $options['dest404page'] = sanitize_text_field(
435 is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : ''
436 );
437 }
438 break;
439
440 case 'theme_default':
441 default:
442 $options['dest404page'] = '0|' . ABJ404_TYPE_404_DISPLAYED;
443 break;
444 }
445
446 return "";
447 }
448
449 /** Update WordPress-specific settings.
450 * @param array<string, mixed> $options The options array to update
451 * @param array<string, mixed> $postData The POST data
452 * @return string Any error messages
453 */
454 private function updateWordPressSettings(array &$options, array $postData): string {
455 $message = "";
456
457 if (isset($postData['ignore_dontprocess'])) {
458 $options['ignore_dontprocess'] = wp_kses_post(is_string($postData['ignore_dontprocess']) ? $postData['ignore_dontprocess'] : '');
459 }
460 if (isset($postData['ignore_doprocess'])) {
461 $options['ignore_doprocess'] = wp_kses_post(is_string($postData['ignore_doprocess']) ? $postData['ignore_doprocess'] : '');
462 }
463 if (isset($postData['recognized_post_types'])) {
464 $options['recognized_post_types'] = wp_kses_post(is_string($postData['recognized_post_types']) ? $postData['recognized_post_types'] : '');
465 }
466 if (isset($postData['recognized_categories'])) {
467 $options['recognized_categories'] = wp_kses_post(is_string($postData['recognized_categories']) ? $postData['recognized_categories'] : '');
468 }
469 if (isset($postData['menuLocation'])) {
470 $options['menuLocation'] = wp_kses_post(is_string($postData['menuLocation']) ? $postData['menuLocation'] : '');
471 }
472
473 if (isset($postData['admin_theme'])) {
474 // Only allow specific theme values
475 $allowed_themes = array('default', 'calm', 'mono', 'neon', 'obsidian');
476 $theme = sanitize_text_field(is_string($postData['admin_theme']) ? $postData['admin_theme'] : '');
477 if (in_array($theme, $allowed_themes)) {
478 $options['admin_theme'] = $theme;
479 } else {
480 $message .= __('Error: Invalid theme selected', '404-solution') . ".<BR/>";
481 }
482 }
483
484 if (isset($postData['plugin_language_override'])) {
485 // Only allow specific locale values
486 $allowed_locales = array('', 'en_US', 'de_DE', 'es_ES', 'fr_FR', 'it_IT', 'pt_BR', 'nl_NL', 'ru_RU', 'ja', 'zh_CN', 'id_ID', 'sv_SE');
487 $locale = sanitize_text_field(is_string($postData['plugin_language_override']) ? $postData['plugin_language_override'] : '');
488 if (in_array($locale, $allowed_locales)) {
489 $options['plugin_language_override'] = $locale;
490 } else {
491 $message .= __('Error: Invalid language selected', '404-solution') . ".<BR/>";
492 }
493 }
494
495 // Handle disable_auto_dark_mode checkbox (unchecked = not in postData)
496 if (isset($postData['disable_auto_dark_mode']) && $postData['disable_auto_dark_mode'] == '1') {
497 $options['disable_auto_dark_mode'] = '1';
498 } else {
499 $options['disable_auto_dark_mode'] = '0';
500 }
501
502 if (isset($postData['days_wait_before_major_update'])) {
503 $rawDaysWait = is_scalar($postData['days_wait_before_major_update']) ? $postData['days_wait_before_major_update'] : '';
504 if (is_numeric($rawDaysWait) && (int)$rawDaysWait >= 0) {
505 $options['days_wait_before_major_update'] = (int)$rawDaysWait;
506 } else {
507 $message .= sprintf(__('Error: The time to wait before an automatic update must be a number between 0 and something around %d.', '404-solution'), PHP_INT_MAX) . "<BR/>";
508 }
509 }
510
511 return $message;
512 }
513
514 /** Update notification settings.
515 * @param array<string, mixed> $options The options array to update
516 * @param array<string, mixed> $postData The POST data
517 * @return string Any error messages
518 */
519 private function updateNotificationSettings(&$options, $postData) {
520 $message = "";
521
522 if (isset($postData['admin_notification'])) {
523 $rawAdminNotification = is_scalar($postData['admin_notification']) ? $postData['admin_notification'] : '';
524 if (is_numeric($rawAdminNotification) && (int)$rawAdminNotification >= 0) {
525 $options['admin_notification'] = (int)$rawAdminNotification;
526 } else if (is_numeric($rawAdminNotification)) {
527 $message .= __('Error: Admin notification threshold must be a non-negative number', '404-solution') . ".<BR/>";
528 }
529 }
530
531 if (isset($postData['admin_notification_email'])) {
532 $options['admin_notification_email'] = trim(wp_kses_post(is_string($postData['admin_notification_email']) ? $postData['admin_notification_email'] : ''));
533 }
534
535 if (isset($postData['admin_notification_frequency'])) {
536 $allowed_frequencies = array('instant', 'daily', 'weekly');
537 $freq = sanitize_text_field(is_string($postData['admin_notification_frequency']) ? $postData['admin_notification_frequency'] : '');
538 if (in_array($freq, $allowed_frequencies, true)) {
539 $options['admin_notification_frequency'] = $freq;
540 // Reschedule digest cron whenever frequency changes.
541 $emailDigest = new ABJ_404_Solution_EmailDigest($this->dao, $this->logger);
542 $emailDigest->scheduleNextDigest();
543 } else {
544 $message .= __('Error: Invalid email notification frequency selected', '404-solution') . ".<BR/>";
545 }
546 }
547
548 if (isset($postData['admin_notification_digest_limit'])) {
549 if (is_numeric($postData['admin_notification_digest_limit']) && $postData['admin_notification_digest_limit'] >= 1) {
550 $options['admin_notification_digest_limit'] = absint($postData['admin_notification_digest_limit']);
551 } else {
552 $message .= __('Error: Digest limit must be a number greater than or equal to 1', '404-solution') . ".<BR/>";
553 }
554 }
555
556 return $message;
557 }
558
559 /**
560 * Validate and set a numeric field value from POST data.
561 * Eliminates duplication in settings update methods.
562 *
563 * @param array<string, mixed> $options Reference to options array to update
564 * @param array<string, mixed> $postData POST data containing field value
565 * @param string $fieldName Name of the field to validate
566 * @param string $errorMessage Error message to display on validation failure
567 * @param int $minValue Minimum allowed value (default: 0)
568 * @param bool $useAbsintForCheck Whether to use absint() before comparison (default: false)
569 * @return string Error message if validation fails, empty string otherwise
570 */
571 private function validateAndSetNumericField(array &$options, array $postData, string $fieldName, string $errorMessage, int $minValue = 0, bool $useAbsintForCheck = false): string {
572 if (isset($postData[$fieldName])) {
573 $value = $postData[$fieldName];
574 $scalarValue = is_scalar($value) ? $value : 0;
575 $passesValidation = false;
576
577 if ($useAbsintForCheck) {
578 // For maximum_log_disk_usage: check absint(value) > minValue
579 $passesValidation = is_numeric($value) && absint($scalarValue) > $minValue;
580 } else {
581 // For other fields: check value >= minValue
582 $passesValidation = is_numeric($value) && $value >= $minValue;
583 }
584
585 if ($passesValidation) {
586 $options[$fieldName] = absint($scalarValue);
587 return "";
588 } else {
589 return __($errorMessage, '404-solution') . ".<BR/>";
590 }
591 }
592 return "";
593 }
594
595 /** Update deletion-related settings.
596 * @param array<string, mixed> $options The options array to update
597 * @param array<string, mixed> $postData The POST data
598 * @return string Any error messages
599 */
600 private function updateDeletionSettings(array &$options, array $postData): string {
601 $message = "";
602
603 $message .= $this->validateAndSetNumericField($options, $postData, 'capture_deletion',
604 'Error: Collected URL deletion value must be a number greater than or equal to zero');
605
606 $message .= $this->validateAndSetNumericField($options, $postData, 'manual_deletion',
607 'Error: Manual redirect deletion value must be a number greater than or equal to zero');
608
609 $message .= $this->validateAndSetNumericField($options, $postData, 'log_deletion',
610 'Error: Log deletion value must be a number greater than or equal to zero');
611
612 $message .= $this->validateAndSetNumericField($options, $postData, 'auto_deletion',
613 'Error: Auto redirect deletion value must be a number greater than or equal to zero');
614
615 $message .= $this->validateAndSetNumericField($options, $postData, 'auto_302_expiration_days',
616 'Error: Auto-redirect expiration days must be a number greater than or equal to zero');
617
618 $message .= $this->validateAndSetNumericField($options, $postData, 'maximum_log_disk_usage',
619 'Error: Maximum log disk usage must be a number greater than zero', 0, true);
620
621 return $message;
622 }
623
624 /** Update suggestion/spelling settings.
625 * @param array<string, mixed> $options The options array to update
626 * @param array<string, mixed> $postData The POST data
627 * @return string Any error messages
628 */
629 private function updateSuggestionSettings(array &$options, array $postData): string {
630 $message = "";
631
632 if (isset($postData['suggest_max'])) {
633 if (is_numeric($postData['suggest_max']) && $postData['suggest_max'] >= 1) {
634 if ($options['suggest_max'] != absint($postData['suggest_max'])) {
635 $this->logger->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
636 ": Truncating spelling cache because the max suggestions # changed from " .
637 $options['suggest_max'] . ' to ' . absint($postData['suggest_max']));
638
639 // the spelling cache only stores up to X entries. X is based on suggest_max
640 // so the spelling cache has to be reset when this number changes.
641 $this->dao->deleteSpellingCache();
642 }
643
644 $options['suggest_max'] = absint($postData['suggest_max']);
645 } else {
646 $message .= __('Error: Maximum number of suggest value must be a number greater than or equal to 1', '404-solution') . ".<BR/>";
647 }
648 }
649
650 if (isset($postData['auto_score'])) {
651 if (is_numeric($postData['auto_score']) && $postData['auto_score'] >= 0 && $postData['auto_score'] <= 99) {
652 $options['auto_score'] = absint($postData['auto_score']);
653 } else {
654 $message .= __('Error: Auto match score value must be a number between 0 and 99', '404-solution') . ".<BR/>";
655 }
656 }
657
658 // Per-engine score overrides: accept empty string (use global) or numeric 0–99
659 $engineScoreKeys = ['auto_score_title', 'auto_score_category_tag', 'auto_score_content'];
660 foreach ($engineScoreKeys as $key) {
661 if (isset($postData[$key])) {
662 $raw = $postData[$key];
663 $val = is_string($raw) ? trim($raw) : (is_numeric($raw) ? trim(strval($raw)) : '');
664 if ($val === '') {
665 $options[$key] = '';
666 } elseif (is_numeric($val) && $val >= 0 && $val <= 99) {
667 $options[$key] = absint($val);
668 } else {
669 $message .= __('Error: Per-engine score override must be empty or a number between 0 and 99', '404-solution') . ".<BR/>";
670 }
671 }
672 }
673
674 return $message;
675 }
676
677 /** Update boolean toggle options (checkboxes).
678 * @param array<string, mixed> $options The options array to update
679 * @param array<string, mixed> $postData The POST data
680 * @return string Any error messages
681 */
682 private function updateBooleanToggles(array &$options, array $postData): string {
683 $message = "";
684
685 // Check if we're in simple or advanced settings mode
686 $settingsMode = $this->getSettingsMode();
687
688 // All boolean options that could be in forms
689 $allBooleanOptions = array('remove_matches', 'debug_mode', 'suggest_cats', 'suggest_tags',
690 'auto_redirects', 'auto_slugs', 'auto_cats', 'auto_tags', 'auto_trash_redirect',
691 'capture_404', 'send_error_logs', 'log_raw_ips',
692 'redirect_all_requests', 'update_suggest_url', 'suggest_minscore_enabled',
693 'auto_trash_junk_urls',
694 );
695
696 // Options that appear in Simple Mode form
697 $simpleModeOptions = array('auto_redirects', 'capture_404', 'auto_trash_junk_urls');
698
699 // Determine which options to process from POST data
700 if ($settingsMode === 'simple') {
701 // Simple mode: only process options that are actually in the form
702 $optionsToProcess = $simpleModeOptions;
703 } else {
704 // Advanced mode: process all options (existing behavior)
705 $optionsToProcess = $allBooleanOptions;
706 }
707
708 foreach ($optionsToProcess as $optionName) {
709 $newVal = (array_key_exists($optionName, $postData) && $postData[$optionName] == "1") ? 1 : 0;
710
711 // in case the suggest_cats or suggest_tags is changed.
712 if (!array_key_exists($optionName, $options) ||
713 $options[$optionName] != $newVal) {
714
715 $this->dao->deleteSpellingCache();
716 }
717 $options[$optionName] = $newVal;
718 }
719
720 // In Simple Mode, sync auto_cats and auto_tags with auto_redirects
721 if ($settingsMode === 'simple') {
722 $autoRedirectsValue = isset($options['auto_redirects']) ? $options['auto_redirects'] : 0;
723 $options['auto_cats'] = $autoRedirectsValue;
724 $options['auto_tags'] = $autoRedirectsValue;
725 }
726
727 return $message;
728 }
729
730 /** Update suggestion HTML display options.
731 * @param array<string, mixed> $options The options array to update
732 * @param array<string, mixed> $postData The POST data
733 * @return string Any error messages
734 */
735 private function updateSuggestionHTMLOptions(array &$options, array $postData): string {
736 $message = "";
737
738 // the suggest_.* options have html in them.
739 $optionsListSuggest = array('suggest_title', 'suggest_before', 'suggest_after', 'suggest_entrybefore',
740 'suggest_entryafter', 'suggest_noresults');
741 foreach ($optionsListSuggest as $optionName) {
742 // Only update if the option was posted (Simple Mode doesn't include these)
743 if (isset($postData[$optionName])) {
744 $options[$optionName] = wp_kses_post(is_string($postData[$optionName]) ? $postData[$optionName] : '');
745 }
746 }
747
748 $this->normalizeSuggestionTemplateOptions($options);
749
750 return $message;
751 }
752
753 /**
754 * Repair malformed suggestion template options.
755 *
756 * Keep valid custom text intact; only heal known-broken literal token forms.
757 *
758 * @param array<string, mixed> $options
759 * @return bool True when any option was changed.
760 */
761 private function normalizeSuggestionTemplateOptions(array &$options): bool {
762 $changed = false;
763 $defaults = $this->getDefaultOptions();
764
765 $titleDefault = isset($defaults['suggest_title']) && is_string($defaults['suggest_title']) ?
766 $defaults['suggest_title'] : '<h3>{suggest_title_text}</h3>';
767 $noResultsDefault = isset($defaults['suggest_noresults']) && is_string($defaults['suggest_noresults']) ?
768 $defaults['suggest_noresults'] : '<p>{suggest_noresults_text}</p>';
769
770 $titleValue = isset($options['suggest_title']) && is_scalar($options['suggest_title']) ?
771 (string)$options['suggest_title'] : '';
772 $titleLower = strtolower(trim($titleValue));
773 $titleHasBareBrokenToken = (strpos($titleValue, 'suggest_title_text') !== false &&
774 strpos($titleValue, '{suggest_title_text}') === false);
775 if (
776 $titleValue === '' ||
777 in_array($titleLower, array('suggest_title_text', '{suggest_title_text}'), true) ||
778 $titleHasBareBrokenToken
779 ) {
780 if ($titleValue !== $titleDefault) {
781 $options['suggest_title'] = $titleDefault;
782 $changed = true;
783 }
784 }
785
786 $noResultsValue = isset($options['suggest_noresults']) && is_scalar($options['suggest_noresults']) ?
787 (string)$options['suggest_noresults'] : '';
788 $noResultsLower = strtolower(trim($noResultsValue));
789 $noResultsHasBareBrokenToken = (strpos($noResultsValue, 'suggest_noresults_text') !== false &&
790 strpos($noResultsValue, '{suggest_noresults_text}') === false);
791 if (
792 $noResultsValue === '' ||
793 in_array($noResultsLower, array('suggest_noresults_text', '{suggest_noresults_text}'), true) ||
794 $noResultsHasBareBrokenToken
795 ) {
796 if ($noResultsValue !== $noResultsDefault) {
797 $options['suggest_noresults'] = $noResultsDefault;
798 $changed = true;
799 }
800 }
801
802 return $changed;
803 }
804
805 /** Update regex pattern settings for ignoring files/folders and suggestion exclusions.
806 * @param array<string, mixed> $options The options array to update
807 * @param array<string, mixed> $postData The POST data
808 * @return string Any error messages
809 */
810 private function updateRegexPatternSettings(array &$options, array $postData): string {
811 $message = "";
812
813 if (isset($postData['folders_files_ignore'])) {
814 $foldersFilesVal = is_string($postData['folders_files_ignore']) ? $postData['folders_files_ignore'] : '';
815 $options['folders_files_ignore'] = wp_unslash(wp_kses_post($foldersFilesVal));
816
817 // make the regular expressions usable.
818 $patternsToIgnore = $this->f->explodeNewline($options['folders_files_ignore']);
819 $usableFilePatterns = array();
820 foreach ($patternsToIgnore as $patternToIgnore) {
821 $newPattern = '^' . preg_quote(trim($patternToIgnore), '/') . '$';
822 $newPattern = $this->f->str_replace("\*",".*", $newPattern);
823 $usableFilePatterns[] = $newPattern;
824 }
825 $options['folders_files_ignore_usable'] = $usableFilePatterns;
826 }
827
828 if ( isset( $postData['suggest_regex_exclusions'] ) ) {
829 // 1. Sanitize the raw input using the appropriate function for multi-line text without HTML.
830 $suggestRegexRaw = is_string($postData['suggest_regex_exclusions']) ? $postData['suggest_regex_exclusions'] : '';
831 $sanitized_exclusions = sanitize_textarea_field( wp_unslash( $suggestRegexRaw ) );
832 $options['suggest_regex_exclusions'] = $sanitized_exclusions;
833
834 // 2. Generate the usable regex patterns *from the sanitized input*.
835 $patternsToIgnore = $this->f->explodeNewline( $sanitized_exclusions );
836 $usableFilePatterns = array();
837 foreach ( $patternsToIgnore as $patternToIgnore ) {
838 $trimmedPattern = trim( $patternToIgnore );
839 // Only process non-empty lines
840 if ( ! empty( $trimmedPattern ) ) {
841 // Escape regex special characters, then convert literal '*' into '.*' for wildcard matching.
842 $newPattern = '^' . preg_quote( $trimmedPattern, '/' ) . '$';
843 // Use standard str_replace; $this->f->str_replace is likely unnecessary here unless it provides specific multibyte handling not needed for '\*'.
844 $newPattern = str_replace( '\*', '.*', $newPattern );
845 $usableFilePatterns[] = $newPattern;
846 }
847 }
848 $options['suggest_regex_exclusions_usable'] = $usableFilePatterns;
849 }
850
851 return $message;
852 }
853
854 /** Update plugin admin users list.
855 * @param array<string, mixed> $options The options array to update
856 * @param array<string, mixed> $postData The POST data
857 * @return string Any error messages
858 */
859 private function updateAdminUsers(array &$options, array $postData): string {
860 $message = "";
861
862 if (isset($postData['plugin_admin_users'])) {
863 $pluginAdminUsers = $postData['plugin_admin_users'];
864 if (is_array($pluginAdminUsers)) {
865 $pluginAdminUsers = array_filter($pluginAdminUsers,
866 array($this->f, 'removeEmptyCustom'));
867 }
868
869 $options['plugin_admin_users'] = $pluginAdminUsers;
870 }
871
872 return $message;
873 }
874
875 /** Update excluded pages list.
876 * @param array<string, mixed> $options The options array to update
877 * @param array<string, mixed> $postData The POST data
878 * @return string Any error messages
879 */
880 private function updateExcludedPages(array &$options, array $postData): string {
881 $message = "";
882
883 if (is_array($options['excludePages[]'])) {
884 $this->logger->warn("Exclude pages settings lost.");
885 $options['excludePages[]'] = '';
886 }
887 if (isset($postData['excludePages[]'])) {
888 $excludePagesStr = is_string($options['excludePages[]']) ? $options['excludePages[]'] : '';
889 $oldExcludePages = json_decode($excludePagesStr);
890 if (!is_array($postData['excludePages[]'])) {
891 $postData['excludePages[]'] = array($postData['excludePages[]']);
892 }
893 $encodedPages = json_encode($postData['excludePages[]']);
894 $options['excludePages[]'] = is_string($encodedPages) ? $encodedPages : '';
895 $newExcludePages = json_decode($options['excludePages[]']);
896 if ($newExcludePages !== $oldExcludePages) {
897 // if any excluded pages changed or if the number of excluded pages changed
898 // then the spelling cache has to be reset.
899 $this->dao->deleteSpellingCache();
900 }
901 } else {
902 $excludePagesStr2 = is_string($options['excludePages[]']) ? $options['excludePages[]'] : '';
903 $oldExcludePages = json_decode($excludePagesStr2);
904 if (null !== $oldExcludePages) {
905 // if any excluded pages changed or if the number of excluded pages changed
906 // then the spelling cache has to be reset.
907 $this->dao->deleteSpellingCache();
908 }
909 $options['excludePages[]'] = null;
910 }
911
912 return $message;
913 }
914
915 }
916