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 / services / NotFoundResponseService.php

NotFoundResponseService.php in 404 Solution trunk, at includes/services/NotFoundResponseService.php

375 lines 16.1 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 * Dispatches unresolved frontend requests to the configured 404 response path.
9 */
10 class ABJ_404_Solution_NotFoundResponseService {
11
12 /** @var ABJ_404_Solution_Functions */
13 private $f;
14
15 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
16 private $redirectsRepo;
17
18 /** @var ABJ_404_Solution_LogsRepositoryInterface */
19 private $logsRepo;
20
21 /** @var ABJ_404_Solution_Logging */
22 private $logger;
23
24 /** @var ABJ_404_Solution_PluginLogicOptionsResolver */
25 private $optionsRepository;
26
27 /** @var ABJ_404_Solution_PreviousRequestCookieTracker */
28 private $previousRequestCookieTracker;
29
30 /** @var ABJ_404_Solution_RedirectLoopGuard */
31 private $loopGuard;
32
33 /** @var ABJ_404_Solution_NearMissRecorder */
34 private $nearMissRecorder;
35
36 /**
37 * @param ABJ_404_Solution_NotFoundResponseDependencies|null $deps
38 */
39 function __construct(?ABJ_404_Solution_NotFoundResponseDependencies $deps = null) {
40 $deps = $deps ?? new ABJ_404_Solution_NotFoundResponseDependencies();
41 $this->f = $deps->functions !== null ? $deps->functions : abj_service('functions');
42 $this->redirectsRepo = $deps->redirectsRepo !== null ? $deps->redirectsRepo : abj_service('redirects_repository');
43 $this->logsRepo = $deps->logsRepo !== null ? $deps->logsRepo : abj_service('logs_repository');
44 $this->logger = $deps->logging !== null ? $deps->logging : abj_service('logging');
45 $this->optionsRepository = $deps->optionsRepository !== null ? $deps->optionsRepository : abj_service('options_repository');
46 $this->previousRequestCookieTracker = $deps->previousRequestCookieTracker !== null
47 ? $deps->previousRequestCookieTracker
48 : abj_service('previous_request_cookie_tracker');
49 $this->nearMissRecorder = $deps->nearMissRecorder !== null
50 ? $deps->nearMissRecorder
51 : abj_service('near_miss_recorder');
52 $this->loopGuard = new ABJ_404_Solution_RedirectLoopGuard(
53 $this->f, $this->logger, $this->previousRequestCookieTracker
54 );
55 }
56
57 /**
58 * @param string $requestedURL
59 * @param string $reason
60 * @param bool $useUserSpecified404
61 * @param array<string, mixed>|null $optionsOverride
62 * @return void
63 */
64 function sendTo404Page(string $requestedURL, string $reason = '', bool $useUserSpecified404 = true, $optionsOverride = null): void {
65 $options = (is_array($optionsOverride) ? $optionsOverride : $this->optionsRepository->getOptions());
66
67 $behavior = isset($options['dest404_behavior']) ? $options['dest404_behavior'] : '';
68 if ($behavior === 'suggest') {
69 $systemPage = ABJ_404_Solution_SystemPage::getInstance();
70 if (!$systemPage->systemPageExists()) {
71 $systemPage->handleSystemPageDeleted();
72 $options = $this->optionsRepository->getOptions(true);
73 }
74 }
75
76 $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null;
77 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : (ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED);
78
79 if ($useUserSpecified404 && $this->thereIsAUserSpecified404Page($dest404page)) {
80 $permalink = ABJ_404_Solution_PermalinkResolver::permalinkInfoToArray($dest404page, 0, null, $options);
81
82 if (!in_array($permalink['status'], array('publish', 'published'))) {
83 $msg = __("The user specified 404 page wasn't found. " .
84 "Please update the user-specified 404 page on the Options page.",
85 '404-solution');
86 $this->logger->infoMessage($msg);
87
88 } else {
89 $redirect = $this->redirectsRepo->getExistingRedirectForURL($requestedURL);
90 $pType = is_scalar($permalink['type']) ? (string)$permalink['type'] : '';
91 $pId = is_scalar($permalink['id']) ? (string)$permalink['id'] : '';
92 $pLink = is_scalar($permalink['link']) ? (string)$permalink['link'] : '';
93 $defRedir = is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '301';
94 if (!isset($redirect['id']) || $redirect['id'] == 0) {
95 $this->redirectsRepo->setupRedirect(
96 $this->capturedRedirectSpec(array(
97 'requestedURL' => $requestedURL,
98 'type' => $pType,
99 'finalDest' => $pId,
100 'code' => $defRedir,
101 )));
102 }
103
104 $this->logsRepo->logRedirectHit(ABJ_404_Solution_RedirectHitLogEntry::create($requestedURL, $pLink, 'user specified 404 page. ' . $reason));
105
106 setcookie(ABJ404_PP . '_STATUS_404', 'true', abj_clock()->now() + 20, "/");
107
108 $this->forceRedirect(esc_url($pLink), (int)$defRedir);
109 exit;
110 }
111 }
112
113 if (@$options['capture_404'] == '1') {
114 $redirect = $this->redirectsRepo->getExistingRedirectForURL($requestedURL);
115 $defRedir2 = is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '301';
116 if (!isset($redirect['id']) || $redirect['id'] == 0) {
117 $this->redirectsRepo->setupRedirect($this->capturedRedirectSpec(
118 array(
119 'requestedURL' => $requestedURL,
120 'type' => (string)ABJ404_TYPE_404_DISPLAYED,
121 'finalDest' => (string)ABJ404_TYPE_404_DISPLAYED,
122 'code' => $defRedir2,
123 )));
124 }
125 } else {
126 $optionsJson = json_encode($options);
127 $this->logger->debugMessage("No permalink found to redirect to. capture_404 is off. Requested URL: " . $requestedURL .
128 " | Redirect: (none)" . " | is_single(): " . is_single() . " | " .
129 "is_page(): " . is_page() . " | is_feed(): " . is_feed() . " | is_trackback(): " .
130 is_trackback() . " | is_preview(): " . is_preview() . " | options: " . wp_kses_post(is_string($optionsJson) ? $optionsJson : ''));
131 }
132 }
133
134 /**
135 * Build the insert for a captured 404, stamped with the confidence score of
136 * the best match the engines found and rejected for this URL.
137 *
138 * A captured row is not a manual redirect: when automatic matching ran and
139 * came close, the score is the one number that tells the admin why the URL
140 * was captured rather than redirected, and the Captured tab has had a
141 * Confidence column, a badge template and two covering indexes waiting for
142 * it. When no near miss was recorded -- automatic matching is off, or
143 * nothing scored at all -- score and engine stay null, which is the honest
144 * answer and what the Score column's no-score branch renders.
145 *
146 * @param array{requestedURL: string, type: string, finalDest: string, code: string} $fields
147 * @return ABJ_404_Solution_RedirectSpec
148 */
149 private function capturedRedirectSpec(array $fields): ABJ_404_Solution_RedirectSpec {
150 $nearMiss = $this->nearMissRecorder->getBestFor($fields['requestedURL']);
151
152 return ABJ_404_Solution_RedirectSpec::fromArray(array(
153 'fromURL' => $fields['requestedURL'],
154 'status' => (string)ABJ404_STATUS_CAPTURED,
155 'type' => $fields['type'],
156 'finalDest' => $fields['finalDest'],
157 'code' => $fields['code'],
158 'disabled' => 0,
159 'engine' => $nearMiss !== null ? $nearMiss->getEngineName() : null,
160 'score' => $nearMiss !== null ? $nearMiss->getScore() : null,
161 ));
162 }
163
164 /** @param string|null $dest404page @return bool */
165 function thereIsAUserSpecified404Page($dest404page): bool {
166 if ($dest404page == null) {
167 return false;
168 }
169 $check1 = ($dest404page !== (ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED));
170 $check2 = ($dest404page !== (string)ABJ404_TYPE_404_DISPLAYED);
171 return $check1 && $check2;
172 }
173
174 /** @return string */
175 function getCommentPartAndQueryPartOfRequest() {
176 $requestUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
177 if ($requestUri !== '' &&
178 strpos($requestUri, '?') === false &&
179 strpos($requestUri, '/comment-page-') === false) {
180 return '';
181 }
182
183 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
184 if ($userRequest === null) {
185 return '';
186 }
187 $queryString = $userRequest->getQueryString();
188 $queryParts = abj_service('query_string_helper')->removePageIDFromQueryString(is_string($queryString) ? $queryString : '');
189 $queryParts = ($queryParts == '') ? '' : '?' . $queryParts;
190 $commentPart = $userRequest->getCommentPagePart();
191 return (is_string($commentPart) ? $commentPart : '') . $queryParts;
192 }
193
194 /**
195 * @param string $location
196 * @param int $status
197 * @param int|string $type only 0 for sending to a 404 page
198 * @param string $requestedURL
199 * @param bool $isCustom404
200 * @return bool true if the user is sent to the default 404 page.
201 */
202 function forceRedirect(string $location, int $status = 302, $type = -1, string $requestedURL = '', bool $isCustom404 = false): bool {
203 if ($status === 410) {
204 $this->sendGoneStatusResponse(
205 410,
206 'gone410.html',
207 esc_html__('This content has been permanently removed.', '404-solution'),
208 esc_html__('The page you requested no longer exists and has not been moved to a new location.', '404-solution')
209 );
210 }
211
212 if ($status === 451) {
213 $this->sendGoneStatusResponse(
214 451,
215 'gone451.html',
216 esc_html__('451 Unavailable For Legal Reasons', '404-solution'),
217 esc_html__('This content is unavailable due to a legal demand.', '404-solution')
218 );
219 }
220
221 if ($status === 0 && $location !== '') {
222 $this->sendMetaRefreshResponse($location);
223 }
224
225 $pluginLogic = abj_service('plugin_logic');
226 if (is_object($pluginLogic) && method_exists($pluginLogic, 'pageOrdering')) {
227 $finalDestination = $pluginLogic->pageOrdering()
228 ->buildFinalRedirectDestination($location, $requestedURL, $isCustom404);
229 } else {
230 $finalDestination = (string)$location . $this->getCommentPartAndQueryPartOfRequest();
231 }
232
233 $terminatingDestination = $this->loopGuard->terminatingDestination(array(
234 'finalDestination' => $finalDestination,
235 'location' => $location,
236 ));
237 if ($terminatingDestination === false) {
238 return false;
239 }
240 $finalDestination = $terminatingDestination;
241
242 if ($type == ABJ404_TYPE_404_DISPLAYED) {
243 $this->sendTo404Page($requestedURL, '', false);
244 return true;
245 }
246
247 if (is_object($this->previousRequestCookieTracker)) {
248 $this->previousRequestCookieTracker->setCookieWithPreviousRequest();
249 }
250 if (!headers_sent()) {
251 return $this->sendHeaderRedirect($finalDestination, $status);
252 }
253
254 return $this->sendJavaScriptRedirectFallback($finalDestination);
255 }
256
257 private function sendGoneStatusResponse(int $status, string $templateFile, string $heading, string $message): void {
258 status_header($status);
259 $templatePath = dirname(__DIR__) . '/html/' . $templateFile;
260 if (file_exists($templatePath)) {
261 $siteName = function_exists('get_bloginfo') ? get_bloginfo('name') : '';
262 $siteUrl = function_exists('home_url') ? home_url('/') : '/';
263 $templateContent = file_get_contents($templatePath);
264 if (is_string($templateContent)) {
265 echo str_replace(
266 array('{site_name}', '{site_url}', '{heading}', '{message}', '{back_home}'),
267 array(
268 esc_html($siteName),
269 esc_url($siteUrl),
270 $heading,
271 $message,
272 esc_html__('Back to home page', '404-solution'),
273 ),
274 $templateContent
275 );
276 }
277 }
278 exit;
279 }
280
281 private function sendMetaRefreshResponse(string $location): void {
282 status_header(200);
283 $templatePath = dirname(__DIR__) . '/html/metaRefresh.html';
284 if (file_exists($templatePath)) {
285 $templateContent = file_get_contents($templatePath);
286 if (is_string($templateContent)) {
287 echo str_replace(
288 array('{url}', '{delay}', '{title}', '{message}'),
289 array(
290 esc_url($location),
291 '0',
292 esc_html__('Redirecting...', '404-solution'),
293 esc_html__('You are being redirected. Click the link if not redirected automatically.', '404-solution'),
294 ),
295 $templateContent
296 );
297 }
298 }
299 exit;
300 }
301
302 private function sendHeaderRedirect(string $finalDestination, int $status): bool {
303 if (function_exists('abj404_benchmark_emit_headers')) {
304 try {
305 abj404_benchmark_emit_headers();
306 } catch (Throwable $e) {
307 $this->warnBenchmarkHeaderFailure($e);
308 }
309 }
310
311 $allowedRedirectHostFilter = $this->addAllowedRedirectHostFilter($finalDestination);
312 wp_safe_redirect($finalDestination, $status, ABJ404_NAME);
313 if ($allowedRedirectHostFilter !== null) {
314 remove_filter('allowed_redirect_hosts', $allowedRedirectHostFilter, 10);
315 }
316 if (!apply_filters('abj404_should_exit', true, array('source' => 'forceRedirect_header'))) {
317 return false;
318 }
319 exit;
320 }
321
322 private function addAllowedRedirectHostFilter(string $finalDestination): ?callable {
323 $destHost = parse_url($finalDestination, PHP_URL_HOST);
324 if (!is_string($destHost) || $destHost === '') {
325 return null;
326 }
327
328 $allowedRedirectHostFilter = static function($hosts) use ($destHost) {
329 $normalizedHosts = array();
330 if (is_array($hosts)) {
331 foreach ($hosts as $host) {
332 if (is_scalar($host) || $host === null || (is_object($host) && method_exists($host, '__toString'))) {
333 $normalizedHosts[] = (string)$host;
334 }
335 }
336 }
337 $normalizedHosts[] = strtolower($destHost);
338 return array_values(array_unique($normalizedHosts));
339 };
340 add_filter('allowed_redirect_hosts', $allowedRedirectHostFilter, 10, 1);
341
342 return $allowedRedirectHostFilter;
343 }
344
345 private function sendJavaScriptRedirectFallback(string $finalDestination): bool {
346 if (function_exists('abj404_benchmark_emit_headers')) {
347 abj404_benchmark_emit_headers();
348 }
349 $template = ABJ_404_Solution_FileSystemService::readFileContents(
350 dirname(__DIR__) . '/html/notFoundRedirectFallback.html',
351 false
352 );
353 echo $this->f->str_replace(
354 array('{destination_json}', '{destination_url}', '{destination_label}'),
355 array((string)wp_json_encode($finalDestination), esc_url($finalDestination), esc_html($finalDestination)),
356 $template
357 );
358 if (!apply_filters('abj404_should_exit', true, array('source' => 'forceRedirect_js'))) {
359 return false;
360 }
361 exit;
362 }
363
364 private function warnBenchmarkHeaderFailure(Throwable $e): void {
365 $message = 'abj404_benchmark_emit_headers failed: ' . $e->getMessage();
366 if (is_object($this->logger) && method_exists($this->logger, 'warn')) {
367 $this->logger->warn($message);
368 return;
369 }
370
371 abj404_logPhpFallback('service-resolution-fallback', $message);
372 }
373
374 }
375