1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* Jetpack_Memberships: wrapper for memberships functions. |
4
|
|
|
* |
5
|
|
|
* @package Jetpack |
6
|
|
|
* @since 7.3.0 |
7
|
|
|
*/ |
8
|
|
|
|
9
|
|
|
/** |
10
|
|
|
* Class Jetpack_Memberships |
11
|
|
|
* This class represents the Memberships functionality. |
12
|
|
|
*/ |
13
|
|
|
class Jetpack_Memberships { |
14
|
|
|
/** |
15
|
|
|
* CSS class prefix to use in the styling. |
16
|
|
|
* |
17
|
|
|
* @var string |
18
|
|
|
*/ |
19
|
|
|
public static $css_classname_prefix = 'jetpack-memberships'; |
20
|
|
|
/** |
21
|
|
|
* Our CPT type for the product (plan). |
22
|
|
|
* |
23
|
|
|
* @var string |
24
|
|
|
*/ |
25
|
|
|
public static $post_type_plan = 'jp_mem_plan'; |
26
|
|
|
/** |
27
|
|
|
* Option that will store currently set up account (Stripe etc) id for memberships. |
28
|
|
|
* |
29
|
|
|
* @var string |
30
|
|
|
*/ |
31
|
|
|
public static $connected_account_id_option_name = 'jetpack-memberships-connected-account-id'; |
32
|
|
|
/** |
33
|
|
|
* Button block type to use. |
34
|
|
|
* |
35
|
|
|
* @var string |
36
|
|
|
*/ |
37
|
|
|
private static $button_block_name = 'recurring-payments'; |
38
|
|
|
/** |
39
|
|
|
* The prefix for transients storing cached subscriber statuses. |
40
|
|
|
* |
41
|
|
|
* @var string |
42
|
|
|
*/ |
43
|
|
|
private static $subscriber_transient_prefix = 'jetpack-payments-subscriber-'; |
44
|
|
|
/** |
45
|
|
|
* Cookie name for subscriber session token. |
46
|
|
|
* The tokens are identifying WPCOM user_id on WPCOM side. |
47
|
|
|
* |
48
|
|
|
* @var string |
49
|
|
|
*/ |
50
|
|
|
private static $subscriber_cookie_name = 'jetpack-payments-subscriber-token'; |
51
|
|
|
/** |
52
|
|
|
* Subscriber session token. This value should come from cookie or a redirect. |
53
|
|
|
* |
54
|
|
|
* @var string |
55
|
|
|
*/ |
56
|
|
|
private $subscriber_token_value = ''; |
57
|
|
|
/** |
58
|
|
|
* Cache for the subscriber data for the current session. |
59
|
|
|
* |
60
|
|
|
* @var array |
61
|
|
|
*/ |
62
|
|
|
private $subscriber_data = array(); |
63
|
|
|
/** |
64
|
|
|
* Array of post IDs where we don't want to render blocks anymore. |
65
|
|
|
* |
66
|
|
|
* @var array |
67
|
|
|
*/ |
68
|
|
|
private $stop_render_for_posts = array(); |
69
|
|
|
/** |
70
|
|
|
* These are defaults for wp_kses ran on the membership button. |
71
|
|
|
* |
72
|
|
|
* @var array |
73
|
|
|
*/ |
74
|
|
|
private static $tags_allowed_in_the_button = array( 'br' => array() ); |
75
|
|
|
/** |
76
|
|
|
* Classic singleton pattern |
77
|
|
|
* |
78
|
|
|
* @var Jetpack_Memberships |
79
|
|
|
*/ |
80
|
|
|
private static $instance; |
81
|
|
|
|
82
|
|
|
/** |
83
|
|
|
* Jetpack_Memberships constructor. |
84
|
|
|
*/ |
85
|
|
|
private function __construct() {} |
86
|
|
|
|
87
|
|
|
/** |
88
|
|
|
* The actual constructor initializing the object. |
89
|
|
|
* |
90
|
|
|
* @return Jetpack_Memberships |
91
|
|
|
*/ |
92
|
|
|
public static function get_instance() { |
93
|
|
|
if ( ! self::$instance ) { |
94
|
|
|
self::$instance = new self(); |
95
|
|
|
self::$instance->register_init_hook(); |
96
|
|
|
} |
97
|
|
|
|
98
|
|
|
return self::$instance; |
99
|
|
|
} |
100
|
|
|
/** |
101
|
|
|
* Get the map that defines the shape of CPT post. keys are names of fields and |
102
|
|
|
* 'meta' is the name of actual WP post meta field that corresponds. |
103
|
|
|
* |
104
|
|
|
* @return array |
105
|
|
|
*/ |
106
|
|
|
private static function get_plan_property_mapping() { |
107
|
|
|
$meta_prefix = 'jetpack_memberships_'; |
108
|
|
|
$properties = array( |
109
|
|
|
'price' => array( |
110
|
|
|
'meta' => $meta_prefix . 'price', |
111
|
|
|
), |
112
|
|
|
'currency' => array( |
113
|
|
|
'meta' => $meta_prefix . 'currency', |
114
|
|
|
), |
115
|
|
|
); |
116
|
|
|
return $properties; |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
/** |
120
|
|
|
* Inits further hooks on init hook. |
121
|
|
|
*/ |
122
|
|
|
private function register_init_hook() { |
123
|
|
|
add_action( 'init', array( $this, 'init_hook_action' ) ); |
124
|
|
|
} |
125
|
|
|
|
126
|
|
|
/** |
127
|
|
|
* This reads the user cookie or a redirect value and sets the user session token. |
128
|
|
|
* User session tokens correspond to a WPCOM user id. |
129
|
|
|
*/ |
130
|
|
|
private function setup_session_token() { |
131
|
|
|
global $_GET, $_COOKIE; |
132
|
|
|
// TODO: We need to hook into the various caching plugins as well, to whitelist this cookie. |
133
|
|
|
if ( isset( $_GET[ self::$subscriber_cookie_name ] ) ) { |
134
|
|
|
$this->subscriber_token_value = $_GET[ self::$subscriber_cookie_name ]; |
135
|
|
|
setcookie( self::$subscriber_cookie_name, $this->subscriber_token_value, time() + 90 * 24 * 3600, COOKIEPATH, COOKIE_DOMAIN ); |
136
|
|
|
} elseif ( isset( $_COOKIE[ self::$subscriber_cookie_name ] ) ) { |
137
|
|
|
$this->subscriber_token_value = $_COOKIE[ self::$subscriber_cookie_name ]; |
138
|
|
|
} |
139
|
|
|
} |
140
|
|
|
|
141
|
|
|
/** |
142
|
|
|
* Get current subscriber data. Tries to get from cache whenever possible, only in last resort does a WPCOM call to get data. |
143
|
|
|
* Cache expires every hour per user. |
144
|
|
|
* |
145
|
|
|
* @return array |
146
|
|
|
*/ |
147
|
|
|
public function get_subscriber_data() { |
148
|
|
|
// If we have stored data that we read previously, we return it. |
149
|
|
|
if ( $this->subscriber_data ) { |
|
|
|
|
150
|
|
|
return $this->subscriber_data; |
151
|
|
|
} |
152
|
|
|
// If we don't know the token of the current customer, return false. |
153
|
|
|
if ( ! $this->subscriber_token_value ) { |
154
|
|
|
return array( |
155
|
|
|
'type' => 'anon', |
156
|
|
|
'subscribed' => false, |
157
|
|
|
); |
158
|
|
|
} |
159
|
|
|
// If we have this data cached in the transient. |
160
|
|
|
$transient_data = get_transient( self::$subscriber_transient_prefix . $this->subscriber_token_value ); |
161
|
|
|
if ( $transient_data ) { |
162
|
|
|
$this->subscriber_data = $transient_data; |
163
|
|
|
return $transient_data; |
164
|
|
|
} |
165
|
|
|
// Ok, looks like we have no data cached on either side. Let us get this data. |
166
|
|
|
$request = sprintf( '/sites/%s/memberships/reader_token/%s/', Jetpack_Options::get_option( 'id' ), $this->subscriber_token_value ); |
167
|
|
|
$response = Jetpack_Client::wpcom_json_api_request_as_blog( $request, '1.1' ); |
168
|
|
|
if ( is_wp_error( $response ) ) { |
169
|
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
170
|
|
|
error_log( |
171
|
|
|
sprintf( |
172
|
|
|
/* translators: 1- error code, 2 - error message */ |
173
|
|
|
__( 'We have encountered an error [%1$s] while communicating with WordPress.com servers: %2$s', 'jetpack' ), |
174
|
|
|
$response->get_error_code(), |
175
|
|
|
$response->get_error_message() |
176
|
|
|
) |
177
|
|
|
); |
178
|
|
|
} |
179
|
|
|
$this->subscriber_data = array( |
180
|
|
|
'type' => 'error', |
181
|
|
|
'subscribed' => false, |
182
|
|
|
); |
183
|
|
|
} |
184
|
|
|
$data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null; |
185
|
|
|
if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) { |
186
|
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
187
|
|
|
error_log( |
188
|
|
|
sprintf( |
189
|
|
|
/* translators: 1- error code, 2 - error message */ |
190
|
|
|
__( 'We have encountered an error [%1$s] after communicating with WordPress.com servers: %2$s', 'jetpack' ), |
191
|
|
|
$data['code'], |
192
|
|
|
$data['message'] |
193
|
|
|
) |
194
|
|
|
); |
195
|
|
|
} |
196
|
|
|
$this->subscriber_data = array( |
197
|
|
|
'type' => 'error', |
198
|
|
|
'subscribed' => false, |
199
|
|
|
); |
200
|
|
|
} else { |
201
|
|
|
$this->subscriber_data = $data; |
|
|
|
|
202
|
|
|
} |
203
|
|
|
// We want a transient also in case of an error. We don't want to spam servers in case of errors. |
204
|
|
|
set_transient( self::$subscriber_transient_prefix . $this->subscriber_token_value, $data, time() + 3600 ); |
205
|
|
|
return $this->subscriber_data; |
206
|
|
|
} |
207
|
|
|
/** |
208
|
|
|
* Actual hooks initializing on init. |
209
|
|
|
*/ |
210
|
|
|
public function init_hook_action() { |
211
|
|
|
add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) ); |
212
|
|
|
add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) ); |
213
|
|
|
$this->setup_cpts(); |
214
|
|
|
$this->setup_session_token(); |
215
|
|
|
$this->setup_paywall(); |
216
|
|
|
} |
217
|
|
|
|
218
|
|
|
/** |
219
|
|
|
* Sets up the custom post types for the module. |
220
|
|
|
*/ |
221
|
|
|
private function setup_cpts() { |
222
|
|
|
/* |
223
|
|
|
* PLAN data structure. |
224
|
|
|
*/ |
225
|
|
|
$capabilities = array( |
226
|
|
|
'edit_post' => 'edit_posts', |
227
|
|
|
'read_post' => 'read_private_posts', |
228
|
|
|
'delete_post' => 'delete_posts', |
229
|
|
|
'edit_posts' => 'edit_posts', |
230
|
|
|
'edit_others_posts' => 'edit_others_posts', |
231
|
|
|
'publish_posts' => 'publish_posts', |
232
|
|
|
'read_private_posts' => 'read_private_posts', |
233
|
|
|
); |
234
|
|
|
$order_args = array( |
235
|
|
|
'label' => esc_html__( 'Plan', 'jetpack' ), |
236
|
|
|
'description' => esc_html__( 'Recurring Payments plans', 'jetpack' ), |
237
|
|
|
'supports' => array( 'title', 'custom-fields', 'content' ), |
238
|
|
|
'hierarchical' => false, |
239
|
|
|
'public' => false, |
240
|
|
|
'show_ui' => false, |
241
|
|
|
'show_in_menu' => false, |
242
|
|
|
'show_in_admin_bar' => false, |
243
|
|
|
'show_in_nav_menus' => false, |
244
|
|
|
'can_export' => true, |
245
|
|
|
'has_archive' => false, |
246
|
|
|
'exclude_from_search' => true, |
247
|
|
|
'publicly_queryable' => false, |
248
|
|
|
'rewrite' => false, |
249
|
|
|
'capabilities' => $capabilities, |
250
|
|
|
'show_in_rest' => false, |
251
|
|
|
); |
252
|
|
|
register_post_type( self::$post_type_plan, $order_args ); |
253
|
|
|
} |
254
|
|
|
|
255
|
|
|
/** |
256
|
|
|
* Allows custom post types to be used by REST API. |
257
|
|
|
* |
258
|
|
|
* @param array $post_types - other post types. |
259
|
|
|
* |
260
|
|
|
* @see hook 'rest_api_allowed_post_types' |
261
|
|
|
* @return array |
262
|
|
|
*/ |
263
|
|
|
public function allow_rest_api_types( $post_types ) { |
264
|
|
|
$post_types[] = self::$post_type_plan; |
265
|
|
|
|
266
|
|
|
return $post_types; |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
/** |
270
|
|
|
* Allows custom meta fields to sync. |
271
|
|
|
* |
272
|
|
|
* @param array $post_meta - previously changet post meta. |
273
|
|
|
* |
274
|
|
|
* @return array |
275
|
|
|
*/ |
276
|
|
|
public function allow_sync_post_meta( $post_meta ) { |
277
|
|
|
$meta_keys = array_map( |
278
|
|
|
array( $this, 'return_meta' ), |
279
|
|
|
$this->get_plan_property_mapping() |
280
|
|
|
); |
281
|
|
|
return array_merge( $post_meta, array_values( $meta_keys ) ); |
282
|
|
|
} |
283
|
|
|
|
284
|
|
|
/** |
285
|
|
|
* This returns meta attribute of passet array. |
286
|
|
|
* Used for array functions. |
287
|
|
|
* |
288
|
|
|
* @param array $map - stuff. |
289
|
|
|
* |
290
|
|
|
* @return mixed |
291
|
|
|
*/ |
292
|
|
|
public function return_meta( $map ) { |
293
|
|
|
return $map['meta']; |
294
|
|
|
} |
295
|
|
|
|
296
|
|
|
/** |
297
|
|
|
* Hooks into the 'render_block' filter in order to block content access. |
298
|
|
|
*/ |
299
|
|
|
private function setup_paywall() { |
300
|
|
|
add_filter( 'render_block', array( $this, 'do_paywall_for_block' ), 10, 2 ); |
301
|
|
|
} |
302
|
|
|
|
303
|
|
|
/** |
304
|
|
|
* This is hooked into `render_block` filter. |
305
|
|
|
* The purpose is to not render any blocks following the paywall block. |
306
|
|
|
* This is achieved by storing the list of post_IDs where rendering of the blocks has been turned off. |
307
|
|
|
* If we are trying to render a block in one of these posts, we return empty string. |
308
|
|
|
* |
309
|
|
|
* @param string $block_content - rendered block. |
310
|
|
|
* @param array $block - block metadata. |
311
|
|
|
* |
312
|
|
|
* @return string |
313
|
|
|
*/ |
314
|
|
|
public function do_paywall_for_block( $block_content, $block ) { |
315
|
|
|
global $post; |
316
|
|
|
// Not in post context. |
317
|
|
|
if ( ! $post ) { |
318
|
|
|
return $block_content; |
319
|
|
|
} |
320
|
|
|
// This block itself is immune. This is checked before block is rendered, so it would not render the block itself. |
321
|
|
|
if ( 'jetpack/' . self::$button_block_name === $block['blockName'] ) { |
322
|
|
|
return $block_content; |
323
|
|
|
} |
324
|
|
|
// This will intercept rendering of any block after this one. |
325
|
|
|
if ( in_array( $post->ID, $this->stop_render_for_posts ) ) { |
326
|
|
|
return ''; |
327
|
|
|
} |
328
|
|
|
return $block_content; |
329
|
|
|
} |
330
|
|
|
|
331
|
|
|
/** |
332
|
|
|
* Marks the rest of the current post as Paywalled. This will stop rendering any further blocks on this post. |
333
|
|
|
* |
334
|
|
|
* @see $this::do_paywall_for_block. |
335
|
|
|
*/ |
336
|
|
|
private function paywall_the_post() { |
337
|
|
|
global $post; |
338
|
|
|
$this->stop_render_for_posts[] = $post->ID; |
339
|
|
|
} |
340
|
|
|
/** |
341
|
|
|
* Callback that parses the membership purchase shortcode. |
342
|
|
|
* |
343
|
|
|
* @param array $attrs - attributes in the shortcode. `id` here is the CPT id of the plan. |
344
|
|
|
* |
345
|
|
|
* @return string |
346
|
|
|
*/ |
347
|
|
|
public function render_button( $attrs ) { |
348
|
|
|
Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name, array( 'thickbox', 'wp-polyfill' ) ); |
349
|
|
|
|
350
|
|
|
if ( empty( $attrs['planId'] ) ) { |
351
|
|
|
return; |
352
|
|
|
} |
353
|
|
|
$id = intval( $attrs['planId'] ); |
354
|
|
|
$product = get_post( $id ); |
355
|
|
|
if ( ! $product || is_wp_error( $product ) ) { |
356
|
|
|
return; |
357
|
|
|
} |
358
|
|
|
if ( $product->post_type !== self::$post_type_plan || 'publish' !== $product->post_status ) { |
359
|
|
|
return; |
360
|
|
|
} |
361
|
|
|
|
362
|
|
|
$data = array( |
363
|
|
|
'blog_id' => self::get_blog_id(), |
364
|
|
|
'id' => $id, |
365
|
|
|
'button_label' => __( 'Your contribution', 'jetpack' ), |
366
|
|
|
'powered_text' => __( 'Powered by WordPress.com', 'jetpack' ), |
367
|
|
|
); |
368
|
|
|
|
369
|
|
|
if ( ! $attrs['paywall'] ) { |
370
|
|
|
return $this->get_purchase_button( $attrs, $data ); |
371
|
|
|
} |
372
|
|
|
$subscriber_data = $this->get_subscriber_data(); |
373
|
|
|
// User is logged in. |
374
|
|
|
// TODO: some more fallback. |
375
|
|
|
if ( 'anon' !== $subscriber_data['type'] ) { |
376
|
|
|
return ''; |
377
|
|
|
} |
378
|
|
|
// We know the user is anonymous. |
379
|
|
|
$this->paywall_the_post(); |
380
|
|
|
$login_link = $this->get_login_link( $data ); |
381
|
|
|
|
382
|
|
|
$purchase_button = $this->get_purchase_button( $attrs, $data ); |
383
|
|
|
$subscriber_message = ''; |
384
|
|
|
if ( isset( $attrs['subscriberMessage'] ) ) { |
385
|
|
|
$subscriber_message = $attrs['subscriberMessage']; |
386
|
|
|
} |
387
|
|
|
$classes = array( |
388
|
|
|
self::$css_classname_prefix . '-subscriber-message', |
389
|
|
|
self::$css_classname_prefix . '-' . $data['id'] . '-subscriber-message', |
390
|
|
|
); |
391
|
|
|
// TODO: This needs better customization. |
392
|
|
|
return "<div>{$purchase_button}<br/>{$login_link}</div>" . sprintf( '<div class="%s">%s</div>', esc_html( $classes ), $subscriber_message ); |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
/** |
396
|
|
|
* Get login URL for WPCOM login flow. |
397
|
|
|
* |
398
|
|
|
* @param array $data - Plan data. |
399
|
|
|
* |
400
|
|
|
* @return string |
401
|
|
|
*/ |
402
|
|
|
private function get_login_link( $data ) { |
403
|
|
|
$current_url = urlencode( ( isset( $_SERVER['HTTPS'] ) ? 'https' : 'http' ) . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]" ); |
404
|
|
|
return "<a href='https://subscribe.wordpress.com/status/?blog={$data['blog_id']}&redirect_url={$current_url}'>LOG IN</a></div>"; |
405
|
|
|
} |
406
|
|
|
|
407
|
|
|
/** |
408
|
|
|
* Get the HTML for the purchase button. |
409
|
|
|
* |
410
|
|
|
* @param array $attrs - block attributes. |
411
|
|
|
* @param array $data - data for the payment plan. |
412
|
|
|
* |
413
|
|
|
* @return string |
414
|
|
|
*/ |
415
|
|
|
private function get_purchase_button( $attrs, $data ) { |
416
|
|
|
$classes = array( |
417
|
|
|
'wp-block-button__link', |
418
|
|
|
'components-button', |
419
|
|
|
'is-primary', |
420
|
|
|
'is-button', |
421
|
|
|
'wp-block-jetpack-' . self::$button_block_name, |
422
|
|
|
self::$css_classname_prefix . '-' . $data['id'], |
423
|
|
|
); |
424
|
|
|
if ( isset( $attrs['className'] ) ) { |
425
|
|
|
array_push( $classes, $attrs['className'] ); |
426
|
|
|
} |
427
|
|
|
if ( isset( $attrs['submitButtonText'] ) ) { |
428
|
|
|
$data['button_label'] = $attrs['submitButtonText']; |
429
|
|
|
} |
430
|
|
|
$button_styles = array(); |
431
|
|
|
if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) { |
432
|
|
|
array_push( |
433
|
|
|
$button_styles, |
434
|
|
|
sprintf( |
435
|
|
|
'background-color: %s', |
436
|
|
|
sanitize_hex_color( $attrs['customBackgroundButtonColor'] ) |
437
|
|
|
) |
438
|
|
|
); |
439
|
|
|
} |
440
|
|
|
if ( ! empty( $attrs['customTextButtonColor'] ) ) { |
441
|
|
|
array_push( |
442
|
|
|
$button_styles, |
443
|
|
|
sprintf( |
444
|
|
|
'color: %s', |
445
|
|
|
sanitize_hex_color( $attrs['customTextButtonColor'] ) |
446
|
|
|
) |
447
|
|
|
); |
448
|
|
|
} |
449
|
|
|
$button_styles = implode( $button_styles, ';' ); |
450
|
|
|
add_thickbox(); |
451
|
|
|
|
452
|
|
|
return sprintf( |
453
|
|
|
'<button data-blog-id="%d" data-powered-text="%s" data-plan-id="%d" data-lang="%s" class="%s" style="%s">%s</button>', |
454
|
|
|
esc_attr( $data['blog_id'] ), |
455
|
|
|
esc_attr( $data['powered_text'] ), |
456
|
|
|
esc_attr( $data['id'] ), |
457
|
|
|
esc_attr( get_locale() ), |
458
|
|
|
esc_attr( implode( $classes, ' ' ) ), |
459
|
|
|
esc_attr( $button_styles ), |
460
|
|
|
wp_kses( $data['button_label'], self::$tags_allowed_in_the_button ) |
461
|
|
|
); |
462
|
|
|
} |
463
|
|
|
|
464
|
|
|
|
465
|
|
|
/** |
466
|
|
|
* Get current blog id. |
467
|
|
|
* |
468
|
|
|
* @return int |
469
|
|
|
*/ |
470
|
|
|
public static function get_blog_id() { |
471
|
|
|
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { |
472
|
|
|
return get_current_blog_id(); |
473
|
|
|
} |
474
|
|
|
|
475
|
|
|
return Jetpack_Options::get_option( 'id' ); |
476
|
|
|
} |
477
|
|
|
|
478
|
|
|
/** |
479
|
|
|
* Get the id of the connected payment acount (Stripe etc). |
480
|
|
|
* |
481
|
|
|
* @return int|void |
482
|
|
|
*/ |
483
|
|
|
public static function get_connected_account_id() { |
484
|
|
|
return get_option( self::$connected_account_id_option_name ); |
485
|
|
|
} |
486
|
|
|
} |
487
|
|
|
Jetpack_Memberships::get_instance(); |
488
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.