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