PluginProbe
PostNL for WooCommerce / 5.9.12
PostNL for WooCommerce v5.9.12
5.9.12 5.9.11 5.9.10 5.9.9 5.9.8 5.9.7 5.9.6 trunk 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 3.1.4 3.1.5 3.1.6 3.1.7 4.0.0 4.0.1 4.0.2 4.3.2 4.3.3 4.4.0 4.4.1 All 72 releases
woo-postnl / src / Rest_API / SDK / Cache_Adapter.php

Cache_Adapter.php in PostNL for WooCommerce 5.9.12, at src/Rest_API/SDK/Cache_Adapter.php

346 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Rest_API\SDK\Cache_Adapter file.
4 *
5 * @package PostNLWooCommerce\Rest_API\SDK
6 */
7
8 declare( strict_types = 1 );
9
10 namespace PostNLWooCommerce\Rest_API\SDK;
11
12 use DateInterval;
13 use Postnl\Sdk\Cache\Adapter\AbstractCacheAdapter;
14 use Psr\Log\LoggerInterface;
15 use RuntimeException;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Class Cache_Adapter
23 *
24 * WordPress-transient-backed cache for the V4 SDK, implementing the SDK's
25 * CacheAdapterInterface (PSR-16 plus isAvailable()). It is purpose-built for
26 * the per-checkout-pageload timeframe/locations responses: only keys whose
27 * prefix is on the allowlist are stored, and anything else bypasses.
28 *
29 * The prefix is a label the caller chooses via the CachingPlugin keyPrefix, not
30 * the request URI. Deciding which endpoints may be cached is CachingPlugin's
31 * job, through its own allowedEndpoints list.
32 *
33 * Transient keys are namespaced with a hash of the V4 API key so two stores on
34 * shared hosting are extremely unlikely to read each other's cached responses.
35 *
36 * @since 6.0.0
37 * @package PostNLWooCommerce\Rest_API\SDK
38 */
39 class Cache_Adapter extends AbstractCacheAdapter {
40
41 /**
42 * Default time-to-live, in seconds, before a cached response expires.
43 * Public so consumers of the postnl_v4_cache_ttl filter (e.g. the V4
44 * Timeframe service) share one source of truth for the default.
45 *
46 * @since 6.0.0
47 * @var int
48 */
49 public const DEFAULT_TTL = 600;
50
51 /**
52 * Cacheable key prefix for the timeframe flow. Pass as the CachingPlugin
53 * keyPrefix so the plugin's generated keys clear this adapter's allowlist.
54 *
55 * @since 6.0.0
56 * @var string
57 */
58 public const PREFIX_TIMEFRAME = 'timeframe';
59
60 /**
61 * Cacheable key prefix for the pickup-locations flow.
62 *
63 * @since 6.0.0
64 * @var string
65 */
66 public const PREFIX_LOCATIONS = 'locations';
67
68 /**
69 * Raw-key prefixes whose responses may be cached. Anything else bypasses.
70 */
71 private const ALLOWED_PREFIXES = array( self::PREFIX_TIMEFRAME, self::PREFIX_LOCATIONS );
72
73 /**
74 * Whether a non-allowlisted key has already been reported for this instance.
75 *
76 * @var bool
77 */
78 private $bypass_logged = false;
79
80 /**
81 * Cache_Adapter constructor.
82 *
83 * The TTL is resolved here, so a filter registered after the adapter is
84 * built does not affect it.
85 *
86 * @param string $v4_key PostNL V4 API key, hashed into the key namespace.
87 * @param LoggerInterface|null $logger Optional PSR-3 logger. Receives lookup-query
88 * failures, allowlist bypasses and rejected writes.
89 */
90 public function __construct( string $v4_key, ?LoggerInterface $logger = null ) {
91 $prefix = 'postnl_v4_' . substr( sha1( $v4_key ), 0, 8 ) . '_';
92
93 parent::__construct( $prefix, self::get_ttl(), $logger );
94 }
95
96 /**
97 * Resolve the cache lifetime, in seconds, after filtering.
98 *
99 * Callers handing a TTL to CachingPlugin should use this rather than
100 * applying the filter themselves. The plugin rejects a value of zero or
101 * less by throwing, while this adapter falls back to the default, so the
102 * guard has to live in one place for the two to agree.
103 *
104 * @since 6.0.0
105 * @return int
106 */
107 public static function get_ttl(): int {
108 /**
109 * Filters the TTL, in seconds, for cached V4 timeframe/locations responses.
110 *
111 * @since 6.0.0
112 *
113 * @param int $ttl Default 600 seconds.
114 */
115 $ttl = (int) apply_filters( 'postnl_v4_cache_ttl', self::DEFAULT_TTL );
116
117 return $ttl > 0 ? $ttl : self::DEFAULT_TTL;
118 }
119
120 /**
121 * Fetch a cached value, or $default on a miss or non-cacheable key.
122 *
123 * A stored boolean false cannot be told apart from a miss and yields
124 * $default; the cached payloads are timeframe/locations arrays, so this
125 * edge does not arise on the wired path.
126 *
127 * @param string $key Cache key.
128 * @param mixed $default Value returned when nothing is cached.
129 * @return mixed
130 * @throws \Postnl\Sdk\Cache\Exceptions\InvalidCacheArgumentException When the key contains a reserved character.
131 */
132 public function get( string $key, mixed $default = null ): mixed { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- name is fixed by the PSR-16 CacheInterface.
133 $this->validateKey( $key );
134
135 if ( ! $this->is_cacheable( $key ) ) {
136 return $default;
137 }
138
139 $value = get_transient( $this->transient_name( $key ) );
140
141 return false === $value ? $default : $value;
142 }
143
144 /**
145 * Whether a non-expired value is cached for the key.
146 *
147 * @param string $key Cache key.
148 * @return bool
149 * @throws \Postnl\Sdk\Cache\Exceptions\InvalidCacheArgumentException When the key contains a reserved character.
150 */
151 public function has( string $key ): bool {
152 $this->validateKey( $key );
153
154 return $this->is_cacheable( $key ) && false !== get_transient( $this->transient_name( $key ) );
155 }
156
157 /**
158 * Store a value. Non-allowlisted keys are not cached and return false.
159 *
160 * WordPress falls back to update_option(), which reports false when a live
161 * entry is rewritten with an identical value, so a false return does not
162 * always mean the write failed.
163 *
164 * @param string $key Cache key.
165 * @param mixed $value Value to cache.
166 * @param DateInterval|int|null $ttl Lifetime; null/0/negative use the default.
167 * @return bool
168 * @throws \Postnl\Sdk\Cache\Exceptions\InvalidCacheArgumentException When the key contains a reserved character.
169 */
170 public function set( string $key, mixed $value, DateInterval|int|null $ttl = null ): bool {
171 $this->validateKey( $key );
172
173 if ( ! $this->is_cacheable( $key ) ) {
174 return false;
175 }
176
177 $seconds = $this->normalizeTtlSeconds( $ttl );
178
179 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- name is fixed by the SDK's AbstractCacheAdapter.
180 $default_seconds = $this->defaultTtl;
181
182 // A zero or negative DateInterval normalizes to 0, which set_transient() reads as
183 // "never expires", so fall back to the default rather than cache permanently.
184 $stored = set_transient( $this->transient_name( $key ), $value, $seconds > 0 ? $seconds : $default_seconds );
185
186 if ( ! $stored && null !== $this->logger ) {
187 // Debug rather than warning: an unchanged rewrite reports false too,
188 // so this is a hint for someone already looking, not an alarm.
189 $this->logger->debug(
190 sprintf( 'PostNL V4 cache write was not stored for key "%s".', substr( $key, 0, 24 ) )
191 );
192 }
193
194 return $stored;
195 }
196
197 /**
198 * Delete a cached value.
199 *
200 * An entry that was never stored makes delete_transient() report false,
201 * which PSR-16 reserves for genuine errors, so an absent key is reported
202 * as a successful delete.
203 *
204 * @param string $key Cache key.
205 * @return bool
206 * @throws \Postnl\Sdk\Cache\Exceptions\InvalidCacheArgumentException When the key contains a reserved character.
207 */
208 public function delete( string $key ): bool {
209 $this->validateKey( $key );
210
211 delete_transient( $this->transient_name( $key ) );
212
213 return true;
214 }
215
216 /**
217 * Remove every transient in this adapter's namespace.
218 *
219 * There is no WordPress API to delete transients by prefix, so the options
220 * table is queried to find this namespace's transients, then each is removed
221 * via delete_transient(). That clears both the value and timeout rows and
222 * invalidates the option cache, whereas a raw DELETE would leave stale cache
223 * entries readable within the same request.
224 *
225 * Returns false whenever the namespace cannot be enumerated, rather than
226 * reporting a success that cleared nothing: under a persistent object cache
227 * transients never reach the options table, and a failed lookup query gives
228 * back the same empty result as a namespace with nothing in it.
229 *
230 * @return bool
231 */
232 public function clear(): bool {
233 global $wpdb;
234
235 if ( wp_using_ext_object_cache() ) {
236 return false;
237 }
238
239 if ( ! isset( $wpdb ) ) {
240 return false;
241 }
242
243 $like = $wpdb->esc_like( '_transient_' . $this->prefix ) . '%';
244 $names = $wpdb->get_col(
245 $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like )
246 );
247
248 if ( ! empty( $wpdb->last_error ) ) {
249 $this->logError( 'clear', new RuntimeException( (string) $wpdb->last_error ) );
250
251 return false;
252 }
253
254 foreach ( (array) $names as $option_name ) {
255 // A row that expired between the lookup and here is already gone, and
256 // delete_transient() cannot tell that apart from a failure, so its
257 // return value carries no signal worth acting on.
258 delete_transient( substr( (string) $option_name, strlen( '_transient_' ) ) );
259 }
260
261 return true;
262 }
263
264 /**
265 * Whether the transient API is loaded.
266 *
267 * @return bool
268 */
269 public function isAvailable(): bool {
270 return function_exists( 'get_transient' );
271 }
272
273 /**
274 * Whether the key belongs to an allowlisted, cacheable endpoint.
275 *
276 * @param string $key Raw (un-hashed) cache key.
277 * @return bool
278 */
279 private function is_cacheable( string $key ): bool {
280 /**
281 * Filters the raw-key prefixes whose V4 responses may be cached.
282 *
283 * @since 6.0.0
284 *
285 * @param string[] $prefixes Default: timeframe and locations.
286 */
287 $allowed = (array) apply_filters( 'postnl_v4_cache_allowed_prefixes', self::ALLOWED_PREFIXES );
288
289 foreach ( $allowed as $prefix ) {
290 // Cast before the emptiness check: null and false both survive a
291 // strict comparison against '' but cast to it, and an empty needle
292 // makes str_starts_with() match every key.
293 $prefix = (string) $prefix;
294
295 if ( '' !== $prefix && str_starts_with( $key, $prefix ) ) {
296 return true;
297 }
298 }
299
300 $this->log_bypass( $key, $allowed );
301
302 return false;
303 }
304
305 /**
306 * Warn once per instance that a key fell outside the allowlist.
307 *
308 * A CachingPlugin built without a matching keyPrefix caches nothing at all,
309 * which is otherwise indistinguishable from a cold cache. Reporting it once
310 * surfaces the mis-wiring without flooding the log on every request.
311 *
312 * @param string $key Rejected cache key.
313 * @param mixed[] $allowed Allowlisted prefixes in effect.
314 * @return void
315 */
316 private function log_bypass( string $key, array $allowed ): void {
317 if ( $this->bypass_logged || null === $this->logger ) {
318 return;
319 }
320
321 $this->bypass_logged = true;
322
323 $this->logger->warning(
324 sprintf(
325 'PostNL V4 cache bypassed: key "%s" matches no allowed prefix (%s). Check the CachingPlugin keyPrefix.',
326 substr( $key, 0, 24 ),
327 implode( ', ', array_map( 'strval', $allowed ) )
328 )
329 );
330 }
331
332 /**
333 * Build a length-safe, namespaced transient name for a key.
334 *
335 * The raw key is hashed so the option name stays well under WordPress's
336 * 172-character limit, while the plain namespace prefix is preserved so
337 * clear() can match it.
338 *
339 * @param string $key Raw cache key.
340 * @return string
341 */
342 private function transient_name( string $key ): string {
343 return $this->prefix . md5( $key );
344 }
345 }
346