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

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

871 lines 38.4 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 * Settings update helpers: table options, POST sanitization, options-from-POST pipeline.
10 * Standalone class extracted from PluginLogicTrait_SettingsUpdate.
11 */
12 class ABJ_404_Solution_PluginLogicSettingsUpdate {
13
14 /** @var ABJ_404_Solution_Functions */
15 private $f;
16
17 /** @var ABJ_404_Solution_Logging */
18 private $logger;
19
20 /** @var ABJ_404_Solution_ContentRepositoryInterface */
21 private $contentRepo;
22
23 /** @var ABJ_404_Solution_PluginLogic */
24 private $pluginLogic;
25
26 /** Allowed column names for orderby parameter.
27 * @var array<int, string> */
28 private static $allowedOrderbyColumns = [
29 'url',
30 'status',
31 'type',
32 'dest',
33 'final_dest',
34 'code',
35 'score',
36 'timestamp',
37 'created',
38 'lastused',
39 'last_used',
40 'logshits',
41 'remote_host',
42 'referrer',
43 'action',
44 'username'
45 ];
46
47 /** Allowed values for order parameter.
48 * @var array<int, string> */
49 private static $allowedOrderValues = ['ASC', 'DESC'];
50
51 /**
52 * @param ABJ_404_Solution_Functions $f
53 * @param ABJ_404_Solution_Logging $logger
54 * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepo
55 * @param ABJ_404_Solution_PluginLogic $pluginLogic
56 */
57 function __construct($f, $logger, $contentRepo, $pluginLogic) {
58 $this->f = $f;
59 $this->logger = $logger;
60 $this->contentRepo = $contentRepo;
61 $this->pluginLogic = $pluginLogic;
62 }
63
64 /**
65 * Read a scalar query parameter directly from REQUEST_URI.
66 *
67 * @param string $name
68 * @return string
69 */
70 private function getQueryParamFromRequestUri($name) {
71 if (!is_string($name) || $name === '') {
72 return '';
73 }
74 $requestUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
75 if ($requestUri === '') {
76 return '';
77 }
78 $queryString = parse_url($requestUri, PHP_URL_QUERY);
79 if (!is_string($queryString) || $queryString === '') {
80 return '';
81 }
82 $query = array();
83 parse_str($queryString, $query);
84 if (!array_key_exists($name, $query) || !is_scalar($query[$name])) {
85 return '';
86 }
87 return sanitize_text_field((string)$query[$name]);
88 }
89
90 /**
91 * @param string $pageBeingViewed
92 * @return array<string, mixed>
93 */
94 function getTableOptions(string $pageBeingViewed): array {
95 $tableOptions = array();
96 $options = $this->pluginLogic->getOptions(true);
97
98 $translationArray = array(
99 '{ABJ404_STATUS_MANUAL_text}' => __('Man', '404-solution'),
100 '{ABJ404_STATUS_AUTO_text}' => __('Auto', '404-solution'),
101 '{ABJ404_STATUS_REGEX_text}' => __('RegEx', '404-solution'),
102 '{ABJ404_TYPE_EXTERNAL_text}' => __('External', '404-solution'),
103 '{ABJ404_TYPE_CAT_text}' => __('Category', '404-solution'),
104 '{ABJ404_TYPE_TAG_text}' => __('Tag', '404-solution'),
105 '{ABJ404_TYPE_HOME_text}' => __('Home Page', '404-solution'),
106 '{ABJ404_TYPE_404_DISPLAYED_text}' => __('(Default 404 Page)', '404-solution'),
107 '{ABJ404_TYPE_SPECIAL_text}' => __('(Special)', '404-solution'),
108 );
109
110 $tableOptions['translations'] = $translationArray;
111
112 $rawFilter = $this->f->getPostOrGetSanitize("filter", "");
113 if ($rawFilter === "") {
114 if ($this->f->getPostOrGetSanitize('subpage') == 'abj404_captured') {
115 $tableOptions['filter'] = ABJ404_STATUS_CAPTURED;
116 } else {
117 $tableOptions['filter'] = 0;
118 }
119 } else {
120 $tableOptions['filter'] = intval($rawFilter);
121 }
122
123 $tableOptions['filterText'] = trim($this->f->getPostOrGetSanitize("filterText", ""));
124 $tableOptions['filterText'] = $this->f->str_replace(array('*', '/', '$'), '', $tableOptions['filterText']);
125
126 $orderbyInput = $this->f->getPostOrGetSanitize('orderby', "");
127 if ($orderbyInput != "" && in_array($orderbyInput, self::$allowedOrderbyColumns, true)) {
128 $tableOptions['orderby'] = $orderbyInput;
129
130 if ($pageBeingViewed == 'abj404_redirects') {
131 $options['page_redirects_order_by'] = $tableOptions['orderby'];
132 $this->pluginLogic->updateOptions($options);
133
134 } else if ($pageBeingViewed == 'abj404_captured') {
135 $options['captured_order_by'] = $tableOptions['orderby'];
136 $this->pluginLogic->updateOptions($options);
137 }
138
139 } else if ($pageBeingViewed == "abj404_logs") {
140 $tableOptions['orderby'] = "timestamp";
141 } else if ($pageBeingViewed == 'abj404_redirects') {
142 $savedRedirectsOrderBy = isset($options['page_redirects_order_by']) && is_scalar($options['page_redirects_order_by'])
143 ? (string)$options['page_redirects_order_by'] : 'url';
144 $tableOptions['orderby'] = in_array($savedRedirectsOrderBy, self::$allowedOrderbyColumns, true)
145 ? $savedRedirectsOrderBy : 'url';
146 } else if ($pageBeingViewed == 'abj404_captured') {
147 $savedCapturedOrderBy = isset($options['captured_order_by']) && is_scalar($options['captured_order_by'])
148 ? (string)$options['captured_order_by'] : 'timestamp';
149 $tableOptions['orderby'] = in_array($savedCapturedOrderBy, self::$allowedOrderbyColumns, true)
150 ? $savedCapturedOrderBy : 'timestamp';
151 } else {
152 $tableOptions['orderby'] = 'url';
153 }
154
155 $orderInput = strtoupper($this->f->getPostOrGetSanitize('order', ''));
156 if ($orderInput != '' && in_array($orderInput, self::$allowedOrderValues, true)) {
157 $tableOptions['order'] = $orderInput;
158
159 if ($pageBeingViewed == 'abj404_redirects') {
160 $options['page_redirects_order'] = $tableOptions['order'];
161 $this->pluginLogic->updateOptions($options);
162
163 } else if ($pageBeingViewed == 'abj404_captured') {
164 $options['captured_order'] = $tableOptions['order'];
165 $this->pluginLogic->updateOptions($options);
166 }
167
168 } else if ($tableOptions['orderby'] == "created" || $tableOptions['orderby'] == "lastused" || $tableOptions['orderby'] == "timestamp") {
169 $tableOptions['order'] = "DESC";
170
171 } else if ($pageBeingViewed == 'abj404_redirects') {
172 $savedRedirectsOrder = isset($options['page_redirects_order']) && is_scalar($options['page_redirects_order'])
173 ? strtoupper((string)$options['page_redirects_order']) : 'ASC';
174 $tableOptions['order'] = in_array($savedRedirectsOrder, self::$allowedOrderValues, true)
175 ? $savedRedirectsOrder : 'ASC';
176
177 } else if ($pageBeingViewed == 'abj404_captured') {
178 $savedCapturedOrder = isset($options['captured_order']) && is_scalar($options['captured_order'])
179 ? strtoupper((string)$options['captured_order']) : 'DESC';
180 $tableOptions['order'] = in_array($savedCapturedOrder, self::$allowedOrderValues, true)
181 ? $savedCapturedOrder : 'DESC';
182
183 } else {
184 $tableOptions['order'] = "ASC";
185 }
186
187 $paged = $this->f->getPostOrGetSanitize("paged", '');
188 if ($paged === '') {
189 $paged = $this->getQueryParamFromRequestUri('paged');
190 }
191 $tableOptions['paged'] = ($paged === '') ? '1' : $paged;
192
193 $perPageOption = ABJ404_OPTION_DEFAULT_PERPAGE;
194 if (isset($options['perpage'])) {
195 $perPageOption = max(absint(is_scalar($options['perpage']) ? $options['perpage'] : 0), ABJ404_OPTION_MIN_PERPAGE);
196 }
197 $tableOptions['perpage'] = $this->f->getPostOrGetSanitize("perpage", (string)$perPageOption);
198
199 $tableOptions['logsid'] = 0;
200 if ($this->f->getPostOrGetSanitize('subpage') == "abj404_logs") {
201 $logId = (string)$this->f->getPostOrGetSanitize('id', '');
202 if ($this->f->regexMatch('[0-9]+', $logId)) {
203 $tableOptions['logsid'] = absint($logId);
204
205 } else {
206 $redirectToDataFieldId = (string)$this->f->getPostOrGetSanitize('redirect_to_data_field_id', '');
207 if ($this->f->regexMatch('[0-9]+', $redirectToDataFieldId)) {
208 $tableOptions['logsid'] = absint($redirectToDataFieldId);
209 }
210 }
211 }
212
213 $rawScoreRange = (string)$this->f->getPostOrGetSanitize('score_range', 'all');
214 $allowedScoreRanges = array('all', 'high', 'medium', 'low', 'manual');
215 $tableOptions['score_range'] = in_array($rawScoreRange, $allowedScoreRanges, true) ? $rawScoreRange : 'all';
216
217 $forceViewRebuild = (string)$this->f->getPostOrGetSanitize('forceViewRebuild', '');
218 if ($forceViewRebuild === '') {
219 $forceViewRebuild = (string)$this->f->getPostOrGetSanitize('abj404_force_view_rebuild', '');
220 }
221 if ($forceViewRebuild === '1') {
222 $tableOptions['_abj404_force_view_rebuild'] = '1';
223 }
224
225 $sanitizedTableOptions = $this->sanitizePostData($tableOptions);
226
227 return $sanitizedTableOptions;
228 }
229
230 /**
231 * @param array<string, mixed> $postData
232 * @param bool $restoreNewlines
233 * @return array<string, mixed>
234 */
235 function sanitizePostData(array $postData, bool $restoreNewlines = false): array {
236 $newData = array();
237 foreach ($postData as $key => $value) {
238 $key = wp_kses_post($key);
239 if (is_array($value)) {
240 $newData[$key] = $this->sanitizePostData($value, $restoreNewlines);
241 } else {
242 if ($value === null) {
243 $newData[$key] = '';
244 } else {
245 $valueStr = is_string($value) ? $value : (is_scalar($value) ? (string)$value : '');
246 $newData[$key] = wp_kses_post($valueStr);
247 $newData[$key] = esc_sql($newData[$key]);
248 if ($restoreNewlines) {
249 $newData[$key] = str_replace('\n', "\n", $newData[$key]);
250 }
251 }
252 }
253 }
254 return $newData;
255 }
256
257 /** Remove non a-zA-Z0-9 or _ characters.
258 * @param string $str
259 * @return string
260 */
261 function sanitizeForSQL($str) {
262 if ($str == null || $str == '') {
263 return '';
264 }
265 $re = '/[^\w_]/';
266
267 $result = preg_replace($re, '', $str);
268 return is_string($result) ? $result : $str;
269 }
270
271 /**
272 * @return array<string, mixed>
273 */
274 function updateOptionsFromPOST() {
275 $message = "";
276 $options = $this->pluginLogic->getOptions();
277
278 $returnData = array();
279 $returnData['newURL'] = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options';
280
281 if (!isset($_POST['encodedData'])) {
282 $this->logger->errorMessage('Missing encodedData in POST');
283 return array(
284 'success' => false,
285 'status' => 400,
286 'message' => 'Missing form data',
287 );
288 }
289
290 $encodedData = $_POST['encodedData'];
291 $postData = $this->f->decodeComplicatedData($encodedData);
292 if (!is_array($postData)) {
293 $this->logger->errorMessage('Invalid JSON encodedData in POST');
294 return array(
295 'success' => false,
296 'status' => 400,
297 'message' => 'Missing form data',
298 );
299 }
300
301 $nonce = isset($postData['nonce']) ? $postData['nonce'] : '';
302 if (!wp_verify_nonce($nonce, 'abj404UpdateOptions') || !is_admin()) {
303 return array(
304 'success' => false,
305 'status' => 403,
306 'message' => 'Invalid security token',
307 );
308 }
309
310 $_POST = $postData;
311
312 if (array_key_exists('deleteDebugFile', $_POST) && $_POST['deleteDebugFile'] == true) {
313 $sub = '';
314 $returnData['error'] = '';
315 $returnData['message'] = $this->pluginLogic->handlePluginAction('updateOptions', $sub);
316
317 } else {
318 $message .= $this->updateRedirectSettings($options, $_POST);
319 $message .= $this->updateWordPressSettings($options, $_POST);
320 $message .= $this->updateNotificationSettings($options, $_POST);
321 $message .= $this->updateDeletionSettings($options, $_POST);
322 $message .= $this->updateSuggestionSettings($options, $_POST);
323 $message .= $this->updateBooleanToggles($options, $_POST);
324 $message .= $this->updateSuggestionHTMLOptions($options, $_POST);
325 $message .= $this->updateRegexPatternSettings($options, $_POST);
326 $message .= $this->updateAdminUsers($options, $_POST);
327 $message .= $this->updateExcludedPages($options, $_POST);
328
329 $excludedPages = $options['excludePages[]'];
330
331 /** Sanitize all data. */
332 $new_options = array();
333 $new_options = $this->sanitizePostData($options, true);
334
335 $excludedPages = $excludedPages == null ? '' : trim($excludedPages);
336 $excludedPages = preg_replace('/[^\[\",\]a-zA-Z\d\|\\\\ ]/', '', $excludedPages);
337 $new_options['excludePages[]'] = $excludedPages;
338
339 $this->pluginLogic->updateOptions($new_options);
340
341 $permalinkCache = abj_service('permalink_cache');
342 $permalinkCache->updatePermalinkCache(2);
343
344 $returnData['error'] = $message;
345 if ($message == "") {
346 $returnData['message'] = __('Options Saved Successfully!', '404-solution');
347 } else {
348 $returnData['message'] = __('Some options were not saved successfully.', '404-solution') .
349 ' ' . $message;
350 }
351 }
352
353 return array(
354 'success' => true,
355 'status' => 200,
356 'data' => $returnData,
357 );
358 }
359
360 /** Update redirect-related settings.
361 * @param array<string, mixed> $options The options array to update
362 * @param array<string, mixed> $postData The POST data
363 * @return string Any error messages
364 */
365 private function updateRedirectSettings(array &$options, array $postData): string {
366 $message = "";
367
368 if (isset($postData['default_redirect'])) {
369 $validDefaultCodes = array('301', '302', '307', '308');
370 if (in_array((string)(is_scalar($postData['default_redirect']) ? $postData['default_redirect'] : ''), $validDefaultCodes, true)) {
371 $options['default_redirect'] = is_scalar($postData['default_redirect']) ? intval($postData['default_redirect']) : 301;
372 } else {
373 $message .= __('Error: Invalid value specified for default redirect type', '404-solution') . ".<BR/>";
374 }
375 }
376
377 if (isset($postData['dest404_behavior'])) {
378 $validBehaviors = array('suggest', 'homepage', 'custom', 'theme_default');
379 $behavior = sanitize_text_field(is_string($postData['dest404_behavior']) ? $postData['dest404_behavior'] : '');
380 if (in_array($behavior, $validBehaviors, true)) {
381 $options['dest404_behavior'] = $behavior;
382 $message .= $this->applyBehaviorToDest404Page($options, $behavior, $postData);
383 } else {
384 $message .= __('Error: Invalid 404 behavior selected', '404-solution') . ".<BR/>";
385 }
386 } else {
387 $candidateUrlLegacy = null;
388 if (isset($postData['redirect_to_data_field_title'])) {
389 $candidateUrlLegacy = sanitize_text_field(is_string($postData['redirect_to_data_field_title']) ? $postData['redirect_to_data_field_title'] : '');
390 if (strlen($candidateUrlLegacy) > ABJ404_MAX_URL_LENGTH) {
391 $message .= sprintf(__('Error: 404 destination URL exceeds the maximum length of %d characters', '404-solution'), ABJ404_MAX_URL_LENGTH) . ".<BR/>";
392 $candidateUrlLegacy = null;
393 }
394 }
395 if ($candidateUrlLegacy !== null) {
396 if (isset($postData['redirect_to_data_field_id'])) {
397 $options['dest404page'] = sanitize_text_field(is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : '');
398 }
399 $options['dest404pageURL'] = $candidateUrlLegacy;
400 if ($options['dest404page'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
401 $options['dest404page'] = $options['dest404pageURL'] . '|' . ABJ404_TYPE_EXTERNAL;
402 }
403 } else if (isset($postData['redirect_to_data_field_id']) && !isset($postData['redirect_to_data_field_title'])) {
404 $options['dest404page'] = sanitize_text_field(is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : '');
405 }
406 }
407
408 if (isset($postData['template_redirect_priority'])) {
409 if (is_numeric($postData['template_redirect_priority']) && $postData['template_redirect_priority'] >= 0 && $postData['template_redirect_priority'] <= 999) {
410 $options['template_redirect_priority'] = absint($postData['template_redirect_priority']);
411 } else {
412 $message .= __('Error: Template redirect priority value must be a number between 0 and 999', '404-solution') . ".<BR/>";
413 }
414 }
415
416 return $message;
417 }
418
419 /**
420 * @param array<string, mixed> $options
421 * @param string $behavior
422 * @param array<string, mixed> $postData
423 * @return string Any error messages
424 */
425 private function applyBehaviorToDest404Page(array &$options, string $behavior, array $postData): string {
426 switch ($behavior) {
427 case 'suggest':
428 $systemPage = ABJ_404_Solution_SystemPage::getInstance();
429 $pageId = $systemPage->getOrCreateSystemPage();
430 if ($pageId > 0) {
431 $options['dest404page'] = $pageId . '|' . ABJ404_TYPE_POST;
432 } else {
433 return __('Error: Could not create the suggestion page', '404-solution') . ".<BR/>";
434 }
435 break;
436
437 case 'homepage':
438 $options['dest404page'] = '0|' . ABJ404_TYPE_HOME;
439 break;
440
441 case 'custom':
442 if (isset($postData['redirect_to_data_field_title'])) {
443 $candidateUrl = sanitize_text_field(
444 is_string($postData['redirect_to_data_field_title']) ? $postData['redirect_to_data_field_title'] : ''
445 );
446 if (strlen($candidateUrl) > ABJ404_MAX_URL_LENGTH) {
447 return sprintf(__('Error: 404 destination URL exceeds the maximum length of %d characters', '404-solution'), ABJ404_MAX_URL_LENGTH) . ".<BR/>";
448 }
449 if (isset($postData['redirect_to_data_field_id'])) {
450 $options['dest404page'] = sanitize_text_field(
451 is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : ''
452 );
453 }
454 $options['dest404pageURL'] = $candidateUrl;
455 if ($options['dest404page'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
456 $options['dest404page'] = $options['dest404pageURL'] . '|' . ABJ404_TYPE_EXTERNAL;
457 }
458 } else if (isset($postData['redirect_to_data_field_id'])) {
459 $options['dest404page'] = sanitize_text_field(
460 is_string($postData['redirect_to_data_field_id']) ? $postData['redirect_to_data_field_id'] : ''
461 );
462 }
463 break;
464
465 case 'theme_default':
466 default:
467 $options['dest404page'] = '0|' . ABJ404_TYPE_404_DISPLAYED;
468 break;
469 }
470
471 return "";
472 }
473
474 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
475 public function updateWordPressSettings(array &$options, array $postData): string {
476 $message = "";
477
478 if (isset($postData['ignore_dontprocess'])) {
479 $options['ignore_dontprocess'] = wp_kses_post(is_string($postData['ignore_dontprocess']) ? $postData['ignore_dontprocess'] : '');
480 }
481 if (isset($postData['ignore_doprocess'])) {
482 $options['ignore_doprocess'] = wp_kses_post(is_string($postData['ignore_doprocess']) ? $postData['ignore_doprocess'] : '');
483 }
484 if (isset($postData['recognized_post_types'])) {
485 $options['recognized_post_types'] = wp_kses_post(is_string($postData['recognized_post_types']) ? $postData['recognized_post_types'] : '');
486 }
487 if (isset($postData['recognized_categories'])) {
488 $options['recognized_categories'] = wp_kses_post(is_string($postData['recognized_categories']) ? $postData['recognized_categories'] : '');
489 }
490 if (isset($postData['menuLocation'])) {
491 $options['menuLocation'] = wp_kses_post(is_string($postData['menuLocation']) ? $postData['menuLocation'] : '');
492 }
493
494 if (isset($postData['admin_theme'])) {
495 $allowed_themes = array('default', 'calm', 'mono', 'neon', 'obsidian');
496 $theme = sanitize_text_field(is_string($postData['admin_theme']) ? $postData['admin_theme'] : '');
497 if (in_array($theme, $allowed_themes)) {
498 $options['admin_theme'] = $theme;
499 } else {
500 $message .= __('Error: Invalid theme selected', '404-solution') . ".<BR/>";
501 }
502 }
503
504 if (isset($postData['plugin_language_override'])) {
505 $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');
506 $locale = sanitize_text_field(is_string($postData['plugin_language_override']) ? $postData['plugin_language_override'] : '');
507 if (in_array($locale, $allowed_locales)) {
508 $options['plugin_language_override'] = $locale;
509 } else {
510 $message .= __('Error: Invalid language selected', '404-solution') . ".<BR/>";
511 }
512 }
513
514 if (isset($postData['disable_auto_dark_mode']) && $postData['disable_auto_dark_mode'] == '1') {
515 $options['disable_auto_dark_mode'] = '1';
516 } else {
517 $options['disable_auto_dark_mode'] = '0';
518 }
519
520 if (isset($postData['days_wait_before_major_update'])) {
521 $rawDaysWait = is_scalar($postData['days_wait_before_major_update']) ? $postData['days_wait_before_major_update'] : '';
522 if (is_numeric($rawDaysWait) && (int)$rawDaysWait >= 0) {
523 $options['days_wait_before_major_update'] = (int)$rawDaysWait;
524 } else {
525 $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/>";
526 }
527 }
528
529 return $message;
530 }
531
532 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
533 private function updateNotificationSettings(&$options, $postData) {
534 $message = "";
535
536 if (isset($postData['admin_notification'])) {
537 $rawAdminNotification = is_scalar($postData['admin_notification']) ? $postData['admin_notification'] : '';
538 if (is_numeric($rawAdminNotification) && (int)$rawAdminNotification >= 0) {
539 $options['admin_notification'] = (int)$rawAdminNotification;
540 } else if (is_numeric($rawAdminNotification)) {
541 $message .= __('Error: Admin notification threshold must be a non-negative number', '404-solution') . ".<BR/>";
542 }
543 }
544
545 if (isset($postData['admin_notification_email'])) {
546 $options['admin_notification_email'] = trim(wp_kses_post(is_string($postData['admin_notification_email']) ? $postData['admin_notification_email'] : ''));
547 }
548
549 if (isset($postData['admin_notification_frequency'])) {
550 $allowed_frequencies = array('instant', 'daily', 'weekly');
551 $freq = sanitize_text_field(is_string($postData['admin_notification_frequency']) ? $postData['admin_notification_frequency'] : '');
552 if (in_array($freq, $allowed_frequencies, true)) {
553 $options['admin_notification_frequency'] = $freq;
554 $emailDigest = new ABJ_404_Solution_EmailDigest(abj_service('logs_repository'), abj_service('stats_repository'), $this->logger);
555 $emailDigest->scheduleNextDigest();
556 } else {
557 $message .= __('Error: Invalid email notification frequency selected', '404-solution') . ".<BR/>";
558 }
559 }
560
561 if (isset($postData['admin_notification_digest_limit'])) {
562 if (is_numeric($postData['admin_notification_digest_limit']) && $postData['admin_notification_digest_limit'] >= 1) {
563 $options['admin_notification_digest_limit'] = absint($postData['admin_notification_digest_limit']);
564 } else {
565 $message .= __('Error: Digest limit must be a number greater than or equal to 1', '404-solution') . ".<BR/>";
566 }
567 }
568
569 return $message;
570 }
571
572 /**
573 * @param array<string, mixed> $options
574 * @param array<string, mixed> $postData
575 * @param string $fieldName
576 * @param string $errorMessage
577 * @param int $minValue
578 * @param bool $useAbsintForCheck
579 * @return string
580 */
581 private function validateAndSetNumericField(array &$options, array $postData, string $fieldName, string $errorMessage, int $minValue = 0, bool $useAbsintForCheck = false): string {
582 if (isset($postData[$fieldName])) {
583 $value = $postData[$fieldName];
584 $scalarValue = is_scalar($value) ? $value : 0;
585 $passesValidation = false;
586
587 if ($useAbsintForCheck) {
588 $passesValidation = is_numeric($value) && absint($scalarValue) > $minValue;
589 } else {
590 $passesValidation = is_numeric($value) && $value >= $minValue;
591 }
592
593 if ($passesValidation) {
594 $options[$fieldName] = absint($scalarValue);
595 return "";
596 } else {
597 return __($errorMessage, '404-solution') . ".<BR/>";
598 }
599 }
600 return "";
601 }
602
603 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
604 public function updateDeletionSettings(array &$options, array $postData): string {
605 $message = "";
606
607 $message .= $this->validateAndSetNumericField($options, $postData, 'capture_deletion',
608 'Error: Collected URL deletion value must be a number greater than or equal to zero');
609
610 $message .= $this->validateAndSetNumericField($options, $postData, 'manual_deletion',
611 'Error: Manual redirect deletion value must be a number greater than or equal to zero');
612
613 $message .= $this->validateAndSetNumericField($options, $postData, 'log_deletion',
614 'Error: Log deletion value must be a number greater than or equal to zero');
615
616 $message .= $this->validateAndSetNumericField($options, $postData, 'auto_deletion',
617 'Error: Auto redirect deletion value must be a number greater than or equal to zero');
618
619 $message .= $this->validateAndSetNumericField($options, $postData, 'auto_302_expiration_days',
620 'Error: Auto-redirect expiration days must be a number greater than or equal to zero');
621
622 $message .= $this->validateAndSetNumericField($options, $postData, 'maximum_log_disk_usage',
623 'Error: Maximum log disk usage must be a number greater than zero', 0, true);
624
625 return $message;
626 }
627
628 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
629 public 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 $this->contentRepo->deleteSpellingCache();
640 }
641
642 $options['suggest_max'] = absint($postData['suggest_max']);
643 } else {
644 $message .= __('Error: Maximum number of suggest value must be a number greater than or equal to 1', '404-solution') . ".<BR/>";
645 }
646 }
647
648 if (isset($postData['auto_score'])) {
649 if (is_numeric($postData['auto_score']) && $postData['auto_score'] >= 0 && $postData['auto_score'] <= 99) {
650 $options['auto_score'] = absint($postData['auto_score']);
651 } else {
652 $message .= __('Error: Auto match score value must be a number between 0 and 99', '404-solution') . ".<BR/>";
653 }
654 }
655
656 $engineScoreKeys = ['auto_score_title', 'auto_score_category_tag', 'auto_score_content'];
657 foreach ($engineScoreKeys as $key) {
658 if (isset($postData[$key])) {
659 $raw = $postData[$key];
660 $val = is_string($raw) ? trim($raw) : (is_numeric($raw) ? trim(strval($raw)) : '');
661 if ($val === '') {
662 $options[$key] = '';
663 } elseif (is_numeric($val) && $val >= 0 && $val <= 99) {
664 $options[$key] = absint($val);
665 } else {
666 $message .= __('Error: Per-engine score override must be empty or a number between 0 and 99', '404-solution') . ".<BR/>";
667 }
668 }
669 }
670
671 return $message;
672 }
673
674 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
675 public function updateBooleanToggles(array &$options, array $postData): string {
676 $message = "";
677
678 $settingsMode = $this->pluginLogic->getSettingsMode();
679
680 $allBooleanOptions = array('remove_matches', 'debug_mode', 'suggest_cats', 'suggest_tags',
681 'auto_redirects', 'auto_slugs', 'auto_cats', 'auto_tags', 'auto_trash_redirect',
682 'capture_404', 'send_error_logs', 'log_raw_ips',
683 'redirect_all_requests', 'update_suggest_url', 'suggest_minscore_enabled',
684 'auto_trash_junk_urls',
685 );
686
687 $simpleModeOptions = array('auto_redirects', 'capture_404', 'auto_trash_junk_urls');
688
689 if ($settingsMode === 'simple') {
690 $optionsToProcess = $simpleModeOptions;
691 } else {
692 $optionsToProcess = $allBooleanOptions;
693 }
694
695 foreach ($optionsToProcess as $optionName) {
696 $newVal = (array_key_exists($optionName, $postData) && $postData[$optionName] == "1") ? 1 : 0;
697
698 if (!array_key_exists($optionName, $options) ||
699 $options[$optionName] != $newVal) {
700
701 $this->contentRepo->deleteSpellingCache();
702 }
703 $options[$optionName] = $newVal;
704 }
705
706 if ($settingsMode === 'simple') {
707 $autoRedirectsValue = isset($options['auto_redirects']) ? $options['auto_redirects'] : 0;
708 $options['auto_cats'] = $autoRedirectsValue;
709 $options['auto_tags'] = $autoRedirectsValue;
710 }
711
712 return $message;
713 }
714
715 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
716 private function updateSuggestionHTMLOptions(array &$options, array $postData): string {
717 $message = "";
718
719 $optionsListSuggest = array('suggest_title', 'suggest_before', 'suggest_after', 'suggest_entrybefore',
720 'suggest_entryafter', 'suggest_noresults');
721 foreach ($optionsListSuggest as $optionName) {
722 if (isset($postData[$optionName])) {
723 $options[$optionName] = wp_kses_post(is_string($postData[$optionName]) ? $postData[$optionName] : '');
724 }
725 }
726
727 $this->normalizeSuggestionTemplateOptions($options);
728
729 return $message;
730 }
731
732 /**
733 * Repair malformed suggestion template options.
734 *
735 * @param array<string, mixed> $options
736 * @return bool True when any option was changed.
737 */
738 function normalizeSuggestionTemplateOptions(array &$options): bool {
739 $changed = false;
740 $defaults = $this->pluginLogic->getDefaultOptions();
741
742 $titleDefault = isset($defaults['suggest_title']) && is_string($defaults['suggest_title']) ?
743 $defaults['suggest_title'] : '<h3>{suggest_title_text}</h3>';
744 $noResultsDefault = isset($defaults['suggest_noresults']) && is_string($defaults['suggest_noresults']) ?
745 $defaults['suggest_noresults'] : '<p>{suggest_noresults_text}</p>';
746
747 $titleValue = isset($options['suggest_title']) && is_scalar($options['suggest_title']) ?
748 (string)$options['suggest_title'] : '';
749 $titleLower = strtolower(trim($titleValue));
750 $titleHasBareBrokenToken = (strpos($titleValue, 'suggest_title_text') !== false &&
751 strpos($titleValue, '{suggest_title_text}') === false);
752 if (
753 $titleValue === '' ||
754 in_array($titleLower, array('suggest_title_text', '{suggest_title_text}'), true) ||
755 $titleHasBareBrokenToken
756 ) {
757 if ($titleValue !== $titleDefault) {
758 $options['suggest_title'] = $titleDefault;
759 $changed = true;
760 }
761 }
762
763 $noResultsValue = isset($options['suggest_noresults']) && is_scalar($options['suggest_noresults']) ?
764 (string)$options['suggest_noresults'] : '';
765 $noResultsLower = strtolower(trim($noResultsValue));
766 $noResultsHasBareBrokenToken = (strpos($noResultsValue, 'suggest_noresults_text') !== false &&
767 strpos($noResultsValue, '{suggest_noresults_text}') === false);
768 if (
769 $noResultsValue === '' ||
770 in_array($noResultsLower, array('suggest_noresults_text', '{suggest_noresults_text}'), true) ||
771 $noResultsHasBareBrokenToken
772 ) {
773 if ($noResultsValue !== $noResultsDefault) {
774 $options['suggest_noresults'] = $noResultsDefault;
775 $changed = true;
776 }
777 }
778
779 return $changed;
780 }
781
782 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
783 private function updateRegexPatternSettings(array &$options, array $postData): string {
784 $message = "";
785
786 if (isset($postData['folders_files_ignore'])) {
787 $foldersFilesVal = is_string($postData['folders_files_ignore']) ? $postData['folders_files_ignore'] : '';
788 $options['folders_files_ignore'] = wp_unslash(wp_kses_post($foldersFilesVal));
789
790 $patternsToIgnore = $this->f->explodeNewline($options['folders_files_ignore']);
791 $usableFilePatterns = array();
792 foreach ($patternsToIgnore as $patternToIgnore) {
793 $newPattern = '^' . preg_quote(trim($patternToIgnore), '/') . '$';
794 $newPattern = $this->f->str_replace("\*",".*", $newPattern);
795 $usableFilePatterns[] = $newPattern;
796 }
797 $options['folders_files_ignore_usable'] = $usableFilePatterns;
798 }
799
800 if ( isset( $postData['suggest_regex_exclusions'] ) ) {
801 $suggestRegexRaw = is_string($postData['suggest_regex_exclusions']) ? $postData['suggest_regex_exclusions'] : '';
802 $sanitized_exclusions = sanitize_textarea_field( wp_unslash( $suggestRegexRaw ) );
803 $options['suggest_regex_exclusions'] = $sanitized_exclusions;
804
805 $patternsToIgnore = $this->f->explodeNewline( $sanitized_exclusions );
806 $usableFilePatterns = array();
807 foreach ( $patternsToIgnore as $patternToIgnore ) {
808 $trimmedPattern = trim( $patternToIgnore );
809 if ( ! empty( $trimmedPattern ) ) {
810 $newPattern = '^' . preg_quote( $trimmedPattern, '/' ) . '$';
811 $newPattern = str_replace( '\*', '.*', $newPattern );
812 $usableFilePatterns[] = $newPattern;
813 }
814 }
815 $options['suggest_regex_exclusions_usable'] = $usableFilePatterns;
816 }
817
818 return $message;
819 }
820
821 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
822 private function updateAdminUsers(array &$options, array $postData): string {
823 $message = "";
824
825 if (isset($postData['plugin_admin_users'])) {
826 $pluginAdminUsers = $postData['plugin_admin_users'];
827 if (is_array($pluginAdminUsers)) {
828 $pluginAdminUsers = array_filter($pluginAdminUsers,
829 array($this->f, 'removeEmptyCustom'));
830 }
831
832 $options['plugin_admin_users'] = $pluginAdminUsers;
833 }
834
835 return $message;
836 }
837
838 /** @param array<string, mixed> $options @param array<string, mixed> $postData @return string */
839 private function updateExcludedPages(array &$options, array $postData): string {
840 $message = "";
841
842 if (is_array($options['excludePages[]'])) {
843 $this->logger->warn("Exclude pages settings lost.");
844 $options['excludePages[]'] = '';
845 }
846 if (isset($postData['excludePages[]'])) {
847 $excludePagesStr = is_string($options['excludePages[]']) ? $options['excludePages[]'] : '';
848 $oldExcludePages = json_decode($excludePagesStr);
849 if (!is_array($postData['excludePages[]'])) {
850 $postData['excludePages[]'] = array($postData['excludePages[]']);
851 }
852 $encodedPages = json_encode($postData['excludePages[]']);
853 $options['excludePages[]'] = is_string($encodedPages) ? $encodedPages : '';
854 $newExcludePages = json_decode($options['excludePages[]']);
855 if ($newExcludePages !== $oldExcludePages) {
856 $this->contentRepo->deleteSpellingCache();
857 }
858 } else {
859 $excludePagesStr2 = is_string($options['excludePages[]']) ? $options['excludePages[]'] : '';
860 $oldExcludePages = json_decode($excludePagesStr2);
861 if (null !== $oldExcludePages) {
862 $this->contentRepo->deleteSpellingCache();
863 }
864 $options['excludePages[]'] = null;
865 }
866
867 return $message;
868 }
869
870 }
871