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 / FrontendRequestPipeline.php

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

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