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

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

316 lines 10.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 helpers for ABJ_404_Solution_SpellChecker:
9 * regex-based matching, slug matching, image detection, permalink lookup,
10 * cache retrieval, and URL utility helpers.
11 */
12 trait SpellCheckerTrait_URLMatching {
13
14 /** Find a match using the user-defined regex patterns.
15 * @param string $requestedURL
16 * @param array<string, mixed>|null $options
17 * @return array<string, mixed>|null
18 */
19 function getPermalinkUsingRegEx(string $requestedURL, $options = null) {
20 if (!is_array($options)) {
21 $options = $this->logic->getOptions();
22 }
23 $isDebug = $this->logger->isDebug();
24
25 $regexURLsRows = $this->dao->getRedirectsWithRegEx();
26
27 // Runtime fallback: also consider MANUAL rows whose stored from_url
28 // contains an unambiguous regex metacharacter. These can exist when
29 // the row was created before the server-side auto-promote landed
30 // (older plugin versions), through a direct DB write, or via a
31 // CSV import path that pre-dated the widened sniff. The stored
32 // status is NOT mutated here; the next save/import on the row
33 // will sweep it into REGEX through the normal path.
34 //
35 // The glob fixup is applied to the in-memory copy of the URL only
36 // so a literal `/sales/*` stored by an older version compiles to
37 // `/sales/.*` and actually matches `/sales/foo`. Without this
38 // step, the pattern would hit PCRE "nothing to repeat" and the
39 // row would silently fail to match (the loop's @-suppressed
40 // preg_match returns false in that case).
41 $manualWithMetachars = $this->dao->getManualRedirectsWithRegexMetachars();
42 if (!empty($manualWithMetachars)) {
43 $filtered = array();
44 foreach ($manualWithMetachars as $manualRow) {
45 $manualUrl = isset($manualRow['url']) && is_string($manualRow['url']) ? $manualRow['url'] : '';
46 if (!ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($manualUrl)) {
47 continue;
48 }
49 $glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($manualUrl);
50 $manualRow['url'] = $glob['url'];
51 $filtered[] = $manualRow;
52 }
53 if (!empty($filtered)) {
54 if ($isDebug) {
55 $this->logger->debugMessage(
56 "Runtime regex fallback: trying " . count($filtered) .
57 " MANUAL row(s) with regex metachars against URL: " . $requestedURL
58 );
59 }
60 $regexURLsRows = array_merge($regexURLsRows, $filtered);
61 }
62 }
63
64 foreach ($regexURLsRows as $row) {
65 $regexURL = $row['url'];
66
67 if ($isDebug) {
68 abj_service('request_context')->debug_info = 'Applying custom regex "' . $regexURL . '" to URL: ' .
69 $requestedURL;
70 }
71 $regexURLStr = is_string($regexURL) ? $regexURL : '';
72 $preparedURL = $this->getPreparedRegexPattern($regexURLStr);
73 // Suppress PHP warnings from invalid stored patterns. Bad rows can
74 // reach here from older imports, manual admin edits, or direct DB
75 // writes; treating them as non-match keeps a single bad row from
76 // polluting every 404 request's log. Same idiom as
77 // RedirectConditionEvaluator.php:316 (`@preg_match` in case 'regex').
78 if (@$this->f->regexMatch($preparedURL, $requestedURL)) {
79 if ($isDebug) {
80 abj_service('request_context')->debug_info = 'Cleared after regex.';
81 }
82 $rowType = isset($row['type']) && is_scalar($row['type']) ? (int)$row['type'] : 0;
83 $rowDest = isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '';
84 if ($rowType === (int)ABJ404_TYPE_EXTERNAL) {
85 // Fast path: external redirects already have a concrete target URL.
86 $permalink = array(
87 'id' => 0,
88 'type' => ABJ404_TYPE_EXTERNAL,
89 'link' => $rowDest,
90 'title' => '',
91 'score' => 100,
92 );
93 } else {
94 $idAndType = $rowDest . '|' . $row['type'];
95 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($idAndType, 0,
96 null, $options);
97 }
98 $permalink['matching_regex'] = $regexURL;
99 $permalink['code'] = isset($row['code']) && is_scalar($row['code']) ? (int)$row['code'] : 0;
100 $originalPermalink = $isDebug ? $permalink : null;
101
102 // If regex has capture groups and destination has replacement markers, resolve them.
103 $permLinkStr = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
104 $hasCaptureGroup = ($this->f->strpos($regexURLStr, '(') !== false);
105 $hasReplacementToken = ($this->f->strpos($permLinkStr, '$') !== false);
106 if ($hasCaptureGroup && $hasReplacementToken) {
107 $results = array();
108 // Pattern already proved itself valid in the gate above;
109 // suppress here too in case e.g. a different prepared-vs-raw
110 // shape compiles inconsistently.
111 @$this->f->regexMatch($regexURLStr, $requestedURL, $results);
112
113 // do a repacement for all of the groups found.
114 $final = $permLinkStr;
115 for ($x = 1; $x < count($results); $x++) {
116 $final = $this->f->str_replace('$' . $x, $results[$x], $final);
117 }
118
119 $permalink['link'] = $final;
120 }
121
122 if ($isDebug) {
123 $this->logger->debugMessage("Found matching regex. Original permalink" .
124 json_encode($originalPermalink) . ", final: " .
125 json_encode($permalink));
126 }
127
128 return $permalink;
129 }
130
131 if ($isDebug) {
132 abj_service('request_context')->debug_info = 'Cleared after regex.';
133 }
134 }
135
136 return null;
137 }
138
139 /**
140 * Normalize and cache regex patterns for reuse within this request.
141 *
142 * @param string $regexURL
143 * @return string
144 */
145 private function getPreparedRegexPattern($regexURL) {
146 if (isset($this->preparedRegexPatternCache[$regexURL])) {
147 return $this->preparedRegexPatternCache[$regexURL];
148 }
149
150 $prepared = $this->f->str_replace('/', '\/', $regexURL);
151 $this->preparedRegexPatternCache[$regexURL] = $prepared;
152 return $prepared;
153 }
154
155 /** Find a match using an exact slug match.
156 * If there is a post that has a slug that matches the user requested slug exactly,
157 * then return the permalink for that post. Otherwise return null.
158 * @param string $requestedURL
159 * @return array<string, mixed>|null
160 */
161 function getPermalinkUsingSlug(string $requestedURL) {
162
163 $exploded = array_filter(explode('/', $requestedURL));
164 if (count($exploded) === 0) {
165 return null;
166 }
167 $postSlug = end($exploded);
168 $postsBySlugRows = $this->dao->getPublishedPagesAndPostsIDs($postSlug);
169 if (count($postsBySlugRows) == 1) {
170 $post = reset($postsBySlugRows);
171 $postId = (is_object($post) && property_exists($post, 'id')) ? $post->id : null;
172 if ($postId === null) {
173 return null;
174 }
175 $permalink = array();
176 $permalink['id'] = $postId;
177 $permalink['type'] = ABJ404_TYPE_POST;
178 // the score doesn't matter.
179 $permalink['score'] = 100;
180 $permalink['title'] = get_the_title($postId);
181 $permalink['link'] = get_permalink($postId);
182
183 return $permalink;
184
185 } else if (count($postsBySlugRows) > 1) {
186 // more than one post has the same slug. I don't know what to do.
187 $this->logger->debugMessage("More than one post found with the slug, so no redirect was " .
188 "created. Slug: " . $postSlug);
189 } else {
190 $this->logger->debugMessage("No posts or pages matching slug: " . esc_html($postSlug));
191 }
192
193 return null;
194 }
195
196 /**
197 * Return true if the last characters of the URL represent an image extension (like jpg, gif, etc).
198 * @param string $requestedURL
199 * @return bool
200 */
201 function requestIsForAnImage(string $requestedURL): bool {
202 $imageExtensions = array(".jpg", ".jpeg", ".gif", ".png", ".tif", ".tiff", ".bmp", ".pdf",
203 ".jif", ".jif", ".jp2", ".jpx", ".j2k", ".j2c", ".pcd");
204
205 $returnVal = false;
206
207 foreach ($imageExtensions as $extension) {
208 if ($this->f->endsWithCaseInsensitive($requestedURL, $extension)) {
209 $returnVal = true;
210 break;
211 }
212 }
213
214 return $returnVal;
215 }
216
217 /**
218 * @param array<int, object> $rowsAsObject
219 * @return array<int, array<string, mixed>>
220 */
221 function getOnlyIDandTermID(array $rowsAsObject): array {
222 $rows = array();
223 $objectRow = array_pop($rowsAsObject);
224 while ($objectRow != null) {
225 $rows[] = array(
226 'id' => property_exists($objectRow, 'id') == true ? $objectRow->id : null,
227 'term_id' => property_exists($objectRow, 'term_id') == true ? $objectRow->term_id : null,
228 'url' => property_exists($objectRow, 'url') == true ? $objectRow->url : null
229 );
230 $objectRow = array_pop($rowsAsObject);
231 }
232
233 return $rows;
234 }
235
236 /**
237 * @param string $requestedURL
238 * @return array<int|string, mixed>
239 */
240 function getFromPermalinkCache(string $requestedURL): array {
241 // The request cache is used when the suggested pages shortcode is used.
242 $ctx = abj_service('request_context');
243 if (!empty($ctx->permalinks_found)) {
244 $permalinks = json_decode($ctx->permalinks_found, true);
245 if (is_array($permalinks)) {
246 return $permalinks;
247 }
248 }
249
250 // check the database cache.
251 $returnValue = $this->dao->getSpellingPermalinksFromCache($requestedURL);
252 if (is_array($returnValue) && !empty($returnValue)) {
253 return $returnValue;
254 }
255
256 return array();
257 }
258
259 /**
260 * Get the permalink for the passed in type (pages, tags, categories, image, etc.
261 * @param int $id
262 * @param string $rowType
263 * @return string|null
264 * @throws Exception
265 */
266 function getPermalink($id, $rowType) {
267 if ($rowType == 'pages') {
268 $link = $this->dao->getPermalinkFromCache($id);
269
270 if ($link === null || trim((string)$link) === '') {
271 $linkResult = get_the_permalink($id);
272 $link = ($linkResult !== false) ? $linkResult : null;
273 }
274 return $this->f->normalizeUrlString($link);
275
276 } else if ($rowType == 'tags') {
277 return $this->f->normalizeUrlString(get_tag_link($id));
278
279 } else if ($rowType == 'categories') {
280 return $this->f->normalizeUrlString(get_category_link($id));
281
282 } else if ($rowType == 'image') {
283 $src = wp_get_attachment_image_src($id, "attached-image");
284 if ($src == false || !is_array($src)) {
285 return null;
286 }
287 return $this->f->normalizeUrlString($src[0]);
288
289 } else {
290 throw new \Exception("Unknown row type ...");
291 }
292 }
293
294 /** Turns "/abc/defg" into "defg"
295 * @param string $url
296 * @return string
297 */
298 function getLastURLPart($url) {
299 $parts = explode("/", $url);
300 $lastPart = '';
301 for ($i = count($parts) - 1; $i >= 0; $i--) {
302 $lastPart = $parts[$i];
303 if (trim($lastPart) != "") {
304 break;
305 }
306 }
307
308 if (trim($lastPart) == "") {
309 return $url;
310 }
311
312 return $lastPart;
313 }
314
315 }
316