| 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 |
|