PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 2.6.1
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v2.6.1
3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 2.3.1 2.3.2 2.3.3 All 194 releases
convertkit / includes / class-convertkit-output-restrict-content.php

class-convertkit-output-restrict-content.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 2.6.1, at includes/class-convertkit-output-restrict-content.php

1,333 lines 39.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ConvertKit Output Restrict Content class.
4 *
5 * @package ConvertKit
6 * @author ConvertKit
7 */
8
9 /**
10 * Restricts (or displays) a single Page, Post or Custom Post Type's content
11 * based on the Post's "Restrict Content" configuration.
12 *
13 * @since 2.1.0
14 */
15 class ConvertKit_Output_Restrict_Content {
16
17 /**
18 * Holds the WP_Error object if an API call / authentication failed,
19 * to display on screen as a notification.
20 *
21 * @since 2.1.0
22 *
23 * @var bool|WP_Error
24 */
25 public $error = false;
26
27 /**
28 * Holds the ConvertKit Plugin Settings class
29 *
30 * @since 2.1.0
31 *
32 * @var bool|ConvertKit_Settings
33 */
34 public $settings = false;
35
36 /**
37 * Holds the ConvertKit Restrict Content Settings class
38 *
39 * @since 2.1.0
40 *
41 * @var bool|ConvertKit_Settings_Restrict_Content
42 */
43 public $restrict_content_settings = false;
44
45 /**
46 * Holds the ConvertKit Post Settings class
47 *
48 * @since 2.1.0
49 *
50 * @var bool|ConvertKit_Post
51 */
52 public $post_settings = false;
53
54 /**
55 * Holds the Resource Type (product|tag) that must be subscribed to in order
56 * to grant access to the Post.
57 *
58 * @since 2.3.8
59 *
60 * @var bool|string
61 */
62 public $resource_type = false;
63
64 /**
65 * Holds the Resource ID that must be subscribed to in order
66 * to grant access to the Post.
67 *
68 * @since 2.3.8
69 *
70 * @var bool|int
71 */
72 public $resource_id = false;
73
74 /**
75 * Holds the Post ID
76 *
77 * @since 2.1.0
78 *
79 * @var bool|int
80 */
81 public $post_id = false;
82
83 /**
84 * Holds the ConvertKit API class
85 *
86 * @since 2.1.0
87 *
88 * @var bool|ConvertKit_API_V4
89 */
90 public $api = false;
91
92 /**
93 * Holds the token returned from calling the subscriber_authentication_send_code API endpoint.
94 *
95 * @since 2.1.0
96 *
97 * @var bool|string
98 */
99 public $token = false;
100
101 /**
102 * Constructor. Registers actions and filters to possibly limit output of a Page/Post/CPT's
103 * content on the frontend site.
104 *
105 * @since 2.1.0
106 */
107 public function __construct() {
108
109 // Initialize classes that will be used.
110 $this->settings = new ConvertKit_Settings();
111 $this->restrict_content_settings = new ConvertKit_Settings_Restrict_Content();
112
113 // Don't register any hooks if this is an AJAX request, otherwise
114 // maybe_run_subscriber_authentication() and maybe_run_subscriber_verification() will run
115 // twice in an AJAX request (once here, and once when called by the ConvertKit_AJAX class).
116 if ( wp_doing_ajax() ) {
117 return;
118 }
119
120 add_action( 'init', array( $this, 'maybe_run_subscriber_authentication' ), 1 );
121 add_action( 'init', array( $this, 'maybe_run_subscriber_verification' ), 2 );
122 add_filter( 'the_content', array( $this, 'maybe_restrict_content' ) );
123 add_filter( 'get_previous_post_where', array( $this, 'maybe_change_previous_post_where_clause' ), 10, 5 );
124 add_filter( 'get_next_post_where', array( $this, 'maybe_change_next_post_where_clause' ), 10, 5 );
125 add_filter( 'get_previous_post_sort', array( $this, 'maybe_change_previous_next_post_order_by_clause' ), 10, 3 );
126 add_filter( 'get_next_post_sort', array( $this, 'maybe_change_previous_next_post_order_by_clause' ), 10, 3 );
127
128 }
129
130 /**
131 * Checks if the request is a Restrict Content request with an email address.
132 * If so, calls the API depending on the Restrict Content resource that's required:
133 * - tag: subscribes the email address to the tag, storing the subscriber ID in a cookie and redirecting
134 * - product: calls the API to send the subscriber a magic link by email containing a code. See maybe_run_subscriber_verification()
135 * for logic once they click the link in the email or enter the code on screen.
136 *
137 * @since 2.1.0
138 */
139 public function maybe_run_subscriber_authentication() {
140
141 // Bail if no nonce was specified.
142 if ( ! array_key_exists( '_wpnonce', $_REQUEST ) ) {
143 return;
144 }
145
146 // Bail if the nonce failed validation.
147 if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'convertkit_restrict_content_login' ) ) {
148 return;
149 }
150
151 // Bail if the expected email, resource ID or Post ID are missing.
152 if ( ! array_key_exists( 'convertkit_email', $_REQUEST ) ) {
153 return;
154 }
155 if ( ! array_key_exists( 'convertkit_resource_type', $_REQUEST ) ) {
156 return;
157 }
158 if ( ! array_key_exists( 'convertkit_resource_id', $_REQUEST ) ) {
159 return;
160 }
161 if ( ! array_key_exists( 'convertkit_post_id', $_REQUEST ) ) {
162 return;
163 }
164
165 // If the Plugin Access Token has not been configured, we can't get this subscriber's ID by email.
166 if ( ! $this->settings->has_access_and_refresh_token() ) {
167 return;
168 }
169
170 // Initialize the API.
171 $this->api = new ConvertKit_API_V4(
172 CONVERTKIT_OAUTH_CLIENT_ID,
173 CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI,
174 $this->settings->get_access_token(),
175 $this->settings->get_refresh_token(),
176 $this->settings->debug_enabled(),
177 'restrict_content'
178 );
179
180 // Sanitize inputs.
181 $email = sanitize_text_field( $_REQUEST['convertkit_email'] );
182 $this->resource_type = sanitize_text_field( $_REQUEST['convertkit_resource_type'] );
183 $this->resource_id = absint( sanitize_text_field( $_REQUEST['convertkit_resource_id'] ) );
184 $this->post_id = absint( sanitize_text_field( $_REQUEST['convertkit_post_id'] ) );
185
186 // Run subscriber authentication / subscription depending on the resource type.
187 switch ( $this->resource_type ) {
188 case 'product':
189 // Send email to subscriber with a link to authenticate they have access to the email address submitted.
190 $result = $this->api->subscriber_authentication_send_code(
191 $email,
192 $this->get_url()
193 );
194
195 // Bail if an error occured.
196 if ( is_wp_error( $result ) ) {
197 $this->error = $result;
198 return;
199 }
200
201 // Clear any existing subscriber ID cookie, as the authentication flow has started by sending the email.
202 $subscriber = new ConvertKit_Subscriber();
203 $subscriber->forget();
204
205 // Store the token so it's included in the subscriber code form.
206 $this->token = $result;
207 break;
208
209 case 'tag':
210 // Tag the subscriber.
211 $result = $this->api->tag_subscribe( $this->resource_id, $email );
212
213 // Bail if an error occured.
214 if ( is_wp_error( $result ) ) {
215 $this->error = $result;
216 return;
217 }
218
219 // Clear any existing subscriber ID cookie, as the authentication flow has started by sending the email.
220 $subscriber = new ConvertKit_Subscriber();
221 $subscriber->forget();
222
223 // Fetch the subscriber ID from the result.
224 $subscriber_id = $result['subscriber']['id'];
225
226 // Store subscriber ID in cookie.
227 $this->store_subscriber_id_in_cookie( $subscriber_id );
228
229 // If this isn't an AJAX request, redirect now to reload the Post.
230 if ( ! wp_doing_ajax() ) {
231 $this->redirect();
232 }
233 break;
234
235 }
236
237 }
238
239 /**
240 * Checks if the request contains a token and subscriber_code i.e. the subscriber clicked
241 * the link in the email sent by the maybe_run_subscriber_authentication() function above.
242 *
243 * This calls the API to verify the token and subscriber code, which tells us that the email
244 * address supplied truly belongs to the user, and that we can safely trust their subscriber ID
245 * to be valid.
246 *
247 * @since 2.1.0
248 */
249 public function maybe_run_subscriber_verification() {
250
251 // Bail if no nonce was specified.
252 if ( ! array_key_exists( '_wpnonce', $_REQUEST ) ) {
253 return;
254 }
255
256 // Bail if the nonce failed validation.
257 if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'convertkit_restrict_content_subscriber_code' ) ) {
258 return;
259 }
260
261 // Bail if the expected token and subscriber code is missing.
262 if ( ! array_key_exists( 'token', $_REQUEST ) ) {
263 return;
264 }
265 if ( ! array_key_exists( 'subscriber_code', $_REQUEST ) ) {
266 return;
267 }
268
269 // If the Plugin Access Token has not been configured, we can't get this subscriber's ID by email.
270 if ( ! $this->settings->has_access_and_refresh_token() ) {
271 return;
272 }
273
274 // Store the token so it's included in the subscriber code form if verification fails.
275 $this->token = sanitize_text_field( $_REQUEST['token'] );
276 $this->post_id = absint( sanitize_text_field( $_REQUEST['convertkit_post_id'] ) );
277
278 // Initialize the API.
279 $this->api = new ConvertKit_API_V4(
280 CONVERTKIT_OAUTH_CLIENT_ID,
281 CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI,
282 $this->settings->get_access_token(),
283 $this->settings->get_refresh_token(),
284 $this->settings->debug_enabled(),
285 'restrict_content'
286 );
287
288 // Verify the token and subscriber code.
289 $subscriber_id = $this->api->subscriber_authentication_verify(
290 sanitize_text_field( $_REQUEST['token'] ),
291 sanitize_text_field( $_REQUEST['subscriber_code'] )
292 );
293
294 // Bail if an error occured.
295 if ( is_wp_error( $subscriber_id ) ) {
296 $this->error = $subscriber_id;
297 return;
298 }
299
300 // Store subscriber ID in cookie.
301 $this->store_subscriber_id_in_cookie( $subscriber_id );
302
303 // If this isn't an AJAX request, redirect now to reload the Post.
304 if ( ! wp_doing_ajax() ) {
305 $this->redirect();
306 }
307
308 }
309
310 /**
311 * Displays (or hides) content on a singular Page, Post or Custom Post Type's Content,
312 * depending on whether the visitor is an authenticated ConvertKit subscriber and has
313 * subscribed to the ConvertKit Product or Tag.
314 *
315 * @since 2.1.0
316 *
317 * @param string $content Post Content.
318 * @return string Post Content with content restricted/not restricted
319 */
320 public function maybe_restrict_content( $content ) {
321
322 // Bail if the Restrict Content setting is not enabled on this Page.
323 if ( ! $this->is_restricted_content() ) {
324 return $content;
325 }
326
327 // Bail if the Page is being edited in a frontend Page Builder / Editor by a logged
328 // in WordPress user who has the capability to edit the Page.
329 // This ensures the User can view all content to edit it, instead of seeing the Restrict Content
330 // view.
331 if ( current_user_can( 'edit_post', get_the_ID() ) && WP_ConvertKit()->is_admin_or_frontend_editor() ) {
332 return $content;
333 }
334
335 // Get resource type (Product or Tag) that the visitor must be subscribed against to access this content.
336 $this->resource_type = $this->get_resource_type();
337
338 // Return the Post Content, unedited, if the Resource Type is false.
339 if ( ! $this->resource_type ) {
340 return $content;
341 }
342
343 // Get resource ID (Product ID or Tag ID) that the visitor must be subscribed against to access this content.
344 $this->resource_id = $this->get_resource_id();
345
346 // Return the full Post Content, unedited, if the Resource ID is false, as this means
347 // no restrict content setting has been defined for this Post.
348 if ( ! $this->resource_id ) {
349 return $content;
350 }
351
352 // Return the full Post Content, unedited, if the request is from a crawler.
353 if ( $this->restrict_content_settings->permit_crawlers() && $this->is_crawler() ) {
354 return $content;
355 }
356
357 // Return if this request is after the user entered their email address,
358 // which means we're going through the authentication flow.
359 if ( $this->in_authentication_flow() ) {
360 return $this->restrict_content( $content );
361 }
362
363 // Get the subscriber ID, either from the request or an existing cookie.
364 $subscriber_id = $this->get_subscriber_id_from_request();
365
366 // If no subscriber ID exists, the visitor cannot view the content.
367 if ( ! $subscriber_id ) {
368 return $this->restrict_content( $content );
369 }
370
371 // If the subscriber is not subscribed to the product, restrict the content.
372 if ( ! $this->subscriber_has_access( $subscriber_id ) ) {
373 // Show an error before the call to action, to tell the subscriber why they still cannot
374 // view the content.
375 $this->error = new WP_Error(
376 'convertkit_restrict_content_subscriber_no_access',
377 esc_html( $this->restrict_content_settings->get_by_key( 'no_access_text' ) )
378 );
379
380 return $this->restrict_content( $content );
381 }
382
383 // If here, the subscriber has subscribed to the product.
384 // Show the full Post Content.
385 return $content;
386
387 }
388
389 /**
390 * Changes how WordPress' get_adjacent_post() function queries Pages, to determine what
391 * the previous Page link is when using the Previous navigation block on a Page that
392 * has the Restrict Content setting defined.
393 *
394 * By default, get_adjacent_post() will query by post_date, which we change to menu_order.
395 *
396 * @since 2.1.0
397 *
398 * @param string $where The `WHERE` clause in the SQL.
399 * @param bool $in_same_term Whether post should be in a same taxonomy term.
400 * @param array $excluded_terms Array of excluded term IDs.
401 * @param string $taxonomy Taxonomy. Used to identify the term used when `$in_same_term` is true.
402 * @param WP_Post $post WP_Post object.
403 * @return string Modified `WHERE` clause
404 */
405 public function maybe_change_previous_post_where_clause( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
406
407 // Bail if the Restrict Content setting is not enabled on this Page.
408 if ( ! $this->is_restricted_content() ) {
409 return $where;
410 }
411
412 // Bail if the Page doesn't match the current Page being viewed, or has no parent Page.
413 if ( ! $this->has_parent_page( $post ) ) {
414 return $where;
415 }
416
417 // Build replacement where statement.
418 $new_where = 'p.post_parent = ' . $post->post_parent . ' AND p.menu_order < ' . $post->menu_order;
419
420 // Replace existing where statement with new statement.
421 $where = 'WHERE ' . $new_where . ' ' . substr( $where, strpos( $where, 'AND' ) );
422
423 // Return.
424 return $where;
425
426 }
427
428 /**
429 * Changes how WordPress' get_adjacent_post() function queries Pages, to determine what
430 * the next Page link is when using the Previous navigation block on a Page that
431 * has the Restrict Content setting defined.
432 *
433 * By default, get_adjacent_post() will query by post_date, which we change to menu_order.
434 *
435 * @since 2.1.0
436 *
437 * @param string $where The `WHERE` clause in the SQL.
438 * @param bool $in_same_term Whether post should be in a same taxonomy term.
439 * @param array $excluded_terms Array of excluded term IDs.
440 * @param string $taxonomy Taxonomy. Used to identify the term used when `$in_same_term` is true.
441 * @param WP_Post $post WP_Post object.
442 * @return string Modified `WHERE` clause
443 */
444 public function maybe_change_next_post_where_clause( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
445
446 // Bail if the Restrict Content setting is not enabled on this Page.
447 if ( ! $this->is_restricted_content() ) {
448 return $where;
449 }
450
451 // Bail if the Page doesn't match the current Page being viewed, or has no parent Page.
452 if ( ! $this->has_parent_page( $post ) ) {
453 return $where;
454 }
455
456 // Build replacement where statement.
457 $new_where = 'p.post_parent = ' . $post->post_parent . ' AND p.menu_order > ' . $post->menu_order;
458
459 // Replace existing where statement with new statement.
460 $where = 'WHERE ' . $new_where . ' ' . substr( $where, strpos( $where, 'AND' ) );
461
462 // Return.
463 return $where;
464
465 }
466
467 /**
468 * Changes how WordPress' get_adjacent_post() function orders Pages, to determine what
469 * the next and previous Page links are when using Previous / Next navigation blocks
470 * on a Page that has the Restrict Content setting defined.
471 *
472 * By default, get_adjacent_post() will sort by Post Date, which we change to Page Order
473 * (called menu_order in WordPress).
474 *
475 * @since 2.1.0
476 *
477 * @param string $order_by SQL ORDER BY statement.
478 * @param WP_Post $post WordPress Post.
479 * @param string $order Order.
480 * @return string Modified SQL ORDER BY statement.
481 */
482 public function maybe_change_previous_next_post_order_by_clause( $order_by, $post, $order ) {
483
484 // Bail if the Restrict Content setting is not enabled on this Page.
485 if ( ! $this->is_restricted_content() ) {
486 return $order_by;
487 }
488
489 // Bail if the Page doesn't match the current Page being viewed, or has no parent Page.
490 if ( ! $this->has_parent_page( $post ) ) {
491 return $order_by;
492 }
493
494 // Order by Page order (menu_order), highest to lowest, instead of post_date.
495 return 'ORDER BY p.menu_order ' . $order . ' LIMIT 1';
496
497 }
498
499 /**
500 * Stores the given subscriber ID in the ck_subscriber_id cookie.
501 *
502 * @since 2.3.7
503 *
504 * @param string|int $subscriber_id Subscriber ID (int if restrict by tag, signed subscriber id string if restrict by product).
505 */
506 private function store_subscriber_id_in_cookie( $subscriber_id ) {
507
508 // Store subscriber ID in cookie.
509 // We don't need to use validate_and_store_subscriber_id() as we just validated the subscriber via authentication above.
510 $subscriber = new ConvertKit_Subscriber();
511 $subscriber->set( $subscriber_id );
512
513 }
514
515 /**
516 * Redirects to the current URL, removing any query parameters (such as tokens), and appending
517 * a ck-cache-bust query parameter to beat caching plugins.
518 *
519 * @since 2.3.7
520 */
521 private function redirect() {
522
523 // Redirect to the Post, appending a query parameter to the URL to prevent caching plugins and
524 // aggressive cache hosting configurations from serving a cached page, which would
525 // result in maybe_restrict_content() not showing an error message or permitting
526 // access to the content.
527 wp_safe_redirect( $this->get_url( true ) );
528 exit;
529
530 }
531
532 /**
533 * Returns the URL for the current request, excluding any query parameters.
534 *
535 * @since 2.1.0
536 *
537 * @param bool $cache_bust Include `ck-cache-bust` parameter in URL.
538 * @return string URL.
539 */
540 public function get_url( $cache_bust = false ) {
541
542 // Get URL of Post.
543 $url = get_permalink( $this->post_id );
544
545 // If no cache busting required, return the URL now.
546 if ( ! $cache_bust ) {
547 return $url;
548 }
549
550 // Append a query parameter to the URL to prevent caching plugins and
551 // aggressive cache hosting configurations from serving a cached page, which would
552 // result in maybe_restrict_content() not showing an error message or permitting
553 // access to the content.
554 return add_query_arg(
555 array(
556 'ck-cache-bust' => microtime(),
557 ),
558 $url
559 );
560
561 }
562
563 /**
564 * Determines if the request is for a WordPress Page that has the Restrict Content
565 * setting defined.
566 *
567 * @since 2.1.0
568 *
569 * @return bool
570 */
571 private function is_restricted_content() {
572
573 // Bail if not a singular Post Type.
574 if ( ! is_singular() ) {
575 return false;
576 }
577
578 // If the Plugin Access Token has not been configured, we can't determine the validity of this subscriber ID
579 // or which resource(s) they have access to.
580 if ( ! $this->settings->has_access_and_refresh_token() ) {
581 return false;
582 }
583
584 // Get Post ID.
585 $this->post_id = get_the_ID();
586
587 // Initialize Settings and Post Setting classes.
588 $this->post_settings = new ConvertKit_Post( $this->post_id );
589
590 // Return whether the Post's settings are set to restrict content.
591 return $this->post_settings->restrict_content_enabled();
592
593 }
594
595 /**
596 * Determines if the user entered a valid email address, and need to be prompted
597 * to enter a code sent to their email address.
598 *
599 * @since 2.1.0
600 *
601 * @return bool
602 */
603 private function in_authentication_flow() {
604
605 return ( $this->token !== false );
606
607 }
608
609 /**
610 * Checks if the given WordPress Page matches the Page ID viewed, and has a parent.
611 *
612 * @since 2.1.0
613 *
614 * @param WP_Post $post WordPress Post.
615 * @return bool Has parent page
616 */
617 private function has_parent_page( $post ) {
618
619 // Bail if the Page doesn't match the current Page being viewed.
620 // This prevents us accidentally interfering with other previous / next link queries, which shouldn't happen
621 // as we check if we're viewing a restricted content page above.
622 if ( $post->ID !== $this->post_id ) {
623 return false;
624 }
625
626 // Bail if the Page doesn't have a parent Page.
627 // We don't want to modify the default sort behaviour in this instance.
628 if ( $post->post_parent === 0 ) {
629 return false;
630 }
631
632 return true;
633
634 }
635
636 /**
637 * Get the Post's Restricted Content resource type.
638 *
639 * @since 2.1.0
640 *
641 * @return bool|string Resource Type (product).
642 */
643 private function get_resource_type() {
644
645 // Initialize Post Setting classes.
646 $this->post_settings = new ConvertKit_Post( $this->post_id );
647
648 // Get resource type.
649 $resource_type = $this->post_settings->get_restrict_content_type();
650
651 /**
652 * Define the ConvertKit Resource Type that the visitor must be subscribed against
653 * to access this content, overriding the Post setting.
654 *
655 * Return false or an empty string to not restrict content.
656 *
657 * @since 2.1.0
658 *
659 * @param string $resource_type Resource Type (product)
660 * @param int $post_id Post ID
661 */
662 $resource_type = apply_filters( 'convertkit_output_restrict_content_get_resource_type', $resource_type, $this->post_id );
663
664 // If resource type is blank, set it to false.
665 if ( empty( $resource_type ) ) {
666 $resource_type = false;
667 }
668
669 // Return.
670 return $resource_type;
671
672 }
673
674 /**
675 * Get the Post's Restricted Content resource ID.
676 *
677 * @since 2.1.0
678 *
679 * @return int Resource ID (product ID).
680 */
681 private function get_resource_id() {
682
683 // Initialize Post Setting classes.
684 $this->post_settings = new ConvertKit_Post( $this->post_id );
685
686 // Get resource ID.
687 $resource_id = $this->post_settings->get_restrict_content_id();
688
689 /**
690 * Define the ConvertKit Resource ID that the visitor must be subscribed against
691 * to access this content, overriding the Post setting.
692 *
693 * Return 0 to not restrict content.
694 *
695 * @since 2.1.0
696 *
697 * @param int $resource_id Resource ID
698 * @param int $post_id Post ID
699 */
700 $resource_id = apply_filters( 'convertkit_output_restrict_content_get_resource_id', $resource_id, $this->post_id );
701
702 // Return.
703 return $resource_id;
704
705 }
706
707 /**
708 * Queries the API to confirm whether the resource exists.
709 *
710 * @since 2.3.3
711 *
712 * @return bool
713 */
714 private function resource_exists() {
715
716 switch ( $this->resource_type ) {
717
718 case 'product':
719 // Get Product.
720 $products = new ConvertKit_Resource_Products( 'restrict_content' );
721 $product = $products->get_by_id( $this->resource_id );
722
723 // If the Product does not exist, return false.
724 if ( ! $product ) {
725 return false;
726 }
727
728 // Product exists in ConvertKit.
729 return true;
730
731 case 'tag':
732 // Get Tag.
733 $tags = new ConvertKit_Resource_Tags( 'restrict_content' );
734 $tag = $tags->get_by_id( $this->resource_id );
735
736 // If the Tag does not exist, return false.
737 if ( ! $tag ) {
738 return false;
739 }
740
741 // Tag exists in ConvertKit.
742 return true;
743
744 default:
745 return false;
746
747 }
748
749 }
750
751 /**
752 * Determines if the given subscriber has an active subscription to
753 * the given resource and its ID.
754 *
755 * @since 2.1.0
756 *
757 * @param string|int $subscriber_id Signed Subscriber ID or Subscriber ID.
758 * @return bool Can view restricted content
759 */
760 private function subscriber_has_access( $subscriber_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
761
762 // Initialize the API.
763 $this->api = new ConvertKit_API_V4(
764 CONVERTKIT_OAUTH_CLIENT_ID,
765 CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI,
766 $this->settings->get_access_token(),
767 $this->settings->get_refresh_token(),
768 $this->settings->debug_enabled(),
769 'restrict_content'
770 );
771
772 // Depending on the resource type, determine if the subscriber has access to it.
773 // This is deliberately a switch statement, because we will likely add in support
774 // for restrict by tag and form later.
775 switch ( $this->resource_type ) {
776 case 'product':
777 // Get products that the subscriber has access to.
778 $result = $this->api->profile( $subscriber_id );
779
780 // If an error occured, the subscriber ID is invalid.
781 if ( is_wp_error( $result ) ) {
782 return false;
783 }
784
785 // If no products exist, there's no access.
786 if ( ! $result['products'] || ! count( $result['products'] ) ) {
787 return false;
788 }
789
790 // Return if the subscriber is not subscribed to the product.
791 if ( ! in_array( absint( $this->resource_id ), $result['products'], true ) ) {
792 return false;
793 }
794
795 // If here, the subscriber is subscribed to the product.
796 return true;
797
798 case 'tag':
799 // Get tags that the subscriber has been assigned.
800 $tags = $this->api->get_subscriber_tags( $subscriber_id );
801
802 // If an error occured, the subscriber ID is invalid.
803 if ( is_wp_error( $tags ) ) {
804 return false;
805 }
806
807 // If no tags exist, there's no access.
808 if ( ! count( $tags['tags'] ) ) {
809 return false;
810 }
811
812 // Iterate through the subscriber's tags to see if they have the required tag.
813 foreach ( $tags['tags'] as $tag ) {
814 if ( $tag['id'] === absint( $this->resource_id ) ) {
815 // Subscriber has the required tag assigned to them - grant access.
816 return true;
817 }
818 }
819
820 // If here, the subscriber does not have the tag.
821 return false;
822 }
823
824 // If here, the subscriber does not have access.
825 return false;
826
827 }
828
829 /**
830 * Gets the subscriber ID from the request (either the cookie or the URL).
831 *
832 * @since 2.1.0
833 *
834 * @return int|string Subscriber ID or Signed ID
835 */
836 public function get_subscriber_id_from_request() {
837
838 // Use ConvertKit_Subscriber class to fetch and validate the subscriber ID.
839 $subscriber = new ConvertKit_Subscriber();
840 $subscriber_id = $subscriber->get_subscriber_id();
841
842 // If an error occured, the subscriber ID in the request/cookie is not a valid subscriber.
843 if ( is_wp_error( $subscriber_id ) ) {
844 return 0;
845 }
846
847 return $subscriber_id;
848
849 }
850
851 /**
852 * Restrict the given Post Content by showing a preview of the content, and appending
853 * the call to action to subscribe or authenticate.
854 *
855 * @since 2.1.0
856 *
857 * @param string $content Post Content.
858 * @return string Post Content preview with call to action
859 */
860 private function restrict_content( $content ) {
861
862 // Check that the resource exists before restricting the content.
863 // This handles cases where e.g. a Tag or Product has been deleted in ConvertKit,
864 // but the Page / Post still references the (now deleted) resource to restrict content with
865 // under the 'Member Content' setting.
866 if ( ! $this->resource_exists() ) {
867 // Return the full Post Content, as we can't restrict it to a Product or Tag that no longer exists.
868 return $content;
869 }
870
871 // Fetch the content preview.
872 $content_preview = $this->get_content_preview( $content );
873
874 /**
875 * Define the output for the content preview when the visitor is not
876 * an authenticated subscriber.
877 *
878 * @since 2.4.1
879 *
880 * @param string $content_preview Content preview.
881 * @param int $post_id Post ID.
882 */
883 $content_preview = apply_filters( 'convertkit_output_restrict_content_content_preview', $content_preview, $this->post_id );
884
885 // Fetch the call to action.
886 $call_to_action = $this->get_call_to_action( $this->post_id );
887
888 /**
889 * Define the output for the call to action, displayed below the content preview,
890 * when the visitor is not an authenticated subscriber.
891 *
892 * @since 2.4.1
893 *
894 * @param string $call_to_action Call to Action.
895 * @param int $post_id Post ID.
896 */
897 $call_to_action = apply_filters( 'convertkit_output_restrict_content_call_to_action', $call_to_action, $this->post_id );
898
899 // Return the content preview and its call to action.
900 return $content_preview . $call_to_action;
901
902 }
903
904 /**
905 * Returns a preview of the given content for visitors that don't have access to restricted content.
906 *
907 * The preview is determined by:
908 * - A single <!--more--> tag being placed between WordPress paragraphs when using the Classic Editor.
909 * Content before the tag will be returned as the preview, unless 'noteaser' is enabled.
910 * - A single 'Read More' block being placed between WordPress blocks when using the Gutenberg Editor.
911 * Content before the Read More block will be returned as the preview, unless 'Hide the excerpt
912 * on the full content page' is enabled.
913 *
914 * If no more tag or Read More block is present, returns the Post's excerpt.
915 *
916 * @since 2.1.0
917 *
918 * @param string $content Post Content.
919 * @return string Post Content Preview.
920 */
921 private function get_content_preview( $content ) {
922
923 global $post;
924
925 // Check if the content contains a <!--more--> tag, which the editor might have placed
926 // in the content through WordPress' Classic Editor.
927 $content_breakdown = get_extended( $content );
928
929 // If the <!-- more --> tag exists, the 'extended' key will contain the restricted content.
930 if ( ! empty( $content_breakdown['extended'] ) ) {
931 // Return the preview content.
932 return $content_breakdown['main'];
933 }
934
935 // Check if the content contains a 'Read More' block, which the editor might have placed
936 // in the content through the Gutenberg Editor.
937 $block_editor_tag = '<span id="more-' . $post->ID . '"></span>';
938 if ( strpos( $content, $block_editor_tag ) !== false ) {
939 // Split content into an array by the tag.
940 $content_breakdown = explode( $block_editor_tag, $content );
941
942 // Return the content before the tag.
943 // If noteaser is enabled, this will correctly be blank.
944 return $content_breakdown[0];
945 }
946
947 // If here, there is no preview content available. Use the Post's excerpt.
948 return $this->get_excerpt( $post->ID );
949
950 }
951
952 /**
953 * Returns the excerpt for the given Post.
954 *
955 * If no excerpt is defined, generates one from the Post's content.
956 *
957 * @since 2.3.7
958 *
959 * @param int $post_id Post ID.
960 * @return string Post excerpt.
961 */
962 private function get_excerpt( $post_id ) {
963
964 // Remove 'the_content' filter, as if the Post contains no defined excerpt, WordPress
965 // will invoke the Post's content to build an excerpt, resulting in an infinite loop.
966 remove_filter( 'the_content', array( $this, 'maybe_restrict_content' ) );
967
968 // Generate the Post's excerpt.
969 $excerpt = get_the_excerpt( $post_id );
970
971 // Restore filters so other functions and Plugins aren't affected.
972 add_filter( 'the_content', array( $this, 'maybe_restrict_content' ) );
973
974 // Return the excerpt.
975 return wpautop( $excerpt );
976
977 }
978
979 /**
980 * Returns the HTML output for the call to action for visitors not subscribed to the required
981 * resource type and ID.
982 *
983 * @since 2.1.0
984 *
985 * @param int $post_id Post ID.
986 * @return string HTML
987 */
988 private function get_call_to_action( $post_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
989
990 // Only load styles if the Disable CSS option is off.
991 if ( ! $this->settings->css_disabled() ) {
992 // Enqueue styles.
993 wp_enqueue_style( 'convertkit-restrict-content', CONVERTKIT_PLUGIN_URL . 'resources/frontend/css/restrict-content.css', array(), CONVERTKIT_PLUGIN_VERSION );
994 }
995
996 // Only load scripts if the Disable Scripts option is off.
997 if ( ! $this->settings->scripts_disabled() ) {
998 // Enqueue scripts.
999 wp_enqueue_script( 'convertkit-restrict-content', CONVERTKIT_PLUGIN_URL . 'resources/frontend/js/restrict-content.js', array(), CONVERTKIT_PLUGIN_VERSION, true );
1000 wp_localize_script(
1001 'convertkit-restrict-content',
1002 'convertkit_restrict_content',
1003 array(
1004 'ajaxurl' => admin_url( 'admin-ajax.php' ),
1005 'debug' => $this->settings->debug_enabled(),
1006 )
1007 );
1008
1009 }
1010
1011 // This is deliberately a switch statement, because we will likely add in support
1012 // for restrict by tag and form later.
1013 switch ( $this->resource_type ) {
1014 case 'product':
1015 // Output product code form if this request is after the user entered their email address,
1016 // which means we're going through the authentication flow.
1017 if ( $this->in_authentication_flow() ) { // phpcs:ignore WordPress.Security.NonceVerification
1018 ob_start();
1019 include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/product-code.php';
1020 return trim( ob_get_clean() );
1021 }
1022
1023 // Output product restricted message and email form.
1024 // Get Product.
1025 $products = new ConvertKit_Resource_Products( 'restrict_content' );
1026 $product = $products->get_by_id( $this->resource_id );
1027
1028 // Get commerce.js URL and enqueue.
1029 $url = $products->get_commerce_js_url();
1030 if ( $url ) {
1031 wp_enqueue_script( 'convertkit-commerce', $url, array(), CONVERTKIT_PLUGIN_VERSION, true );
1032 }
1033
1034 // If scripts are enabled, output the email login form in a modal, which will be displayed
1035 // when the 'log in' link is clicked.
1036 if ( ! $this->settings->scripts_disabled() ) {
1037 add_action(
1038 'wp_footer',
1039 function () {
1040
1041 include_once CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/product-modal.php';
1042
1043 }
1044 );
1045 }
1046
1047 // Output.
1048 ob_start();
1049 $button = $products->get_html( $this->resource_id, $this->restrict_content_settings->get_by_key( 'subscribe_button_label' ) );
1050 include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/product.php';
1051 return trim( ob_get_clean() );
1052
1053 case 'tag':
1054 // Output.
1055 ob_start();
1056 include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/tag.php';
1057 return trim( ob_get_clean() );
1058
1059 default:
1060 return '';
1061
1062 }
1063
1064 }
1065
1066 /**
1067 * Whether this request is from a search engine crawler.
1068 *
1069 * @since 2.4.2
1070 *
1071 * @return bool
1072 */
1073 private function is_crawler() {
1074
1075 // Define permitted user agent crawlers and their IP addresses.
1076 $permitted_user_agent_ip_ranges = array(
1077 // Google.
1078 // https://developers.google.com/static/search/apis/ipranges/googlebot.json.
1079 'Googlebot' => array(
1080 '192.178.5.0/27',
1081 '34.100.182.96/28',
1082 '34.101.50.144/28',
1083 '34.118.254.0/28',
1084 '34.118.66.0/28',
1085 '34.126.178.96/28',
1086 '34.146.150.144/28',
1087 '34.147.110.144/28',
1088 '34.151.74.144/28',
1089 '34.152.50.64/28',
1090 '34.154.114.144/28',
1091 '34.155.98.32/28',
1092 '34.165.18.176/28',
1093 '34.175.160.64/28',
1094 '34.176.130.16/28',
1095 '34.22.85.0/27',
1096 '34.64.82.64/28',
1097 '34.65.242.112/28',
1098 '34.80.50.80/28',
1099 '34.88.194.0/28',
1100 '34.89.10.80/28',
1101 '34.89.198.80/28',
1102 '34.96.162.48/28',
1103 '35.247.243.240/28',
1104 '66.249.64.0/27',
1105 '66.249.64.128/27',
1106 '66.249.64.160/27',
1107 '66.249.64.192/27',
1108 '66.249.64.224/27',
1109 '66.249.64.32/27',
1110 '66.249.64.64/27',
1111 '66.249.64.96/27',
1112 '66.249.65.0/27',
1113 '66.249.65.160/27',
1114 '66.249.65.192/27',
1115 '66.249.65.224/27',
1116 '66.249.65.32/27',
1117 '66.249.65.64/27',
1118 '66.249.65.96/27',
1119 '66.249.66.0/27',
1120 '66.249.66.128/27',
1121 '66.249.66.160/27',
1122 '66.249.66.192/27',
1123 '66.249.66.32/27',
1124 '66.249.66.64/27',
1125 '66.249.66.96/27',
1126 '66.249.68.0/27',
1127 '66.249.68.32/27',
1128 '66.249.68.64/27',
1129 '66.249.69.0/27',
1130 '66.249.69.128/27',
1131 '66.249.69.160/27',
1132 '66.249.69.192/27',
1133 '66.249.69.224/27',
1134 '66.249.69.32/27',
1135 '66.249.69.64/27',
1136 '66.249.69.96/27',
1137 '66.249.70.0/27',
1138 '66.249.70.128/27',
1139 '66.249.70.160/27',
1140 '66.249.70.192/27',
1141 '66.249.70.224/27',
1142 '66.249.70.32/27',
1143 '66.249.70.64/27',
1144 '66.249.70.96/27',
1145 '66.249.71.0/27',
1146 '66.249.71.128/27',
1147 '66.249.71.160/27',
1148 '66.249.71.192/27',
1149 '66.249.71.224/27',
1150 '66.249.71.32/27',
1151 '66.249.71.64/27',
1152 '66.249.71.96/27',
1153 '66.249.72.0/27',
1154 '66.249.72.128/27',
1155 '66.249.72.160/27',
1156 '66.249.72.192/27',
1157 '66.249.72.224/27',
1158 '66.249.72.32/27',
1159 '66.249.72.64/27',
1160 '66.249.72.96/27',
1161 '66.249.73.0/27',
1162 '66.249.73.128/27',
1163 '66.249.73.160/27',
1164 '66.249.73.192/27',
1165 '66.249.73.224/27',
1166 '66.249.73.32/27',
1167 '66.249.73.64/27',
1168 '66.249.73.96/27',
1169 '66.249.74.0/27',
1170 '66.249.74.128/27',
1171 '66.249.74.32/27',
1172 '66.249.74.64/27',
1173 '66.249.74.96/27',
1174 '66.249.75.0/27',
1175 '66.249.75.128/27',
1176 '66.249.75.160/27',
1177 '66.249.75.192/27',
1178 '66.249.75.224/27',
1179 '66.249.75.32/27',
1180 '66.249.75.64/27',
1181 '66.249.75.96/27',
1182 '66.249.76.0/27',
1183 '66.249.76.128/27',
1184 '66.249.76.160/27',
1185 '66.249.76.192/27',
1186 '66.249.76.224/27',
1187 '66.249.76.32/27',
1188 '66.249.76.64/27',
1189 '66.249.76.96/27',
1190 '66.249.77.0/27',
1191 '66.249.77.128/27',
1192 '66.249.77.160/27',
1193 '66.249.77.192/27',
1194 '66.249.77.224/27',
1195 '66.249.77.32/27',
1196 '66.249.77.64/27',
1197 '66.249.77.96/27',
1198 '66.249.78.0/27',
1199 '66.249.78.32/27',
1200 '66.249.79.0/27',
1201 '66.249.79.128/27',
1202 '66.249.79.160/27',
1203 '66.249.79.192/27',
1204 '66.249.79.224/27',
1205 '66.249.79.32/27',
1206 '66.249.79.64/27',
1207 '66.249.79.96/27',
1208 ),
1209
1210 // Bing.
1211 // https://www.bing.com/toolbox/bingbot.json.
1212 'Bingbot' => array(
1213 '157.55.39.0/24',
1214 '207.46.13.0/24',
1215 '40.77.167.0/24',
1216 '13.66.139.0/24',
1217 '13.66.144.0/24',
1218 '52.167.144.0/24',
1219 '13.67.10.16/28',
1220 '13.69.66.240/28',
1221 '13.71.172.224/28',
1222 '139.217.52.0/28',
1223 '191.233.204.224/28',
1224 '20.36.108.32/28',
1225 '20.43.120.16/28',
1226 '40.79.131.208/28',
1227 '40.79.186.176/28',
1228 '52.231.148.0/28',
1229 '20.79.107.240/28',
1230 '51.105.67.0/28',
1231 '20.125.163.80/28',
1232 '40.77.188.0/22',
1233 '65.55.210.0/24',
1234 '199.30.24.0/23',
1235 '40.77.202.0/24',
1236 '40.77.139.0/25',
1237 '20.74.197.0/28',
1238 '20.15.133.160/27',
1239 '40.77.177.0/24',
1240 '40.77.178.0/23',
1241 ),
1242 );
1243
1244 /**
1245 * Define the permitted user agents and their IP address ranges that can bypass
1246 * Restrict Content to index content for search engines.
1247 *
1248 * @since 2.4.2
1249 *
1250 * @param array $permitted Permitted user agent and IP address ranges.
1251 */
1252 $permitted_user_agent_ip_ranges = apply_filters( 'convertkit_output_restrict_content_is_crawler_permitted_user_agent_ip_ranges', $permitted_user_agent_ip_ranges );
1253
1254 // Not a crawler if no user agent defined or client IP address defined.
1255 if ( ! array_key_exists( 'HTTP_USER_AGENT', $_SERVER ) || ! array_key_exists( 'REMOTE_ADDR', $_SERVER ) ) {
1256 return false;
1257 }
1258
1259 // Iterate through permitted crawler IP addresses.
1260 foreach ( $permitted_user_agent_ip_ranges as $permitted_user_agent => $permitted_ip_addresses ) {
1261 // Skip this user agent's IP addresses if the client user agent doesn't contain this user agent.
1262 if ( stripos( $_SERVER['HTTP_USER_AGENT'], $permitted_user_agent ) === false ) {
1263 continue;
1264 }
1265
1266 // Check IP address.
1267 foreach ( $permitted_ip_addresses as $permitted_ip_range ) {
1268 if ( ! $this->ip_in_range( $_SERVER['REMOTE_ADDR'], $permitted_ip_range ) ) {
1269 continue;
1270 }
1271
1272 // The client user agent and IP address match a known crawler and its IP address.
1273 // This is a crawler.
1274 return true;
1275 }
1276 }
1277
1278 // If here, the client IP address isn't from a crawler.
1279 return false;
1280
1281 }
1282
1283 /**
1284 * Determines if the given IP address falls within the given CIDR range.
1285 *
1286 * @since 2.4.2
1287 *
1288 * @param string $ip Client IP Address (e.g. 127.0.0.1).
1289 * @param string $range IP Address and bits (e.g. 127.0.0.1/27).
1290 * @return bool Client IP Address matches range.
1291 */
1292 public function ip_in_range( $ip, $range ) {
1293
1294 // Return false if the IP address isn't valid.
1295 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1296 return false;
1297 }
1298
1299 // Return false if the range doesn't include the CIDR.
1300 if ( strpos( $range, '/' ) === false ) {
1301 return false;
1302 }
1303
1304 // Get subnet and bits from range.
1305 list( $subnet, $bits ) = explode( '/', $range );
1306
1307 // Return false if the CIDR isn't numerical.
1308 if ( ! is_numeric( $bits ) ) {
1309 return false;
1310 }
1311
1312 // Cast CIDR to integer.
1313 $bits = (int) $bits;
1314
1315 // Return false if the CIDR is not wihtin the permitted range.
1316 if ( $bits < 0 || $bits > 32 ) {
1317 return false;
1318 }
1319
1320 // Convert to long representation.
1321 $ip = ip2long( $ip );
1322 $subnet = ip2long( $subnet );
1323 $mask = -1 << ( 32 - $bits );
1324
1325 // If the supplied subnet wasn't correctly aligned.
1326 $subnet &= $mask;
1327
1328 return ( $ip & $mask ) === $subnet;
1329
1330 }
1331
1332 }
1333