Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
Content_Gate
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
3 / 3
17
100.00% covered (success)
100.00%
1 / 1
 is_gated
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 public_teaser
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 access_level
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Whether a post's body is withheld from the public by a content gate.
4 *
5 * @package automattic/jetpack-seo-package
6 */
7
8namespace Automattic\Jetpack\SEO;
9
10/**
11 * Whether a post's body is withheld from an anonymous reader.
12 *
13 * Crawler-facing surfaces (meta descriptions, Open Graph tags, JSON-LD, llms.txt)
14 * read the raw `post_content` column, so they never pass through the `the_content`
15 * chain where the subscriptions paywall is installed and must ask this themselves.
16 *
17 * Visitor-independent by design: those surfaces are cached and shared, so their
18 * output must not vary by who fetched them.
19 *
20 * @since 0.9.2
21 */
22class Content_Gate {
23
24    /**
25     * Post meta set when a post contains paid content.
26     *
27     * @var string
28     */
29    const META_CONTAINS_PAID_CONTENT = '_jetpack_memberships_contains_paid_content';
30
31    /**
32     * Post meta set when a post contains paywalled content.
33     *
34     * @var string
35     */
36    const META_CONTAINS_PAYWALLED_CONTENT = '_jetpack_memberships_contains_paywalled_content';
37
38    /**
39     * Post meta holding the post-level Newsletter access setting.
40     *
41     * @var string
42     */
43    const META_NEWSLETTER_ACCESS = '_jetpack_newsletter_access';
44
45    /**
46     * The access level that means "no gate".
47     *
48     * @var string
49     */
50    const ACCESS_LEVEL_EVERYBODY = 'everybody';
51
52    /**
53     * The block that splits a post into a public teaser and a gated body.
54     *
55     * @var string
56     */
57    const BLOCK_PAYWALL = 'jetpack/paywall';
58
59    /**
60     * The namespace whose blocks hide their own content at render time.
61     *
62     * @var string
63     */
64    const BLOCK_NAMESPACE_PREMIUM_CONTENT = 'premium-content';
65
66    /**
67     * The block that wraps gated content.
68     *
69     * @var string
70     */
71    const BLOCK_PREMIUM_CONTENT = self::BLOCK_NAMESPACE_PREMIUM_CONTENT . '/container';
72
73    /**
74     * Whether this post's body is withheld from an anonymous reader.
75     *
76     * Any Newsletter access level other than "everybody" counts as gated, even one
77     * the front end would not enforce (an unknown value, or the Subscriptions
78     * module switched off after the level was set). Erring that way withholds a
79     * summary; erring the other way publishes a paywalled body.
80     *
81     * @since 0.9.2
82     *
83     * @param \WP_Post|int|null $post Post, post ID, or null for the global post.
84     * @return bool True when the body must not be published.
85     */
86    public static function is_gated( $post = null ) {
87        // A WP_Post is taken as given: get_post() would re-query it by ID.
88        $post = $post instanceof \WP_Post ? $post : get_post( $post );
89
90        if ( ! $post instanceof \WP_Post ) {
91            return true;
92        }
93
94        if ( ! empty( $post->post_password ) ) {
95            return true;
96        }
97
98        if (
99            get_post_meta( $post->ID, self::META_CONTAINS_PAID_CONTENT, true )
100            || get_post_meta( $post->ID, self::META_CONTAINS_PAYWALLED_CONTENT, true )
101            || has_block( self::BLOCK_PREMIUM_CONTENT, $post )
102            || has_block( self::BLOCK_PAYWALL, $post )
103        ) {
104            return true;
105        }
106
107        return self::ACCESS_LEVEL_EVERYBODY !== self::access_level( $post->ID );
108    }
109
110    /**
111     * The part of a gated post's body that is published to everyone anyway.
112     *
113     * A `jetpack/paywall` post serves everything above the block to anonymous
114     * readers, but only because `do_blocks()` then renders that prefix: a
115     * `premium-content` block inside it hides itself, which raw markup does not.
116     *
117     * @since 0.9.2
118     *
119     * @param \WP_Post|int|null $post Post, post ID, or null for the global post.
120     * @return string Public teaser, or '' when no part of the body is public.
121     */
122    public static function public_teaser( $post = null ) {
123        $post = $post instanceof \WP_Post ? $post : get_post( $post );
124
125        if ( ! $post instanceof \WP_Post || ! empty( $post->post_password ) ) {
126            return '';
127        }
128
129        // Split on the same delimiter the subscriptions paywall splits on, so the
130        // teaser we summarize is the one the front end actually serves.
131        $delimiter = '<!-- wp:' . self::BLOCK_PAYWALL . ' /-->';
132        if ( ! str_contains( $post->post_content, $delimiter ) ) {
133            return '';
134        }
135
136        $teaser = strstr( $post->post_content, $delimiter, true );
137
138        // Callers summarize this without rendering it, so a block that withholds
139        // its own content at render time would publish here as plain text.
140        if ( str_contains( $teaser, '<!-- wp:' . self::BLOCK_NAMESPACE_PREMIUM_CONTENT . '/' ) ) {
141            return '';
142        }
143
144        return $teaser;
145    }
146
147    /**
148     * The post's Newsletter access level.
149     *
150     * Unset and non-string values read as "everybody", as the front end treats
151     * them, so a post the site renders in full keeps its summary.
152     *
153     * @param int $post_id Post ID.
154     * @return string
155     */
156    private static function access_level( $post_id ) {
157        $access_level = get_post_meta( $post_id, self::META_NEWSLETTER_ACCESS, true );
158
159        if ( empty( $access_level ) || ! is_string( $access_level ) ) {
160            return self::ACCESS_LEVEL_EVERYBODY;
161        }
162
163        return $access_level;
164    }
165}