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 / frontend / SuggestionPublisher.php

SuggestionPublisher.php in 404 Solution trunk, at includes/frontend/SuggestionPublisher.php

193 lines 7.1 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 * Owns the frontend write side of the `abj404_suggest_<md5(url)>` transient:
9 * the handoff between the request that discovered a 404 and the shortcode that
10 * renders suggestions on the 404 page.
11 *
12 * Two ways a request can fill that slot, and this class owns both so the key
13 * derivation, the TTLs and the pending/complete state machine have one home:
14 *
15 * - The suggestions were already computed while resolving the request (the
16 * spelling scan ran and produced candidates that scored under the
17 * auto-redirect threshold): publish them directly.
18 * - Nothing is computed yet: mark the slot pending, dispatch a non-blocking
19 * loopback request to admin-ajax.php to do the work. A failed dispatch
20 * leaves its owned pending marker intact so polling can report the
21 * dispatch timeout without deleting state published by another request.
22 *
23 * This lived on SpellChecker, which made a Levenshtein-scoring domain class
24 * also own an HTTP self-request, a TLS-verification policy and a transient
25 * lifecycle. SuggestionTransient owns the shared URL normalization, key shape,
26 * and TTL constants used by every producer and consumer.
27 */
28 class ABJ_404_Solution_SuggestionPublisher {
29
30 /** @var ABJ_404_Solution_Logging */
31 private $logger;
32
33 /**
34 * @param ABJ_404_Solution_Logging $logger
35 */
36 public function __construct($logger) {
37 $this->logger = $logger;
38 }
39
40 /**
41 * Publish an already-computed suggestion packet so the shortcode renders it
42 * immediately instead of dispatching a background compute for work that is
43 * already done. The completed packet is authoritative over pending work and
44 * is written directly, avoiding a read-before-write producer race.
45 *
46 * @param string $fullRequestedURL The URL as requested, before normalization.
47 * @param array<int, mixed> $permalinksPacket Two-tuple from the spell checker.
48 * @return void
49 */
50 public function cacheComputedSuggestionsForShortcode(string $fullRequestedURL, array $permalinksPacket): void {
51 $normalizedURL = ABJ_404_Solution_SuggestionTransient::normalizedUrl($fullRequestedURL);
52 $transientKey = ABJ_404_Solution_SuggestionTransient::transientKeyForNormalizedUrl($normalizedURL);
53 $claim = $this->acquireStateLock($normalizedURL);
54 if ($claim === null) {
55 $this->logger->debugMessage('Suggestion cache write skipped because another writer owns ' .
56 esc_html($normalizedURL));
57 return;
58 }
59
60 try {
61 // allow-cache-empty: factory-built typed array; SuggestionTransient::completeArray
62 // always returns a non-empty associative array with at minimum a 'status' key.
63 $stored = set_transient(
64 $transientKey,
65 ABJ_404_Solution_SuggestionTransient::completeArray(
66 $normalizedURL,
67 $permalinksPacket,
68 abj_clock()->now(),
69 ''
70 ),
71 ABJ_404_Solution_SuggestionTransient::COMPLETE_TTL_SECONDS
72 );
73 } finally {
74 $this->releaseStateLock($claim);
75 }
76
77 if (!$stored) {
78 $this->logger->warn('[SUGGESTION_CACHE_WRITE_FAILED] Could not store completed suggestions for ' .
79 esc_html($normalizedURL) . '. Recovery: the shortcode will compute suggestions synchronously.');
80 return;
81 }
82
83 $this->logger->debugMessage("Cached spell-check suggestions for shortcode: " .
84 esc_html($normalizedURL));
85 }
86
87 public function triggerAsyncSuggestions(string $requestedURL): bool {
88 $normalizedURL = ABJ_404_Solution_SuggestionTransient::normalizedUrl($requestedURL);
89 $transientKey = ABJ_404_Solution_SuggestionTransient::transientKeyForNormalizedUrl($normalizedURL);
90 $adminAjaxUrl = $this->localAdminAjaxUrl();
91 if ($adminAjaxUrl === '') {
92 return false;
93 }
94
95 $claim = $this->acquireStateLock($normalizedURL);
96 if ($claim === null) {
97 $this->logger->debugMessage('Async suggestions: another publisher owns ' . esc_html($normalizedURL));
98 return false;
99 }
100
101 try {
102 $existing = ABJ_404_Solution_SuggestionTransient::fromRaw(get_transient($transientKey));
103 if ($existing !== null) {
104 $this->logger->debugMessage("Async suggestions: skipping, transient already exists for " .
105 esc_html($normalizedURL) . " (status: " . esc_html($existing->getStatus()) . ")");
106 return false;
107 }
108
109 $token = wp_generate_password(32, false);
110
111 // allow-cache-empty: pendingArray always returns a typed, non-empty state packet.
112 $stored = set_transient(
113 $transientKey,
114 ABJ_404_Solution_SuggestionTransient::pendingArray(
115 $normalizedURL,
116 $token,
117 0,
118 abj_clock()->now()
119 ),
120 ABJ_404_Solution_SuggestionTransient::PENDING_TTL_SECONDS
121 );
122
123 if (!$stored) {
124 $this->logger->warn('[SUGGESTION_PENDING_WRITE_FAILED] Could not persist the async suggestion job for ' .
125 esc_html($normalizedURL) . '. Recovery: the request will use synchronous suggestions.');
126 return false;
127 }
128 } finally {
129 $this->releaseStateLock($claim);
130 }
131
132 $this->logger->debugMessage("Async suggestions: triggering background computation for " .
133 esc_html($normalizedURL));
134
135 // Verify TLS by default because the body contains the one-shot worker
136 // token. Sites with an intentionally self-signed loopback can still use
137 // WordPress's standard https_local_ssl_verify filter explicitly.
138 $response = wp_remote_post($adminAjaxUrl, array(
139 'blocking' => false,
140 'timeout' => 5,
141 'sslverify' => apply_filters('https_local_ssl_verify', true),
142 'body' => array(
143 'action' => 'abj404_compute_suggestions',
144 'url' => $normalizedURL,
145 'token' => $token
146 )
147 ));
148
149 if (is_wp_error($response)) {
150 $this->logger->warn('[SUGGESTION_DISPATCH_FAILED] Async suggestion dispatch failed for ' .
151 esc_html($normalizedURL) . ' (' . $response->get_error_code() . '): ' .
152 $response->get_error_message() . '. Recovery: polling will fall back after the dispatch timeout.');
153 return false;
154 }
155
156 return true;
157 }
158
159 /** @return array{key: string, owner: string}|null */
160 private function acquireStateLock(string $normalizedURL): ?array {
161 $key = ABJ_404_Solution_SuggestionTransient::lockKeyForNormalizedUrl($normalizedURL);
162 $owner = abj_service('sync_utils')
163 ->synchronizerAcquireLockTry($key);
164 return $owner === '' ? null : array('key' => $key, 'owner' => $owner);
165 }
166
167 /** @param array{key: string, owner: string} $claim */
168 private function releaseStateLock(array $claim): void {
169 abj_service('sync_utils')
170 ->synchronizerReleaseLock($claim['owner'], $claim['key']);
171 }
172
173 /**
174 * Resolve the loopback endpoint and reject filters that move it off-site.
175 * The dispatch body contains a requested URL and one-shot worker token, so
176 * an externally filtered admin_url must never receive it.
177 */
178 private function localAdminAjaxUrl(): string {
179 $adminAjaxUrl = admin_url('admin-ajax.php');
180 $adminHost = parse_url($adminAjaxUrl, PHP_URL_HOST);
181 $homeHost = parse_url(home_url('/'), PHP_URL_HOST);
182 if (is_string($adminHost) && $adminHost !== '' && is_string($homeHost)
183 && $homeHost !== '' && strcasecmp($adminHost, $homeHost) === 0
184 ) {
185 return $adminAjaxUrl;
186 }
187
188 $this->logger->warn('[SUGGESTION_DISPATCH_OFFSITE] Refused async suggestion dispatch because admin_url ' .
189 'does not use the site host. Recovery: remove the admin_url filter or use synchronous suggestions.');
190 return '';
191 }
192 }
193