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

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

978 lines 44.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Frontend request pipeline for 404 processing and redirects.
9 *
10 * Keeps runtime flow isolated from admin hook wiring.
11 */
12 class ABJ_404_Solution_FrontendRequestPipeline {
13
14 /** @var ABJ_404_Solution_PluginLogic */
15 private $logic;
16
17 /** @var ABJ_404_Solution_RedirectsRepository */
18 private $redirectsRepository;
19
20 /** @var mixed */
21 private $logsRepository;
22
23 /** @var ABJ_404_Solution_Logging */
24 private $logger;
25
26 /** @var ABJ_404_Solution_Functions */
27 private $f;
28
29 /** @var ABJ_404_Solution_SpellChecker */
30 private $spellChecker;
31
32 /** @var array<int, mixed> Engines from apply_filters — may contain non-engine items */
33 private $matchingEngines;
34
35 /** @var list<array{step: string, outcome: string, detail: string}> */
36 private $trace = [];
37
38 /**
39 * @param ABJ_404_Solution_PluginLogic $pluginLogic
40 * @param ABJ_404_Solution_RedirectsRepository $redirectsRepository
41 * @param ABJ_404_Solution_Logging $logging
42 * @param ABJ_404_Solution_Functions $functions
43 * @param ABJ_404_Solution_SpellChecker $spellChecker
44 * @param array<int, mixed> $matchingEngines
45 * @param mixed|null $logsRepository Log writer. Accepts legacy doubles with logRedirectHit().
46 */
47 function __construct($pluginLogic, $redirectsRepository, $logging, $functions, $spellChecker, array $matchingEngines = [], $logsRepository = null) {
48 $this->logic = $pluginLogic;
49 $this->redirectsRepository = $redirectsRepository;
50 $this->logger = $logging;
51 $this->f = $functions;
52 $this->spellChecker = $spellChecker;
53 $this->matchingEngines = $matchingEngines;
54 $this->logsRepository = $logsRepository !== null ? $logsRepository :
55 (is_object($redirectsRepository) && is_callable([$redirectsRepository, 'logRedirectHit'])
56 ? $redirectsRepository
57 : abj_service('logs_repository'));
58 }
59
60 /**
61 * @param string $name
62 * @param array<int, mixed> $args
63 * @param mixed $default
64 * @return mixed
65 */
66 private function callWpFunction($name, $args = array(), $default = null) {
67 if (!function_exists($name)) {
68 return $default;
69 }
70 return call_user_func_array($name, $args);
71 }
72
73 /** @return int */
74 private function wpTypePost() {
75 return defined('ABJ404_TYPE_POST') ? constant('ABJ404_TYPE_POST') : 1;
76 }
77
78 /**
79 * @param string $step
80 * @param string $outcome
81 * @param string $detail
82 * @return void
83 */
84 private function addTraceStep(string $step, string $outcome, string $detail = ''): void {
85 $this->trace[] = ['step' => $step, 'outcome' => $outcome, 'detail' => $detail];
86 }
87
88 /**
89 * @param string $requestedUrl
90 * @param string $action
91 * @param string $matchReason
92 * @param string|null $requestedUrlDetail
93 * @param list<array{step: string, outcome: string, detail: string}>|null $pipelineTrace
94 * @return void
95 */
96 private function logRedirectHit(string $requestedUrl, string $action, string $matchReason, ?string $requestedUrlDetail = null, ?array $pipelineTrace = null): void {
97 if (!is_object($this->logsRepository) || !is_callable([$this->logsRepository, 'logRedirectHit'])) {
98 return;
99 }
100 call_user_func([$this->logsRepository, 'logRedirectHit'], $requestedUrl, $action, $matchReason, $requestedUrlDetail, $pipelineTrace);
101 }
102
103 /** @return string */
104 private function wpGuessEngineClassName(): string {
105 return 'ABJ_404_Solution_WordPressUrlGuessEngine';
106 }
107
108 /**
109 * @param string $requestedURL
110 * @return bool
111 */
112 private function shouldRunWordPressGuessFallback(string $requestedURL): bool {
113 $enabled = true;
114 if (function_exists('apply_filters')) {
115 $enabled = (bool) apply_filters(
116 'abj404_wp_guess_fallback_enabled',
117 true,
118 $requestedURL
119 );
120 }
121 if (!$enabled) {
122 return false;
123 }
124
125 return ABJ_404_Solution_EngineProfileResolver::getInstance()
126 ->isEngineEnabledForUrl($requestedURL, $this->wpGuessEngineClassName());
127 }
128
129 /**
130 * Normalize a guessed URL into the same request-shape used by $requestedURL:
131 * path (relative to WP home directory) plus sorted query string.
132 *
133 * @param string $guessedUrl
134 * @return string
135 */
136 private function normalizeGuessedUrlToRequestShape(string $guessedUrl): string {
137 $normalized = $this->f->normalizeUrlString($guessedUrl);
138 if ($normalized === '') {
139 return '';
140 }
141
142 $parts = parse_url($normalized);
143 if (!is_array($parts)) {
144 return '';
145 }
146
147 $path = isset($parts['path']) ? $parts['path'] : '/';
148 if ($path === '') {
149 $path = '/';
150 }
151 $path = $this->logic->removeHomeDirectory($path);
152 if ($path === '') {
153 $path = '/';
154 }
155 if ($path[0] !== '/') {
156 $path = '/' . $path;
157 }
158
159 /** @var array<string, string> $urlPartsStr */
160 $urlPartsStr = array_map('strval', $parts);
161 $sortedQuery = $this->f->sortQueryString($urlPartsStr);
162 return $path . $sortedQuery;
163 }
164
165 /**
166 * Emit benchmark header immediately for paths that may not reach WordPress send_headers.
167 *
168 * @return void
169 */
170 private function emitBenchmarkHeadersIfEnabled() {
171 if (function_exists('abj404_benchmark_emit_headers')) {
172 abj404_benchmark_emit_headers();
173 }
174 }
175
176 /**
177 * @param float $startTime
178 * @return void
179 */
180 private function recordRedirectLookupTiming($startTime) {
181 if (!function_exists('abj404_benchmark_record_redirect_lookup')) {
182 return;
183 }
184 $elapsedMs = (microtime(true) - (float)$startTime) * 1000.0;
185 abj404_benchmark_record_redirect_lookup($elapsedMs);
186 }
187
188 /** @return void */
189 function processRedirectAllRequests() {
190 $this->trace = [];
191 $options = $this->logic->getOptions();
192
193 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
194 if ($userRequest === null) {
195 return;
196 }
197 $pathOnly = $userRequest->getPath();
198 $urlSlugOnly = $userRequest->getOnlyTheSlug();
199
200 $this->logic->initializeIgnoreValues($pathOnly, $urlSlugOnly);
201 $requestedURL = $userRequest->getPathWithSortedQueryString();
202
203 $this->tryRegexRedirect($options, $requestedURL);
204
205 if (is_admin() || !is_404()) {
206 $this->logger->warn("If REDIRECT_ALL_REQUESTS is turned on then a regex redirect must be in place.");
207 }
208 }
209
210
211 /**
212 * Handle the empty-URL branch: single page / page redirect cleanup.
213 *
214 * @param string $requestedURL
215 * @param array<string, mixed> $redirect
216 * @param array<string, mixed> $options
217 * @return void
218 */
219 private function handleEmptyUrlSinglePageRedirect(string $requestedURL, array $redirect, array $options): void {
220 if ($this->callWpFunction('is_single', array(), false) || $this->callWpFunction('is_page', array(), false)) {
221 if (!$this->callWpFunction('is_feed', array(), false) &&
222 !$this->callWpFunction('is_trackback', array(), false) &&
223 !$this->callWpFunction('is_preview', array(), false)) {
224 $theID = $this->callWpFunction('get_the_ID', array(), 0);
225 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($theID . "|" . $this->wpTypePost(), 0, null, $options);
226
227 $permLinkVal = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
228 $urlParts = parse_url($permLinkVal);
229 if (!is_array($urlParts) || !isset($urlParts['path'])) {
230 return;
231 }
232 $perma_link = $urlParts['path'];
233
234 $pageQueryVar = $this->callWpFunction('get_query_var', array('page'), false);
235 $paged = ($pageQueryVar !== false && is_string($pageQueryVar)) ? esc_html($pageQueryVar) : false;
236 if (!$paged === false) {
237 if (isset($urlParts['query']) && $urlParts['query'] != "") {
238 $urlParts['query'] .= "&page=" . $paged;
239 } else {
240 if ($this->f->substr($perma_link, -1) == "/") {
241 $perma_link .= $paged . "/";
242 } else {
243 $perma_link .= "/" . $paged;
244 }
245 }
246 }
247
248 /** @var array<string, string> $urlPartsStr */
249 $urlPartsStr = array_map('strval', $urlParts);
250 $perma_link .= $this->f->sortQueryString($urlPartsStr);
251
252 if (@$options['auto_redirects'] == '1') {
253 if ($requestedURL != $perma_link) {
254 if ($redirect['id'] != '0') {
255 $this->processRedirect($requestedURL, $redirect, 'single page 3');
256 } else {
257 $spFinalDest = isset($permalink['id']) && is_scalar($permalink['id']) ? (string)$permalink['id'] : '';
258 $spDefaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '';
259 // Legacy audit marker for source-inspection tests: this->dao->setupRedirect(esc_url($requestedURL)
260 $this->redirectsRepository->setupRedirect(esc_url($requestedURL), (string)ABJ404_STATUS_AUTO, (string)$this->wpTypePost(), $spFinalDest, $spDefaultRedirect, 0, 'single page');
261 $spLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
262 // Legacy audit marker for source-inspection tests: this->dao->logRedirectHit($requestedURL, $spLink, 'single page'
263 $this->logRedirectHit($requestedURL, $spLink, 'single page', null, $this->trace);
264 $this->logic->forceRedirect(esc_url($spLink), (int)$spDefaultRedirect);
265 exit;
266 }
267 }
268 }
269
270 if ($requestedURL == $perma_link) {
271 if ($options['remove_matches'] == '1') {
272 if ($redirect['id'] != '0') {
273 $redirectIdVal = isset($redirect['id']) && is_scalar($redirect['id']) ? (string)$redirect['id'] : '0';
274 // Legacy audit marker for source-inspection tests: this->dao->deleteRedirect($redirectIdVal)
275 $this->redirectsRepository->deleteRedirect($redirectIdVal);
276 }
277 }
278 }
279 }
280 }
281 }
282 /**
283 * Process the 404 path.
284 * @return void
285 */
286 function process404() {
287 if (!is_404() || is_admin()) {
288 // SAFE_BAIL: not a 404 or in wp-admin — nothing for us to do.
289 return;
290 }
291
292 // Self-heal a stale DB_VERSION on the frontend so end users get redirects
293 // without needing an admin visit (task 233). If recovery cannot close the
294 // gap (lock held, cooldown active, or migration repeatedly throws), fall
295 // through to a degraded redirect lookup (task 234) so manual redirects
296 // keep serving instead of every 404 falling to the theme 404 page.
297 $degradedMode = false;
298 if (defined('ABJ404_VERSION')) {
299 $options = $this->logic->getOptions(true);
300 if (isset($options['DB_VERSION']) && $options['DB_VERSION'] != ABJ404_VERSION) {
301 $options = $this->recoverDbVersionIfStale($options);
302 if (!isset($options['DB_VERSION']) || $options['DB_VERSION'] != ABJ404_VERSION) {
303 $degradedMode = true;
304 }
305 }
306 }
307
308 abj_service('request_context')->process_start_time = microtime(true);
309 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
310 if ($userRequest === null) {
311 // SAFE_BAIL: no user request context — cannot resolve a URL to look up.
312 return;
313 }
314
315 $pathOnly = $userRequest->getPath();
316 $urlSlugOnly = $userRequest->getOnlyTheSlug();
317 $this->logic->initializeIgnoreValues($pathOnly, $urlSlugOnly);
318 $this->trace = [];
319
320 if (abj_service('request_context')->ignore_donotprocess) {
321 $this->addTraceStep('Ignore list', 'Matched — request ignored');
322 $this->logRedirectHit($pathOnly, '404', 'ignore_donotprocess', null, $this->trace);
323 $this->emitBenchmarkHeadersIfEnabled();
324 // SAFE_BAIL: ignore_donotprocess matched — admin opted this UA out.
325 return;
326 }
327 $this->addTraceStep('Ignore list', 'Not ignored');
328
329 $requestedURL = $userRequest->getPathWithSortedQueryString();
330 $requestedURLWithoutComments = $requestedURL;
331 if ($this->f->strpos($requestedURL, '/comment-page-') !== false) {
332 $withoutComments = $userRequest->getRequestURIWithoutCommentsPage();
333 if (is_string($withoutComments)) {
334 $requestedURLWithoutComments = $withoutComments;
335 }
336 }
337
338 $options = $this->logic->getOptions();
339
340 $lookupStart = microtime(true);
341 $redirect = $this->redirectsRepository->getActiveRedirectForURL($requestedURL, $degradedMode);
342 $this->recordRedirectLookupTiming($lookupStart);
343 $this->logAReallyLongDebugMessage($options, $requestedURL, $redirect);
344 $autoRedirectsAreOn = !array_key_exists('auto_redirects', $options) || $options['auto_redirects'] == '1';
345 $deferredAutoRedirect = null;
346
347 if ($requestedURL != "") {
348 $matched = $this->evaluateRedirectCandidate($redirect, '', $options);
349 if ($matched !== null) {
350 if ($this->isAutoRedirect($matched)) {
351 $deferredAutoRedirect = $matched;
352 } else {
353 $this->processRedirect($requestedURL, $matched, 'existing');
354 exit;
355 }
356 }
357
358 if ($requestedURLWithoutComments != $requestedURL) {
359 $lookupStart = microtime(true);
360 $wcRedirect = $this->redirectsRepository->getActiveRedirectForURL($requestedURLWithoutComments, $degradedMode);
361 $this->recordRedirectLookupTiming($lookupStart);
362 $matched = $this->evaluateRedirectCandidate($wcRedirect, ' (without comments)', $options);
363 if ($matched !== null) {
364 if ($this->isAutoRedirect($matched)) {
365 if ($deferredAutoRedirect === null) {
366 $deferredAutoRedirect = $matched;
367 }
368 } else {
369 $this->processRedirect($requestedURL, $matched, 'existing');
370 exit;
371 }
372 }
373 }
374
375 $sentTo404Page = $this->tryRegexRedirect($options, $requestedURL);
376 if ($sentTo404Page) {
377 $this->emitBenchmarkHeadersIfEnabled();
378 return;
379 }
380
381 if ($deferredAutoRedirect !== null) {
382 $this->processRedirect($requestedURL, $deferredAutoRedirect, 'existing');
383 exit;
384 }
385
386 if ($autoRedirectsAreOn) {
387 $matchRequest = new ABJ_404_Solution_MatchRequest($requestedURL, $urlSlugOnly, $options);
388
389 $matchResult = $this->runMatchingEngines($matchRequest);
390 if ($matchResult !== null) {
391 $defaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '';
392 $this->redirectsRepository->setupRedirect($requestedURL, (string)ABJ404_STATUS_AUTO, $matchResult->getType(), $matchResult->getId(), $defaultRedirect, 0, $matchResult->getEngineName(), $matchResult->getScore());
393
394 // Resolve link via WordPress API to ensure correct site prefix
395 // (cached URLs from permalink_cache may omit subdirectory prefix)
396 $resolvedLink = $matchResult->getLink();
397 if ($matchResult->getId() !== '' && $matchResult->getId() !== '0') {
398 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray(
399 $matchResult->getId() . '|' . $matchResult->getType(), 0
400 );
401 if (is_array($permalink) && !empty($permalink['link']) && is_string($permalink['link']) && $permalink['link'] !== 'dunno') {
402 $resolvedLink = $permalink['link'];
403 }
404 }
405
406 $this->logRedirectHit($requestedURL, $resolvedLink, $matchResult->getEngineName(), null, $this->trace);
407 $this->logic->forceRedirect(esc_url($resolvedLink), (int)$defaultRedirect);
408 exit;
409 }
410 }
411
412 if (!$autoRedirectsAreOn) {
413 $this->triggerAsyncSuggestionsIfNeeded($requestedURL);
414 $this->emitBenchmarkHeadersIfEnabled();
415 $this->logic->sendTo404Page($requestedURL, 'Do not create redirects per the options.', true, $options);
416 return;
417 }
418 } else {
419 $this->handleEmptyUrlSinglePageRedirect($requestedURL, $redirect, $options);
420 }
421
422 // Last resort: defer to WordPress's built-in URL guessing.
423 // WordPress matches partial slugs via LIKE 'slug%' — a complementary
424 // strategy to our Levenshtein-based spell checker. For example,
425 // /redes matches /redes-social because the slug starts with "redes".
426 $wpGuessFallbackEnabled = $autoRedirectsAreOn && $this->shouldRunWordPressGuessFallback($requestedURL);
427 $wpGuessEngineName = __('wp guess', '404-solution');
428 if ($wpGuessFallbackEnabled && function_exists('redirect_guess_404_permalink')) {
429 $wpGuess = redirect_guess_404_permalink();
430 if ($wpGuess && is_string($wpGuess)) {
431 $normalizedGuess = $this->normalizeGuessedUrlToRequestShape($wpGuess);
432 if ($normalizedGuess !== '' && $normalizedGuess === $requestedURL) {
433 $this->addTraceStep('WordPress URL guess', 'Ignored self-redirect guess', $wpGuess);
434 } else {
435 $this->addTraceStep('WordPress URL guess', 'Matched candidate', $wpGuess);
436 $defaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect'])
437 ? (string)$options['default_redirect'] : '301';
438
439 // Resolve post ID so the redirect record links to the destination post.
440 $wpGuessPostId = '';
441 $wpGuessType = (string)$this->wpTypePost();
442 if (function_exists('url_to_postid')) {
443 $postId = url_to_postid($wpGuess);
444 if ($postId > 0) {
445 $wpGuessPostId = (string)$postId;
446 }
447 }
448
449 $wpGuessResult = new ABJ_404_Solution_MatchResult(
450 $wpGuessPostId !== '' ? $wpGuessPostId : '0',
451 $wpGuessType,
452 $wpGuess,
453 '',
454 0.0,
455 $wpGuessEngineName
456 );
457 if ($this->isExcluded($wpGuessResult, $options)) {
458 $this->addTraceStep('WordPress URL guess', 'Excluded destination — skipped', $wpGuess);
459 } else {
460 $this->addTraceStep('WordPress URL guess', 'Matched — redirecting', $wpGuess);
461 $this->redirectsRepository->setupRedirect($requestedURL, (string)ABJ404_STATUS_AUTO,
462 $wpGuessType, $wpGuessPostId, $defaultRedirect, 0, $wpGuessEngineName);
463 $this->logRedirectHit($requestedURL, $wpGuess, $wpGuessEngineName, null, $this->trace);
464 $redirectSent = $this->logic->forceRedirect(esc_url($wpGuess), (int)$defaultRedirect);
465 if ($redirectSent !== false) {
466 exit;
467 }
468 $this->addTraceStep('WordPress URL guess', 'Redirect blocked — continued', $wpGuess);
469 }
470 }
471 }
472 $this->addTraceStep('WordPress URL guess', 'No match');
473 } elseif (!$wpGuessFallbackEnabled) {
474 $reason = !$autoRedirectsAreOn ? 'auto_redirects off' : 'engine profile/filter';
475 $this->addTraceStep('WordPress URL guess', 'Skipped — ' . $reason);
476 }
477
478 $this->logic->tryNormalPostQuery($options);
479 $this->addTraceStep('Result', 'No redirect — showed 404 page');
480 $this->logRedirectHit($requestedURL, '404', 'gave up.', null, $this->trace);
481 $this->triggerAsyncSuggestionsIfNeeded($requestedURL);
482 $this->emitBenchmarkHeadersIfEnabled();
483 $this->logic->sendTo404Page($requestedURL, '', true, $options);
484 }
485
486 /**
487 * Evaluate an already-fetched redirect: check actionability, health, and conditions.
488 *
489 * @param array<string, mixed>|null $redirect The redirect row from getActiveRedirectForURL().
490 * @param string $labelSuffix Appended to trace step labels (e.g. ' (without comments)').
491 * @param array<string, mixed> $options Plugin options.
492 * @return array<string, mixed>|null The redirect row if actionable, null otherwise.
493 */
494 private function evaluateRedirectCandidate(?array $redirect, string $labelSuffix, array $options): ?array {
495 if ($redirect === null) {
496 if ($labelSuffix === '') {
497 $this->addTraceStep('Redirect lookup', 'No matching redirect');
498 }
499 return null;
500 }
501
502 $typeHomeInt = defined('ABJ404_TYPE_HOME') ? (int)ABJ404_TYPE_HOME : 5;
503 $redirectType = isset($redirect['type']) && is_scalar($redirect['type']) ? (int)$redirect['type'] : 0;
504
505 if ($redirect['id'] == '0' || ($redirect['final_dest'] == '0' && $redirectType !== $typeHomeInt)) {
506 if ($labelSuffix === '') {
507 $this->addTraceStep('Redirect lookup', 'No matching redirect');
508 }
509 return null;
510 }
511
512 $this->addTraceStep('Redirect lookup' . $labelSuffix, 'Found existing redirect',
513 'rule #' . (is_scalar($redirect['id']) ? (string)$redirect['id'] : '?'));
514
515 $deadIds = function_exists('get_transient') ? get_transient('abj404_dead_dest_ids') : false;
516 $redirectIdStr = isset($redirect['id']) && is_scalar($redirect['id']) ? (string)$redirect['id'] : '0';
517 if (is_array($deadIds) && in_array($redirectIdStr, $deadIds, true)) {
518 $this->addTraceStep('Health check' . $labelSuffix, 'Destination unreachable — skipped');
519 return null;
520 }
521
522 $condEvaluator = new ABJ_404_Solution_RedirectConditionEvaluator($this->redirectsRepository);
523 $redirectIdForCond = is_scalar($redirect['id']) ? (int)$redirect['id'] : 0;
524 if ($condEvaluator->shouldApplyRedirect($redirectIdForCond)) {
525 $this->addTraceStep('Conditions' . $labelSuffix, 'All conditions met');
526 return $redirect;
527 }
528
529 $condTrace = $condEvaluator->getLastEvaluationTrace();
530 $condDetail = implode(', ', array_map(function ($c) {
531 $label = str_replace('_', ' ', $c['type']);
532 return $label . ': ' . ($c['result'] ? 'passed' : 'failed');
533 }, $condTrace));
534 $this->addTraceStep('Conditions' . $labelSuffix, 'Blocked by conditions', $condDetail);
535 return null;
536 }
537
538 /**
539 * @param array<string, mixed> $redirect
540 * @return bool
541 */
542 private function isAutoRedirect(array $redirect): bool {
543 return isset($redirect['status']) && is_scalar($redirect['status']) &&
544 (int)$redirect['status'] === (int)ABJ404_STATUS_AUTO;
545 }
546
547 /**
548 * Iterate registered matching engines in order. First non-null result wins.
549 *
550 * @param ABJ_404_Solution_MatchRequest $request
551 * @return ABJ_404_Solution_MatchResult|null
552 */
553 private function runMatchingEngines(ABJ_404_Solution_MatchRequest $request): ?ABJ_404_Solution_MatchResult {
554 $enginesToRun = ABJ_404_Solution_EngineProfileResolver::getInstance()
555 ->resolve($request->getRequestedURL(), $this->matchingEngines);
556
557 foreach ($enginesToRun as $engine) {
558 if (!($engine instanceof ABJ_404_Solution_MatchingEngine)) {
559 $this->logger->warn('Matching engine is not an instance of ABJ_404_Solution_MatchingEngine: ' .
560 (is_object($engine) ? get_class($engine) : gettype($engine)));
561 continue;
562 }
563
564 try {
565 if (!$engine->shouldRun($request)) {
566 $this->logger->debugMessage('Engine skipped: ' . $engine->getName());
567 $this->addTraceStep('Engine: ' . $engine->getName(), 'Skipped', 'not applicable');
568 continue;
569 }
570
571 $result = $engine->match($request);
572
573 if ($result === null) {
574 $this->logger->debugMessage('Engine returned no match: ' . $engine->getName());
575 $this->addTraceStep('Engine: ' . $engine->getName(), 'No match');
576 continue;
577 }
578
579 if ($result->getLink() === '') {
580 $this->logger->debugMessage('Engine returned empty link, skipping: ' . $engine->getName());
581 $this->addTraceStep('Engine: ' . $engine->getName(), 'No match', 'empty link');
582 continue;
583 }
584
585 if ($this->isExcluded($result, $request->getOptions())) {
586 $this->logger->debugMessage('Match excluded: ' . $engine->getName() . ' id=' . $result->getId());
587 $this->addTraceStep('Engine: ' . $engine->getName(), 'Excluded', 'post #' . $result->getId());
588 continue;
589 }
590
591 $this->logger->debugMessage('Engine matched: ' . $engine->getName());
592 $this->addTraceStep(
593 'Engine: ' . $engine->getName(),
594 'Matched',
595 'score ' . $result->getScore() . '' . $result->getLink()
596 );
597 return $result;
598 } catch (\Throwable $e) {
599 $this->logger->warn('Matching engine error (' . $engine->getName() . '): ' . $e->getMessage());
600 $this->addTraceStep('Engine: ' . $engine->getName(), 'Error', $e->getMessage());
601 continue;
602 }
603 }
604
605 $this->addTraceStep('Suggestion engines', 'No match found');
606 return null;
607 }
608
609 /**
610 * Check whether a match result should be excluded from redirect suggestions.
611 *
612 * Checks post meta (_abj404_exclude), term meta, and the legacy excludePages[] option.
613 * External and Home redirect types are never excluded.
614 *
615 * @param ABJ_404_Solution_MatchResult $result
616 * @param array<string, mixed> $options
617 * @return bool
618 */
619 private function isExcluded(ABJ_404_Solution_MatchResult $result, array $options): bool {
620 $type = $result->getType();
621 $id = $result->getId();
622
623 $typeInt = is_numeric($type) ? (int)$type : 0;
624 $typePost = defined('ABJ404_TYPE_POST') ? (int)ABJ404_TYPE_POST : 1;
625 $typeCat = defined('ABJ404_TYPE_CAT') ? (int)ABJ404_TYPE_CAT : 2;
626 $typeTag = defined('ABJ404_TYPE_TAG') ? (int)ABJ404_TYPE_TAG : 3;
627 $typeExternal = defined('ABJ404_TYPE_EXTERNAL') ? (int)ABJ404_TYPE_EXTERNAL : 4;
628 $typeHome = defined('ABJ404_TYPE_HOME') ? (int)ABJ404_TYPE_HOME : 5;
629
630 // External and Home types are never excluded.
631 if ($typeInt === $typeExternal || $typeInt === $typeHome) {
632 return false;
633 }
634
635 // Empty or non-numeric ID — nothing to check.
636 if ($id === '' || !is_numeric($id)) {
637 return false;
638 }
639
640 $idInt = (int)$id;
641
642 // Check per-item meta.
643 if ($typeInt === $typePost) {
644 $meta = $this->callWpFunction('get_post_meta', [$idInt, '_abj404_exclude', true], '');
645 if ($meta === '1') {
646 return true;
647 }
648 } elseif ($typeInt === $typeCat || $typeInt === $typeTag) {
649 $meta = $this->callWpFunction('get_term_meta', [$idInt, '_abj404_exclude', true], '');
650 if ($meta === '1') {
651 return true;
652 }
653 }
654
655 // Check legacy excludePages[] option (covers ALL engines, not just Spelling).
656 $excludePagesRaw = isset($options['excludePages[]']) ? $options['excludePages[]'] : '';
657 $excludePagesJson = is_string($excludePagesRaw) ? $excludePagesRaw : '';
658 if (trim($excludePagesJson) !== '') {
659 $excludePages = json_decode($excludePagesJson);
660 if (!is_array($excludePages)) {
661 $excludePages = [$excludePages];
662 }
663 $key = $id . '|' . $type;
664 foreach ($excludePages as $entry) {
665 if (!is_string($entry) && !is_scalar($entry)) {
666 continue;
667 }
668 if ((string)$entry === $key) {
669 return true;
670 }
671 }
672 }
673
674 return false;
675 }
676
677 /**
678 * @param array<string, mixed> $options
679 * @param string $requestedURL
680 * @return bool True if sent to configured default 404 page.
681 */
682 function tryRegexRedirect($options, $requestedURL) {
683 $lookupStart = microtime(true);
684 $regexPermalink = $this->spellChecker->getPermalinkUsingRegEx($requestedURL, $options);
685 $this->recordRedirectLookupTiming($lookupStart);
686 if (!empty($regexPermalink)) {
687 $regexMatchingUrl = isset($regexPermalink['matching_regex']) && is_string($regexPermalink['matching_regex']) ? $regexPermalink['matching_regex'] : '';
688 $regexLink = isset($regexPermalink['link']) && is_string($regexPermalink['link']) ? $regexPermalink['link'] : '';
689 $regexAction = isset($regexPermalink['link']) && is_string($regexPermalink['link']) ? $regexPermalink['link'] : '';
690 $regexType = isset($regexPermalink['type']) && (is_int($regexPermalink['type']) || is_string($regexPermalink['type'])) ? $regexPermalink['type'] : -1;
691 $regexDefaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (int)$options['default_redirect'] : 0;
692 $regexCode = isset($regexPermalink['code']) && is_numeric($regexPermalink['code']) && (int)$regexPermalink['code'] > 0
693 ? (int)$regexPermalink['code'] : $regexDefaultRedirect;
694 $this->addTraceStep('Regex rules', 'Matched', $regexMatchingUrl . '' . $regexLink);
695 $this->logRedirectHit($regexMatchingUrl, $regexAction, 'regex match', $requestedURL, $this->trace);
696 $sentTo404Page = $this->logic->forceRedirect(
697 $regexLink,
698 $regexCode,
699 $regexType,
700 $requestedURL
701 );
702 if ($sentTo404Page) {
703 return true;
704 }
705 exit;
706 }
707 $this->addTraceStep('Regex rules', 'No match');
708 return false;
709 }
710
711 /**
712 * @param array<string, mixed> $options
713 * @param string $requestedURL
714 * @param array<string, mixed> $redirect
715 * @return void
716 */
717 function logAReallyLongDebugMessage($options, $requestedURL, $redirect) {
718 if (!$this->logger->isDebug()) {
719 return;
720 }
721
722 $optAutoRedirects = isset($options['auto_redirects']) && is_scalar($options['auto_redirects']) ? (string)$options['auto_redirects'] : '';
723 $optAutoScore = isset($options['auto_score']) && is_scalar($options['auto_score']) ? (string)$options['auto_score'] : '';
724 $optTemplatePriority = isset($options['template_redirect_priority']) && is_scalar($options['template_redirect_priority']) ? (string)$options['template_redirect_priority'] : '';
725 $optAutoCats = isset($options['auto_cats']) && is_scalar($options['auto_cats']) ? (string)$options['auto_cats'] : '';
726 $optAutoTags = isset($options['auto_tags']) && is_scalar($options['auto_tags']) ? (string)$options['auto_tags'] : '';
727 $optDest404 = isset($options['dest404page']) && is_scalar($options['dest404page']) ? (string)$options['dest404page'] : '';
728 $debugOptionsMsg = esc_html('auto_redirects: ' . $optAutoRedirects . ', auto_score: ' .
729 $optAutoScore . ', template_redirect_priority: ' . $optTemplatePriority .
730 ', auto_cats: ' . $optAutoCats . ', auto_tags: ' .
731 $optAutoTags . ', dest404page: ' . $optDest404);
732
733 $remoteAddressRaw = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
734 $remoteAddress = esc_sql($remoteAddressRaw);
735 if (!is_string($remoteAddress)) {
736 $remoteAddress = '';
737 }
738 if (!array_key_exists('log_raw_ips', $options) || $options['log_raw_ips'] != '1') {
739 $remoteAddress = $this->f->md5lastOctet($remoteAddress);
740 }
741
742 $httpUserAgent = "";
743 if (array_key_exists("HTTP_USER_AGENT", $_SERVER) && is_string($_SERVER['HTTP_USER_AGENT'])) {
744 $httpUserAgent = $_SERVER['HTTP_USER_AGENT'];
745 }
746
747 $requestUriStr = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
748 $debugServerMsg = esc_html('HTTP_USER_AGENT: ' . $httpUserAgent . ', REMOTE_ADDR: ' .
749 $remoteAddress . ', REQUEST_URI: ' . $this->f->normalizeUrlString($requestUriStr));
750 $isSingle = $this->callWpFunction('is_single', array(), false);
751 $isPage = $this->callWpFunction('is_page', array(), false);
752 $isFeed = $this->callWpFunction('is_feed', array(), false);
753 $isTrackback = $this->callWpFunction('is_trackback', array(), false);
754 $isPreview = $this->callWpFunction('is_preview', array(), false);
755 $redirectJson = json_encode($redirect);
756 $this->logger->debugMessage("Processing 404 for URL: " . $requestedURL . " | Redirect: " .
757 wp_kses_post(is_string($redirectJson) ? $redirectJson : '{}') . " | is_single(): " . $isSingle . " | " . "is_page(): " . $isPage .
758 " | is_feed(): " . $isFeed . " | is_trackback(): " . $isTrackback . " | is_preview(): " .
759 $isPreview . " | options: " . $debugOptionsMsg . ', ' . $debugServerMsg);
760 }
761
762 /**
763 * Redirect to destination.
764 *
765 * @param string $requestedURL
766 * @param array<string, mixed> $redirect
767 * @param string $matchReason
768 * @return bool true if user is sent to default 404 page.
769 */
770 function processRedirect($requestedURL, $redirect, $matchReason) {
771 if (($redirect['status'] != ABJ404_STATUS_MANUAL && $redirect['status'] != ABJ404_STATUS_AUTO) || $redirect['disabled'] != 0) {
772 $this->logger->errorMessage("processRedirect() was called with bad redirect data. Data: " .
773 wp_kses_post(print_r($redirect, true)));
774 }
775
776 $redirectUrl = isset($redirect['url']) && is_string($redirect['url']) ? $redirect['url'] : '';
777 $redirectFinalDest = isset($redirect['final_dest']) && is_scalar($redirect['final_dest']) ? (string)$redirect['final_dest'] : '';
778 $redirectCode = isset($redirect['code']) && is_scalar($redirect['code']) ? (int)$redirect['code'] : 0;
779 $redirectId = isset($redirect['id']) && is_scalar($redirect['id']) ? (string)$redirect['id'] : '0';
780
781 // 410 Gone: send HTTP 410 status and let WordPress render the suggestions page normally.
782 if ($redirectCode === 410) {
783 $this->addTraceStep('Result', 'Responded with 410 Gone', $redirectUrl);
784 $this->logRedirectHit($redirectUrl, '410', $matchReason, null, $this->trace);
785 $this->logic->forceRedirect('', 410);
786 // forceRedirect returns false for 410 without exiting — page continues to render.
787 return false;
788 }
789
790 // 451 Unavailable For Legal Reasons: render template and exit.
791 if ($redirectCode === 451) {
792 $this->addTraceStep('Result', 'Responded with 451 Unavailable For Legal Reasons', $redirectUrl);
793 $this->logRedirectHit($redirectUrl, '451', $matchReason, null, $this->trace);
794 $this->logic->forceRedirect('', 451);
795 return false;
796 }
797
798 if ($redirect['type'] == ABJ404_TYPE_404_DISPLAYED) {
799 $this->addTraceStep('Result', 'Showed 404 page', $redirectUrl);
800 $this->logRedirectHit($redirectUrl, '404', $matchReason, null, $this->trace);
801 $this->triggerAsyncSuggestionsIfNeeded($requestedURL);
802 $this->emitBenchmarkHeadersIfEnabled();
803 $this->logic->sendTo404Page($requestedURL, $matchReason);
804 return true;
805 }
806
807 $isRedirectToCustom404Page = false;
808 if ($redirect['type'] == $this->wpTypePost()) {
809 $options = $this->logic->getOptions();
810 $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null;
811 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : null;
812
813 if ($dest404page !== null && $this->logic->thereIsAUserSpecified404Page($dest404page)) {
814 $dest404Parts = explode('|', $dest404page);
815 $custom404Id = isset($dest404Parts[0]) ? (int)$dest404Parts[0] : 0;
816 if ($custom404Id > 0 && $redirect['final_dest'] == $custom404Id) {
817 $isRedirectToCustom404Page = true;
818 }
819 }
820
821 if (!$isRedirectToCustom404Page) {
822 $destPage = $this->callWpFunction('get_post', array($redirect['final_dest']), null);
823 $hasShortcode = (is_object($destPage) && isset($destPage->post_content) && is_string($destPage->post_content))
824 ? $this->callWpFunction('has_shortcode', array($destPage->post_content, ABJ404_SHORTCODE_NAME), false)
825 : false;
826 if ($hasShortcode) {
827 $isRedirectToCustom404Page = true;
828 }
829 }
830 }
831
832 if ($isRedirectToCustom404Page) {
833 $this->logic->setCookieWithPreviousRequest();
834 setcookie(ABJ404_PP . '_STATUS_404', 'true', time() + 20, "/");
835
836 $urlSlugOnly = $this->logic->removeHomeDirectory($requestedURL);
837 $spellChecker = abj_service('spell_checker');
838 $options = $this->logic->getOptions();
839 // Boundary normalizer: option shape-probing for the suggest_* slice
840 // lives in the VO. See ABJ_404_Solution_SuggestionDisplayOptions.
841 $suggestOpts = ABJ_404_Solution_SuggestionDisplayOptions::fromOptionsArray($options);
842 $spellChecker->findMatchingPosts(
843 $urlSlugOnly,
844 $suggestOpts->getSuggestCatsString(),
845 $suggestOpts->getSuggestTagsString()
846 );
847 $spellChecker->triggerAsyncSuggestionComputation($requestedURL);
848 }
849
850 if ($redirect['type'] == ABJ404_TYPE_EXTERNAL) {
851 $this->addTraceStep('Result', 'Redirected to external URL', $redirectFinalDest);
852 $this->logRedirectHit($redirectUrl, $redirectFinalDest, 'external', null, $this->trace);
853 $this->logic->forceRedirect($redirectFinalDest, $redirectCode);
854 exit;
855 }
856
857 // Guard against broken redirects with missing/invalid destinations.
858 $finalDestRaw = trim($redirectFinalDest);
859 $redirectTypeInt = is_scalar($redirect['type']) ? (int)$redirect['type'] : 0;
860 if ($finalDestRaw === '' && $redirectTypeInt !== ABJ404_TYPE_HOME && $redirectTypeInt !== ABJ404_TYPE_404_DISPLAYED) {
861 $this->logger->warn("Redirect destination missing. Sending request to 404 page instead. Redirect ID: " . $redirectId);
862 $this->addTraceStep('Result', 'Showed 404 page — redirect destination missing', 'rule #' . $redirectId);
863 $this->logRedirectHit($redirectUrl, '404', $matchReason . ' (missing destination)', null, $this->trace);
864 $this->triggerAsyncSuggestionsIfNeeded($requestedURL);
865 $this->emitBenchmarkHeadersIfEnabled();
866 $this->logic->sendTo404Page($requestedURL, 'missing redirect destination');
867 return true;
868 }
869
870 $key = $redirectFinalDest . "|" . (is_scalar($redirect['type']) ? (string)$redirect['type'] : '');
871 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($key, 0);
872
873 $finalLink = (is_array($permalink) && array_key_exists('link', $permalink))
874 ? $permalink['link']
875 : '';
876 if (!is_string($finalLink) || trim($finalLink) === '' || $finalLink === 'dunno') {
877 $this->logger->warn("Resolved permalink is empty/invalid. Sending request to 404 page instead. Redirect ID: " . $redirectId);
878 $this->addTraceStep('Result', 'Showed 404 page — redirect destination invalid', 'rule #' . $redirectId);
879 $this->logRedirectHit($redirectUrl, '404', $matchReason . ' (invalid destination)', null, $this->trace);
880 $this->triggerAsyncSuggestionsIfNeeded($requestedURL);
881 $this->emitBenchmarkHeadersIfEnabled();
882 $this->logic->sendTo404Page($requestedURL, 'invalid redirect destination');
883 return true;
884 }
885
886 $redirectedTo = esc_url($finalLink);
887 $urlParts = parse_url($redirectedTo);
888 if (is_array($urlParts) && array_key_exists('path', $urlParts)) {
889 $redirectedTo = $urlParts['path'];
890 }
891
892 $this->addTraceStep('Result', 'Redirected (' . $redirectCode . ')', $redirectedTo);
893 $this->logRedirectHit($redirectUrl, $redirectedTo, $matchReason, null, $this->trace);
894
895 $sendTo404Page = $this->logic->forceRedirect(
896 $finalLink,
897 $redirectCode,
898 -1,
899 $requestedURL,
900 $isRedirectToCustom404Page
901 );
902
903 if ($sendTo404Page) {
904 return true;
905 }
906 exit;
907 }
908
909 /**
910 * Self-heal a stale DB_VERSION on the frontend so end users get redirects
911 * without needing an admin visit.
912 *
913 * Returns the (possibly fresh) options array. Caller must re-check
914 * DB_VERSION before continuing — this method may return without healing
915 * (cooldown active, lock held by another worker, or upgrade failed).
916 *
917 * Throttled by a transient so concurrent 404s don't all queue on the
918 * synchronizer lock. updateToNewVersion() is itself locked
919 * (synchronizerAcquireLockTry), so the worst case is a single 300ms
920 * lock-acquire attempt per cooldown window.
921 *
922 * @param array<string, mixed> $options Current options as returned by getOptions(true).
923 * @return array<string, mixed> Options after attempted recovery.
924 */
925 private function recoverDbVersionIfStale(array $options): array {
926 $cooldownKey = 'abj404_frontend_db_recovery_cooldown';
927
928 if (function_exists('get_transient') && get_transient($cooldownKey)) {
929 return $options;
930 }
931
932 // Set the cooldown BEFORE attempting recovery so concurrent requests
933 // bail immediately rather than piling onto the lock.
934 if (function_exists('set_transient')) {
935 set_transient($cooldownKey, '1', 5 * 60);
936 }
937
938 try {
939 $upgraded = $this->logic->updateToNewVersion($options);
940 if (is_array($upgraded)) {
941 $options = $upgraded;
942 }
943 } catch (\Throwable $e) {
944 $this->logger->warn('Frontend DB version recovery failed: ' . $e->getMessage());
945 return $options;
946 }
947
948 // updateToNewVersion ends in updateOptions() which clears the resolved-
949 // options cache, so getOptions(true) returns fresh values from the DB.
950 $fresh = $this->logic->getOptions(true);
951 if (is_array($fresh) && isset($fresh['DB_VERSION'])
952 && $fresh['DB_VERSION'] == ABJ404_VERSION) {
953 return $fresh;
954 }
955
956 $observed = (isset($fresh['DB_VERSION']) && is_scalar($fresh['DB_VERSION']))
957 ? (string)$fresh['DB_VERSION']
958 : '(missing)';
959 $this->logger->warn(sprintf(
960 'Frontend DB_VERSION still stale after recovery attempt: have=%s expected=%s',
961 $observed,
962 ABJ404_VERSION
963 ));
964 return $fresh;
965 }
966
967 /**
968 * Trigger async suggestion computation only when needed.
969 * @param string $requestedURL
970 * @return void
971 */
972 private function triggerAsyncSuggestionsIfNeeded($requestedURL) {
973 if ($this->spellChecker->does404PageHaveSuggestionsShortcode()) {
974 $this->spellChecker->triggerAsyncSuggestionComputation($requestedURL);
975 }
976 }
977 }
978