| 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 |
|