| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* URL-level matching: regex-based matching, slug matching, image detection, |
| 9 |
* permalink lookup, cache retrieval, and URL utility helpers. |
| 10 |
* |
| 11 |
* Extracted from SpellCheckerTrait_URLMatching as a standalone class with |
| 12 |
* explicit dependency injection. |
| 13 |
*/ |
| 14 |
class ABJ_404_Solution_SpellURLMatcher { |
| 15 |
|
| 16 |
/** @var ABJ_404_Solution_Functions */ |
| 17 |
private $f; |
| 18 |
|
| 19 |
/** @var ABJ_404_Solution_PluginLogic */ |
| 20 |
private $logic; |
| 21 |
|
| 22 |
/** @var ABJ_404_Solution_Logging */ |
| 23 |
private $logger; |
| 24 |
|
| 25 |
/** @var ABJ_404_Solution_ContentRepository */ |
| 26 |
private $contentRepository; |
| 27 |
|
| 28 |
/** @var mixed */ |
| 29 |
private $viewReadService; |
| 30 |
|
| 31 |
/** @var string|int|null */ |
| 32 |
private $custom404PageID; |
| 33 |
|
| 34 |
/** @var array<string, string> */ |
| 35 |
private array $preparedRegexPatternCache = array(); |
| 36 |
|
| 37 |
/** |
| 38 |
* @param ABJ_404_Solution_Functions $functions |
| 39 |
* @param ABJ_404_Solution_PluginLogic $logic |
| 40 |
* @param ABJ_404_Solution_Logging $logger |
| 41 |
* @param ABJ_404_Solution_ContentRepository $contentRepository |
| 42 |
* @param mixed $viewReadService |
| 43 |
* @param string|int|null $custom404PageID |
| 44 |
*/ |
| 45 |
public function __construct($functions, $logic, $logger, $contentRepository, $viewReadService, $custom404PageID) { |
| 46 |
$this->f = $functions; |
| 47 |
$this->logic = $logic; |
| 48 |
$this->logger = $logger; |
| 49 |
$this->contentRepository = $contentRepository; |
| 50 |
$this->viewReadService = $viewReadService; |
| 51 |
$this->custom404PageID = $custom404PageID; |
| 52 |
} |
| 53 |
|
| 54 |
/** @return array<int, array<string, mixed>> */ |
| 55 |
private function getRedirectsWithRegEx(): array { |
| 56 |
if (!is_object($this->viewReadService) || !is_callable(array($this->viewReadService, 'getRedirectsWithRegEx'))) { |
| 57 |
return array(); |
| 58 |
} |
| 59 |
$rows = call_user_func(array($this->viewReadService, 'getRedirectsWithRegEx')); |
| 60 |
return $this->normalizeRows($rows); |
| 61 |
} |
| 62 |
|
| 63 |
/** @return array<int, array<string, mixed>> */ |
| 64 |
private function getManualRedirectsWithRegexMetachars(): array { |
| 65 |
if (!is_object($this->viewReadService) || !is_callable(array($this->viewReadService, 'getManualRedirectsWithRegexMetachars'))) { |
| 66 |
return array(); |
| 67 |
} |
| 68 |
$rows = call_user_func(array($this->viewReadService, 'getManualRedirectsWithRegexMetachars')); |
| 69 |
return $this->normalizeRows($rows); |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* @param mixed $rows |
| 74 |
* @return array<int, array<string, mixed>> |
| 75 |
*/ |
| 76 |
private function normalizeRows($rows): array { |
| 77 |
if (!is_array($rows)) { |
| 78 |
return array(); |
| 79 |
} |
| 80 |
$normalized = array(); |
| 81 |
foreach ($rows as $row) { |
| 82 |
if (is_array($row)) { |
| 83 |
$normalized[] = $row; |
| 84 |
} |
| 85 |
} |
| 86 |
return $normalized; |
| 87 |
} |
| 88 |
|
| 89 |
/** Find a match using the user-defined regex patterns. |
| 90 |
* @param string $requestedURL |
| 91 |
* @param array<string, mixed>|null $options |
| 92 |
* @return array<string, mixed>|null |
| 93 |
*/ |
| 94 |
function getPermalinkUsingRegEx(string $requestedURL, $options = null) { |
| 95 |
if (!is_array($options)) { |
| 96 |
$options = $this->logic->getOptions(); |
| 97 |
} |
| 98 |
$isDebug = $this->logger->isDebug(); |
| 99 |
|
| 100 |
$regexURLsRows = $this->getRedirectsWithRegEx(); |
| 101 |
|
| 102 |
$manualWithMetachars = $this->getManualRedirectsWithRegexMetachars(); |
| 103 |
if (!empty($manualWithMetachars)) { |
| 104 |
$filtered = array(); |
| 105 |
foreach ($manualWithMetachars as $manualRow) { |
| 106 |
$manualUrl = isset($manualRow['url']) && is_string($manualRow['url']) ? $manualRow['url'] : ''; |
| 107 |
if (!ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($manualUrl)) { |
| 108 |
continue; |
| 109 |
} |
| 110 |
$glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($manualUrl); |
| 111 |
$manualRow['url'] = $glob['url']; |
| 112 |
$filtered[] = $manualRow; |
| 113 |
} |
| 114 |
if (!empty($filtered)) { |
| 115 |
if ($isDebug) { |
| 116 |
$this->logger->debugMessage( |
| 117 |
"Runtime regex fallback: trying " . count($filtered) . |
| 118 |
" MANUAL row(s) with regex metachars against URL: " . $requestedURL |
| 119 |
); |
| 120 |
} |
| 121 |
$regexURLsRows = array_merge($regexURLsRows, $filtered); |
| 122 |
} |
| 123 |
} |
| 124 |
|
| 125 |
foreach ($regexURLsRows as $row) { |
| 126 |
$regexURL = $row['url']; |
| 127 |
|
| 128 |
if ($isDebug) { |
| 129 |
abj_service('request_context')->debug_info = 'Applying custom regex "' . $regexURL . '" to URL: ' . |
| 130 |
$requestedURL; |
| 131 |
} |
| 132 |
$regexURLStr = is_string($regexURL) ? $regexURL : ''; |
| 133 |
$preparedURL = $this->getPreparedRegexPattern($regexURLStr); |
| 134 |
if (@$this->f->regexMatch($preparedURL, $requestedURL)) { |
| 135 |
if ($isDebug) { |
| 136 |
abj_service('request_context')->debug_info = 'Cleared after regex.'; |
| 137 |
} |
| 138 |
$rowType = isset($row['type']) && is_scalar($row['type']) ? (int)$row['type'] : 0; |
| 139 |
$rowDest = isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : ''; |
| 140 |
if ($rowType === (int)ABJ404_TYPE_EXTERNAL) { |
| 141 |
$permalink = array( |
| 142 |
'id' => 0, |
| 143 |
'type' => ABJ404_TYPE_EXTERNAL, |
| 144 |
'link' => $rowDest, |
| 145 |
'title' => '', |
| 146 |
'score' => 100, |
| 147 |
); |
| 148 |
} else { |
| 149 |
$idAndType = $rowDest . '|' . $row['type']; |
| 150 |
$permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($idAndType, 0, |
| 151 |
null, $options); |
| 152 |
} |
| 153 |
$permalink['matching_regex'] = $regexURL; |
| 154 |
$permalink['code'] = isset($row['code']) && is_scalar($row['code']) ? (int)$row['code'] : 0; |
| 155 |
$originalPermalink = $isDebug ? $permalink : null; |
| 156 |
|
| 157 |
$permLinkStr = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : ''; |
| 158 |
$hasCaptureGroup = ($this->f->strpos($regexURLStr, '(') !== false); |
| 159 |
$hasReplacementToken = ($this->f->strpos($permLinkStr, '$') !== false); |
| 160 |
if ($hasCaptureGroup && $hasReplacementToken) { |
| 161 |
$results = array(); |
| 162 |
@$this->f->regexMatch($regexURLStr, $requestedURL, $results); |
| 163 |
|
| 164 |
$final = $permLinkStr; |
| 165 |
for ($x = 1; $x < count($results); $x++) { |
| 166 |
$final = $this->f->str_replace('$' . $x, $results[$x], $final); |
| 167 |
} |
| 168 |
|
| 169 |
$permalink['link'] = $final; |
| 170 |
} |
| 171 |
|
| 172 |
if ($isDebug) { |
| 173 |
$this->logger->debugMessage("Found matching regex. Original permalink" . |
| 174 |
json_encode($originalPermalink) . ", final: " . |
| 175 |
json_encode($permalink)); |
| 176 |
} |
| 177 |
|
| 178 |
return $permalink; |
| 179 |
} |
| 180 |
|
| 181 |
if ($isDebug) { |
| 182 |
abj_service('request_context')->debug_info = 'Cleared after regex.'; |
| 183 |
} |
| 184 |
} |
| 185 |
|
| 186 |
return null; |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* @param string $regexURL |
| 191 |
* @return string |
| 192 |
*/ |
| 193 |
private function getPreparedRegexPattern($regexURL) { |
| 194 |
if (isset($this->preparedRegexPatternCache[$regexURL])) { |
| 195 |
return $this->preparedRegexPatternCache[$regexURL]; |
| 196 |
} |
| 197 |
|
| 198 |
$prepared = $this->f->str_replace('/', '\/', $regexURL); |
| 199 |
$this->preparedRegexPatternCache[$regexURL] = $prepared; |
| 200 |
return $prepared; |
| 201 |
} |
| 202 |
|
| 203 |
/** Find a match using an exact slug match. |
| 204 |
* @param string $requestedURL |
| 205 |
* @return array<string, mixed>|null |
| 206 |
*/ |
| 207 |
function getPermalinkUsingSlug(string $requestedURL) { |
| 208 |
|
| 209 |
$exploded = array_filter(explode('/', $requestedURL)); |
| 210 |
if (count($exploded) === 0) { |
| 211 |
return null; |
| 212 |
} |
| 213 |
$postSlug = end($exploded); |
| 214 |
$postsBySlugRows = $this->contentRepository->getPublishedPagesAndPostsIDs($postSlug); |
| 215 |
if (count($postsBySlugRows) == 1) { |
| 216 |
$post = reset($postsBySlugRows); |
| 217 |
$postId = (is_object($post) && property_exists($post, 'id')) ? $post->id : null; |
| 218 |
if ($postId === null) { |
| 219 |
return null; |
| 220 |
} |
| 221 |
$permalink = array(); |
| 222 |
$permalink['id'] = $postId; |
| 223 |
$permalink['type'] = ABJ404_TYPE_POST; |
| 224 |
$permalink['score'] = 100; |
| 225 |
$permalink['title'] = get_the_title($postId); |
| 226 |
$permalink['link'] = get_permalink($postId); |
| 227 |
|
| 228 |
return $permalink; |
| 229 |
|
| 230 |
} else if (count($postsBySlugRows) > 1) { |
| 231 |
$this->logger->debugMessage("More than one post found with the slug, so no redirect was " . |
| 232 |
"created. Slug: " . $postSlug); |
| 233 |
} else { |
| 234 |
$this->logger->debugMessage("No posts or pages matching slug: " . esc_html($postSlug)); |
| 235 |
} |
| 236 |
|
| 237 |
return null; |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* @param string $requestedURL |
| 242 |
* @return bool |
| 243 |
*/ |
| 244 |
function requestIsForAnImage(string $requestedURL): bool { |
| 245 |
$imageExtensions = array(".jpg", ".jpeg", ".gif", ".png", ".tif", ".tiff", ".bmp", ".pdf", |
| 246 |
".jif", ".jif", ".jp2", ".jpx", ".j2k", ".j2c", ".pcd"); |
| 247 |
|
| 248 |
$returnVal = false; |
| 249 |
|
| 250 |
foreach ($imageExtensions as $extension) { |
| 251 |
if ($this->f->endsWithCaseInsensitive($requestedURL, $extension)) { |
| 252 |
$returnVal = true; |
| 253 |
break; |
| 254 |
} |
| 255 |
} |
| 256 |
|
| 257 |
return $returnVal; |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* @param array<int, object> $rowsAsObject |
| 262 |
* @return array<int, array<string, mixed>> |
| 263 |
*/ |
| 264 |
function getOnlyIDandTermID(array $rowsAsObject): array { |
| 265 |
$rows = array(); |
| 266 |
$objectRow = array_pop($rowsAsObject); |
| 267 |
while ($objectRow != null) { |
| 268 |
$rows[] = array( |
| 269 |
'id' => property_exists($objectRow, 'id') == true ? $objectRow->id : null, |
| 270 |
'term_id' => property_exists($objectRow, 'term_id') == true ? $objectRow->term_id : null, |
| 271 |
'url' => property_exists($objectRow, 'url') == true ? $objectRow->url : null |
| 272 |
); |
| 273 |
$objectRow = array_pop($rowsAsObject); |
| 274 |
} |
| 275 |
|
| 276 |
return $rows; |
| 277 |
} |
| 278 |
|
| 279 |
/** |
| 280 |
* @param string $requestedURL |
| 281 |
* @return array<int|string, mixed> |
| 282 |
*/ |
| 283 |
function getFromPermalinkCache(string $requestedURL): array { |
| 284 |
$ctx = abj_service('request_context'); |
| 285 |
if (!empty($ctx->permalinks_found)) { |
| 286 |
$permalinks = json_decode($ctx->permalinks_found, true); |
| 287 |
if (is_array($permalinks)) { |
| 288 |
return $permalinks; |
| 289 |
} |
| 290 |
} |
| 291 |
|
| 292 |
$returnValue = $this->contentRepository->getSpellingPermalinksFromCache($requestedURL); |
| 293 |
if (is_array($returnValue) && !empty($returnValue)) { |
| 294 |
return $returnValue; |
| 295 |
} |
| 296 |
|
| 297 |
return array(); |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* @param int $id |
| 302 |
* @param string $rowType |
| 303 |
* @return string|null |
| 304 |
* @throws Exception |
| 305 |
*/ |
| 306 |
function getPermalink($id, $rowType) { |
| 307 |
if ($rowType == 'pages') { |
| 308 |
$link = $this->contentRepository->getPermalinkFromCache($id); |
| 309 |
|
| 310 |
if ($link === null || trim((string)$link) === '') { |
| 311 |
$linkResult = get_the_permalink($id); |
| 312 |
$link = ($linkResult !== false) ? $linkResult : null; |
| 313 |
} |
| 314 |
return $this->f->normalizeUrlString($link); |
| 315 |
|
| 316 |
} else if ($rowType == 'tags') { |
| 317 |
return $this->f->normalizeUrlString(get_tag_link($id)); |
| 318 |
|
| 319 |
} else if ($rowType == 'categories') { |
| 320 |
return $this->f->normalizeUrlString(get_category_link($id)); |
| 321 |
|
| 322 |
} else if ($rowType == 'image') { |
| 323 |
$src = wp_get_attachment_image_src($id, "attached-image"); |
| 324 |
if ($src == false || !is_array($src)) { |
| 325 |
return null; |
| 326 |
} |
| 327 |
return $this->f->normalizeUrlString($src[0]); |
| 328 |
|
| 329 |
} else { |
| 330 |
throw new \Exception("Unknown row type ..."); // allow-raw-error: assertion, should never reach user |
| 331 |
} |
| 332 |
} |
| 333 |
|
| 334 |
/** Turns "/abc/defg" into "defg" |
| 335 |
* @param string $url |
| 336 |
* @return string |
| 337 |
*/ |
| 338 |
function getLastURLPart($url) { |
| 339 |
$parts = explode("/", $url); |
| 340 |
$lastPart = ''; |
| 341 |
for ($i = count($parts) - 1; $i >= 0; $i--) { |
| 342 |
$lastPart = $parts[$i]; |
| 343 |
if (trim($lastPart) != "") { |
| 344 |
break; |
| 345 |
} |
| 346 |
} |
| 347 |
|
| 348 |
if (trim($lastPart) == "") { |
| 349 |
return $url; |
| 350 |
} |
| 351 |
|
| 352 |
return $lastPart; |
| 353 |
} |
| 354 |
|
| 355 |
} |
| 356 |
|