PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / Functions.php

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

968 lines 34.6 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 $unlinkErr = null;
502 if (!@unlink($directory)) {
503 $lastErr = error_get_last();
504 $unlinkErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
505 }
506
507 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
508 error_log("ABJ-404-SOLUTION (ERROR) " . date('Y-m-d H:i:s T') . ": Error creating the directory " .
509 $directory . ". A file with that name already exists" .
510 ($unlinkErr !== null ? " and unlink() failed: " . $unlinkErr : "") .
511 ". Action: aborting directory creation, returning false.");
512 return false;
513 }
514
515 } else if (!@mkdir($directory, 0755, true)) {
516 $lastErr = error_get_last();
517 $mkdirErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
518 error_log("ABJ-404-SOLUTION (ERROR) " . date('Y-m-d H:i:s T') . ": Error creating the directory " .
519 $directory . ". mkdir() failed: " . $mkdirErr .
520 ". Action: aborting directory creation, returning false.");
521 return false;
522 }
523 }
524 return true;
525 }
526
527 /** Turns ID|TYPE, SCORE into an array with id, type, score, link, and title.
528 *
529 * @param string $idAndType e.g. 15|POST is a page ID of 15 and a type POST.
530 * @param int|float $linkScore
531 * @param string $rowType if this is "image" then wp_get_attachment_image_src() is used.
532 * @param array<string, mixed>|null $options in case an external URL is used.
533 * @return array<string, mixed> an array with id, type, score, link, and title.
534 */
535 static function permalinkInfoToArray($idAndType, $linkScore, $rowType = null, $options = null) {
536 $abj404logging = abj_service('logging');
537 $permalink = array();
538
539 if ($idAndType == null) {
540 $permalink['score'] = -999;
541 return $permalink;
542 }
543
544 $meta = explode("|", $idAndType);
545
546 $permalink['id'] = $meta[0];
547 // Handle malformed data that doesn't contain a pipe separator
548 $permalink['type'] = isset($meta[1]) ? $meta[1] : '';
549 $permalink['score'] = $linkScore;
550 $permalink['status'] = 'unknown';
551 $permalink['link'] = 'dunno';
552
553 /** @var int $idInt */
554 $idInt = (int)$permalink['id'];
555
556 // Use strict comparison to avoid null/false == 0 issues with type coercion
557 // Cast to int for comparison since ABJ404_TYPE_* constants are integers
558 $typeInt = is_numeric($permalink['type']) ? (int)$permalink['type'] : -1;
559
560 if ($typeInt === ABJ404_TYPE_POST) {
561 if ($rowType == 'image') {
562 $imageURL = wp_get_attachment_image_src($idInt, "attached-image");
563 $permalink['link'] = is_array($imageURL) ? $imageURL[0] : '';
564 } else {
565 $permalink['link'] = get_permalink($idInt);
566 }
567 $permalink['title'] = get_the_title($idInt);
568 $permalink['status'] = get_post_status($idInt);
569
570 } else if ($typeInt === ABJ404_TYPE_TAG) {
571 $permalink['link'] = get_tag_link($idInt);
572 $tag = get_term($idInt);
573 if (is_object($tag) && !is_wp_error($tag)) {
574 $permalink['title'] = $tag->name;
575 } else {
576 $permalink['title'] = $permalink['link'];
577 }
578 if ($permalink['title'] == null || $permalink['title'] == '') {
579 $permalink['status'] = 'trash';
580 } else {
581 $permalink['status'] = 'published';
582 }
583
584 } else if ($typeInt === ABJ404_TYPE_CAT) {
585 // Use get_term_link() instead of get_category_link() to support
586 // custom taxonomies like WooCommerce product_cat.
587 $catTerm = get_term($idInt);
588 if (is_object($catTerm) && !is_wp_error($catTerm)) {
589 $termLink = get_term_link($catTerm);
590 $permalink['link'] = is_wp_error($termLink) ? get_category_link($idInt) : $termLink;
591 $permalink['title'] = $catTerm->name;
592 } else {
593 $permalink['link'] = get_category_link($idInt);
594 $permalink['title'] = $permalink['link'];
595 }
596 if ($permalink['title'] == null || $permalink['title'] == '') {
597 $permalink['status'] = 'trash';
598 } else {
599 $permalink['status'] = 'published';
600 }
601
602 } else if ($typeInt === ABJ404_TYPE_HOME) {
603 $permalink['link'] = get_home_url();
604 $permalink['title'] = get_bloginfo('name');
605 $permalink['status'] = 'published';
606
607 } else if ($typeInt === ABJ404_TYPE_EXTERNAL) {
608 $permalink['link'] = $permalink['id'];
609 if ($permalink['link'] == ABJ404_TYPE_EXTERNAL) {
610 if ($options == null) {
611 $abj404logic = abj_service('plugin_logic');
612 $options = $abj404logic->getOptions();
613 }
614 $urlDestination = (array_key_exists('dest404pageURL', $options) &&
615 isset($options['dest404pageURL']) ? $options['dest404pageURL'] :
616 'External URL not found in options ABJ404 Solution Error');
617 $permalink['link'] = $urlDestination;
618 }
619 $permalink['status'] = 'published';
620
621 } else if ($typeInt === ABJ404_TYPE_404_DISPLAYED) {
622 $permalink['link'] = '404';
623 $permalink['status'] = 'published';
624
625 } else {
626 $abj404logging->errorMessage("Unrecognized permalink type: " .
627 wp_kses_post((string)json_encode($permalink)));
628 }
629
630 if ($permalink['status'] === false) {
631 $permalink['status'] = 'trash';
632 }
633
634 // Decode anything that might be encoded to support utf8 characters
635 if (array_key_exists('link', $permalink)) {
636 $f = abj_service('functions');
637 $linkVal = is_string($permalink['link']) ? $permalink['link'] : (is_scalar($permalink['link']) ? (string)$permalink['link'] : '');
638 $permalink['link'] = $f->normalizeUrlString($linkVal);
639 }
640 $titleVal = (array_key_exists('title', $permalink) && is_string($permalink['title'])) ? $permalink['title'] : '';
641 $permalink['title'] = abj_service('functions')->normalizeUrlString($titleVal);
642
643 return $permalink;
644 }
645
646 /** Returns true if the file does not exist after calling this method.
647 * @param string $path
648 * @return boolean
649 */
650 static function safeUnlink($path) {
651 if (file_exists($path)) {
652 return unlink($path);
653 }
654 return true;
655 }
656
657 /** Returns true if the file does not exist after calling this method.
658 * @param string $path
659 * @return boolean
660 */
661 static function safeRmdir($path) {
662 if (file_exists($path)) {
663 return rmdir($path);
664 }
665 return true;
666 }
667
668 /** Recursively delete a directory.
669 * @param string $dir
670 * @throws Exception
671 * @return boolean
672 */
673 static function deleteDirectoryRecursively($dir) {
674 // if the directory isn't a part of our plugin then don't do it.
675 if (strpos($dir, ABJ404_PATH) === false) {
676 throw new Exception("Can't delete " . esc_html($dir));
677 }
678
679 // if it's already gone then we're done.
680 if (!file_exists($dir)) {
681 return true;
682 }
683
684 // if it's not a directory then delete the file.
685 if (!is_dir($dir)) {
686 return unlink($dir);
687 }
688
689 // get a list of all files (and directories) in the directory.
690 $items = scandir($dir);
691 if (!is_array($items)) { $items = array(); }
692 foreach ($items as $item) {
693 if ($item == '.' || $item == '..') {
694 continue;
695 }
696
697 // call self to delete the file/directory.
698 if (!self::deleteDirectoryRecursively($dir . DIRECTORY_SEPARATOR . $item)) {
699 return false;
700 }
701
702 }
703
704 // remove the original directory.
705 return rmdir($dir);
706 }
707
708 /** Reads an entire file at once into a string and return it.
709 * @param string $path
710 * @param boolean $appendExtraData
711 * @throws Exception
712 * @return string
713 */
714 static function readFileContents($path, $appendExtraData = true) {
715 // modify what's returned to make debugging easier.
716 $dataSupplement = self::getDataSupplement($path, $appendExtraData);
717
718 if (!file_exists($path)) {
719 throw new Exception("Error: Can't find file: " . esc_html($path));
720 }
721
722 $fileContents = file_get_contents($path);
723 if ($fileContents !== false) {
724 return $dataSupplement['prefix'] . $fileContents . $dataSupplement['suffix'];
725 }
726
727 // if we can't read the file that way then try curl.
728 if (!function_exists('curl_init')) {
729 throw new Exception("Error: Can't read file: " . esc_html($path) .
730 "\n file_get_contents didn't work and curl is not installed.");
731 }
732 $ch = curl_init();
733 curl_setopt($ch, CURLOPT_URL, 'file://' . $path);
734 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
735 $output = curl_exec($ch);
736
737 if ($output == null) {
738 throw new Exception("Error: Can't read file, even with cURL: " . esc_html($path));
739 }
740
741 return $dataSupplement['prefix'] . $output . $dataSupplement['suffix'];
742 }
743
744 /**
745 * @param string $filePath
746 * @param bool $appendExtraData
747 * @return array<string, string>
748 */
749 private static function getDataSupplement(string $filePath, bool $appendExtraData = true): array {
750 $f = abj_service('functions');
751 $path = strtolower($filePath);
752
753 // remove the first part of the path because some people don't want to see
754 // it in the log file.
755 $homepath = dirname(ABSPATH);
756 $beginningOfPath = substr($path, 0, strlen($homepath));
757 if (strtolower($beginningOfPath) == strtolower($homepath)) {
758 $path = substr($path, strlen($homepath));
759 }
760
761 $supplement = array();
762
763 if (!$appendExtraData) {
764 $supplement['prefix'] = '';
765 $supplement['suffix'] = '';
766
767 } else if ($f->endsWithCaseInsensitive($path, '.sql')) {
768 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN ----- */ \n";
769 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END ----- */ \n";
770
771 } else if ($f->endsWithCaseInsensitive($path, '.html')) {
772 $supplement['prefix'] = "\n<!-- ------------------ " . $filePath . " BEGIN ----- --> \n";
773 $supplement['suffix'] = "\n<!-- ------------------ " . $filePath . " END ----- --> \n";
774
775 } else {
776 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN unknown file type in "
777 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
778 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END unknown file type in "
779 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
780 }
781
782 return $supplement;
783 }
784
785 /** Deletes the existing file at $filePath and puts the URL contents in it's place.
786 * @param string $url
787 * @param string $filePath
788 * @return void
789 */
790 function readURLtoFile(string $url, string $filePath): void {
791 $abj404logging = abj_service('logging');
792
793 ABJ_404_Solution_Functions::safeUnlink($filePath);
794
795 // if we can't read the file that way then try curl.
796 if (function_exists('curl_init')) {
797 try {
798 //This is the file where we save the information
799 $destinationFileWriteHandle = fopen($filePath, 'w+');
800 //Here is the file we are downloading, replace spaces with %20
801 $ch = curl_init($this->str_replace(" ", "%20", $url));
802 curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 '
803 . '(KHTML, like Gecko) Chrome/27.0.1453.94 Safari/537.36 (404 Solution WordPress Plugin)');
804 curl_setopt($ch, CURLOPT_TIMEOUT, 10);
805 // write curl response to file
806 curl_setopt($ch, CURLOPT_FILE, $destinationFileWriteHandle);
807 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
808 // get curl response
809 curl_exec($ch);
810 if (is_resource($destinationFileWriteHandle)) {
811 fclose($destinationFileWriteHandle);
812 }
813
814 if (file_exists($filePath) && filesize($filePath) > 0) {
815 return;
816 }
817 } catch (Exception $e) {
818 $abj404logging->debugMessage("curl didn't work for downloading a URL. " . $e->getMessage());
819 }
820 }
821
822 // Fallback to file_put_contents if curl didn't work or isn't available
823 ABJ_404_Solution_Functions::safeUnlink($filePath);
824 try {
825 $fileHandle = @fopen($url, 'r');
826 if ($fileHandle === false) {
827 $abj404logging->errorMessage("Failed to open URL for reading: " . $url);
828 return;
829 }
830 $result = file_put_contents($filePath, $fileHandle);
831 fclose($fileHandle);
832
833 if ($result === false) {
834 $abj404logging->errorMessage("Failed to write file: " . $filePath);
835 }
836 } catch (Exception $e) {
837 $abj404logging->errorMessage("Failed to download URL to file. URL: " . $url . ", Error: " . $e->getMessage());
838 }
839 }
840
841 /**
842 * @param string $haystack
843 * @param string $needle
844 * @return bool
845 */
846 function endsWithCaseInsensitive(string $haystack, string $needle): bool {
847 $f = abj_service('functions');
848 $length = $f->strlen($needle);
849 if ($f->strlen($haystack) < $length) {
850 return false;
851 }
852
853 $lowerNeedle = $this->strtolower($needle);
854 $lowerHay = $this->strtolower($haystack);
855
856 return ($f->substr($lowerHay, -$length) == $lowerNeedle);
857 }
858
859 /**
860 * @param string $haystack
861 * @param string $needle
862 * @return bool
863 */
864 function endsWithCaseSensitive(string $haystack, string $needle): bool {
865 $f = abj_service('functions');
866 $length = $f->strlen($needle);
867 if ($f->strlen($haystack) < $length) {
868 return false;
869 }
870
871 return ($f->substr($haystack, -$length) == $needle);
872 }
873
874 /** Sort the QUERY parts of the requested URL.
875 * This is in place because these are stored as part of the URL in the database and used for forwarding to another page.
876 * This is done because sometimes different query parts result in a completely different page. Therefore we have to
877 * take into account the query part of the URL (?query=part) when looking for a page to redirect to.
878 *
879 * Here we sort the query parts so that the same request will always look the same.
880 * @param array<string, string> $urlParts
881 * @return string
882 */
883 function sortQueryString(array $urlParts): string {
884 if (!array_key_exists('query', $urlParts) || $urlParts['query'] == '') {
885 return '';
886 }
887
888 // parse it into an array
889 $queryParts = array();
890 parse_str($urlParts['query'], $queryParts);
891
892 // sort the parts
893 ksort($queryParts);
894
895 $sanitized = $this->sanitizeUrlComponent($queryParts);
896 $queryParts = is_array($sanitized) ? $sanitized : $queryParts;
897 $built = http_build_query($queryParts, '', '&', PHP_QUERY_RFC3986);
898 $decoded = rawurldecode($built);
899 return $this->normalizeUrlString($decoded, array('decode' => false));
900 }
901
902 /** We have to remove any 'p=##' because it will cause a 404 otherwise.
903 * @param string $queryString
904 * @return string
905 */
906 function removePageIDFromQueryString($queryString) {
907 // parse the string
908 $queryParts = array();
909 parse_str($queryString, $queryParts);
910
911 // remove the page id
912 if (array_key_exists('p', $queryParts)) {
913 unset($queryParts['p']);
914 }
915
916 // rebuild the string.
917 $sanitized = $this->sanitizeUrlComponent($queryParts);
918 $queryParts = is_array($sanitized) ? $sanitized : $queryParts;
919 $built = http_build_query($queryParts, '', '&', PHP_QUERY_RFC3986);
920 $decoded = rawurldecode($built);
921 return $this->normalizeUrlString($decoded, array('decode' => false));
922 }
923
924 /**
925 * Check if a URL appears to contain regex patterns.
926 *
927 * This is used to warn users when a redirect URL looks like it contains
928 * regex syntax but is not marked as a regex redirect.
929 *
930 * @param string $url The URL to check
931 * @return bool True if the URL appears to contain regex patterns
932 */
933 static function urlLooksLikeRegex($url) {
934 if (empty($url) || !is_string($url)) {
935 return false;
936 }
937
938 // Common regex patterns that are unlikely to appear in normal URLs
939 $regexIndicators = array(
940 '/\(\.\*\)/', // (.*) - common capture-all pattern
941 '/\(\.\+\)/', // (.+) - one or more of anything
942 '/\(\?\:/', // (?: - non-capturing group
943 '/\(\?=/', // (?= - positive lookahead
944 '/\(\?!/', // (?! - negative lookahead
945 '/\[\^[^\]]+\]/', // [^...] - negated character class
946 '/\[[a-z]-[a-z]\]/i', // [a-z] or [A-Z] - character range
947 '/\[[0-9]-[0-9]\]/', // [0-9] - digit range
948 '/\\\\d/', // \d - digit shorthand
949 '/\\\\w/', // \w - word character shorthand
950 '/\\\\s/', // \s - whitespace shorthand
951 '/\.\*/', // .* - match anything (greedy)
952 '/\.\+/', // .+ - match one or more of anything
953 '/\.\?/', // .? - match zero or one of anything
954 '/\{\d+,?\d*\}/', // {n} or {n,} or {n,m} - quantifiers
955 '/\|/', // | - alternation (but common in some URLs, so check context)
956 );
957
958 foreach ($regexIndicators as $pattern) {
959 if (preg_match($pattern, $url)) {
960 return true;
961 }
962 }
963
964 return false;
965 }
966
967 }
968