PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / frontend / FrontendRequestPipeline.php

FrontendRequestPipeline.php in 404 Solution trunk, at includes/frontend/FrontendRequestPipeline.php

255 lines 10.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 * 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