| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Frontend request pipeline for 404 processing and redirects. |
| 9 |
* |
| 10 |
* Orchestrator: sequences strategies and delegates the work to focused |
| 11 |
* collaborators. The phases of a 404 request: |
| 12 |
* |
| 13 |
* 1. (process404 only) Self-heal stale DB_VERSION via FrontendDbVersionRecovery. |
| 14 |
* 2. Initialize ignore values + check do-not-process list. |
| 15 |
* 3. Lookup existing redirect for URL, evaluate via RedirectCandidateEvaluator. |
| 16 |
* 4. tryRegexRedirect (via RedirectDispatcher). |
| 17 |
* 5. Run matching engines (via MatchingEngineOrchestrator). |
| 18 |
* 6. WordPress 404-permalink-guess fallback (via WordPressGuessFallback). |
| 19 |
* 7. Emit 404 page (via NotFoundResponseService) and log "gave up". |
| 20 |
* |
| 21 |
* Construction accepts a dependency bundle so collaborator assembly stays out |
| 22 |
* of the request orchestration path. |
| 23 |
*/ |
| 24 |
class ABJ_404_Solution_FrontendRequestPipeline { |
| 25 |
|
| 26 |
/** @var ABJ_404_Solution_Functions */ |
| 27 |
private $f; |
| 28 |
|
| 29 |
/** @var ABJ_404_Solution_Logging */ |
| 30 |
private $logger; |
| 31 |
|
| 32 |
/** @var ABJ_404_Solution_NotFoundResponseService */ |
| 33 |
private $notFoundResponse; |
| 34 |
|
| 35 |
/** @var ABJ_404_Solution_RequestIgnoreNormalizer */ |
| 36 |
private $requestIgnoreNormalizer; |
| 37 |
|
| 38 |
/** @var ABJ_404_Solution_FrontendPipelineTrace */ |
| 39 |
private $trace; |
| 40 |
|
| 41 |
/** @var ABJ_404_Solution_MatchingEngineOrchestrator */ |
| 42 |
private $matchingEngineOrchestrator; |
| 43 |
|
| 44 |
/** @var ABJ_404_Solution_WordPressGuessFallback */ |
| 45 |
private $wpGuessFallback; |
| 46 |
|
| 47 |
/** @var ABJ_404_Solution_RedirectDispatcher */ |
| 48 |
private $dispatcher; |
| 49 |
|
| 50 |
/** @var ABJ_404_Solution_FrontendDbVersionRecovery */ |
| 51 |
private $dbVersionRecovery; |
| 52 |
|
| 53 |
/** @var ABJ_404_Solution_FrontendPipelineTelemetry */ |
| 54 |
private $telemetry; |
| 55 |
|
| 56 |
/** @var ABJ_404_Solution_FrontendHitRecorder */ |
| 57 |
private $hitRecorder; |
| 58 |
|
| 59 |
/** @var ABJ_404_Solution_FrontendAsyncSuggestionTrigger */ |
| 60 |
private $asyncSuggestionTrigger; |
| 61 |
|
| 62 |
/** @var ABJ_404_Solution_FrontendRuntimeOptions */ |
| 63 |
private $runtimeOptions; |
| 64 |
|
| 65 |
/** @var ABJ_404_Solution_ExistingRedirectLookup */ |
| 66 |
private $existingRedirectLookup; |
| 67 |
|
| 68 |
/** @var ABJ_404_Solution_AutoRedirectHandler */ |
| 69 |
private $autoRedirectHandler; |
| 70 |
|
| 71 |
/** @var ABJ_404_Solution_CanonicalPaginationRedirect */ |
| 72 |
private $canonicalPaginationRedirect; |
| 73 |
|
| 74 |
/** |
| 75 |
* @param ABJ_404_Solution_FrontendPipelineDependencies $dependencies |
| 76 |
*/ |
| 77 |
function __construct(ABJ_404_Solution_FrontendPipelineDependencies $dependencies) { |
| 78 |
$this->f = $dependencies->functions(); |
| 79 |
$this->logger = $dependencies->logging(); |
| 80 |
$this->notFoundResponse = $dependencies->notFoundResponse(); |
| 81 |
$this->requestIgnoreNormalizer = $dependencies->requestIgnoreNormalizer(); |
| 82 |
$this->trace = $dependencies->trace(); |
| 83 |
$this->matchingEngineOrchestrator = $dependencies->matchingEngineOrchestrator(); |
| 84 |
$this->telemetry = $dependencies->telemetry(); |
| 85 |
$this->wpGuessFallback = $dependencies->wpGuessFallback(); |
| 86 |
$this->dispatcher = $dependencies->dispatcher(); |
| 87 |
$this->dbVersionRecovery = $dependencies->dbVersionRecovery(); |
| 88 |
$this->hitRecorder = $dependencies->hitRecorder(); |
| 89 |
$this->asyncSuggestionTrigger = $dependencies->asyncSuggestionTrigger(); |
| 90 |
$this->runtimeOptions = $dependencies->runtimeOptions(); |
| 91 |
$this->existingRedirectLookup = $dependencies->existingRedirectLookup(); |
| 92 |
$this->autoRedirectHandler = $dependencies->autoRedirectHandler(); |
| 93 |
$this->canonicalPaginationRedirect = $dependencies->canonicalPaginationRedirect(); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Exposed for tests that need to exercise the engine fanout in isolation |
| 98 |
* (see ExclusionMetaTest). Production callers route through process404(). |
| 99 |
* |
| 100 |
* @return ABJ_404_Solution_MatchingEngineOrchestrator |
| 101 |
*/ |
| 102 |
function getMatchingEngineOrchestrator(): ABJ_404_Solution_MatchingEngineOrchestrator { |
| 103 |
return $this->matchingEngineOrchestrator; |
| 104 |
} |
| 105 |
|
| 106 |
/** @return void */ |
| 107 |
function processRedirectAllRequests() { |
| 108 |
$this->trace->reset(); |
| 109 |
$options = $this->runtimeOptions->get(); |
| 110 |
|
| 111 |
$userRequest = ABJ_404_Solution_UserRequest::getInstance(); |
| 112 |
if ($userRequest === null) { |
| 113 |
return; |
| 114 |
} |
| 115 |
$pathOnly = $userRequest->getPath(); |
| 116 |
$urlSlugOnly = $userRequest->getOnlyTheSlug(); |
| 117 |
|
| 118 |
$this->requestIgnoreNormalizer->initializeIgnoreValues($pathOnly, $urlSlugOnly); |
| 119 |
$requestedURL = $userRequest->getPathWithSortedQueryString(); |
| 120 |
|
| 121 |
$this->dispatcher->tryRegexRedirect($options, $requestedURL, $this->trace); |
| 122 |
|
| 123 |
if (is_admin() || !is_404()) { |
| 124 |
$this->logger->warn('If REDIRECT_ALL_REQUESTS is turned on then a regex redirect must be in place.'); |
| 125 |
} |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Process the 404 path. |
| 130 |
* @return void |
| 131 |
*/ |
| 132 |
function process404() { |
| 133 |
if (!is_404() || is_admin()) { |
| 134 |
// SAFE_BAIL: not a 404 or in wp-admin - nothing for us to do. |
| 135 |
return; |
| 136 |
} |
| 137 |
|
| 138 |
// Read whether core's canonical redirect was still going to run, HERE, |
| 139 |
// because here is the only place the answer is true: this is inside |
| 140 |
// template_redirect, on a real front-end 404, ahead of core's priority |
| 141 |
// 10. Taking the same reading when the support report is assembled |
| 142 |
// would read an admin-ajax request, and a suppressor guarded by |
| 143 |
// `if (!is_admin())` is invisible from there. Bounded by construction: |
| 144 |
// the hot path computes a fingerprint and returns, and only a changed |
| 145 |
// (or day-old) hook set costs a write. See |
| 146 |
// ABJ_404_Solution_CanonicalRedirectHookCensus. |
| 147 |
ABJ_404_Solution_CanonicalRedirectHookCensus::recordFromFrontend404(); |
| 148 |
|
| 149 |
// Self-heal a stale DB_VERSION on the frontend so end users get redirects |
| 150 |
// without needing an admin visit (task 233). If recovery cannot close the |
| 151 |
// gap (lock held, cooldown active, or migration repeatedly throws), fall |
| 152 |
// through to a degraded redirect lookup (task 234) so manual redirects |
| 153 |
// keep serving instead of every 404 falling to the theme 404 page. |
| 154 |
$degradedMode = false; |
| 155 |
if (defined('ABJ404_VERSION')) { |
| 156 |
$options = $this->runtimeOptions->get(true); |
| 157 |
if (isset($options['DB_VERSION']) && $options['DB_VERSION'] != ABJ404_VERSION) { |
| 158 |
$options = $this->dbVersionRecovery->recoverIfStale($options); |
| 159 |
if (!isset($options['DB_VERSION']) || $options['DB_VERSION'] != ABJ404_VERSION) { |
| 160 |
$degradedMode = true; |
| 161 |
} |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
abj_service('request_context')->process_start_time = abj_clock()->nowFloat(); |
| 166 |
$userRequest = ABJ_404_Solution_UserRequest::getInstance(); |
| 167 |
if ($userRequest === null) { |
| 168 |
// SAFE_BAIL: no user request context - cannot resolve a URL to look up. |
| 169 |
return; |
| 170 |
} |
| 171 |
|
| 172 |
$pathOnly = $userRequest->getPath(); |
| 173 |
$urlSlugOnly = $userRequest->getOnlyTheSlug(); |
| 174 |
$this->requestIgnoreNormalizer->initializeIgnoreValues($pathOnly, $urlSlugOnly); |
| 175 |
$this->trace->reset(); |
| 176 |
|
| 177 |
if (abj_service('request_context')->ignore_donotprocess) { |
| 178 |
$this->trace->add('Ignore list', 'Matched - request ignored', ''); |
| 179 |
$this->hitRecorder->record($pathOnly, '404', 'ignore_donotprocess', null, $this->trace->getSteps()); |
| 180 |
$this->telemetry->emitBenchmarkHeadersIfEnabled(); |
| 181 |
// SAFE_BAIL: ignore_donotprocess matched - admin opted this UA out. |
| 182 |
return; |
| 183 |
} |
| 184 |
$this->trace->add('Ignore list', 'Not ignored'); |
| 185 |
|
| 186 |
$requestedURL = $userRequest->getPathWithSortedQueryString(); |
| 187 |
$requestedURLWithoutComments = $requestedURL; |
| 188 |
if ($this->f->strpos($requestedURL, '/comment-page-') !== false) { |
| 189 |
$withoutComments = $userRequest->getRequestURIWithoutCommentsPage(); |
| 190 |
if (is_string($withoutComments)) { |
| 191 |
$requestedURLWithoutComments = $withoutComments; |
| 192 |
} |
| 193 |
} |
| 194 |
|
| 195 |
$options = $this->runtimeOptions->get(); |
| 196 |
$autoRedirectsAreOn = !array_key_exists('auto_redirects', $options) || $options['auto_redirects'] == '1'; |
| 197 |
|
| 198 |
if ($requestedURL != '') { |
| 199 |
$deferredAutoRedirect = $this->existingRedirectLookup->dispatchManualOrFindDeferredAuto( |
| 200 |
$requestedURL, |
| 201 |
$requestedURLWithoutComments, |
| 202 |
$options, |
| 203 |
$degradedMode, |
| 204 |
$this->trace |
| 205 |
); |
| 206 |
|
| 207 |
$sentTo404Page = $this->dispatcher->tryRegexRedirect($options, $requestedURL, $this->trace); |
| 208 |
if ($sentTo404Page) { |
| 209 |
$this->telemetry->emitBenchmarkHeadersIfEnabled(); |
| 210 |
return; |
| 211 |
} |
| 212 |
|
| 213 |
// Ranked between the admin's own rules and the plugin's guesses. |
| 214 |
// Manual and regex rules above have already dispatched and exited, |
| 215 |
// so admin intent still wins; below this line every candidate is |
| 216 |
// something the plugin inferred, and WordPress's own canonical |
| 217 |
// answer beats an inference. Running here also means a stale AUTO |
| 218 |
// row invented for one of these URLs before the class was |
| 219 |
// recognized stops being served, without the admin deleting it. |
| 220 |
if ($this->canonicalPaginationRedirect->redirectIfCanonicalizable($requestedURL, $options, $this->trace)) { |
| 221 |
$this->telemetry->emitBenchmarkHeadersIfEnabled(); |
| 222 |
return; |
| 223 |
} |
| 224 |
|
| 225 |
if ($deferredAutoRedirect !== null) { |
| 226 |
$this->dispatcher->processRedirect($requestedURL, $deferredAutoRedirect, 'existing', $this->trace); |
| 227 |
exit; |
| 228 |
} |
| 229 |
|
| 230 |
if ($autoRedirectsAreOn) { |
| 231 |
$this->autoRedirectHandler->promoteAndRedirectIfMatched($requestedURL, $urlSlugOnly, $options, $this->trace); |
| 232 |
} |
| 233 |
|
| 234 |
if (!$autoRedirectsAreOn) { |
| 235 |
$this->asyncSuggestionTrigger->triggerIfNeeded($requestedURL); |
| 236 |
$this->telemetry->emitBenchmarkHeadersIfEnabled(); |
| 237 |
$this->notFoundResponse->sendTo404Page($requestedURL, 'Do not create redirects per the options.', true, $options); |
| 238 |
return; |
| 239 |
} |
| 240 |
} else { |
| 241 |
$redirect = $this->existingRedirectLookup->lookupForEmptyUrl($requestedURL, $options, $degradedMode); |
| 242 |
$this->dispatcher->handleEmptyUrlSinglePageRedirect($requestedURL, $redirect, $options, $this->trace); |
| 243 |
} |
| 244 |
|
| 245 |
$this->wpGuessFallback->tryFallback($autoRedirectsAreOn, $requestedURL, $options, $this->trace); |
| 246 |
|
| 247 |
$this->requestIgnoreNormalizer->tryNormalPostQuery($options); |
| 248 |
$this->trace->add('Result', 'No redirect - showed 404 page'); |
| 249 |
$this->hitRecorder->record($requestedURL, '404', 'gave up.', null, $this->trace->getSteps()); |
| 250 |
$this->asyncSuggestionTrigger->triggerIfNeeded($requestedURL); |
| 251 |
$this->telemetry->emitBenchmarkHeadersIfEnabled(); |
| 252 |
$this->notFoundResponse->sendTo404Page($requestedURL, '', true, $options); |
| 253 |
} |
| 254 |
} |
| 255 |
|