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 / matching / WordPressGuessFallback.php

WordPressGuessFallback.php in 404 Solution trunk, at includes/matching/WordPressGuessFallback.php

225 lines 9.0 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 * Last-resort redirect attempt using WordPress's built-in 404 permalink guess.
9 *
10 * WordPress matches partial slugs via LIKE 'slug%', a complementary strategy
11 * to our Levenshtein-based spell checker (e.g. /redes matches /redes-social
12 * because the slug starts with "redes"). When a guess matches and is not
13 * excluded, this records the redirect and emits it, then exits. If no guess
14 * matches, the guess is a self-redirect, the destination is excluded, or the
15 * emit is blocked, it records a trace step and returns so the caller can
16 * fall through to the normal-post query / 404 page.
17 *
18 * Gated by the `abj404_wp_guess_fallback_enabled` filter and the engine
19 * profile resolver (per-URL engine enablement).
20 */
21 class ABJ_404_Solution_WordPressGuessFallback {
22
23 /** @var ABJ_404_Solution_PluginLogicUrlNormalization */
24 private $urlNormalization;
25
26 /** @var ABJ_404_Solution_RedirectsRepository */
27 private $redirectsRepository;
28
29 /** @var ABJ_404_Solution_NotFoundResponseService */
30 private $notFoundResponse;
31
32 /** @var ABJ_404_Solution_RedirectExclusionPolicy */
33 private $exclusionPolicy;
34
35 /** @var mixed */
36 private $logsRepository;
37
38 /**
39 * @param ABJ_404_Solution_PluginLogicUrlNormalization $urlNormalization
40 * @param ABJ_404_Solution_RedirectsRepository $redirectsRepository
41 * @param ABJ_404_Solution_NotFoundResponseService $notFoundResponse
42 * @param ABJ_404_Solution_RedirectExclusionPolicy $exclusionPolicy
43 * @param mixed $logsRepository Object with logRedirectHit(); shape is duck-typed.
44 */
45 function __construct($urlNormalization, $redirectsRepository, $notFoundResponse, $exclusionPolicy, $logsRepository) {
46 $this->urlNormalization = $urlNormalization;
47 $this->redirectsRepository = $redirectsRepository;
48 $this->notFoundResponse = $notFoundResponse;
49 $this->exclusionPolicy = $exclusionPolicy;
50 $this->logsRepository = $logsRepository;
51 }
52
53 /**
54 * @param bool $autoRedirectsAreOn Whether auto-redirect creation is enabled.
55 * @param string $requestedURL The normalized requested URL that 404'd.
56 * @param array<string, mixed> $options
57 * @param ABJ_404_Solution_FrontendPipelineTrace $trace
58 */
59 function tryFallback(bool $autoRedirectsAreOn, string $requestedURL, array $options, ABJ_404_Solution_FrontendPipelineTrace $trace): void {
60 $wpGuessFallbackEnabled = $autoRedirectsAreOn && $this->shouldRunGuess($requestedURL);
61 if (!$wpGuessFallbackEnabled) {
62 $reason = !$autoRedirectsAreOn ? 'auto_redirects off' : 'engine profile/filter';
63 $trace->add('WordPress URL guess', 'Skipped: ' . $reason);
64 return;
65 }
66
67 // The original branch ran only when redirect_guess_404_permalink() existed and
68 // did nothing (no trace) when it was absent; preserve that silent no-op.
69 if (!function_exists('redirect_guess_404_permalink')) {
70 return;
71 }
72
73 $wpGuess = redirect_guess_404_permalink();
74 if ($wpGuess && is_string($wpGuess)) {
75 // attemptGuessRedirect() either exit()s on a successful redirect or returns
76 // so the shared 'No match' trace below is recorded for every fall-through.
77 $this->attemptGuessRedirect($wpGuess, $requestedURL, $options, $trace);
78 }
79 $trace->add('WordPress URL guess', 'No match');
80 }
81
82 /**
83 * Given a concrete WordPress permalink guess, decide whether to redirect to it and,
84 * if so, record the redirect and emit it (which exits). Each branch (self-redirect,
85 * excluded destination, blocked redirect) records a trace step and returns so the
86 * caller can fall through to the normal-post query / 404 page.
87 *
88 * @param string $wpGuess The candidate URL returned by redirect_guess_404_permalink().
89 * @param string $requestedURL The normalized requested URL that 404'd.
90 * @param array<string, mixed> $options
91 * @param ABJ_404_Solution_FrontendPipelineTrace $trace
92 */
93 private function attemptGuessRedirect(string $wpGuess, string $requestedURL, array $options, ABJ_404_Solution_FrontendPipelineTrace $trace): void {
94 $wpGuessEngineName = __('wp guess', '404-solution');
95
96 $normalizedGuess = $this->normalizeGuessedUrlToRequestShape($wpGuess);
97 if ($normalizedGuess !== '' && $normalizedGuess === $requestedURL) {
98 $trace->add('WordPress URL guess', 'Ignored self-redirect guess', $wpGuess);
99 return;
100 }
101
102 $trace->add('WordPress URL guess', 'Matched candidate', $wpGuess);
103 $defaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect'])
104 ? (string)$options['default_redirect'] : '301';
105 $wpGuessType = (string)$this->typePost();
106 $wpGuessPostId = $this->resolveGuessPostId($wpGuess);
107
108 $wpGuessResult = new ABJ_404_Solution_MatchResult(
109 $wpGuessPostId !== '' ? $wpGuessPostId : '0',
110 $wpGuessType,
111 $wpGuess,
112 '',
113 0.0,
114 $wpGuessEngineName
115 );
116 if ($this->exclusionPolicy->isExcluded($wpGuessResult, $options)) {
117 $trace->add('WordPress URL guess', 'Excluded destination: skipped', $wpGuess);
118 return;
119 }
120
121 $trace->add('WordPress URL guess', 'Matched: redirecting', $wpGuess);
122 $this->redirectsRepository->setupRedirect(ABJ_404_Solution_RedirectSpec::create(
123 $requestedURL, (string)ABJ404_STATUS_AUTO,
124 $wpGuessType, $wpGuessPostId, $defaultRedirect, 0, $wpGuessEngineName
125 ));
126 $this->writeHit($requestedURL, $wpGuess, $wpGuessEngineName, null, $trace->getSteps());
127 $redirectSent = $this->notFoundResponse->forceRedirect(esc_url($wpGuess), (int)$defaultRedirect);
128 if ($redirectSent !== false) {
129 exit;
130 }
131 $trace->add('WordPress URL guess', 'Redirect blocked: continued', $wpGuess);
132 }
133
134 /**
135 * Resolve a guessed URL to its WordPress post id as a string, or '' when the URL
136 * maps to no post (or url_to_postid() is unavailable).
137 *
138 * @param string $wpGuess
139 * @return string
140 */
141 private function resolveGuessPostId(string $wpGuess): string {
142 if (!function_exists('url_to_postid')) {
143 return '';
144 }
145 $postId = url_to_postid($wpGuess);
146 return $postId > 0 ? (string)$postId : '';
147 }
148
149 /**
150 * @param string $requestedURL
151 * @return bool
152 */
153 private function shouldRunGuess(string $requestedURL): bool {
154 $enabled = true;
155 if (function_exists('apply_filters')) {
156 $enabled = (bool) apply_filters(
157 'abj404_wp_guess_fallback_enabled',
158 true,
159 $requestedURL
160 );
161 }
162 if (!$enabled) {
163 return false;
164 }
165
166 return ABJ_404_Solution_EngineProfileResolver::getInstance()
167 ->isEngineEnabledForUrl($requestedURL, 'ABJ_404_Solution_WordPressUrlGuessEngine');
168 }
169
170 /**
171 * Normalize a guessed URL into the same request-shape used by $requestedURL:
172 * path (relative to WP home directory) plus sorted query string.
173 *
174 * @param string $guessedUrl
175 * @return string
176 */
177 private function normalizeGuessedUrlToRequestShape(string $guessedUrl): string {
178 $normalized = abj_service('sanitizer')->normalizeUrlString($guessedUrl);
179 if ($normalized === '') {
180 return '';
181 }
182
183 $parts = parse_url($normalized);
184 if (!is_array($parts)) {
185 return '';
186 }
187
188 $path = isset($parts['path']) ? $parts['path'] : '/';
189 if ($path === '') {
190 $path = '/';
191 }
192 $path = $this->urlNormalization->removeHomeDirectory($path);
193 if ($path === '') {
194 $path = '/';
195 }
196 if ($path[0] !== '/') {
197 $path = '/' . $path;
198 }
199
200 /** @var array<string, string> $urlPartsStr */
201 $urlPartsStr = array_map('strval', $parts);
202 $sortedQuery = abj_service('query_string_helper')->sortQueryString($urlPartsStr);
203 return $path . $sortedQuery;
204 }
205
206 /** @return int */
207 private function typePost(): int {
208 return (int)ABJ404_TYPE_POST;
209 }
210
211 /**
212 * @param string $requestedUrl
213 * @param string $action
214 * @param string $matchReason
215 * @param string|null $requestedUrlDetail
216 * @param list<array{step: string, outcome: string, detail: string}>|null $pipelineTrace
217 */
218 private function writeHit(string $requestedUrl, string $action, string $matchReason, ?string $requestedUrlDetail = null, ?array $pipelineTrace = null): void {
219 if (!is_object($this->logsRepository) || !is_callable(array($this->logsRepository, 'logRedirectHit'))) {
220 return;
221 }
222 call_user_func(array($this->logsRepository, 'logRedirectHit'), ABJ_404_Solution_RedirectHitLogEntry::create($requestedUrl, $action, $matchReason, $requestedUrlDetail, $pipelineTrace));
223 }
224 }
225