Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.33% covered (warning)
71.33%
102 / 143
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Storage_Post_Type
71.33% covered (warning)
71.33%
102 / 143
53.85% covered (warning)
53.85%
7 / 13
69.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 post_type_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 row_prefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
 set
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 get
86.21% covered (warning)
86.21%
25 / 29
0.00% covered (danger)
0.00%
0 / 1
12.38
 refuse_row
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_post_by_name
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 clear
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 clear_bulk
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 clear_manually
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Cache for Jetpack Boost that uses CPTs to store the data.
4 *
5 * @link       https://automattic.com
6 * @since      1.0.0
7 * @package    automattic/jetpack-boost
8 */
9
10namespace Automattic\Jetpack_Boost\Lib;
11
12/**
13 * Class Storage_Post_type
14 *
15 * Internal to Boost. Stores arrays and scalars only, never objects.
16 */
17class Storage_Post_Type {
18
19    /**
20     * Cache format version. Part of the transient key, and the prefix on post_content.
21     *
22     * On the transient key it keeps get() away from transients an earlier release
23     * wrote, which were cached without the object checks below. On the row it marks
24     * where the encoded payload starts; get() validates every row, prefixed or not,
25     * before serving it.
26     *
27     * @since 4.7.0
28     */
29    const CACHE_VERSION = 'v2';
30
31    /**
32     * Matches a serialized object (O:), custom-serialized class (C:) or enum case
33     * (E:) anywhere in a serialized string. Boost never stores objects, so a match
34     * means the row is not one Boost wrote.
35     *
36     * @since 4.7.0
37     */
38    const OBJECT_TOKEN_PATTERN = '/(^|[;{])[OCE]:\+?[0-9]+:/';
39
40    /**
41     * The name.
42     *
43     * @var string
44     */
45    private $name;
46
47    /**
48     * Slugs this class has registered on this request, keyed by slug.
49     *
50     * Tells "somebody else owns this slug" apart from "we already registered it".
51     *
52     * @since 4.7.0
53     *
54     * @var array<string, bool>
55     */
56    private static $registered = array();
57
58    /**
59     * Storage_Post_type constructor.
60     *
61     * @param string $name The name.
62     */
63    public function __construct( $name ) {
64        $this->name = sanitize_title( $name );
65        $this->init();
66    }
67
68    /**
69     * Get the post type slug.
70     */
71    public function post_type_slug() {
72        return 'jb_store_' . $this->name;
73    }
74
75    /**
76     * Get the transient key backing a cache entry.
77     *
78     * @since 4.7.0
79     *
80     * @param string $key Cache key name.
81     *
82     * @return string
83     */
84    private function transient_key( $key ) {
85        return $this->post_type_slug() . '_' . self::CACHE_VERSION . '_' . $key;
86    }
87
88    /**
89     * The prefix set() puts in front of the encoded payload.
90     *
91     * Sits outside the payload so get() knows where the encoded bytes start on a row
92     * this release wrote, without decoding to find out. A row without it is still
93     * read; get() just decodes from the first byte.
94     *
95     * @since 4.7.0
96     *
97     * @return string
98     */
99    private function row_prefix() {
100        return self::CACHE_VERSION . ':';
101    }
102
103    /**
104     * Static initialization.
105     */
106    private function init() {
107        // Check if post type already registered.
108        if ( post_type_exists( $this->post_type_slug() ) ) {
109            if ( ! isset( self::$registered[ $this->post_type_slug() ] ) ) {
110                Debug::log( 'cache post type "' . $this->post_type_slug() . '" was already registered elsewhere; Boost\'s REST and capability restrictions are not in effect for it' );
111            }
112
113            return;
114        }
115
116        self::$registered[ $this->post_type_slug() ] = true;
117
118        register_post_type(
119            $this->post_type_slug(),
120            array(
121                'description'      => 'Cache entries for the Jetpack Boost plugin.',
122                'public'           => false,
123                'show_in_rest'     => false,
124                'rewrite'          => false,
125                'can_export'       => false,
126                'delete_with_user' => false,
127                // Boost writes through wp_insert_post and friends, which bypass capability
128                // checks, so denying every generated capability closes the write vector into
129                // get()'s unserialize sink (CWE-502).
130                'map_meta_cap'     => true,
131                'capabilities'     => array(
132                    'read'                   => 'do_not_allow',
133                    'create_posts'           => 'do_not_allow',
134                    'edit_posts'             => 'do_not_allow',
135                    'edit_others_posts'      => 'do_not_allow',
136                    'edit_published_posts'   => 'do_not_allow',
137                    'edit_private_posts'     => 'do_not_allow',
138                    'publish_posts'          => 'do_not_allow',
139                    'read_private_posts'     => 'do_not_allow',
140                    'delete_posts'           => 'do_not_allow',
141                    'delete_others_posts'    => 'do_not_allow',
142                    'delete_published_posts' => 'do_not_allow',
143                    'delete_private_posts'   => 'do_not_allow',
144                ),
145            )
146        );
147    }
148
149    /**
150     * Sets the cache entry using a CPT.
151     *
152     * @param string $key    Cache key name.
153     * @param mixed  $value  Cache value.
154     * @param int    $expiry Cache expiration in seconds.
155     *
156     * @return void
157     */
158    public function set( $key, $value, $expiry = 0 ) {
159        $data_post_data = array(
160            'post_type'   => $this->post_type_slug(),
161            'post_title'  => $key,
162            'post_name'   => $key,
163            'post_status' => 'publish',
164        );
165
166        $data_post        = $this->get_post_by_name( $key );
167        $expiry_timestamp = 0;
168
169        if ( $expiry ) {
170            $expiry_timestamp = time() + $expiry;
171        }
172
173        $value                          = array(
174            'data'   => $value,
175            'expiry' => $expiry_timestamp,
176        );
177        $data_post_data['post_content'] = $this->row_prefix() . base64_encode( maybe_serialize( $value ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
178
179        // Update an existing data post if we have one or create a new one.
180        if ( $data_post ) {
181            $data_post_data['ID'] = $data_post->ID;
182            wp_update_post( $data_post_data );
183        } else {
184            wp_insert_post( $data_post_data );
185        }
186
187        delete_transient( $this->transient_key( $key ) );
188    }
189
190    /**
191     * Gets a cache entry using a CPT.
192     *
193     * @param string $key     Cache key name.
194     * @param mixed  $default Default value.
195     *
196     * @return mixed
197     */
198    public function get( $key, $default ) {
199        $cached = get_transient( $this->transient_key( $key ) );
200        if ( $cached ) {
201            return $cached;
202        }
203
204        $data_post = $this->get_post_by_name( $key );
205
206        if ( ! $data_post ) {
207            return $default;
208        }
209
210        /**
211         * Array(
212         *   'data' => mixed,
213         *   'expiry' => int,
214         * )
215         */
216
217        $prefix  = $this->row_prefix();
218        $encoded = $data_post->post_content;
219
220        // A row without the prefix was written before this release, when the REST route
221        // and the default capabilities let anyone create one. Most such rows are cache
222        // Boost itself wrote, so the prefix only marks where the payload starts, not
223        // whether the row can be served: that is settled below, by the same object-token
224        // scan and allowed_classes => false every row runs through. The strip is
225        // conditional because ':' is not in the base64 alphabet, so a legitimate
226        // unprefixed payload can never begin with the prefix, and stripping one that is
227        // not there would misalign the decode.
228        if ( 0 === strpos( $encoded, $prefix ) ) {
229            $encoded = substr( $encoded, strlen( $prefix ) );
230        }
231
232        // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged
233        $decoded = base64_decode( $encoded );
234        $value   = $decoded;
235
236        // Ahead of is_serialized(), which has no case for C:, and ahead of unserialize(),
237        // so no object is instantiated: not O:/C: as __PHP_Incomplete_Class, and not E:,
238        // which allowed_classes does not filter at all.
239        if ( preg_match( self::OBJECT_TOKEN_PATTERN, trim( $decoded ) ) ) {
240            $this->refuse_row( $key, 'contains an object' );
241
242            return $default;
243        }
244
245        if ( is_serialized( $decoded ) ) {
246            $trimmed = trim( $decoded );
247
248            // Degrades O:/C: to an inert __PHP_Incomplete_Class if the pattern above ever
249            // misses one. It is not a second layer for E:, where the pattern is the only guard.
250            $value = @unserialize( $trimmed, array( 'allowed_classes' => false ) );
251
252            // Claimed serialized and would not deserialize: not a row Boost wrote.
253            // b:0; legitimately gives false.
254            if ( false === $value && 'b:0;' !== $trimmed ) {
255                $this->refuse_row( $key, 'is malformed' );
256
257                return $default;
258            }
259        }
260        // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged
261
262        if ( isset( $value['expiry'] ) && intval( $value['expiry'] ) > 0 ) {
263            if ( time() > intval( $value['expiry'] ) ) {
264                // The cache entry expired. Clear it.
265                $this->delete( $key );
266
267                return $default;
268            }
269        }
270
271        if ( ! isset( $value['data'] ) ) {
272            return $default;
273        }
274
275        set_transient( $this->transient_key( $key ), $value['data'], HOUR_IN_SECONDS );
276
277        return $value['data'];
278    }
279
280    /**
281     * Serve the default for a row get() will not read, and leave the row alone.
282     *
283     * The checks that land here are inexact. serialize() embeds CSS and markup
284     * verbatim, so ordinary content can carry something the object pattern matches,
285     * and a truncated row looks the same as a crafted one. Deleting would cost a
286     * regeneration on a false positive; the next set() overwrites the row anyway.
287     *
288     * @since 4.7.0
289     *
290     * @param string $key    Cache key name.
291     * @param string $reason For the debug log.
292     *
293     * @return void
294     */
295    private function refuse_row( $key, $reason ) {
296        Debug::log( 'refused cache entry "' . $key . '": stored content ' . $reason );
297    }
298
299    /**
300     * Delete a cache entry from a CPT.
301     *
302     * @param string $key Cache key name.
303     *
304     * @return void
305     */
306    public function delete( $key ) {
307        $data_post = $this->get_post_by_name( $key );
308
309        // Delete the post.
310        if ( $data_post ) {
311            wp_delete_post( $data_post->ID, true );
312        }
313
314        // Match set(), which clears the transient whenever the row changes.
315        delete_transient( $this->transient_key( $key ) );
316    }
317
318    /**
319     * Returns a single WP post based on the `post_name` property.
320     *
321     * Note: `post_name` is indexed in the DB.
322     *
323     * @see https://codex.wordpress.org/Database_Description#Indexes_6
324     *
325     * @param string $post_name Post name.
326     *
327     * @return bool|\WP_Post
328     */
329    public function get_post_by_name( $post_name ) {
330        $post_query = new \WP_Query(
331            array(
332                'name'                   => $post_name,
333                'post_type'              => $this->post_type_slug(),
334                'post_status'            => 'publish',
335                'posts_per_page'         => 1,
336                'ignore_sticky_posts'    => true,
337                'no_found_rows'          => true,
338                'update_post_meta_cache' => false,
339                'update_post_term_cache' => false,
340            )
341        );
342
343        if ( ! $post_query->have_posts() ) {
344            return false;
345        }
346
347        $posts = $post_query->posts;
348        if ( ! is_array( $posts ) || ! isset( $posts[0] ) || ! $posts[0] instanceof \WP_Post ) {
349            return false;
350        }
351
352        return $posts[0];
353    }
354
355    /**
356     * Clear all data stored in post types. On systems which support it, this
357     * will use wp_cache_flush_group and a db query to efficiently flush the
358     * cache. Otherwise, it will fall back to deleting each item.
359     */
360    public function clear() {
361        if (
362            function_exists( 'wp_cache_flush_group' ) &&
363            function_exists( 'wp_cache_supports' ) &&
364            wp_cache_supports( 'flush_group' )
365        ) {
366            $this->clear_bulk();
367        } else {
368            $this->clear_manually();
369        }
370    }
371
372    /**
373     * Clear all data stored in post types using wp_cache_flush_group and a db
374     * query. This is more efficient than deleting each item individually.
375     * Make sure that wp_cache_supports( 'flush_group' ) returns true before
376     * calling this method.
377     */
378    private function clear_bulk() {
379        global $wpdb;
380
381        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
382        $wpdb->delete(
383            $wpdb->posts,
384            array( 'post_type' => $this->post_type_slug() ),
385            array( '%s' )
386        );
387
388        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
389        $wpdb->query(
390            $wpdb->prepare(
391                "DELETE FROM $wpdb->options WHERE option_name LIKE %s",
392                '_transient_' . $this->post_type_slug() . '_%'
393            )
394        );
395
396        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
397        $wpdb->query(
398            $wpdb->prepare(
399                "DELETE FROM $wpdb->options WHERE option_name LIKE %s",
400                '_transient_timeout_' . $this->post_type_slug() . '_%'
401            )
402        );
403
404        wp_cache_flush_group( $this->post_type_slug() );
405        wp_cache_flush_group( 'options' );
406    }
407
408    /**
409     * Clear all data stored in post types by deleting each item individually.
410     * This is less efficient than using wp_cache_flush_group and a db query,
411     * but works on all systems.
412     */
413    private function clear_manually() {
414        $posts = get_posts(
415            array(
416                'post_type'      => $this->post_type_slug(),
417                'posts_per_page' => -1,
418            )
419        );
420
421        foreach ( $posts as $post ) {
422            wp_delete_post( $post->ID, true );
423            wp_cache_delete( $post->post_name, $this->post_type_slug() );
424            // The versioned key, and the key a pre-CACHE_VERSION release wrote.
425            delete_transient( $this->transient_key( $post->post_name ) );
426            delete_transient( $this->post_type_slug() . '_' . $post->post_name );
427        }
428    }
429}