PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 / SpellChecker.php

SpellChecker.php in 404 Solution 4.3.0, at includes/spelling/SpellChecker.php

501 lines 19.0 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 /* Finds similar pages.
9 * Finds search suggestions. */
10
11 class ABJ_404_Solution_SpellChecker {
12
13 /** @var array<int, string> */
14 private array $separatingCharacters = array("-","_",".","~",'%20');
15
16 /** Same as above except without the period (.) because of the extension in the file name.
17 * @var array<int, string> */
18 private array $separatingCharactersForImages = array("-","_","~",'%20');
19
20 const MAX_DIST = 2083;
21
22 const MAX_LIKELY_DISTANCE = 300;
23
24 const NGRAM_PREFILTER_THRESHOLD = 0.3;
25
26 const NGRAM_PREFILTER_MAX_CANDIDATES = 500;
27
28 const NGRAM_MIN_CACHE_ENTRIES = 50;
29
30 const NGRAM_SECONDARY_THRESHOLD = 0.4;
31
32 const NGRAM_SECONDARY_MAX_CANDIDATES = 100;
33
34 const NGRAM_MIN_COVERAGE_RATIO = 0.8;
35
36 const NGRAM_SECONDARY_MIN_CANDIDATES = 50;
37
38 private static ?self $instance = null;
39
40 /** @var ABJ_404_Solution_Functions */
41 private $f;
42
43 /** @var ABJ_404_Solution_PluginLogic */
44 private $logic;
45
46 /** @var ABJ_404_Solution_NotFoundResponseService|null */
47 private $notFoundResponse;
48
49 /** @var ABJ_404_Solution_ContentRepository */
50 private $contentRepository;
51
52 /** @var ABJ_404_Solution_Logging */
53 private $logger;
54
55 /** @var ABJ_404_Solution_SpellURLMatcher */
56 private $urlMatcher;
57
58 /** @var ABJ_404_Solution_SpellLevenshteinEngine */
59 private $levenshteinEngine;
60
61 /** @var ABJ_404_Solution_SpellCandidateFilter */
62 private $candidateFilter;
63
64 /** @var ABJ_404_Solution_SpellPostListeners */
65 private $postListeners;
66
67 /** @var ABJ_404_Solution_SpellSuggestionShortcodeDetector */
68 private $shortcodeDetector;
69
70 /**
71 * @param ABJ_404_Solution_SpellCheckerDependencies|null $deps
72 */
73 public function __construct(?ABJ_404_Solution_SpellCheckerDependencies $deps = null) {
74 $deps = $deps ?? new ABJ_404_Solution_SpellCheckerDependencies();
75 $contentRepository = $deps->contentRepository;
76 $this->f = $deps->functions !== null ? $deps->functions : abj_service('functions');
77 $this->logic = $deps->pluginLogic !== null ? $deps->pluginLogic : abj_service('plugin_logic');
78 $resolvedNotFoundResponse = function_exists('abj_service_optional')
79 ? abj_service_optional('not_found_response') : null;
80 $this->notFoundResponse = $resolvedNotFoundResponse instanceof ABJ_404_Solution_NotFoundResponseService
81 ? $resolvedNotFoundResponse : null;
82 $this->contentRepository = $contentRepository !== null ? $contentRepository : abj_service('content_repository');
83 $this->logger = $deps->logging !== null ? $deps->logging : abj_service('logging');
84 $permalinkCacheResolved = $deps->permalinkCache !== null ? $deps->permalinkCache : abj_service('permalink_cache');
85 $ngramFilterResolved = $deps->ngramFilter !== null ? $deps->ngramFilter : abj_service('ngram_filter');
86 $viewReadServiceResolved = $deps->viewReadService !== null ? $deps->viewReadService :
87 (is_object($contentRepository) && method_exists($contentRepository, 'getRedirectsWithRegEx') ? $contentRepository : abj_service('view_read_service'));
88
89 $options = abj_service('options_repository')->getOptions(true);
90 $custom404PageIDRaw =
91 (is_array($options) && isset($options['dest404page']) ?
92 $options['dest404page'] : null);
93 $custom404PageID = is_string($custom404PageIDRaw) ? $custom404PageIDRaw : (is_int($custom404PageIDRaw) ? (string)$custom404PageIDRaw : null);
94 $custom404PageIDResolved = null;
95 if ($this->notFoundResponse instanceof ABJ_404_Solution_NotFoundResponseService
96 && $this->notFoundResponse->thereIsAUserSpecified404Page($custom404PageID)) {
97 $custom404PageIDResolved = $custom404PageID;
98 }
99
100 $this->urlMatcher = new ABJ_404_Solution_SpellURLMatcher(
101 $this->f, $this->logger, $this->contentRepository,
102 $viewReadServiceResolved, $custom404PageIDResolved
103 );
104
105 $this->postListeners = new ABJ_404_Solution_SpellPostListeners(
106 $this->f, $this->logger, $this->contentRepository,
107 $permalinkCacheResolved, $ngramFilterResolved
108 );
109
110 $this->levenshteinEngine = new ABJ_404_Solution_SpellLevenshteinEngine(
111 new ABJ_404_Solution_SpellLevenshteinEngineDependencies(
112 $this->f, $this->logic, $this->logger, $this->contentRepository,
113 $ngramFilterResolved, $this->urlMatcher, $this->separatingCharacters
114 )
115 );
116
117 $this->candidateFilter = new ABJ_404_Solution_SpellCandidateFilter(
118 $this->f, $this->logic, $this->logger, $this->contentRepository,
119 $this->urlMatcher, $this->levenshteinEngine, $this->postListeners,
120 $custom404PageIDResolved, $this->separatingCharacters, $this->separatingCharactersForImages
121 );
122
123 $this->shortcodeDetector = new ABJ_404_Solution_SpellSuggestionShortcodeDetector(
124 $this->notFoundResponse
125 );
126 }
127
128 /**
129 * Test seam (M105): install the cached singleton; pass null to clear it.
130 * @param self|null $instance
131 * @return void
132 */
133 public static function setInstance($instance) {
134 self::$instance = $instance;
135 }
136
137 /**
138 * Return the already-built singleton without resolving the container or
139 * building a new one, so the `spell_checker` factory can honor a
140 * test-installed override. Mirrors PluginLogic / Logging peekInstance().
141 * @return self|null
142 */
143 public static function peekInstance(): ?self {
144 return self::$instance;
145 }
146
147 public static function getInstance(): self {
148 if (self::$instance !== null) {
149 return self::$instance;
150 }
151
152 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
153 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('spell_checker');
154 if ($resolved instanceof self) {
155 self::$instance = $resolved;
156 return self::$instance;
157 }
158 }
159
160 self::$instance = new ABJ_404_Solution_SpellChecker();
161
162 return self::$instance;
163 }
164
165 public function enablePerformanceCounters(bool $enable = true): void {
166 $this->levenshteinEngine->enablePerformanceCounters($enable);
167 }
168
169 public function setSkipNgramGate4(bool $skip = true): void {
170 $this->levenshteinEngine->setSkipNgramGate4($skip);
171 }
172
173 public function resetPerformanceCounters(): void {
174 $this->levenshteinEngine->resetPerformanceCounters();
175 }
176
177 /**
178 * @return array{levenshtein_calls: int, pages_considered: int, efficiency_percent: float}
179 */
180 public function getPerformanceCounters(): array {
181 return $this->levenshteinEngine->getPerformanceCounters();
182 }
183
184 /** @return array<string, mixed>|null */
185 function getPermalinkUsingRegEx(string $requestedURL, $options = null) {
186 return $this->urlMatcher->getPermalinkUsingRegEx($requestedURL, $options);
187 }
188
189 /** @return array<string, mixed>|null */
190 function getPermalinkUsingSlug(string $requestedURL) {
191 return $this->urlMatcher->getPermalinkUsingSlug($requestedURL);
192 }
193
194 function requestIsForAnImage(string $requestedURL): bool {
195 return $this->urlMatcher->requestIsForAnImage($requestedURL);
196 }
197
198 /** @return array<int, array<string, mixed>> */
199 function getOnlyIDandTermID(array $rowsAsObject): array {
200 return $this->urlMatcher->getOnlyIDandTermID($rowsAsObject);
201 }
202
203 /** @return array<int|string, mixed> */
204 function getFromPermalinkCache(string $requestedURL): array {
205 return $this->urlMatcher->getFromPermalinkCache($requestedURL);
206 }
207
208 /**
209 * @return string|null
210 * @throws Exception
211 */
212 function getPermalink($id, $rowType) {
213 return $this->urlMatcher->getPermalink($id, $rowType);
214 }
215
216 function getLastURLPart($url) {
217 return $this->urlMatcher->getLastURLPart($url);
218 }
219
220 /** @return array<int, mixed> */
221 function findMatchingPosts(string $requestedURLRaw, string $includeCats = '1', string $includeTags = '1') {
222 return $this->candidateFilter->findMatchingPosts($requestedURLRaw, $includeCats, $includeTags);
223 }
224
225 /** @return array<string, string> */
226 function removeExcludedPages(array $options, array $permalinks): array {
227 return $this->candidateFilter->removeExcludedPages($options, $permalinks);
228 }
229
230 /** @return array<string, string> */
231 function removeExcludedPagesWithRegex(array $options, array $permalinks, int $maxCacheCount): array {
232 return $this->candidateFilter->removeExcludedPagesWithRegex($options, $permalinks, $maxCacheCount);
233 }
234
235 /** @return array<string, string> */
236 function matchOnCats(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
237 return $this->candidateFilter->matchOnCats($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
238 }
239
240 /** @return array<string, string> */
241 function matchOnTags(array $permalinks, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
242 return $this->candidateFilter->matchOnTags($permalinks, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
243 }
244
245 /** @return array<string, string> */
246 function matchOnPosts(array $permalinks, string $requestedURLRaw, string $requestedURLCleaned, string $fullURLspacesCleaned, string $rowType): array {
247 return $this->candidateFilter->matchOnPosts($permalinks, $requestedURLRaw, $requestedURLCleaned, $fullURLspacesCleaned, $rowType);
248 }
249
250 /** @return array<int|string, mixed> */
251 function getLikelyMatchIDs(string $requestedURLCleaned, string $fullURLspaces, string $rowType, ?array $rows = null) {
252 return $this->levenshteinEngine->getLikelyMatchIDs($requestedURLCleaned, $fullURLspaces, $rowType, $rows);
253 }
254
255 function customLevenshtein($str1, $str2) {
256 return $this->levenshteinEngine->customLevenshtein($str1, $str2);
257 }
258
259 function save_postListener($post_id, $post = null, $update = null): void {
260 // @hook-lifecycle: opt-out - delegated SpellPostListeners::save_postListener owns request-level dedup.
261 $this->postListeners->save_postListener($post_id, $post, $update);
262 }
263
264 function delete_postListener($post_id, $post = null): void {
265 $this->postListeners->delete_postListener($post_id, $post);
266 }
267
268 function term_changedListener(int $term_id, int $tt_id = 0, string $taxonomy = ''): void {
269 $this->postListeners->term_changedListener($term_id, $tt_id, $taxonomy);
270 }
271
272 function savePostHandler($post_id, $post, $update, $saveOrDelete): void {
273 $this->postListeners->savePostHandler($post_id, $post, $update, $saveOrDelete);
274 }
275
276 function permalinkStructureChanged($var1, $newStructure): void {
277 $this->postListeners->permalinkStructureChanged($var1, $newStructure);
278 }
279
280 function initializePublishedPostsProvider(): void {
281 $this->postListeners->initializePublishedPostsProvider();
282 }
283
284 /**
285 * @return array<int, mixed>
286 */
287 public function findSuggestionsForURLUsingSmartCache($requestedURL, $includeCats = '1', $includeTags = true) {
288 $includeTagsStr = $includeTags ? '1' : '0';
289 return $this->findMatchingPosts($requestedURL, $includeCats, $includeTagsStr);
290 }
291
292 static function init(): void {
293 $me = abj_service('spell_checker');
294
295 add_action('updated_option', array($me,'permalinkStructureChanged'), 10, 2);
296 add_action('save_post', array($me,'save_postListener'), 10, 3);
297 add_action('delete_post', array($me,'delete_postListener'), 10, 2);
298 // A category/tag create/rename/delete changes spelling-match results, so
299 // invalidate the spelling cache (including memoized no-match entries).
300 add_action('created_term', array($me,'term_changedListener'), 10, 3);
301 add_action('edited_term', array($me,'term_changedListener'), 10, 3);
302 add_action('delete_term', array($me,'term_changedListener'), 10, 3);
303 }
304
305 /**
306 * True only for a cached confirmed no-match: a [permalinks, rowType] packet
307 * whose permalink list is an empty array. A cache miss (empty array()), a
308 * positive result, and any malformed payload all return false so the caller
309 * recomputes -- the negative short-circuit can never emit a destination.
310 *
311 * @param mixed $cachedPacket the getFromPermalinkCache() return value
312 * @return bool
313 */
314 private function isCachedNoMatchResult($cachedPacket): bool {
315 return is_array($cachedPacket) && count($cachedPacket) === 2
316 && array_key_exists(0, $cachedPacket) && is_array($cachedPacket[0])
317 && empty($cachedPacket[0]);
318 }
319
320 /**
321 * @return array<string, mixed>|null
322 */
323 function getPermalinkUsingSpelling(string $requestedURL, ?string $fullRequestedURL = null, $optionsOverride = null) {
324 $abj404spellChecker = abj_service('spell_checker');
325
326 $options = is_array($optionsOverride) ? $optionsOverride : abj_service('options_repository')->getOptions();
327
328 if (@$options['auto_redirects'] == '1') {
329 $autoCats = isset($options['auto_cats']) && is_string($options['auto_cats']) ? $options['auto_cats'] : '1';
330 $autoTags = isset($options['auto_tags']) && is_string($options['auto_tags']) ? $options['auto_tags'] : '1';
331
332 // Negative-result memoization. A repeated 404 to a URL with no
333 // spelling match would otherwise re-run the full Levenshtein scan
334 // on every hit: captured rows are excluded from the pre-match
335 // redirect lookup (status/type filter in getPermalinkFromURL.sql),
336 // so an already-seen no-match URL never short-circuits before this
337 // point. We short-circuit ONLY on a cached no-match (an empty
338 // permalink list). The outcome is identical to recomputing a
339 // no-match (return null), so a stale entry can at worst miss a
340 // redirect (graceful 404), never cause a wrong one. The spelling
341 // cache is invalidated on any content change (SpellPostListeners:
342 // save/delete post, created/edited/deleted category or tag), so a
343 // URL that becomes matchable is recomputed on its next hit. A
344 // cached POSITIVE result is intentionally ignored and recomputed
345 // fresh so a deleted or unpublished destination can never be
346 // served from cache. Positive matches do not recur here in any
347 // case: a successful match is promoted to a stored AUTO redirect,
348 // so the next hit short-circuits at getActiveRedirectForURL.
349 $cachedPacket = $abj404spellChecker->getFromPermalinkCache($requestedURL);
350 if ($this->isCachedNoMatchResult($cachedPacket)) {
351 return null;
352 }
353
354 $permalinksPacket = $abj404spellChecker->findMatchingPosts($requestedURL,
355 $autoCats, $autoTags);
356
357 $permalinks = $permalinksPacket[0];
358 $rowType = $permalinksPacket[1];
359
360 $minScore = $options['auto_score'];
361
362 if (!is_array($permalinks) || empty($permalinks)) {
363 return null;
364 }
365 $linkScore = reset($permalinks);
366 $idAndType = key($permalinks);
367 $idAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
368 $linkScoreInt = is_scalar($linkScore) ? (int)$linkScore : 0;
369 $permalink = ABJ_404_Solution_PermalinkResolver::permalinkInfoToArray($idAndTypeStr, $linkScoreInt,
370 is_string($rowType) ? $rowType : null, $options);
371
372 if ($permalink['score'] >= $minScore) {
373 $redirectType = $permalink['type'];
374 if (('' . $redirectType != ABJ404_TYPE_404_DISPLAYED) && ('' . $redirectType != ABJ404_TYPE_HOME)) {
375 return $permalink;
376
377 } else {
378 $permalinkJson = json_encode($permalink);
379 $this->logger->errorMessage("Unhandled permalink type: " .
380 wp_kses_post(is_string($permalinkJson) ? $permalinkJson : '{}'));
381 return null;
382 }
383 }
384
385 if ($fullRequestedURL !== null) {
386 $this->cacheComputedSuggestionsForShortcode($fullRequestedURL, $permalinksPacket);
387 }
388 }
389
390 return null;
391 }
392
393 private function cacheComputedSuggestionsForShortcode(string $fullRequestedURL, array $permalinksPacket): void {
394 $normalizedURL = abj_service('url_encoder')->normalizeURLForCacheKey($fullRequestedURL);
395
396 $urlKey = md5($normalizedURL);
397 $transientKey = 'abj404_suggest_' . $urlKey;
398
399 $existing = get_transient($transientKey);
400 if ($existing !== false) {
401 return;
402 }
403
404 // allow-cache-empty: factory-built typed array; SuggestionTransient::completeArray
405 // always returns a non-empty associative array with at minimum a 'status' key.
406 set_transient(
407 $transientKey,
408 ABJ_404_Solution_SuggestionTransient::completeArray(
409 $normalizedURL,
410 $permalinksPacket,
411 abj_clock()->now(),
412 ''
413 ),
414 300
415 ); // 5 minute TTL
416
417 $this->logger->debugMessage("Cached spell-check suggestions for shortcode: " .
418 esc_html($normalizedURL));
419 }
420
421 public function triggerAndCleanupOnFailure(string $requestedURL): bool {
422 $normalizedURL = abj_service('url_encoder')->normalizeURLForCacheKey($requestedURL);
423
424 $urlKey = md5($normalizedURL);
425 $transientKey = 'abj404_suggest_' . $urlKey;
426
427 $existing = ABJ_404_Solution_SuggestionTransient::fromRaw(get_transient($transientKey));
428 if ($existing !== null) {
429 $this->logger->debugMessage("Async suggestions: skipping, transient already exists for " .
430 esc_html($normalizedURL) . " (status: " . esc_html($existing->getStatus()) . ")");
431 return false;
432 }
433
434 $token = wp_generate_password(32, false);
435
436 // allow-cache-empty: factory-built typed array; keep the TTL at 120
437 // seconds so slow hosts can start before the polling UI gives up.
438 set_transient(
439 $transientKey,
440 ABJ_404_Solution_SuggestionTransient::pendingArray(
441 $normalizedURL,
442 $token,
443 0,
444 abj_clock()->now()
445 ),
446 120
447 ); // 2 minute TTL
448
449 $this->logger->debugMessage("Async suggestions: triggering background computation for " .
450 esc_html($normalizedURL));
451
452 // Loopback self-dispatch to admin-ajax.php on this same host. The
453 // sslverify default of false matches WP core's own loopback convention
454 // (see wp-includes/cron.php spawn_cron(), which uses the same
455 // apply_filters('https_local_ssl_verify', false) pattern) and is
456 // intentional for three reasons:
457 // 1. The request never leaves the host. Intercepting it requires an
458 // attacker who already controls the local machine, at which point
459 // they can read the transient and dispatch the AJAX directly
460 // without bothering with MITM on loopback.
461 // 2. WP sites routinely run on self-signed or hostname-mismatched
462 // certs in dev / behind a TLS-terminating proxy. Hardcoding
463 // sslverify true would break dispatch for those installs with no
464 // affordance for the admin to recover.
465 // 3. The body carries only a one-shot suggestion-compute token bound
466 // to a 2-minute pending transient (set above). Worst case for a
467 // hypothetical local MITM is they re-trigger the same compute the
468 // site is already running, which is rate-limited downstream.
469 // Admins on hostile-loopback topologies (e.g. reverse proxy spanning an
470 // untrusted segment) can return true from the https_local_ssl_verify
471 // filter to opt into strict TLS. Tests for both behaviors live in
472 // AsyncSuggestionsTest::testWpRemotePostSslVerify*. M104 in the design
473 // audit re-flags this every pass; this comment is the documented
474 // trade-off so the next audit can mark it accepted.
475 $response = wp_remote_post(admin_url('admin-ajax.php'), array(
476 'blocking' => false,
477 'timeout' => 5,
478 'sslverify' => apply_filters('https_local_ssl_verify', false),
479 'body' => array(
480 'action' => 'abj404_compute_suggestions',
481 'url' => $normalizedURL,
482 'token' => $token
483 )
484 ));
485
486 if (is_wp_error($response)) {
487 $this->logger->debugMessage("Async suggestions: dispatch failed for " .
488 esc_html($normalizedURL) . " - " . $response->get_error_message());
489 delete_transient($transientKey);
490 return false;
491 }
492
493 return true;
494 }
495
496 public function does404PageHaveSuggestionsShortcode() {
497 return $this->shortcodeDetector->does404PageHaveSuggestionsShortcode();
498 }
499
500 }
501