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 / core / PluginLogicUrlNormalization.php

PluginLogicUrlNormalization.php in 404 Solution trunk, at includes/core/PluginLogicUrlNormalization.php

474 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * URL normalization and multilingual redirect translation helpers.
10 * Standalone class extracted from PluginLogicTrait_UrlNormalization.
11 */
12 class ABJ_404_Solution_PluginLogicUrlNormalization {
13
14 /** @var ABJ_404_Solution_Functions */
15 private $f;
16
17 /** @var string */
18 private $urlHomeDirectory;
19
20 /** @var int */
21 private $urlHomeDirectoryLength;
22
23 /**
24 * @param ABJ_404_Solution_Functions $f
25 * @param string $urlHomeDirectory
26 * @param int $urlHomeDirectoryLength
27 */
28 function __construct($f, $urlHomeDirectory, $urlHomeDirectoryLength) {
29 $this->f = $f;
30 $this->urlHomeDirectory = $urlHomeDirectory;
31 $this->urlHomeDirectoryLength = $urlHomeDirectoryLength;
32 }
33
34 /** If a page's URL is /blogName/pageName then this returns /pageName.
35 * @param string|null $urlRequest
36 * @return string
37 */
38 function removeHomeDirectory($urlRequest): string {
39 if ($urlRequest === null) {
40 return '';
41 }
42 $f = $this->f;
43 $urlHomeDirectory = $this->urlHomeDirectory;
44 $homeLen = $this->urlHomeDirectoryLength;
45
46 if ($homeLen === 0) {
47 return $urlRequest;
48 }
49
50 if ($this->f->substr($urlRequest, 0, $homeLen) == $urlHomeDirectory) {
51 $nextChar = $this->f->substr($urlRequest, $homeLen, 1);
52 if ($nextChar === '/' || $nextChar === '?' || $nextChar === '#' || $nextChar === '') {
53 if ($nextChar === '/' || $nextChar === '') {
54 $urlRequest = $this->f->substr($urlRequest, ($homeLen + 1));
55 } else {
56 $urlRequest = '/' . $this->f->substr($urlRequest, $homeLen);
57 }
58 }
59 }
60
61 return $urlRequest;
62 }
63
64 /**
65 * Normalize URL to relative path by removing WordPress subdirectory.
66 *
67 * @param string|null $url Full URL or path
68 * @return string Relative path without subdirectory
69 */
70 function normalizeToRelativePath($url): string {
71 if ($url === '') {
72 return '/';
73 }
74
75 if ($url === null) {
76 return '/';
77 }
78 $url = trim($url);
79
80 if (preg_match('#^https?://#i', $url)) {
81 $parsed = parse_url($url);
82 if ($parsed === false || !isset($parsed['path'])) {
83 return '/';
84 }
85 $url = $parsed['path'];
86 if (!empty($parsed['query'])) {
87 $url .= '?' . $parsed['query'];
88 }
89 if (!empty($parsed['fragment'])) {
90 $url .= '#' . $parsed['fragment'];
91 }
92 }
93
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 $relativePath = $this->removeHomeDirectory($url);
110
111 if ($relativePath === '') {
112 return '/';
113 }
114
115 $relativePathCleaned = preg_replace('#/+#', '/', $relativePath);
116 $relativePath = is_string($relativePathCleaned) ? $relativePathCleaned : $relativePath;
117
118 $relativePath = '/' . ltrim($relativePath, '/');
119
120 return $relativePath;
121 }
122
123 /**
124 * Normalize a user-provided path for storage/matching.
125 *
126 * @param string|null $url
127 * @return string
128 */
129 function normalizeUserProvidedPath($url) {
130 $url = $this->sanitizeRedirectSource($url);
131 if ($url === '') {
132 return '';
133 }
134
135 return $this->normalizeToRelativePath($url);
136 }
137
138 /**
139 * Sanitize a redirect source without imposing ordinary URL-path syntax.
140 *
141 * Regex patterns must retain leading anchors and other metacharacters, so
142 * callers deciding whether a source is regex use this before applying any
143 * path normalization.
144 *
145 * @param string|null $url
146 * @return string
147 */
148 function sanitizeRedirectSource($url): string {
149 return abj_service('sanitizer')->normalizeUrlString($url);
150 }
151
152 /**
153 * Normalize a redirect source according to its persisted status.
154 *
155 * Ordinary redirects match canonical site-relative paths. Regex redirects
156 * are programs, not paths: adding, removing, or collapsing slashes changes
157 * their meaning (for example ^/ becomes /^/ and can never match).
158 *
159 * @param string|null $url
160 * @param int|string $status
161 * @return string
162 */
163 function normalizeRedirectSourceForStatus($url, $status): string {
164 $source = $this->sanitizeRedirectSource($url);
165 if ($source === '') {
166 return '';
167 }
168 if ((int)$status === ABJ404_STATUS_REGEX) {
169 return $source;
170 }
171 return $this->normalizeToRelativePath($source);
172 }
173
174 /**
175 * Normalize an external destination URL for storage.
176 *
177 * @param string|null $url
178 * @return string
179 */
180 function normalizeExternalDestinationUrl($url) {
181 return abj_service('sanitizer')->normalizeUrlString($url);
182 }
183
184 /**
185 * Generate normalized lookup variants for URL matching.
186 *
187 * @param string|null $url
188 * @return array<int, string>
189 */
190 function getNormalizedUrlCandidates($url) {
191 $decoded = $this->normalizeUserProvidedPath($url);
192 if ($decoded === '') {
193 return array();
194 }
195
196 $candidates = array($decoded);
197
198 $lower = function_exists('mb_strtolower') ? mb_strtolower($decoded, 'UTF-8') : strtolower($decoded);
199 if ($lower !== $decoded) {
200 $candidates[] = $lower;
201 }
202
203 $encoded = $this->normalizeToRelativePath(abj_service('url_encoder')->encodeUrlForLegacyMatch($decoded));
204 if ($encoded !== $decoded) {
205 $candidates[] = $encoded;
206 }
207
208 return array_values(array_unique($candidates));
209 }
210
211 /**
212 * Translate a redirect destination URL to the current language when possible.
213 *
214 * @param string $location Full URL or path to redirect to.
215 * @param string $requestedURL Original requested path/URL that triggered the 404.
216 * @return string URL to use for redirect.
217 */
218 function maybeTranslateRedirectUrl($location, $requestedURL = '') {
219 if (!is_string($location) || $location === '') {
220 return $location;
221 }
222
223 $translated = $this->translatePressRedirectUrl($location, $requestedURL);
224 if ($translated !== null && $translated !== '') {
225 $location = $translated;
226 }
227
228 if ($translated === null || $translated === '') {
229 $translated = $this->wpmlRedirectUrl($location, $requestedURL);
230 if ($translated !== null && $translated !== '') {
231 $location = $translated;
232 }
233 }
234
235 if ($translated === null || $translated === '') {
236 $translated = $this->polylangRedirectUrl($location, $requestedURL);
237 if ($translated !== null && $translated !== '') {
238 $location = $translated;
239 }
240 }
241
242 return apply_filters('abj404_translate_redirect_url', $location, $requestedURL);
243 }
244
245 /** @return string|null */
246 public function translatePressRedirectUrl(string $location, string $requestedURL) {
247 if (!$this->translatePressIntegrationAvailable()) {
248 return null;
249 }
250
251 if (!$this->isLocalUrl($location)) {
252 return null;
253 }
254
255 $language = $this->getTranslatePressLanguageFromRequest($requestedURL);
256 if ($language === '') {
257 return null;
258 }
259
260 $translated = $this->translatePressTranslateUrl($location, $language);
261 if (!is_string($translated) || $translated === '' || $translated === $location) {
262 return null;
263 }
264
265 if (!$this->isLocalUrl($translated)) {
266 return null;
267 }
268
269 return $translated;
270 }
271
272 /** @return bool */
273 public function translatePressIntegrationAvailable(): bool {
274 return function_exists('trp_get_language_from_url') ||
275 function_exists('trp_get_current_language') ||
276 function_exists('trp_get_url_for_language') ||
277 function_exists('trp_translate_url') ||
278 has_filter('trp_translate_url');
279 }
280
281 /** @return mixed */
282 public function translatePressTranslateUrl(string $url, string $language) {
283 if (function_exists('trp_get_url_for_language')) {
284 return trp_get_url_for_language($language, $url);
285 }
286
287 if (function_exists('trp_translate_url')) {
288 return trp_translate_url($url, $language);
289 }
290
291 return apply_filters('trp_translate_url', $url, $language);
292 }
293
294 public function getTranslatePressLanguageFromRequest(string $requestedURL): string {
295 $fullRequestedUrl = $this->buildFullUrlFromRequest($requestedURL);
296
297 if (function_exists('trp_get_language_from_url')) {
298 $language = trp_get_language_from_url($fullRequestedUrl);
299 if (is_string($language) && $language !== '') {
300 return $language;
301 }
302 }
303
304 if (function_exists('trp_get_current_language')) {
305 $language = trp_get_current_language();
306 if (is_string($language) && $language !== '') {
307 return $language;
308 }
309 }
310
311 return '';
312 }
313
314 /** @return string|null */
315 private function wpmlRedirectUrl(string $location, string $requestedURL) {
316 if (!$this->wpmlIntegrationAvailable()) {
317 return null;
318 }
319
320 if (!$this->isLocalUrl($location)) {
321 return null;
322 }
323
324 $language = $this->getWpmlLanguageFromRequest($requestedURL);
325 if ($language === '') {
326 return null;
327 }
328
329 $translated = $this->wpmlTranslateUrl($location, $language);
330 if (!is_string($translated) || $translated === '' || $translated === $location) {
331 return null;
332 }
333
334 if (!$this->isLocalUrl($translated)) {
335 return null;
336 }
337
338 return $translated;
339 }
340
341 private function wpmlIntegrationAvailable(): bool {
342 return function_exists('wpml_current_language') ||
343 has_filter('wpml_current_language') ||
344 has_filter('wpml_language_from_url') ||
345 has_filter('wpml_permalink');
346 }
347
348 /** @return mixed */
349 private function wpmlTranslateUrl(string $url, string $language) {
350 if (has_filter('wpml_permalink')) {
351 return apply_filters('wpml_permalink', $url, $language);
352 }
353
354 return null;
355 }
356
357 private function getWpmlLanguageFromRequest(string $requestedURL): string {
358 $fullRequestedUrl = $this->buildFullUrlFromRequest($requestedURL);
359
360 if (has_filter('wpml_language_from_url')) {
361 $language = apply_filters('wpml_language_from_url', '', $fullRequestedUrl);
362 if (is_string($language) && $language !== '') {
363 return $language;
364 }
365 }
366
367 if (function_exists('wpml_current_language')) {
368 $language = wpml_current_language();
369 if (is_string($language) && $language !== '') {
370 return $language;
371 }
372 }
373
374 if (has_filter('wpml_current_language')) {
375 $language = apply_filters('wpml_current_language', null);
376 if (is_string($language) && $language !== '') {
377 return $language;
378 }
379 }
380
381 return '';
382 }
383
384 /** @return string|null */
385 private function polylangRedirectUrl(string $location, string $requestedURL) {
386 if (!$this->polylangIntegrationAvailable()) {
387 return null;
388 }
389
390 if (!$this->isLocalUrl($location)) {
391 return null;
392 }
393
394 $language = $this->getPolylangLanguageFromRequest($requestedURL);
395 if ($language === '') {
396 return null;
397 }
398
399 $translated = $this->polylangTranslateUrl($location, $language);
400 if (!is_string($translated) || $translated === '' || $translated === $location) {
401 return null;
402 }
403
404 if (!$this->isLocalUrl($translated)) {
405 return null;
406 }
407
408 return $translated;
409 }
410
411 private function polylangIntegrationAvailable(): bool {
412 return function_exists('pll_current_language') ||
413 function_exists('pll_translate_url');
414 }
415
416 /** @return mixed */
417 private function polylangTranslateUrl(string $url, string $language) {
418 if (function_exists('pll_translate_url')) {
419 return pll_translate_url($url, $language);
420 }
421
422 return null;
423 }
424
425 private function getPolylangLanguageFromRequest(string $requestedURL): string {
426 if (function_exists('pll_current_language')) {
427 $language = pll_current_language();
428 if (is_string($language) && $language !== '') {
429 return $language;
430 }
431 }
432
433 return '';
434 }
435
436 public function buildFullUrlFromRequest(string $requestedURL): string {
437 $path = $requestedURL;
438 if ($path === '') {
439 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
440 if ($userRequest !== null) {
441 $path = $userRequest->getPathWithSortedQueryString();
442 }
443 }
444
445 if ($path === '') {
446 return home_url('/');
447 }
448
449 if (preg_match('#^https?://#i', $path)) {
450 return $path;
451 }
452
453 return home_url($path);
454 }
455
456 public function isLocalUrl(string $url): bool {
457 if (!is_string($url) || $url === '') {
458 return false;
459 }
460
461 $parsedUrl = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
462 if (!is_array($parsedUrl) || !isset($parsedUrl['host'])) {
463 return true;
464 }
465
466 $siteUrl = home_url();
467 $parsedSite = function_exists('wp_parse_url') ? wp_parse_url($siteUrl) : parse_url($siteUrl);
468 $siteHost = is_array($parsedSite) && isset($parsedSite['host']) ? strtolower($parsedSite['host']) : '';
469
470 return $siteHost !== '' && strtolower($parsedUrl['host']) === $siteHost;
471 }
472
473 }
474