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 / spelling / SpellURLMatcher.php

SpellURLMatcher.php in 404 Solution trunk, at includes/spelling/SpellURLMatcher.php

378 lines 11.2 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-level matching: regex-based matching, slug matching, image detection,
9 * permalink lookup, cache retrieval, and URL utility helpers.
10 *
11 * Extracted from SpellCheckerTrait_URLMatching as a standalone class with
12 * explicit dependency injection.
13 */
14 class ABJ_404_Solution_SpellURLMatcher {
15
16 /** @var ABJ_404_Solution_Functions */
17 private $f;
18
19 /** @var ABJ_404_Solution_Logging */
20 private $logger;
21
22 /** @var ABJ_404_Solution_ContentRepository */
23 private $contentRepository;
24
25 /** @var mixed */
26 private $viewReadService;
27
28 /** @var string|int|null */
29 private $custom404PageID;
30
31 /** @var array<string, string> */
32 private array $preparedRegexPatternCache = array();
33
34 /**
35 * @param ABJ_404_Solution_Functions $functions
36 * @param ABJ_404_Solution_Logging $logger
37 * @param ABJ_404_Solution_ContentRepository $contentRepository
38 * @param mixed $viewReadService
39 * @param string|int|null $custom404PageID
40 */
41 public function __construct($functions, $logger, $contentRepository, $viewReadService, $custom404PageID) {
42 $this->f = $functions;
43 $this->logger = $logger;
44 $this->contentRepository = $contentRepository;
45 $this->viewReadService = $viewReadService;
46 $this->custom404PageID = $custom404PageID;
47 }
48
49 /** @return iterable<int, array<string, mixed>> */
50 private function getRedirectsWithRegEx(): iterable {
51 if (!is_object($this->viewReadService) || !is_callable(array($this->viewReadService, 'getRedirectsWithRegEx'))) {
52 return array();
53 }
54 $rows = call_user_func(array($this->viewReadService, 'getRedirectsWithRegEx'));
55 return is_iterable($rows) ? $rows : array();
56 }
57
58 /** @return array<int, array<string, mixed>> */
59 private function getManualRedirectsWithRegexMetachars(): array {
60 if (!is_object($this->viewReadService) || !is_callable(array($this->viewReadService, 'getManualRedirectsWithRegexMetachars'))) {
61 return array();
62 }
63 $rows = call_user_func(array($this->viewReadService, 'getManualRedirectsWithRegexMetachars'));
64 return $this->normalizeRows($rows);
65 }
66
67 /**
68 * @param mixed $rows
69 * @return array<int, array<string, mixed>>
70 */
71 private function normalizeRows($rows): array {
72 if (!is_array($rows)) {
73 return array();
74 }
75 $normalized = array();
76 foreach ($rows as $row) {
77 if (is_array($row)) {
78 $normalized[] = $row;
79 }
80 }
81 return $normalized;
82 }
83
84 /** Find a match using the user-defined regex patterns.
85 * @param string $requestedURL
86 * @param array<string, mixed>|null $options
87 * @return array<string, mixed>|null
88 */
89 function getPermalinkUsingRegEx(string $requestedURL, $options = null) {
90 if (!is_array($options)) {
91 $options = abj_service('options_repository')->getOptions(true);
92 }
93 $isDebug = $this->logger->isDebug();
94
95 $regexURLsRows = $this->getRedirectsWithRegEx();
96
97 $manualWithMetachars = $this->getManualRedirectsWithRegexMetachars();
98 $filtered = array();
99 if (!empty($manualWithMetachars)) {
100 foreach ($manualWithMetachars as $manualRow) {
101 $manualUrl = isset($manualRow['url']) && is_string($manualRow['url']) ? $manualRow['url'] : '';
102 if (!ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($manualUrl)) {
103 continue;
104 }
105 $glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($manualUrl);
106 $manualRow['url'] = $glob['url'];
107 $filtered[] = $manualRow;
108 }
109 if (!empty($filtered)) {
110 if ($isDebug) {
111 $this->logger->debugMessage(
112 "Runtime regex fallback: trying " . count($filtered) .
113 " MANUAL row(s) with regex metachars against URL: " . $requestedURL
114 );
115 }
116 }
117 }
118 $regexURLsRows = $this->appendRegexFallbackRows($regexURLsRows, $filtered);
119
120 foreach ($regexURLsRows as $row) {
121 $regexURL = $row['url'];
122
123 if ($isDebug) {
124 abj_service('request_context')->debug_info = 'Applying custom regex "' . $regexURL . '" to URL: ' .
125 $requestedURL;
126 }
127 $regexURLStr = is_string($regexURL) ? $regexURL : '';
128 $preparedURL = $this->getPreparedRegexPattern($regexURLStr);
129 if (@$this->f->regexMatch($preparedURL, $requestedURL)) {
130 if ($isDebug) {
131 abj_service('request_context')->debug_info = 'Cleared after regex.';
132 }
133 $rowType = isset($row['type']) && is_scalar($row['type']) ? (int)$row['type'] : 0;
134 $rowDest = isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '';
135 if ($rowType === (int)ABJ404_TYPE_EXTERNAL) {
136 $permalink = array(
137 'id' => 0,
138 'type' => ABJ404_TYPE_EXTERNAL,
139 'link' => $rowDest,
140 'title' => '',
141 'score' => 100,
142 );
143 } else {
144 $idAndType = $rowDest . '|' . $row['type'];
145 $permalink = ABJ_404_Solution_PermalinkResolver::permalinkInfoToArray($idAndType, 0,
146 null, $options);
147 }
148 $permalink['matching_regex'] = $regexURL;
149 $permalink['code'] = isset($row['code']) && is_scalar($row['code']) ? (int)$row['code'] : 0;
150 $originalPermalink = $isDebug ? $permalink : null;
151
152 $permLinkStr = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
153 $hasCaptureGroup = ($this->f->strpos($regexURLStr, '(') !== false);
154 $hasReplacementToken = ($this->f->strpos($permLinkStr, '$') !== false);
155 if ($hasCaptureGroup && $hasReplacementToken) {
156 $results = array();
157 @$this->f->regexMatch($regexURLStr, $requestedURL, $results);
158 $results = is_array($results) ? $results : array();
159
160 $permalink['link'] = $this->substituteRegexDestination($permLinkStr, $results);
161 }
162
163 if ($isDebug) {
164 $this->logger->debugMessage("Found matching regex. Original permalink" .
165 json_encode($originalPermalink) . ", final: " .
166 json_encode($permalink));
167 }
168
169 return $permalink;
170 }
171
172 if ($isDebug) {
173 abj_service('request_context')->debug_info = 'Cleared after regex.';
174 }
175 }
176
177 return null;
178 }
179
180 /**
181 * @param iterable<int, array<string, mixed>> $regexRows
182 * @param array<int, array<string, mixed>> $fallbackRows
183 * @return iterable<int, array<string, mixed>>
184 */
185 private function appendRegexFallbackRows(iterable $regexRows, array $fallbackRows): iterable {
186 foreach ($regexRows as $row) {
187 yield $row;
188 }
189 foreach ($fallbackRows as $row) {
190 yield $row;
191 }
192 }
193
194 /**
195 * @param array<int|string, string> $results
196 */
197 private function substituteRegexDestination(string $template, array $results): string {
198 $substituted = preg_replace_callback(
199 '/\\$([1-9][0-9]*)/',
200 static function(array $tokenMatch) use ($results): string {
201 $groupNumber = (int)$tokenMatch[1];
202 return array_key_exists($groupNumber, $results)
203 ? (string)$results[$groupNumber]
204 : '';
205 },
206 $template
207 );
208 return is_string($substituted) ? $substituted : $template;
209 }
210
211 /**
212 * @param string $regexURL
213 * @return string
214 */
215 private function getPreparedRegexPattern($regexURL) {
216 if (isset($this->preparedRegexPatternCache[$regexURL])) {
217 return $this->preparedRegexPatternCache[$regexURL];
218 }
219
220 $prepared = $this->f->str_replace('/', '\/', $regexURL);
221 $this->preparedRegexPatternCache[$regexURL] = $prepared;
222 return $prepared;
223 }
224
225 /** Find a match using an exact slug match.
226 * @param string $requestedURL
227 * @return array<string, mixed>|null
228 */
229 function getPermalinkUsingSlug(string $requestedURL) {
230
231 $exploded = array_filter(explode('/', $requestedURL));
232 if (count($exploded) === 0) {
233 return null;
234 }
235 $postSlug = end($exploded);
236 $postsBySlugRows = $this->contentRepository->getPublishedPagesAndPostsIDs(array('slug' => $postSlug));
237 if (count($postsBySlugRows) == 1) {
238 $post = reset($postsBySlugRows);
239 $postId = (is_object($post) && property_exists($post, 'id')) ? $post->id : null;
240 if ($postId === null) {
241 return null;
242 }
243 $permalink = array();
244 $permalink['id'] = $postId;
245 $permalink['type'] = ABJ404_TYPE_POST;
246 $permalink['score'] = 100;
247 $permalink['title'] = get_the_title($postId);
248 $permalink['link'] = get_permalink($postId);
249
250 return $permalink;
251
252 } else if (count($postsBySlugRows) > 1) {
253 $this->logger->debugMessage("More than one post found with the slug, so no redirect was " .
254 "created. Slug: " . $postSlug);
255 } else {
256 $this->logger->debugMessage("No posts or pages matching slug: " . esc_html($postSlug));
257 }
258
259 return null;
260 }
261
262 /**
263 * @param string $requestedURL
264 * @return bool
265 */
266 function requestIsForAnImage(string $requestedURL): bool {
267 $imageExtensions = array(".jpg", ".jpeg", ".gif", ".png", ".tif", ".tiff", ".bmp", ".pdf",
268 ".jif", ".jif", ".jp2", ".jpx", ".j2k", ".j2c", ".pcd");
269
270 $returnVal = false;
271
272 foreach ($imageExtensions as $extension) {
273 if ($this->f->endsWithCaseInsensitive($requestedURL, $extension)) {
274 $returnVal = true;
275 break;
276 }
277 }
278
279 return $returnVal;
280 }
281
282 /**
283 * @param array<int, object> $rowsAsObject
284 * @return array<int, array<string, mixed>>
285 */
286 function getOnlyIDandTermID(array $rowsAsObject): array {
287 $rows = array();
288 $objectRow = array_pop($rowsAsObject);
289 while ($objectRow != null) {
290 $rows[] = array(
291 'id' => property_exists($objectRow, 'id') == true ? $objectRow->id : null,
292 'term_id' => property_exists($objectRow, 'term_id') == true ? $objectRow->term_id : null,
293 'url' => property_exists($objectRow, 'url') == true ? $objectRow->url : null
294 );
295 $objectRow = array_pop($rowsAsObject);
296 }
297
298 return $rows;
299 }
300
301 /**
302 * @param string $requestedURL
303 * @return array<int|string, mixed>
304 */
305 function getFromPermalinkCache(string $requestedURL): array {
306 $ctx = abj_service('request_context');
307 if (!empty($ctx->permalinks_found)) {
308 $permalinks = json_decode($ctx->permalinks_found, true);
309 if (is_array($permalinks)) {
310 return $permalinks;
311 }
312 }
313
314 $returnValue = $this->contentRepository->getSpellingPermalinksFromCache($requestedURL);
315 if (is_array($returnValue) && !empty($returnValue)) {
316 return $returnValue;
317 }
318
319 return array();
320 }
321
322 /**
323 * @param int $id
324 * @param string $rowType
325 * @return string|null
326 * @throws Exception
327 */
328 function getPermalink($id, $rowType) {
329 if ($rowType == 'pages') {
330 $link = $this->contentRepository->getPermalinkFromCache($id);
331
332 if ($link === null || trim((string)$link) === '') {
333 $linkResult = get_the_permalink($id);
334 $link = ($linkResult !== false) ? $linkResult : null;
335 }
336 return abj_service('sanitizer')->normalizeUrlString($link);
337
338 } else if ($rowType == 'tags') {
339 return abj_service('sanitizer')->normalizeUrlString(get_tag_link($id));
340
341 } else if ($rowType == 'categories') {
342 return abj_service('sanitizer')->normalizeUrlString(get_category_link($id));
343
344 } else if ($rowType == 'image') {
345 $src = wp_get_attachment_image_src($id, "attached-image");
346 if ($src == false || !is_array($src)) {
347 return null;
348 }
349 return abj_service('sanitizer')->normalizeUrlString($src[0]);
350
351 } else {
352 throw new \Exception("Unknown row type ..."); // allow-raw-error: assertion, should never reach user
353 }
354 }
355
356 /** Turns "/abc/defg" into "defg"
357 * @param string $url
358 * @return string
359 */
360 function getLastURLPart($url) {
361 $parts = explode("/", $url);
362 $lastPart = '';
363 for ($i = count($parts) - 1; $i >= 0; $i--) {
364 $lastPart = $parts[$i];
365 if (trim($lastPart) != "") {
366 break;
367 }
368 }
369
370 if (trim($lastPart) == "") {
371 return $url;
372 }
373
374 return $lastPart;
375 }
376
377 }
378