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

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

1,132 lines 40.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Static functions that can be used from anywhere. */
9 abstract class ABJ_404_Solution_Functions {
10
11 /** @var self|null */
12 private static $instance = null;
13
14 private const FILE_READ_MAX_ATTEMPTS = 3;
15 private const FILE_READ_RETRY_BASE_US = 10000;
16
17 /** @return self */
18 public static function getInstance() {
19 if (self::$instance !== null) {
20 return self::$instance;
21 }
22
23 // If the DI container is initialized, prefer it.
24 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
25 $service = ABJ_404_Solution_ServiceContainer::safeGet('functions');
26 if ($service instanceof self) {
27 self::$instance = $service;
28 return self::$instance;
29 }
30 }
31
32 if (extension_loaded('mbstring')) {
33 self::$instance = new ABJ_404_Solution_FunctionsMBString();
34
35 } else {
36 self::$instance = new ABJ_404_Solution_FunctionsPreg();
37 }
38
39 return self::$instance;
40 }
41
42 /**
43 * This function selectively urlencodes a string. Characters outside of the latin1
44 * range (0-255) are urlencoded, while characters inside the range are kept as is.
45 * @param string|array<int|string, mixed> $input The string to be selectively urlencoded.
46 * @return string|array<int|string, mixed> The urlencoded string or array of strings.
47 */
48 function selectivelyURLEncode($input) {
49 $f = abj_service('functions');
50
51 // Handle array input
52 if (is_array($input)) {
53 /** @var callable(mixed): mixed $callback */
54 $callback = [$f, 'selectivelyURLEncode'];
55 return array_map($callback, $input);
56 }
57
58 if (!is_string($input)) {
59 $input = strval($input);
60 }
61
62 // Define replacements for unsafe characters
63 $replacements = [
64 '<' => '%3C',
65 '>' => '%3E',
66 '"' => '%22',
67 "'" => '%27',
68 '`' => '%60',
69 '{' => '%7B',
70 '}' => '%7D',
71 '(' => '%28',
72 ')' => '%29',
73 ];
74
75 // Perform replacements
76 $input = strtr($input, $replacements);
77
78 $encodedString = '';
79 // Iterate through each character in the string
80 for ($i = 0; $i < strlen($input); $i++) {
81 $char = $input[$i];
82 $ord = $f->ord($char);
83
84 // If the character is outside of latin1 range or is not representable
85 if ($ord > 255) {
86 // Convert to hexadecimal representation
87 $encodedString .= urlencode($char);
88 } else {
89 // Keep the original character if it's in the latin1 range
90 $encodedString .= $char;
91 }
92 }
93
94 return $encodedString;
95 }
96
97 /**
98 * Recursively applies `sanitize_text_field` to strings in an array or other data structure.
99 * @param mixed $data The data to sanitize. If an array, will recursively
100 * apply this function to all elements.
101 * @return mixed The sanitized data.
102 */
103 function sanitize_text_field_recursive($data) {
104 if (is_array($data)) {
105 // Recursively apply to each element
106 return array_map([$this, 'sanitize_text_field_recursive'], $data);
107 }
108
109 return sanitize_text_field(is_string($data) ? $data : (is_scalar($data) ? (string)$data : ''));
110 }
111
112 /** Escape a string to avoid Cross Site Scripting (XSS) attacks by encoding unsafe HTML characters.
113 * @param string $value The string to be escaped.
114 * @return string The escaped string.
115 */
116 function escapeForXSS(?string $value): string {
117 if ($value === null) {
118 return '';
119 }
120 // Remove control characters and other unsafe characters
121 $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '';
122 // Remove any other characters you consider unsafe
123 $value = preg_replace('/[<>"\'`{}()]/u', '', $value) ?? '';
124
125 return $value;
126 }
127
128 /**
129 * Normalize a URL string for storage or matching.
130 * - Optionally decode percent-encoded octets
131 * - Strip invalid UTF-8/control bytes
132 *
133 * @param string|null $url
134 * @param array<string, bool> $options Supported keys: decode (bool)
135 * @return string
136 */
137 function normalizeUrlString($url, array $options = array()) {
138 $options = array_merge(array('decode' => true), $options);
139
140 if ($url === null || $url === '') {
141 return '';
142 }
143
144 if (!is_string($url)) {
145 $url = strval($url);
146 }
147
148 $url = trim($url);
149 if ($options['decode']) {
150 $url = rawurldecode($url);
151 }
152
153 $url = $this->sanitizeInvalidUTF8($url);
154 // Remove remaining control characters (keep whitespace)
155 $url = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $url) ?? $url;
156
157 return $url;
158 }
159
160 /**
161 * Sanitize URL components without stripping reserved characters.
162 * Keeps characters like ()[]{} for matching but removes invalid UTF-8/control bytes.
163 *
164 * @param mixed $value
165 * @return mixed
166 */
167 function sanitizeUrlComponent($value) {
168 if (is_array($value)) {
169 return array_map([$this, 'sanitizeUrlComponent'], $value);
170 }
171
172 if ($value === null || $value === '') {
173 return '';
174 }
175
176 if (!is_string($value)) {
177 $value = is_scalar($value) ? strval($value) : '';
178 }
179
180 $value = $this->sanitizeInvalidUTF8($value);
181 $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value);
182
183 return $value;
184 }
185
186 /**
187 * Encode a URL for legacy matching while preserving URL delimiters.
188 *
189 * @param string|null $url
190 * @return string
191 */
192 function encodeUrlForLegacyMatch($url) {
193 if ($url === null || $url === '') {
194 return '';
195 }
196
197 if (!is_string($url)) {
198 $url = strval($url);
199 }
200
201 $encoded = rawurlencode($url);
202 $encoded = str_replace(
203 array('%2F', '%3F', '%26', '%3D', '%23', '%3A', '%40'),
204 array('/', '?', '&', '=', '#', ':', '@'),
205 $encoded
206 );
207
208 return $encoded;
209 }
210
211 /**
212 * Normalize a URL for use as a cache/transient key.
213 *
214 * This function ensures consistent URL normalization across the codebase:
215 * - Strips query strings (removes everything after '?')
216 * - Applies esc_url for security and consistency
217 *
218 * IMPORTANT: All code that computes cache keys or transient keys from URLs
219 * should use this function to ensure keys match across different code paths.
220 *
221 * Used by: SpellChecker, ShortCode, Ajax_SuggestionPolling, PluginLogic
222 *
223 * @param string $url The URL to normalize
224 * @return string The normalized URL (query string stripped, esc_url applied)
225 */
226 function normalizeURLForCacheKey($url) {
227 $url = $this->normalizeUrlString($url);
228 // Strip query string (everything after '?')
229 $normalized = $this->regexReplace('\?.*', '', $url) ?? $url;
230 // Apply esc_url for security and consistency
231 return esc_url($normalized);
232 }
233
234 /** Only URL encode emojis from a string.
235 * @param string $url
236 * @return string
237 */
238 function urlencodeEmojis($url) {
239 // Get all emojis in the string.
240 $matches = [];
241 $emojiPattern = '/[\x{1F000}-\x{1F6FF}\x{1F900}-\x{1F9FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F1E6}-\x{1F1FF}]/u';
242 // next try: = '/[\x{1F6000}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F700}-\x{1F77F}\x{1F780}-\x{1F7FF}\x{1F800}-\x{1F8FF}\x{1F900}-\x{1F9FF}\x{1FA00}-\x{1FA6F}\x{1FA70}-\x{1FAFF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{2300}-\x{23FF}]/u';
243 $emojis = preg_match_all($emojiPattern, $url, $matches);
244
245 // If there are any emojis in the string, urlencode them.
246 if ($emojis > 0) {
247 foreach ($matches[0] as $emoji) {
248 $url = str_replace($emoji, urlencode($emoji), $url);
249 }
250 }
251
252 // Return the urlencoded string.
253 return $url;
254 }
255
256 /**
257 * Check whether a string contains any UTF-8 4-byte characters (codepoints > U+FFFF).
258 * These characters require utf8mb4 storage; they cannot exist in a utf8mb3 or latin1 column.
259 *
260 * @param string $string
261 * @return bool true if the string contains at least one 4-byte UTF-8 character
262 */
263 function containsUtf8mb4Characters(string $string): bool {
264 if ($string === '') {
265 return false;
266 }
267 // 4-byte UTF-8 sequences start with a byte in the range F0-F4
268 // followed by three continuation bytes (80-BF).
269 return (bool) preg_match('/[\xF0-\xF4][\x80-\xBF]{3}/', $string);
270 }
271
272 /** Uses explode() to return an array.
273 * @param string $string
274 * @return array<int, string>
275 */
276 function explodeNewline(string $string): array {
277 $normalized = str_replace("\r\n", "\n", $string);
278 $normalized = str_replace('\n', "\n", $normalized);
279 $result = array_filter(explode("\n", $this->strtolower($normalized)),
280 array($this, 'removeEmptyCustom'));
281
282 return $result;
283 }
284
285 /** First urldecode then json_decode the data, then return it.
286 * All of this encoding and decoding is so that [] characters are supported.
287 * @param string $data
288 * @return mixed
289 */
290 function decodeComplicatedData($data) {
291 $dataDecoded = urldecode($data);
292
293 // JSON.stringify escapes single quotes and json_decode does not want them to be escaped.
294 $dataStripped = str_replace("\'", "'", $dataDecoded);
295 $fixedData = json_decode($dataStripped, true);
296
297 $jsonErrorNumber = json_last_error();
298 if ($jsonErrorNumber != 0) {
299 $errorMsg = json_last_error_msg();
300 $lastMessagePart = ", Decoded: " . $dataDecoded;
301 if ($dataStripped != null && mb_strlen($dataStripped) > 1) {
302 $lastMessagePart = ", Stripped: " . $dataStripped;
303 }
304
305 $logger = abj_service('logging');
306 $logger->errorMessage("Error " . $jsonErrorNumber . " parsing JSON in "
307 . __CLASS__ . "->" . __FUNCTION__ . "(). Error message: " . $errorMsg . $lastMessagePart);
308 }
309
310 return $fixedData;
311 }
312
313 /**
314 * @param string|array<int, string> $needle
315 * @param string|array<int, mixed>|null $replacement
316 * @param string $haystack
317 * @return string
318 */
319 function str_replace($needle, $replacement, string $haystack): string {
320 if ($replacement === null) {
321 $replacement = '';
322 }
323 /** @var string $result */
324 $result = str_replace($needle, $replacement, $haystack);
325 return $result;
326 }
327
328 /**
329 * @param string $needle
330 * @param string $replacement
331 * @param string $haystack
332 * @return string
333 */
334 function single_str_replace(string $needle, string $replacement, string $haystack): string {
335 if ($haystack == "" || $this->strlen($haystack) == 0) {
336 return "";
337
338 } else if ($needle === '' || $this->strpos($haystack, $needle) === false) {
339 return $haystack;
340 }
341
342 $splitResult = explode($needle, $haystack);
343 $implodeResult = implode($replacement, $splitResult);
344
345 return $implodeResult;
346 }
347
348 /** Hash the last octet of an IP address.
349 * @param string $ip
350 * @return string
351 */
352 function md5lastOctet($ip) {
353 if (trim($ip) == "") {
354 return $ip;
355 }
356 $partsToStrip = 1;
357 $separatorChar = ".";
358
359 // split into parts
360 $parts = explode(".", $ip);
361 if (count($parts) == 1) {
362 $parts = explode(":", $ip);
363 // if exploding on : worked then assume we have an IPv6.
364 if (count($parts) > 1) {
365 $partsToStrip = max(count($parts) - 3, 1);
366 $separatorChar = ":";
367 }
368 }
369 $firstPart = implode($separatorChar, array_slice($parts, 0, count($parts) - $partsToStrip));
370 $partToHash = $parts[count($parts) - $partsToStrip];
371 $lastPart = $separatorChar . substr(base_convert(md5($partToHash), 16,32), 0, 12);
372
373 return $firstPart . $lastPart;
374 }
375
376 /** @return int */
377 abstract function ord(string $char): int;
378
379 /** @return string */
380 abstract function strtolower(string $string): string;
381
382 /** @return int */
383 abstract function strlen(string $string): int;
384
385 /** @return int|false */
386 abstract function strpos(string $haystack, string $needle, int $offset = 0);
387
388 /** @return string */
389 abstract function substr(string $str, int $start, ?int $length = null): string;
390
391 /**
392 * @param string $pattern
393 * @param string $string
394 * @param array<int, string>|null $regs
395 * @return bool|int
396 */
397 abstract function regexMatch(string $pattern, string $string, ?array &$regs = null);
398
399 /**
400 * @param string $pattern
401 * @param string $string
402 * @param array<int, string>|null $regs
403 * @return bool|int
404 */
405 abstract function regexMatchi(string $pattern, string $string, ?array &$regs = null);
406
407 /**
408 * @param string $pattern
409 * @param string $replacement
410 * @param string $string
411 * @return string|null
412 */
413 abstract function regexReplace($pattern, $replacement, $string);
414
415 /**
416 * @param string|null $string
417 * @return string
418 */
419 abstract function sanitizeInvalidUTF8(?string $string): string;
420
421 /** Used with array_filter()
422 * @param string $value
423 * @return boolean
424 */
425 function removeEmptyCustom($value) {
426 if ($value == null) {
427 return false;
428 }
429 return trim($value) !== '';
430 }
431
432 /**
433 * @return float|string
434 */
435 function getExecutionTime() {
436 $startTime = abj_service('request_context')->process_start_time;
437 if ($startTime !== null) {
438 $elapsedTime = microtime(true) - $startTime;
439
440 return $elapsedTime;
441 }
442
443 return '';
444 }
445
446 /** Replace constants and translations.
447 * @param string $text
448 * @return string
449 */
450 function doNormalReplacements($text) {
451 global $wpdb;
452
453 // known strings that do not exist in the translation file.
454 $knownReplacements = array(
455 '{ABJ404_STATUS_AUTO}' => ABJ404_STATUS_AUTO,
456 '{ABJ404_STATUS_MANUAL}' => ABJ404_STATUS_MANUAL,
457 '{ABJ404_STATUS_CAPTURED}' => ABJ404_STATUS_CAPTURED,
458 '{ABJ404_STATUS_IGNORED}' => ABJ404_STATUS_IGNORED,
459 '{ABJ404_STATUS_LATER}' => ABJ404_STATUS_LATER,
460 '{ABJ404_STATUS_REGEX}' => ABJ404_STATUS_REGEX,
461 '{ABJ404_TYPE_404_DISPLAYED}' => ABJ404_TYPE_404_DISPLAYED,
462 '{ABJ404_TYPE_POST}' => ABJ404_TYPE_POST,
463 '{ABJ404_TYPE_CAT}' => ABJ404_TYPE_CAT,
464 '{ABJ404_TYPE_TAG}' => ABJ404_TYPE_TAG,
465 '{ABJ404_TYPE_EXTERNAL}' => ABJ404_TYPE_EXTERNAL,
466 '{ABJ404_TYPE_HOME}' => ABJ404_TYPE_HOME,
467 '{ABJ404_HOME_URL}' => ABJ404_HOME_URL,
468 '{PLUGIN_NAME}' => PLUGIN_NAME,
469 '{ABJ404_VERSION}' => ABJ404_VERSION,
470 '{PHP_VERSION}' => phpversion(),
471 '{WP_VERSION}' => get_bloginfo('version'),
472 '{MYSQL_VERSION}' => $wpdb->db_version(),
473 '{ABJ404_MAX_AJAX_DROPDOWN_SIZE}' => ABJ404_MAX_AJAX_DROPDOWN_SIZE,
474 '{WP_MEMORY_LIMIT}' => WP_MEMORY_LIMIT,
475 '{MBSTRING}' => extension_loaded('mbstring') ? 'true' : 'false',
476 );
477
478 // replace known strings that do not exist in the translation file.
479 $text = $this->str_replace(array_keys($knownReplacements), array_values($knownReplacements), $text);
480
481 // Find the strings to replace in the content.
482 $re = '/\{(.+?)\}/x';
483 $stringsToReplace = array();
484 // TODO does this need to be $f->regexMatch?
485 preg_match_all($re, $text, $stringsToReplace, PREG_PATTERN_ORDER);
486
487 // Iterate through each string to replace.
488 foreach ($stringsToReplace[1] as $stringToReplace) {
489 $regexSearchString = '{' . $stringToReplace . '}';
490 $text = $this->str_replace($regexSearchString,
491 __($stringToReplace, '404-solution'), $text);
492 }
493
494 return $text;
495 }
496
497 /**
498 * @param string $directory
499 * @return boolean
500 */
501 function createDirectoryWithErrorMessages($directory) {
502 if (!is_dir($directory)) {
503 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
504 $unlinkErr = null;
505 if (!@unlink($directory)) {
506 $lastErr = error_get_last();
507 $unlinkErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
508 }
509
510 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
511 error_log("ABJ-404-SOLUTION (ERROR) " . date('Y-m-d H:i:s T') . ": Error creating the directory " .
512 $directory . ". A file with that name already exists" .
513 ($unlinkErr !== null ? " and unlink() failed: " . $unlinkErr : "") .
514 ". Action: aborting directory creation, returning false.");
515 return false;
516 }
517
518 } else if (!@mkdir($directory, 0755, true)) {
519 $lastErr = error_get_last();
520 $mkdirErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
521 error_log("ABJ-404-SOLUTION (ERROR) " . date('Y-m-d H:i:s T') . ": Error creating the directory " .
522 $directory . ". mkdir() failed: " . $mkdirErr .
523 ". Action: aborting directory creation, returning false.");
524 return false;
525 }
526 }
527 return true;
528 }
529
530 /** Turns ID|TYPE, SCORE into an array with id, type, score, link, and title.
531 *
532 * @param string $idAndType e.g. 15|POST is a page ID of 15 and a type POST.
533 * @param int|float $linkScore
534 * @param string $rowType if this is "image" then wp_get_attachment_image_src() is used.
535 * @param array<string, mixed>|null $options in case an external URL is used.
536 * @return array<string, mixed> an array with id, type, score, link, and title.
537 */
538 static function permalinkInfoToArray($idAndType, $linkScore, $rowType = null, $options = null) {
539 $abj404logging = abj_service('logging');
540 $permalink = array();
541
542 if ($idAndType == null) {
543 $permalink['score'] = -999;
544 return $permalink;
545 }
546
547 $meta = explode("|", $idAndType);
548
549 $permalink['id'] = $meta[0];
550 // Handle malformed data that doesn't contain a pipe separator
551 $permalink['type'] = isset($meta[1]) ? $meta[1] : '';
552 $permalink['score'] = $linkScore;
553 $permalink['status'] = 'unknown';
554 $permalink['link'] = 'dunno';
555
556 /** @var int $idInt */
557 $idInt = (int)$permalink['id'];
558
559 // Use strict comparison to avoid null/false == 0 issues with type coercion
560 // Cast to int for comparison since ABJ404_TYPE_* constants are integers
561 $typeInt = is_numeric($permalink['type']) ? (int)$permalink['type'] : -1;
562
563 if ($typeInt === ABJ404_TYPE_POST) {
564 if ($rowType == 'image') {
565 $imageURL = wp_get_attachment_image_src($idInt, "attached-image");
566 $permalink['link'] = is_array($imageURL) ? $imageURL[0] : '';
567 } else {
568 $permalink['link'] = get_permalink($idInt);
569 }
570 $permalink['title'] = get_the_title($idInt);
571 $permalink['status'] = get_post_status($idInt);
572
573 } else if ($typeInt === ABJ404_TYPE_TAG) {
574 $permalink['link'] = get_tag_link($idInt);
575 $tag = get_term($idInt);
576 if (is_object($tag) && !is_wp_error($tag)) {
577 $permalink['title'] = $tag->name;
578 } else {
579 $permalink['title'] = $permalink['link'];
580 }
581 if ($permalink['title'] == null || $permalink['title'] == '') {
582 $permalink['status'] = 'trash';
583 } else {
584 $permalink['status'] = 'published';
585 }
586
587 } else if ($typeInt === ABJ404_TYPE_CAT) {
588 // Use get_term_link() instead of get_category_link() to support
589 // custom taxonomies like WooCommerce product_cat.
590 $catTerm = get_term($idInt);
591 if (is_object($catTerm) && !is_wp_error($catTerm)) {
592 $termLink = get_term_link($catTerm);
593 $permalink['link'] = is_wp_error($termLink) ? get_category_link($idInt) : $termLink;
594 $permalink['title'] = $catTerm->name;
595 } else {
596 $permalink['link'] = get_category_link($idInt);
597 $permalink['title'] = $permalink['link'];
598 }
599 if ($permalink['title'] == null || $permalink['title'] == '') {
600 $permalink['status'] = 'trash';
601 } else {
602 $permalink['status'] = 'published';
603 }
604
605 } else if ($typeInt === ABJ404_TYPE_HOME) {
606 $permalink['link'] = get_home_url();
607 $permalink['title'] = get_bloginfo('name');
608 $permalink['status'] = 'published';
609
610 } else if ($typeInt === ABJ404_TYPE_EXTERNAL) {
611 $permalink['link'] = $permalink['id'];
612 if ($permalink['link'] == ABJ404_TYPE_EXTERNAL) {
613 if ($options == null) {
614 $abj404logic = abj_service('plugin_logic');
615 $options = $abj404logic->getOptions();
616 }
617 $urlDestination = (array_key_exists('dest404pageURL', $options) &&
618 isset($options['dest404pageURL']) ? $options['dest404pageURL'] :
619 'External URL not found in options ABJ404 Solution Error');
620 $permalink['link'] = $urlDestination;
621 }
622 $permalink['status'] = 'published';
623
624 } else if ($typeInt === ABJ404_TYPE_404_DISPLAYED) {
625 $permalink['link'] = '404';
626 $permalink['status'] = 'published';
627
628 } else {
629 $abj404logging->errorMessage("Unrecognized permalink type: " .
630 wp_kses_post((string)json_encode($permalink)));
631 }
632
633 if ($permalink['status'] === false) {
634 $permalink['status'] = 'trash';
635 }
636
637 // Decode anything that might be encoded to support utf8 characters
638 if (array_key_exists('link', $permalink)) {
639 $f = abj_service('functions');
640 $linkVal = is_string($permalink['link']) ? $permalink['link'] : (is_scalar($permalink['link']) ? (string)$permalink['link'] : '');
641 $permalink['link'] = $f->normalizeUrlString($linkVal);
642 }
643 $titleVal = (array_key_exists('title', $permalink) && is_string($permalink['title'])) ? $permalink['title'] : '';
644 $permalink['title'] = abj_service('functions')->normalizeUrlString($titleVal);
645
646 return $permalink;
647 }
648
649 /** Returns true if the file does not exist after calling this method.
650 * @param string $path
651 * @return boolean
652 */
653 static function safeUnlink($path) {
654 if (file_exists($path)) {
655 return unlink($path);
656 }
657 return true;
658 }
659
660 /** Returns true if the file does not exist after calling this method.
661 * @param string $path
662 * @return boolean
663 */
664 static function safeRmdir($path) {
665 if (file_exists($path)) {
666 return rmdir($path);
667 }
668 return true;
669 }
670
671 /** Recursively delete a directory.
672 * @param string $dir
673 * @throws Exception
674 * @return boolean
675 */
676 static function deleteDirectoryRecursively($dir) {
677 // if the directory isn't a part of our plugin then don't do it.
678 if (strpos($dir, ABJ404_PATH) === false) {
679 throw new Exception("Can't delete " . esc_html($dir));
680 }
681
682 // if it's already gone then we're done.
683 if (!file_exists($dir)) {
684 return true;
685 }
686
687 // if it's not a directory then delete the file.
688 if (!is_dir($dir)) {
689 return unlink($dir);
690 }
691
692 // get a list of all files (and directories) in the directory.
693 $items = scandir($dir);
694 if (!is_array($items)) { $items = array(); }
695 foreach ($items as $item) {
696 if ($item == '.' || $item == '..') {
697 continue;
698 }
699
700 // call self to delete the file/directory.
701 if (!self::deleteDirectoryRecursively($dir . DIRECTORY_SEPARATOR . $item)) {
702 return false;
703 }
704
705 }
706
707 // remove the original directory.
708 return rmdir($dir);
709 }
710
711 /** Reads an entire file at once into a string and return it.
712 * @param string $path
713 * @param boolean $appendExtraData
714 * @throws Exception
715 * @return string
716 */
717 static function readFileContents($path, $appendExtraData = true) {
718 // modify what's returned to make debugging easier.
719 $dataSupplement = self::getDataSupplement($path, $appendExtraData);
720
721 if (!file_exists($path)) {
722 throw new Exception("Error: Can't find file: " . esc_html($path));
723 }
724
725 $readResult = self::fileGetContentsWithTransientRetry($path);
726 $fileContents = $readResult['contents'];
727 if ($fileContents !== false) {
728 if (!empty($readResult['warnings'])) {
729 error_log(
730 '404 Solution: readFileContents recovered after transient file-open failure for '
731 . $path . '. ' . self::formatFileReadWarnings($readResult['warnings'])
732 );
733 }
734 return $dataSupplement['prefix'] . $fileContents . $dataSupplement['suffix'];
735 }
736
737 $warningDetails = self::formatFileReadWarnings($readResult['warnings']);
738
739 // if we can't read the file that way then try curl.
740 if (!function_exists('curl_init')) {
741 throw new Exception("Error: Can't read file: " . esc_html($path) .
742 "\n file_get_contents didn't work and curl is not installed." . $warningDetails);
743 }
744 $ch = curl_init();
745 curl_setopt($ch, CURLOPT_URL, 'file://' . $path);
746 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
747 $output = curl_exec($ch);
748
749 if ($output == null) {
750 throw new Exception("Error: Can't read file, even with cURL: " . esc_html($path) . $warningDetails);
751 }
752
753 if ($warningDetails !== '') {
754 error_log(
755 '404 Solution: readFileContents used cURL fallback after file_get_contents failed for '
756 . $path . '. ' . $warningDetails
757 );
758 }
759
760 return $dataSupplement['prefix'] . $output . $dataSupplement['suffix'];
761 }
762
763 /**
764 * Reads a file while retrying only the transient EINTR-style failures
765 * emitted by Patchwork's stream wrapper under high ParaTest load.
766 *
767 * @param string $path
768 * @return array{contents:string|false,warnings:array<int,array{errno:int,message:string,file:string,line:int}>}
769 */
770 private static function fileGetContentsWithTransientRetry($path): array {
771 $warnings = array();
772
773 for ($attempt = 1; $attempt <= self::FILE_READ_MAX_ATTEMPTS; $attempt++) {
774 $attemptWarnings = array();
775
776 set_error_handler(
777 static function(int $errno, string $errstr, string $errfile = '', int $errline = 0) use (&$attemptWarnings): bool {
778 if (($errno & (E_WARNING | E_USER_WARNING)) === 0) {
779 return false;
780 }
781 $attemptWarnings[] = array(
782 'errno' => $errno,
783 'message' => $errstr,
784 'file' => $errfile,
785 'line' => $errline,
786 );
787 return true;
788 },
789 E_WARNING | E_USER_WARNING
790 );
791 try {
792 $contents = file_get_contents($path);
793 } finally {
794 restore_error_handler();
795 }
796
797 if ($contents !== false) {
798 return array(
799 'contents' => $contents,
800 'warnings' => array_merge($warnings, $attemptWarnings),
801 );
802 }
803
804 $warnings = array_merge($warnings, $attemptWarnings);
805 if (!self::isTransientFileReadWarning($attemptWarnings)) {
806 break;
807 }
808
809 if ($attempt < self::FILE_READ_MAX_ATTEMPTS) {
810 usleep(self::FILE_READ_RETRY_BASE_US * $attempt);
811 }
812 }
813
814 return array(
815 'contents' => false,
816 'warnings' => $warnings,
817 );
818 }
819
820 /**
821 * @param array<int,array{errno:int,message:string,file:string,line:int}> $warnings
822 * @return bool
823 */
824 private static function isTransientFileReadWarning(array $warnings): bool {
825 foreach ($warnings as $warning) {
826 if (strpos(strtolower($warning['message']), 'interrupted system call') !== false) {
827 return true;
828 }
829 }
830 return false;
831 }
832
833 /**
834 * @param array<int,array{errno:int,message:string,file:string,line:int}> $warnings
835 * @return string
836 */
837 private static function formatFileReadWarnings(array $warnings): string {
838 if (empty($warnings)) {
839 return '';
840 }
841
842 $parts = array();
843 foreach ($warnings as $warning) {
844 $parts[] = sprintf(
845 '[%d] %s in %s:%d',
846 $warning['errno'],
847 $warning['message'],
848 $warning['file'],
849 $warning['line']
850 );
851 }
852
853 return "\n file_get_contents warnings: " . implode(' | ', $parts);
854 }
855
856 /**
857 * @param string $filePath
858 * @param bool $appendExtraData
859 * @return array<string, string>
860 */
861 private static function getDataSupplement(string $filePath, bool $appendExtraData = true): array {
862 $f = abj_service('functions');
863 $path = strtolower($filePath);
864
865 // remove the first part of the path because some people don't want to see
866 // it in the log file.
867 $homepath = dirname(ABSPATH);
868 $beginningOfPath = substr($path, 0, strlen($homepath));
869 if (strtolower($beginningOfPath) == strtolower($homepath)) {
870 $path = substr($path, strlen($homepath));
871 }
872
873 $supplement = array();
874
875 if (!$appendExtraData) {
876 $supplement['prefix'] = '';
877 $supplement['suffix'] = '';
878
879 } else if ($f->endsWithCaseInsensitive($path, '.sql')) {
880 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN ----- */ \n";
881 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END ----- */ \n";
882
883 } else if ($f->endsWithCaseInsensitive($path, '.html')) {
884 $supplement['prefix'] = "\n<!-- ------------------ " . $filePath . " BEGIN ----- --> \n";
885 $supplement['suffix'] = "\n<!-- ------------------ " . $filePath . " END ----- --> \n";
886
887 } else {
888 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN unknown file type in "
889 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
890 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END unknown file type in "
891 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
892 }
893
894 return $supplement;
895 }
896
897 /** Deletes the existing file at $filePath and puts the URL contents in it's place.
898 * @param string $url
899 * @param string $filePath
900 * @return void
901 */
902 function readURLtoFile(string $url, string $filePath): void {
903 $abj404logging = abj_service('logging');
904
905 ABJ_404_Solution_Functions::safeUnlink($filePath);
906
907 // if we can't read the file that way then try curl.
908 if (function_exists('curl_init')) {
909 try {
910 //This is the file where we save the information
911 $destinationFileWriteHandle = fopen($filePath, 'w+');
912 //Here is the file we are downloading, replace spaces with %20
913 $ch = curl_init($this->str_replace(" ", "%20", $url));
914 curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 '
915 . '(KHTML, like Gecko) Chrome/27.0.1453.94 Safari/537.36 (404 Solution WordPress Plugin)');
916 curl_setopt($ch, CURLOPT_TIMEOUT, 10);
917 // write curl response to file
918 curl_setopt($ch, CURLOPT_FILE, $destinationFileWriteHandle);
919 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
920 // get curl response
921 curl_exec($ch);
922 if (is_resource($destinationFileWriteHandle)) {
923 fclose($destinationFileWriteHandle);
924 }
925
926 if (file_exists($filePath) && filesize($filePath) > 0) {
927 return;
928 }
929 } catch (Exception $e) {
930 $abj404logging->debugMessage("curl didn't work for downloading a URL. " . $e->getMessage());
931 }
932 }
933
934 // Fallback to file_put_contents if curl didn't work or isn't available
935 ABJ_404_Solution_Functions::safeUnlink($filePath);
936 try {
937 $fileHandle = @fopen($url, 'r');
938 if ($fileHandle === false) {
939 $abj404logging->errorMessage("Failed to open URL for reading: " . $url);
940 return;
941 }
942 $result = file_put_contents($filePath, $fileHandle);
943 fclose($fileHandle);
944
945 if ($result === false) {
946 $abj404logging->errorMessage("Failed to write file: " . $filePath);
947 }
948 } catch (Exception $e) {
949 $abj404logging->errorMessage("Failed to download URL to file. URL: " . $url . ", Error: " . $e->getMessage());
950 }
951 }
952
953 /**
954 * @param string $haystack
955 * @param string $needle
956 * @return bool
957 */
958 function endsWithCaseInsensitive(string $haystack, string $needle): bool {
959 $f = abj_service('functions');
960 $length = $f->strlen($needle);
961 if ($f->strlen($haystack) < $length) {
962 return false;
963 }
964
965 $lowerNeedle = $this->strtolower($needle);
966 $lowerHay = $this->strtolower($haystack);
967
968 return ($f->substr($lowerHay, -$length) == $lowerNeedle);
969 }
970
971 /**
972 * @param string $haystack
973 * @param string $needle
974 * @return bool
975 */
976 function endsWithCaseSensitive(string $haystack, string $needle): bool {
977 $f = abj_service('functions');
978 $length = $f->strlen($needle);
979 if ($f->strlen($haystack) < $length) {
980 return false;
981 }
982
983 return ($f->substr($haystack, -$length) == $needle);
984 }
985
986 /** Sort the QUERY parts of the requested URL.
987 * This is in place because these are stored as part of the URL in the database and used for forwarding to another page.
988 * This is done because sometimes different query parts result in a completely different page. Therefore we have to
989 * take into account the query part of the URL (?query=part) when looking for a page to redirect to.
990 *
991 * Here we sort the query parts so that the same request will always look the same.
992 * @param array<string, string> $urlParts
993 * @return string
994 */
995 function sortQueryString(array $urlParts): string {
996 if (!array_key_exists('query', $urlParts) || $urlParts['query'] == '') {
997 return '';
998 }
999
1000 // parse it into an array
1001 $queryParts = array();
1002 parse_str($urlParts['query'], $queryParts);
1003
1004 // sort the parts
1005 ksort($queryParts);
1006
1007 $sanitized = $this->sanitizeUrlComponent($queryParts);
1008 $queryParts = is_array($sanitized) ? $sanitized : $queryParts;
1009 $built = http_build_query($queryParts, '', '&', PHP_QUERY_RFC3986);
1010 $decoded = rawurldecode($built);
1011 return $this->normalizeUrlString($decoded, array('decode' => false));
1012 }
1013
1014 /** We have to remove any 'p=##' because it will cause a 404 otherwise.
1015 * @param string $queryString
1016 * @return string
1017 */
1018 function removePageIDFromQueryString($queryString) {
1019 // parse the string
1020 $queryParts = array();
1021 parse_str($queryString, $queryParts);
1022
1023 // remove the page id
1024 if (array_key_exists('p', $queryParts)) {
1025 unset($queryParts['p']);
1026 }
1027
1028 // rebuild the string.
1029 $sanitized = $this->sanitizeUrlComponent($queryParts);
1030 $queryParts = is_array($sanitized) ? $sanitized : $queryParts;
1031 $built = http_build_query($queryParts, '', '&', PHP_QUERY_RFC3986);
1032 $decoded = rawurldecode($built);
1033 return $this->normalizeUrlString($decoded, array('decode' => false));
1034 }
1035
1036 /**
1037 * Check if a URL appears to contain regex patterns.
1038 *
1039 * This is used to warn users when a redirect URL looks like it contains
1040 * regex syntax but is not marked as a regex redirect.
1041 *
1042 * @param string $url The URL to check
1043 * @return bool True if the URL appears to contain regex patterns
1044 */
1045 static function urlLooksLikeRegex($url) {
1046 if (empty($url) || !is_string($url)) {
1047 return false;
1048 }
1049
1050 // Common regex patterns that are unlikely to appear in normal URLs
1051 $regexIndicators = array(
1052 '/\(\.\*\)/', // (.*) - common capture-all pattern
1053 '/\(\.\+\)/', // (.+) - one or more of anything
1054 '/\(\?\:/', // (?: - non-capturing group
1055 '/\(\?=/', // (?= - positive lookahead
1056 '/\(\?!/', // (?! - negative lookahead
1057 '/\[\^[^\]]+\]/', // [^...] - negated character class
1058 '/\[[a-z]-[a-z]\]/i', // [a-z] or [A-Z] - character range
1059 '/\[[0-9]-[0-9]\]/', // [0-9] - digit range
1060 '/\\\\d/', // \d - digit shorthand
1061 '/\\\\w/', // \w - word character shorthand
1062 '/\\\\s/', // \s - whitespace shorthand
1063 '/\.\*/', // .* - match anything (greedy)
1064 '/\.\+/', // .+ - match one or more of anything
1065 '/\.\?/', // .? - match zero or one of anything
1066 '/\{\d+,?\d*\}/', // {n} or {n,} or {n,m} - quantifiers
1067 '/\|/', // | - alternation (but common in some URLs, so check context)
1068 );
1069
1070 foreach ($regexIndicators as $pattern) {
1071 if (preg_match($pattern, $url)) {
1072 return true;
1073 }
1074 }
1075
1076 return false;
1077 }
1078
1079 // =========================================================================
1080 // Request parameter sanitization (relocated from DataAccessTrait_Stats, Phase 4)
1081 // =========================================================================
1082
1083 /**
1084 * @param string $name The key to retrieve the value for.
1085 * @param string|null $defaultValue The value to return if the value is not set.
1086 * @return string The sanitized value.
1087 */
1088 function getPostOrGetSanitize($name, $defaultValue = null) {
1089 $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
1090 if ($returnValue === null && $name === 'action') {
1091 $returnValue = isset($_GET['abj404action']) ? $_GET['abj404action'] : (isset($_POST['abj404action']) ? $_POST['abj404action'] : null);
1092 }
1093 if ($returnValue !== null) {
1094 if (is_array($returnValue)) {
1095 $returnValue = array_map('sanitize_text_field', $returnValue);
1096 } else {
1097 $returnValue = sanitize_text_field($returnValue);
1098 }
1099 }
1100 $finalValue = $returnValue ?? $defaultValue;
1101 return is_string($finalValue) ? $finalValue : (is_string($defaultValue) ? $defaultValue : '');
1102 }
1103
1104 /**
1105 * @param string $name The key to retrieve the value for.
1106 * @param string|null $defaultValue The value to return if the value is not set.
1107 * @return string|array<string>|null The normalized URL value.
1108 */
1109 function getPostOrGetSanitizeUrl($name, $defaultValue = null) {
1110 $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
1111 if ($returnValue === null) {
1112 return $defaultValue;
1113 }
1114
1115 $f = abj_service('functions');
1116 $unslash = function($value) {
1117 return function_exists('wp_unslash') ? wp_unslash($value) : $value;
1118 };
1119
1120 if (is_array($returnValue)) {
1121 return array_map(function($value) use ($f, $unslash) {
1122 $value = $unslash($value);
1123 return $f->normalizeUrlString($value);
1124 }, $returnValue);
1125 }
1126
1127 $returnValue = $unslash($returnValue);
1128 return $f->normalizeUrlString($returnValue);
1129 }
1130
1131 }
1132