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

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

452 lines 14.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 * URL normalization and multilingual redirect translation helpers.
9 * Used by ABJ_404_Solution_PluginLogic via `use`.
10 */
11 trait ABJ_404_Solution_PluginLogicTrait_UrlNormalization {
12
13 /** If a page's URL is /blogName/pageName then this returns /pageName.
14 * @param string|null $urlRequest
15 * @return string
16 */
17 function removeHomeDirectory($urlRequest): string {
18 if ($urlRequest === null) {
19 return '';
20 }
21 $f = $this->f;
22 $urlHomeDirectory = $this->urlHomeDirectory;
23 $homeLen = $this->urlHomeDirectoryLength !== null ? $this->urlHomeDirectoryLength : 0;
24
25 // Fix CRITICAL #1 (5th review): Skip processing for root installations
26 // When WordPress is at domain root, urlHomeDirectoryLength is 0
27 // Without this check, substr($url, 0, 0) == '' is always TRUE, incorrectly stripping leading slash
28 if ($homeLen === 0) {
29 return $urlRequest;
30 }
31
32 // Fix CRITICAL #1 (2nd review): Check path boundary to prevent false positives
33 // e.g., /blog should match /blog/page but NOT /blogpost or /blog-archive
34 if ($this->f->substr($urlRequest, 0, $homeLen) == $urlHomeDirectory) {
35 // Verify path boundary: next character must be '/', '?', '#', or end of string
36 $nextChar = $this->f->substr($urlRequest, $homeLen, 1);
37 if ($nextChar === '/' || $nextChar === '?' || $nextChar === '#' || $nextChar === '') {
38 // Fix CRITICAL #2 (3rd review): Don't strip query/fragment markers
39 if ($nextChar === '/' || $nextChar === '') {
40 // Strip subdirectory + slash for paths: /blog/page → /page
41 $urlRequest = $this->f->substr($urlRequest, ($homeLen + 1));
42 } else {
43 // Fix HIGH #1 (4th review): Add leading slash for query/fragment
44 // Strip subdirectory, add leading slash: /blog?q=1 → /?q=1
45 $urlRequest = '/' . $this->f->substr($urlRequest, $homeLen);
46 }
47 }
48 // else: false positive (e.g., /blogpost when subdirectory is /blog) - don't strip
49 }
50
51 return $urlRequest;
52 }
53
54 /**
55 * Normalize URL to relative path by removing WordPress subdirectory.
56 * This ensures URLs are stored/matched independently of subdirectory changes.
57 * Fixes Issue #24: Redirects now survive WordPress subdirectory changes.
58 *
59 * @param string|null $url Full URL or path
60 * @return string Relative path without subdirectory
61 */
62 function normalizeToRelativePath($url): string {
63 // Fix Issue #5: Handle empty URLs explicitly
64 if ($url === '') {
65 return '/';
66 }
67
68 // Fix HIGH #2: Trim whitespace
69 if ($url === null) {
70 return '/';
71 }
72 $url = trim($url);
73
74 // Fix CRITICAL #2 (4th review): REMOVED rawurldecode() - URLs already decoded by UserRequest
75 // Subdirectory decoding is now handled in constructor for consistency
76
77 // Fix HIGH #2: If full URL, extract path only
78 if (preg_match('#^https?://#i', $url)) {
79 $parsed = parse_url($url);
80 if ($parsed === false || !isset($parsed['path'])) {
81 return '/';
82 }
83 $url = $parsed['path'];
84 // Preserve query and fragment
85 if (!empty($parsed['query'])) {
86 $url .= '?' . $parsed['query'];
87 }
88 if (!empty($parsed['fragment'])) {
89 $url .= '#' . $parsed['fragment'];
90 }
91 }
92
93 // Fix HIGH #2: Handle protocol-relative URLs (//example.com/path)
94 if (strpos($url, '//') === 0) {
95 $parsed = parse_url('http:' . $url);
96 if ($parsed !== false && isset($parsed['path'])) {
97 $url = $parsed['path'];
98 if (!empty($parsed['query'])) {
99 $url .= '?' . $parsed['query'];
100 }
101 if (!empty($parsed['fragment'])) {
102 $url .= '#' . $parsed['fragment'];
103 }
104 } else {
105 return '/';
106 }
107 }
108
109 // Remove home directory if present
110 $relativePath = $this->removeHomeDirectory($url);
111
112 // Fix Issue #5: Check if removeHomeDirectory() returned empty unexpectedly
113 if ($relativePath === '') {
114 // Return root path for empty results
115 return '/';
116 }
117
118 // Fix HIGH #2: Normalize multiple slashes to single slash
119 $relativePathCleaned = preg_replace('#/+#', '/', $relativePath);
120 $relativePath = is_string($relativePathCleaned) ? $relativePathCleaned : $relativePath;
121
122 // Ensure consistent leading slash (but not multiple)
123 $relativePath = '/' . ltrim($relativePath, '/');
124
125 return $relativePath;
126 }
127
128 /**
129 * Normalize a user-provided path for storage/matching.
130 * Decodes percent-encoded octets and strips invalid UTF-8/control bytes.
131 *
132 * @param string|null $url
133 * @return string
134 */
135 private function normalizeUserProvidedPath($url) {
136 $url = $this->f->normalizeUrlString($url);
137 if ($url === '') {
138 return '';
139 }
140
141 return $this->normalizeToRelativePath($url);
142 }
143
144 /**
145 * Normalize an external destination URL for storage.
146 * Decodes percent-encoded octets and strips invalid UTF-8/control bytes.
147 *
148 * @param string|null $url
149 * @return string
150 */
151 private function normalizeExternalDestinationUrl($url) {
152 return $this->f->normalizeUrlString($url);
153 }
154
155 /**
156 * Generate normalized lookup variants for URL matching.
157 * Includes decoded form and a legacy encoded fallback.
158 *
159 * @param string|null $url
160 * @return array<int, string>
161 */
162 function getNormalizedUrlCandidates($url) {
163 $decoded = $this->normalizeUserProvidedPath($url);
164 if ($decoded === '') {
165 return array();
166 }
167
168 $candidates = array($decoded);
169
170 // Case-insensitive fallback: URLs are case-insensitive in practice,
171 // but the DB uses BINARY comparison for performance. Try the lowercase
172 // variant so /E2E-Case matches a redirect stored as /e2e-case.
173 $lower = function_exists('mb_strtolower') ? mb_strtolower($decoded, 'UTF-8') : strtolower($decoded);
174 if ($lower !== $decoded) {
175 $candidates[] = $lower;
176 }
177
178 // Legacy fallback for stored percent-encoded slugs.
179 $encoded = $this->normalizeToRelativePath($this->f->encodeUrlForLegacyMatch($decoded));
180 if ($encoded !== $decoded) {
181 $candidates[] = $encoded;
182 }
183
184 return array_values(array_unique($candidates));
185 }
186
187 /**
188 * Translate a redirect destination URL to the current language when possible.
189 *
190 * @param string $location Full URL or path to redirect to.
191 * @param string $requestedURL Original requested path/URL that triggered the 404.
192 * @return string URL to use for redirect.
193 */
194 function maybeTranslateRedirectUrl($location, $requestedURL = '') {
195 if (!is_string($location) || $location === '') {
196 return $location;
197 }
198
199 $translated = $this->translatePressRedirectUrl($location, $requestedURL);
200 if ($translated !== null && $translated !== '') {
201 $location = $translated;
202 }
203
204 if ($translated === null || $translated === '') {
205 $translated = $this->wpmlRedirectUrl($location, $requestedURL);
206 if ($translated !== null && $translated !== '') {
207 $location = $translated;
208 }
209 }
210
211 if ($translated === null || $translated === '') {
212 $translated = $this->polylangRedirectUrl($location, $requestedURL);
213 if ($translated !== null && $translated !== '') {
214 $location = $translated;
215 }
216 }
217
218 // Allow other multilingual plugins/themes to override redirect destinations.
219 return apply_filters('abj404_translate_redirect_url', $location, $requestedURL);
220 }
221
222 /** @return string|null */
223 private function translatePressRedirectUrl(string $location, string $requestedURL) {
224 if (!$this->translatePressIntegrationAvailable()) {
225 return null;
226 }
227
228 if (!$this->isLocalUrl($location)) {
229 return null;
230 }
231
232 $language = $this->getTranslatePressLanguageFromRequest($requestedURL);
233 if ($language === '') {
234 return null;
235 }
236
237 $translated = $this->translatePressTranslateUrl($location, $language);
238 if (!is_string($translated) || $translated === '' || $translated === $location) {
239 return null;
240 }
241
242 if (!$this->isLocalUrl($translated)) {
243 return null;
244 }
245
246 return $translated;
247 }
248
249 /** @return bool */
250 private function translatePressIntegrationAvailable(): bool {
251 return function_exists('trp_get_language_from_url') ||
252 function_exists('trp_get_current_language') ||
253 function_exists('trp_get_url_for_language') ||
254 function_exists('trp_translate_url') ||
255 has_filter('trp_translate_url');
256 }
257
258 /** @return mixed */
259 private function translatePressTranslateUrl(string $url, string $language) {
260 if (function_exists('trp_get_url_for_language')) {
261 return trp_get_url_for_language($language, $url);
262 }
263
264 if (function_exists('trp_translate_url')) {
265 return trp_translate_url($url, $language);
266 }
267
268 return apply_filters('trp_translate_url', $url, $language);
269 }
270
271 private function getTranslatePressLanguageFromRequest(string $requestedURL): string {
272 $fullRequestedUrl = $this->buildFullUrlFromRequest($requestedURL);
273
274 if (function_exists('trp_get_language_from_url')) {
275 $language = trp_get_language_from_url($fullRequestedUrl);
276 if (is_string($language) && $language !== '') {
277 return $language;
278 }
279 }
280
281 if (function_exists('trp_get_current_language')) {
282 $language = trp_get_current_language();
283 if (is_string($language) && $language !== '') {
284 return $language;
285 }
286 }
287
288 return '';
289 }
290
291 /** @return string|null */
292 private function wpmlRedirectUrl(string $location, string $requestedURL) {
293 if (!$this->wpmlIntegrationAvailable()) {
294 return null;
295 }
296
297 if (!$this->isLocalUrl($location)) {
298 return null;
299 }
300
301 $language = $this->getWpmlLanguageFromRequest($requestedURL);
302 if ($language === '') {
303 return null;
304 }
305
306 $translated = $this->wpmlTranslateUrl($location, $language);
307 if (!is_string($translated) || $translated === '' || $translated === $location) {
308 return null;
309 }
310
311 if (!$this->isLocalUrl($translated)) {
312 return null;
313 }
314
315 return $translated;
316 }
317
318 private function wpmlIntegrationAvailable(): bool {
319 return function_exists('wpml_current_language') ||
320 has_filter('wpml_current_language') ||
321 has_filter('wpml_language_from_url') ||
322 has_filter('wpml_permalink');
323 }
324
325 /** @return mixed */
326 private function wpmlTranslateUrl(string $url, string $language) {
327 if (has_filter('wpml_permalink')) {
328 return apply_filters('wpml_permalink', $url, $language);
329 }
330
331 return null;
332 }
333
334 private function getWpmlLanguageFromRequest(string $requestedURL): string {
335 $fullRequestedUrl = $this->buildFullUrlFromRequest($requestedURL);
336
337 if (has_filter('wpml_language_from_url')) {
338 $language = apply_filters('wpml_language_from_url', '', $fullRequestedUrl);
339 if (is_string($language) && $language !== '') {
340 return $language;
341 }
342 }
343
344 if (function_exists('wpml_current_language')) {
345 $language = wpml_current_language();
346 if (is_string($language) && $language !== '') {
347 return $language;
348 }
349 }
350
351 if (has_filter('wpml_current_language')) {
352 $language = apply_filters('wpml_current_language', null);
353 if (is_string($language) && $language !== '') {
354 return $language;
355 }
356 }
357
358 return '';
359 }
360
361 /** @return string|null */
362 private function polylangRedirectUrl(string $location, string $requestedURL) {
363 if (!$this->polylangIntegrationAvailable()) {
364 return null;
365 }
366
367 if (!$this->isLocalUrl($location)) {
368 return null;
369 }
370
371 $language = $this->getPolylangLanguageFromRequest($requestedURL);
372 if ($language === '') {
373 return null;
374 }
375
376 $translated = $this->polylangTranslateUrl($location, $language);
377 if (!is_string($translated) || $translated === '' || $translated === $location) {
378 return null;
379 }
380
381 if (!$this->isLocalUrl($translated)) {
382 return null;
383 }
384
385 return $translated;
386 }
387
388 private function polylangIntegrationAvailable(): bool {
389 return function_exists('pll_current_language') ||
390 function_exists('pll_translate_url');
391 }
392
393 /** @return mixed */
394 private function polylangTranslateUrl(string $url, string $language) {
395 if (function_exists('pll_translate_url')) {
396 return pll_translate_url($url, $language);
397 }
398
399 return null;
400 }
401
402 private function getPolylangLanguageFromRequest(string $requestedURL): string {
403 if (function_exists('pll_current_language')) {
404 $language = pll_current_language();
405 if (is_string($language) && $language !== '') {
406 return $language;
407 }
408 }
409
410 return '';
411 }
412
413 private function buildFullUrlFromRequest(string $requestedURL): string {
414 $path = $requestedURL;
415 if ($path === '') {
416 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
417 if ($userRequest !== null) {
418 $path = $userRequest->getPathWithSortedQueryString();
419 }
420 }
421
422 if ($path === '') {
423 return home_url('/');
424 }
425
426 if (preg_match('#^https?://#i', $path)) {
427 return $path;
428 }
429
430 return home_url($path);
431 }
432
433 private function isLocalUrl(string $url): bool {
434 if (!is_string($url) || $url === '') {
435 return false;
436 }
437
438 $parsedUrl = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
439 if (!is_array($parsedUrl) || !isset($parsedUrl['host'])) {
440 // Relative URLs are treated as local.
441 return true;
442 }
443
444 $siteUrl = home_url();
445 $parsedSite = function_exists('wp_parse_url') ? wp_parse_url($siteUrl) : parse_url($siteUrl);
446 $siteHost = is_array($parsedSite) && isset($parsedSite['host']) ? strtolower($parsedSite['host']) : '';
447
448 return $siteHost !== '' && strtolower($parsedUrl['host']) === $siteHost;
449 }
450
451 }
452