PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.1.13, at includes/Functions.php

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