PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.8
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.8
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / expiring-store / application / expiring-store.php

expiring-store.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 27.8, at src/expiring-store/application/expiring-store.php

425 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yoast\WP\SEO\Expiring_Store\Application;
4
5 use InvalidArgumentException;
6 use JsonException;
7 use JsonSerializable;
8 use Yoast\WP\SEO\Expiring_Store\Application\Ports\Expiring_Store_Repository_Interface;
9 use Yoast\WP\SEO\Expiring_Store\Domain\Corrupted_Value_Exception;
10 use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception;
11 use Yoast\WP\SEO\Expiring_Store\Domain\No_Current_User_Exception;
12 use Yoast\WP\SEO\Helpers\Date_Helper;
13
14 /**
15 * Reliable temporary storage with expiration.
16 *
17 * Backed by a custom database table (one per multisite network) instead of transients,
18 * ensuring values are not lost due to cache eviction or transient purging.
19 *
20 * ## When to use this
21 *
22 * Use Expiring_Store when losing a value before its TTL has real consequences:
23 * - OAuth handshakes and short-lived tokens (e.g. PKCE code verifiers).
24 * - Locks that prevent concurrent operations (e.g. token refresh race conditions).
25 * - Any value where a missing entry causes user-facing errors or excessive API calls.
26 *
27 * ## When to use transients or wp_cache instead
28 *
29 * - **`wp_cache`**: For data that only needs to live within the current request, or that
30 * benefits from a persistent object cache but can be recomputed cheaply if lost.
31 * - **Transients**: For data that is purely a performance optimization (caching). If the
32 * transient disappears, the worst case is a slower request while the value is recomputed.
33 * Never use transients for data whose loss would cause functional failures.
34 *
35 * ## Scoping strategies
36 *
37 * - **Blog-scoped** (`persist`, `get`, `delete`): Keys are prefixed with the current blog ID.
38 * Use for data that belongs to a specific site in a multisite network.
39 * - **User-scoped** (`*_for_user`): Keys are prefixed with the given or current user ID.
40 * Use for per-user data like OAuth tokens or verification codes.
41 * Accepts an optional `$user_id`; when omitted (or 0), falls back to the current user.
42 * Throws {@see No_Current_User_Exception} when no user ID is given and no user is logged in.
43 * - **Network-scoped** (`*_for_multisite`): Keys are stored as-is without any prefix.
44 * Use for data shared across all sites in the network.
45 *
46 * ## Behavior
47 *
48 * Values are JSON-encoded for storage (not PHP-serialized) to avoid object injection risks.
49 * Any JSON-encodable value is accepted: scalars, arrays, or {@see \JsonSerializable} objects.
50 *
51 * If a key already exists, `persist` overwrites it (upsert behavior).
52 * If a key is not found or has expired, `get` throws a {@see Key_Not_Found_Exception}.
53 * If a key's value cannot be decoded from JSON, `get` throws a {@see Corrupted_Value_Exception}.
54 *
55 * Expired entries are cleaned up automatically by the hourly `wpseo_cleanup_cron` job
56 * and can be triggered manually via `wp yoast cleanup`.
57 */
58 class Expiring_Store {
59
60 /**
61 * The repository for database operations.
62 *
63 * @var Expiring_Store_Repository_Interface
64 */
65 private $repository;
66
67 /**
68 * The date helper.
69 *
70 * @var Date_Helper
71 */
72 private $date_helper;
73
74 /**
75 * The constructor.
76 *
77 * @param Expiring_Store_Repository_Interface $repository The repository for database operations.
78 * @param Date_Helper $date_helper The date helper.
79 */
80 public function __construct( Expiring_Store_Repository_Interface $repository, Date_Helper $date_helper ) {
81 $this->repository = $repository;
82 $this->date_helper = $date_helper;
83 }
84
85 /**
86 * Persists a value scoped to the current blog.
87 *
88 * @param string $key The key.
89 * @param scalar|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
90 * @param int $ttl_in_seconds The time-to-live in seconds.
91 *
92 * @return void
93 * @throws InvalidArgumentException When the value is not JSON-encodable.
94 */
95 public function persist( string $key, $value, int $ttl_in_seconds ): void {
96 $this->do_persist( $this->prefix_for_blog( $key ), $value, $ttl_in_seconds );
97 }
98
99 /**
100 * Persists a value scoped to a user.
101 *
102 * @param string $key The key.
103 * @param scalar|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
104 * @param int $ttl_in_seconds The time-to-live in seconds.
105 * @param int $user_id The user ID. Defaults to the current user.
106 *
107 * @return void
108 * @throws InvalidArgumentException When the value is not JSON-encodable.
109 * @throws No_Current_User_Exception When no user ID is given and no user is logged in.
110 */
111 public function persist_for_user( string $key, $value, int $ttl_in_seconds, int $user_id = 0 ): void {
112 $this->do_persist( $this->prefix_for_user( $key, $user_id ), $value, $ttl_in_seconds );
113 }
114
115 /**
116 * Persists a value shared across the entire multisite network.
117 *
118 * @param string $key The key.
119 * @param scalar|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
120 * @param int $ttl_in_seconds The time-to-live in seconds.
121 *
122 * @return void
123 * @throws InvalidArgumentException When the value is not JSON-encodable.
124 */
125 public function persist_for_multisite( string $key, $value, int $ttl_in_seconds ): void {
126 $this->do_persist( $key, $value, $ttl_in_seconds );
127 }
128
129 /**
130 * Persists a value scoped to the current blog, only if the key does not already exist.
131 *
132 * @param string $key The key.
133 * @param scalar|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
134 * @param int $ttl_in_seconds The time-to-live in seconds.
135 *
136 * @return bool True if the value was inserted, false if the key already exists.
137 * @throws InvalidArgumentException When the value is not JSON-encodable.
138 */
139 public function persist_if_absent( string $key, $value, int $ttl_in_seconds ): bool {
140 return $this->do_persist_if_absent( $this->prefix_for_blog( $key ), $value, $ttl_in_seconds );
141 }
142
143 /**
144 * Persists a value scoped to a user, only if the key does not already exist.
145 *
146 * @param string $key The key.
147 * @param scalar|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
148 * @param int $ttl_in_seconds The time-to-live in seconds.
149 * @param int $user_id The user ID. Defaults to the current user.
150 *
151 * @return bool True if the value was inserted, false if the key already exists.
152 * @throws InvalidArgumentException When the value is not JSON-encodable.
153 * @throws No_Current_User_Exception When no user ID is given and no user is logged in.
154 */
155 public function persist_if_absent_for_user( string $key, $value, int $ttl_in_seconds, int $user_id = 0 ): bool {
156 return $this->do_persist_if_absent( $this->prefix_for_user( $key, $user_id ), $value, $ttl_in_seconds );
157 }
158
159 /**
160 * Persists a value shared across the entire multisite network, only if the key does not already exist.
161 *
162 * @param string $key The key.
163 * @param scalar|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
164 * @param int $ttl_in_seconds The time-to-live in seconds.
165 *
166 * @return bool True if the value was inserted, false if the key already exists.
167 * @throws InvalidArgumentException When the value is not JSON-encodable.
168 */
169 public function persist_if_absent_for_multisite( string $key, $value, int $ttl_in_seconds ): bool {
170 return $this->do_persist_if_absent( $key, $value, $ttl_in_seconds );
171 }
172
173 /**
174 * Gets a value scoped to the current blog.
175 *
176 * @param string $key The key.
177 *
178 * @return scalar|array<string|int|float|bool|array|null> The stored value.
179 * @throws Key_Not_Found_Exception When the key is not found or has expired.
180 * @throws Corrupted_Value_Exception When the stored value cannot be decoded from JSON.
181 */
182 public function get( string $key ) {
183 return $this->do_get( $this->prefix_for_blog( $key ) );
184 }
185
186 /**
187 * Gets a value scoped to a user.
188 *
189 * @param string $key The key.
190 * @param int $user_id The user ID. Defaults to the current user.
191 *
192 * @return scalar|array<string|int|float|bool|array|null> The stored value.
193 * @throws Key_Not_Found_Exception When the key is not found or has expired.
194 * @throws Corrupted_Value_Exception When the stored value cannot be decoded from JSON.
195 * @throws No_Current_User_Exception When no user ID is given and no user is logged in.
196 */
197 public function get_for_user( string $key, int $user_id = 0 ) {
198 return $this->do_get( $this->prefix_for_user( $key, $user_id ) );
199 }
200
201 /**
202 * Gets a value shared across the entire multisite network.
203 *
204 * @param string $key The key.
205 *
206 * @return scalar|array<string|int|float|bool|array|null> The stored value.
207 * @throws Key_Not_Found_Exception When the key is not found or has expired.
208 * @throws Corrupted_Value_Exception When the stored value cannot be decoded from JSON.
209 */
210 public function get_for_multisite( string $key ) {
211 return $this->do_get( $key );
212 }
213
214 /**
215 * Checks whether a non-expired value exists for a blog-scoped key.
216 *
217 * @param string $key The key.
218 *
219 * @return bool
220 */
221 public function has( string $key ): bool {
222 return $this->do_has( $this->prefix_for_blog( $key ) );
223 }
224
225 /**
226 * Checks whether a non-expired value exists for a user-scoped key.
227 *
228 * @param string $key The key.
229 * @param int $user_id The user ID. Defaults to the current user.
230 *
231 * @return bool
232 * @throws No_Current_User_Exception When no user ID is given and no user is logged in.
233 */
234 public function has_for_user( string $key, int $user_id = 0 ): bool {
235 return $this->do_has( $this->prefix_for_user( $key, $user_id ) );
236 }
237
238 /**
239 * Checks whether a non-expired value exists for a multisite-scoped key.
240 *
241 * @param string $key The key.
242 *
243 * @return bool
244 */
245 public function has_for_multisite( string $key ): bool {
246 return $this->do_has( $key );
247 }
248
249 /**
250 * Deletes a value scoped to the current blog.
251 *
252 * @param string $key The key.
253 *
254 * @return void
255 */
256 public function delete( string $key ): void {
257 $this->repository->delete( $this->prefix_for_blog( $key ) );
258 }
259
260 /**
261 * Deletes a value scoped to a user.
262 *
263 * @param string $key The key.
264 * @param int $user_id The user ID. Defaults to the current user.
265 *
266 * @return void
267 * @throws No_Current_User_Exception When no user ID is given and no user is logged in.
268 */
269 public function delete_for_user( string $key, int $user_id = 0 ): void {
270 $this->repository->delete( $this->prefix_for_user( $key, $user_id ) );
271 }
272
273 /**
274 * Deletes a value shared across the entire multisite network.
275 *
276 * @param string $key The key.
277 *
278 * @return void
279 */
280 public function delete_for_multisite( string $key ): void {
281 $this->repository->delete( $key );
282 }
283
284 /**
285 * Cleans up all expired entries.
286 *
287 * @return int The number of deleted entries.
288 */
289 public function cleanup_expired(): int {
290 return $this->repository->delete_expired( $this->current_datetime() );
291 }
292
293 /**
294 * Persists a value with the given prefixed key.
295 *
296 * @param string $prefixed_key The prefixed key.
297 * @param string|int|float|bool|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
298 * @param int $ttl_in_seconds The time-to-live in seconds.
299 *
300 * @return void
301 * @throws InvalidArgumentException When the value is not JSON-encodable.
302 */
303 private function do_persist( string $prefixed_key, $value, int $ttl_in_seconds ): void {
304 $json = $this->json_encode_value( $value );
305 $exp = \gmdate( 'Y-m-d H:i:s', ( $this->date_helper->current_time() + $ttl_in_seconds ) );
306
307 $this->repository->upsert( $prefixed_key, $json, $exp );
308 }
309
310 /**
311 * Persists a value only if the prefixed key does not already exist.
312 *
313 * @param string $prefixed_key The prefixed key.
314 * @param string|int|float|bool|array<string|int|float|bool|array|null>|JsonSerializable $value The value to store.
315 * @param int $ttl_in_seconds The time-to-live in seconds.
316 *
317 * @return bool True if the value was inserted, false if the key already exists.
318 * @throws InvalidArgumentException When the value is not JSON-encodable.
319 */
320 private function do_persist_if_absent( string $prefixed_key, $value, int $ttl_in_seconds ): bool {
321 $json = $this->json_encode_value( $value );
322 $now = $this->date_helper->current_time();
323 $exp = \gmdate( 'Y-m-d H:i:s', ( $now + $ttl_in_seconds ) );
324
325 return $this->repository->insert_if_absent( $prefixed_key, $json, $exp, \gmdate( 'Y-m-d H:i:s', $now ) );
326 }
327
328 /**
329 * Gets and decodes a value by prefixed key.
330 *
331 * @param string $prefixed_key The prefixed key.
332 *
333 * @return string|int|float|bool|array<string|int|float|bool|array|null> The stored value.
334 * @throws Key_Not_Found_Exception When the key is not found or has expired.
335 * @throws Corrupted_Value_Exception When the stored value cannot be decoded from JSON.
336 */
337 private function do_get( string $prefixed_key ) {
338 $json = $this->repository->find( $prefixed_key, $this->current_datetime() );
339
340 if ( $json === null ) {
341 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
342 throw new Key_Not_Found_Exception( "Key '{$prefixed_key}' not found or expired." );
343 }
344
345 try {
346 return \json_decode( $json, true, 512, \JSON_THROW_ON_ERROR );
347 } catch ( JsonException $e ) {
348 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This is an exception message, not output.
349 throw new Corrupted_Value_Exception( $prefixed_key, $e->getMessage() );
350 }
351 }
352
353 /**
354 * Checks whether a non-expired value exists for the given prefixed key.
355 *
356 * @param string $prefixed_key The prefixed key.
357 *
358 * @return bool
359 */
360 private function do_has( string $prefixed_key ): bool {
361 return $this->repository->find( $prefixed_key, $this->current_datetime() ) !== null;
362 }
363
364 /**
365 * JSON-encodes a value.
366 *
367 * @param string|int|float|bool|array<string|int|float|bool|array|null>|JsonSerializable $value The value to encode.
368 *
369 * @return string The JSON-encoded value.
370 * @throws InvalidArgumentException When the value is not JSON-encodable.
371 */
372 private function json_encode_value( $value ): string {
373 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- WPSEO_Utils::format_json_encode we don't intend to output this.
374 $encoded = \wp_json_encode( $value );
375
376 if ( $encoded === false ) {
377 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This is an exception message, not output.
378 throw new InvalidArgumentException( 'Expiring_Store: value must be JSON-encodable. ' . \json_last_error_msg() );
379 }
380
381 return $encoded;
382 }
383
384 /**
385 * Prefixes a key for blog scope.
386 *
387 * @param string $key The key.
388 *
389 * @return string The prefixed key.
390 */
391 private function prefix_for_blog( string $key ): string {
392 return 'blog_' . \get_current_blog_id() . ':' . $key;
393 }
394
395 /**
396 * Prefixes a key for user scope.
397 *
398 * @param string $key The key.
399 * @param int $user_id The user ID. When 0, falls back to the current user.
400 *
401 * @return string The prefixed key.
402 * @throws No_Current_User_Exception When no user ID is given and no user is logged in.
403 */
404 private function prefix_for_user( string $key, int $user_id = 0 ): string {
405 if ( $user_id <= 0 ) {
406 $user_id = \get_current_user_id();
407 }
408
409 if ( $user_id === 0 ) {
410 throw new No_Current_User_Exception( 'Cannot use user-scoped expiring store methods without a logged-in user.' );
411 }
412
413 return 'user_' . $user_id . ':' . $key;
414 }
415
416 /**
417 * Returns the current datetime in 'Y-m-d H:i:s' format.
418 *
419 * @return string The current datetime.
420 */
421 private function current_datetime(): string {
422 return \gmdate( 'Y-m-d H:i:s', $this->date_helper->current_time() );
423 }
424 }
425