PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / admin / campaign-builder / class-campaign-builder-preview.php

class-campaign-builder-preview.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/admin/campaign-builder/class-campaign-builder-preview.php

615 lines 21.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class that sets up the Campaign builder preview abilities.
4 *
5 * @package Charitable
6 * @author David Bisset
7 * @copyright Copyright (c) 2023, WP Charitable LLC
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.8.0
10 * @version 1.8.0
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Campaign_Builder_Preview' ) ) :
19
20 /**
21 * Campaign preview.
22 *
23 * @since 1.8.0
24 * @version 1.8.8.6
25 */
26 class Campaign_Builder_Preview { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- Class name is used by other code. Changing it would break existing functionality.
27
28 /**
29 * One is the loneliest number that you'll ever do.
30 *
31 * @since 1.8.0
32 *
33 * @var object
34 */
35 private static $instance;
36
37 /**
38 * Whether a campaign preview render is already in progress anywhere up the call stack.
39 *
40 * Static on purpose, and this is the whole point of it. This class is instantiated in more
41 * than one place - once from the plugin bootstrap and again from
42 * templates/campaign/builder/content.php, which the render itself includes - and every
43 * instance whose is_preview_page() passes registers its own the_content callback. WordPress
44 * keys object callbacks by spl_object_hash, so the remove_filter() around the render can only
45 * ever detach the *calling* instance's callback. The instance created during the render is a
46 * new object identity, so it re-arms the hook the level above just disarmed, and each nesting
47 * level adds another handler: measured at 2, 3, 4 and climbing, exhausting 256M within a
48 * handful of renders (HelpScout #11576). A per-instance guard cannot see that; a class-level
49 * one can.
50 *
51 * @since 1.8.12.4
52 *
53 * @var bool
54 */
55 private static $is_rendering = false;
56
57 /**
58 * Campaign data.
59 *
60 * @since 1.8.0
61 *
62 * @var array
63 */
64 public $campaign_data;
65
66 /**
67 * Constructor.
68 *
69 * @since 1.8.0
70 */
71 public function __construct() {
72
73 if ( ! $this->is_preview_page() ) {
74 return;
75 }
76
77 $this->hooks();
78 }
79
80 /**
81 * Main Instance.
82 *
83 * @since 1.8.0
84 *
85 * @return Charitable_Builder
86 */
87 public static function get_instance() {
88 if ( ! isset( self::$instance ) && ! ( self::$instance instanceof Campaign_Builder_Preview ) ) {
89
90 self::$instance = new Campaign_Builder_Preview();
91
92 }
93
94 return self::$instance;
95 }
96
97 /**
98 * Init
99 *
100 * @since 1.8.0
101 *
102 * @return void
103 */
104 public function init() {
105 }
106
107 /**
108 * Check if current page request meets requirements for campaign preview page.
109 *
110 * @since 1.8.0
111 *
112 * @return bool
113 */
114 public function is_preview_page() {
115
116 // Only proceed for the campaign preview page.
117 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
118 if ( empty( $_GET['charitable_campaign_preview'] ) ) {
119 return false;
120 }
121
122 // Check for logged-in user with correct capabilities.
123 if ( ! is_user_logged_in() ) {
124 return false;
125 }
126
127 $campaign_id = isset( $_GET['p'] ) ? absint( $_GET['p'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
128 $campaign_id = ( 0 === $campaign_id && ! empty( $_GET['charitable_campaign_preview'] ) ) ? absint( $_GET['charitable_campaign_preview'] ) : $campaign_id; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
129
130 if ( 0 === $campaign_id && ! empty( $_GET['charitable_campaign_preview'] ) && 1 === intval( $_GET['charitable_campaign_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
131 global $post;
132 if ( ! empty( $post->post_id ) ) {
133 $campaign_id = absint( $post->post_id );
134 }
135 }
136
137 // check if this custom post type actually exists.
138 global $wpdb;
139 if ( ! $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE ID=%s AND post_status != 'trash'", array( $campaign_id ) ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
140 return false;
141 }
142
143 // check permissions.
144 if ( ! charitable_current_user_can( 'edit_post', $campaign_id ) ) { // this was 'view_campaign_single'.
145 return false;
146 }
147
148 // Fetch campaign details.
149 $this->campaign_data = get_post_meta( $campaign_id, 'campaign_settings_v2', true );
150
151 // Check valid campaign was found.
152 if ( empty( $this->campaign_data ) || ( empty( $this->campaign_data['id'] ) && empty( absint( $_GET['charitable_campaign_preview'] ) ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
153 return false;
154 }
155
156 return true;
157 }
158
159 /**
160 * Hooks.
161 *
162 * @since 1.8.0
163 */
164 public function hooks() {
165
166 add_action( 'pre_get_posts', [ $this, 'pre_get_posts' ] );
167 add_filter( 'the_title', [ $this, 'the_title' ], 100, 1 );
168 add_filter( 'the_content', [ $this, 'the_content' ], 999 );
169 // Accept the second argument get_the_excerpt passes (the WP_Post being excerpted) so the
170 // handler can tell whose excerpt it is and leave other posts alone. See the_content().
171 add_filter( 'get_the_excerpt', [ $this, 'the_content' ], 999, 2 );
172 add_filter( 'home_template_hierarchy', [ $this, 'force_page_template_hierarchy' ], 999 );
173 add_filter( 'frontpage_template_hierarchy', [ $this, 'force_page_template_hierarchy' ], 999 );
174 add_filter( 'post_thumbnail_html', '__return_empty_string' );
175 add_filter( 'charitable_campaign_can_receive_donations', [ $this, 'enable_campaign_form' ], 10, 2 );
176 }
177
178 /**
179 * The campaign donation form should be enabled for a campaign preview page, so on a preview page it's "ok" for a campaign to recieve a donation.
180 *
181 * @param boolean $can Whether the campaign form accepts donations.
182 * @param mixed $the_object Charitable_Campaign.
183 *
184 * @return boolean Whether the campaign form can be enabled for the given object.
185 */
186 public function enable_campaign_form( $can, $the_object ) { // phpcs:ignore
187 return true;
188 }
189
190 /**
191 * Modify query, limit to one post.
192 *
193 * @since 1.8.0
194 *
195 * @param \WP_Query $query The WP_Query instance.
196 */
197 public function pre_get_posts( $query ) {
198
199 if ( is_admin() || ! $query->is_main_query() ) {
200 return;
201 }
202
203 $query->set( 'page_id', '' );
204 $query->set( 'post_type', 'campaign' );
205 $query->set( 'post__in', empty( $this->campaign_data['id'] ) ? [] : [ (int) $this->campaign_data['id'] ] );
206 $query->set( 'posts_per_page', 1 );
207 }
208
209 /**
210 * Customize campaign preview page title.
211 *
212 * @since 1.5.1
213 *
214 * @param string $title Page title.
215 *
216 * @return string
217 */
218 public function the_title( $title ) {
219
220 if ( in_the_loop() ) {
221 $title = sprintf( /* translators: %s - campaign title. */
222 esc_html__( '%s Preview', 'charitable' ),
223 ! empty( $this->campaign_data['settings']['campaign_title'] ) ? sanitize_text_field( $this->campaign_data['settings']['campaign_title'] ) : esc_html__( 'Campaign', 'charitable' )
224 );
225 }
226
227 return $title;
228 }
229
230 /**
231 * The ID of the campaign this request is previewing.
232 *
233 * A brand-new campaign that has never been saved has no id in its payload, in which case the
234 * only place the ID exists is the query argument, so fall back to that.
235 *
236 * @since 1.8.12.4
237 *
238 * @return int Campaign ID, or 0 if none could be resolved.
239 */
240 public function get_preview_campaign_id() {
241
242 $campaign_id = isset( $this->campaign_data['id'] ) ? absint( $this->campaign_data['id'] ) : 0;
243
244 if ( 0 === $campaign_id && ! empty( $_GET['charitable_campaign_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
245 $campaign_id = absint( $_GET['charitable_campaign_preview'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
246 }
247
248 return $campaign_id;
249 }
250
251 /**
252 * Customize campaign preview page content.
253 *
254 * @since 1.8.0
255 * @version 1.8.12.3 Detach from the content filters while the campaign renders, to prevent an infinite the_content loop that exhausts memory.
256 * @version 1.8.12.4 Leave other posts' excerpts alone. This handler is also attached to
257 * get_the_excerpt, and returned the whole rendered campaign for whatever post
258 * was being excerpted, so any excerpt elsewhere on the preview page (a query
259 * loop, a posts list) was replaced by a duplicate of the campaign.
260 *
261 * @param string $content The incoming content or excerpt.
262 * @param WP_Post|null $post Only supplied by get_the_excerpt: the post being excerpted.
263 * Null on the_content, which passes a single argument.
264 *
265 * @return string
266 */
267 public function the_content( $content = '', $post = null ) {
268
269 /*
270 * get_the_excerpt names the post it is asking about, so when it is asking about anything
271 * other than the campaign being previewed, hand the excerpt straight back untouched.
272 * Without this the handler ignored the question and answered with the full campaign render
273 * every time, so a preview page containing any other post's excerpt showed the campaign
274 * again in its place.
275 *
276 * Deliberately keyed on the argument rather than on in_the_loop() or get_the_ID(): both of
277 * those read the global post, which under a block theme is only set up when core's
278 * singular-template loop workaround fires (it is gated on is_singular(),
279 * wp-includes/block-template.php). The passed WP_Post is reliable regardless of theme.
280 *
281 * the_content is left alone on purpose. It passes no post argument, and core's
282 * core/post-content block calls get_the_content() with no ID precisely so the queried
283 * object wins, so that path is expected to act on the ambient post.
284 */
285 if ( $post instanceof WP_Post && absint( $post->ID ) !== $this->get_preview_campaign_id() ) {
286 return $content;
287 }
288
289 /*
290 * A render is already running further up the stack, so this application of the filter is
291 * a re-entry. Hand the content back untouched instead of starting a second render.
292 *
293 * This is what actually stops the loop. The remove_filter() further down cannot, because
294 * it only detaches THIS instance's callback, and the instance created inside the render
295 * (content.php, "preview page check") re-arms the hook under a fresh object identity. See
296 * the note on self::$is_rendering.
297 */
298 if ( self::$is_rendering ) {
299 return $content;
300 }
301
302 /*
303 * Never render the campaign into an excerpt. This catches BOTH the direct
304 * get_the_excerpt application and the indirect one: core's wp_trim_excerpt() re-applies
305 * the_content from inside the get_the_excerpt filter, so an SEO or Open Graph plugin
306 * asking for a meta description costs a full campaign render every time it asks. That is
307 * what exhausted 256M on the reporting site, and it is why deactivating SiteSEO there
308 * made the preview work (HelpScout #11576). doing_filter() sees the enclosing filter, so
309 * it recognises the nested the_content application that a check on the current filter
310 * alone would miss.
311 *
312 * Deliberately not bounded by a render counter instead: whichever caller asked first
313 * would win, and an SEO plugin building meta tags in wp_head asks before the body
314 * renders, which would leave the preview body empty.
315 */
316 if ( doing_filter( 'get_the_excerpt' ) ) {
317
318 /*
319 * Two different applications reach here while an excerpt is being built: the
320 * get_the_excerpt filter itself, and the the_content filter that core's
321 * wp_trim_excerpt() applies from inside it. Only the first is asking us for a
322 * summary. The second is asking "what is this post's content", about whatever post
323 * is being excerpted, which may not be the campaign at all, so answering it with the
324 * campaign's description would put the campaign's words into an unrelated post's
325 * excerpt. Hand that one straight back.
326 *
327 * Note wp_trim_excerpt() only takes that branch when the post has no excerpt of its
328 * own (formatting.php, '' === trim( $text )), which is why a post WITH an excerpt
329 * never exposed this.
330 */
331 if ( 'get_the_excerpt' !== current_filter() ) {
332 return $content;
333 }
334
335 /*
336 * Answer with the campaign's own description rather than nothing, so an SEO or Open
337 * Graph plugin still gets a usable meta description on a preview page. This reads the
338 * settings array is_preview_page() already loaded, so it costs no render and no query,
339 * and charitable_find_description_in_campaign_settings() ends on a Charitable filter
340 * rather than the_content, so it cannot re-enter this handler.
341 *
342 * Tags are stripped and the result trimmed because the caller wants a summary, not
343 * markup. 55 words is about the length a meta description survives at.
344 */
345 if ( ! function_exists( 'charitable_find_description_in_campaign_settings' ) ) {
346 return $content;
347 }
348
349 $charitable_preview_description = charitable_find_description_in_campaign_settings( $this->campaign_data, 0 );
350
351 if ( ! is_string( $charitable_preview_description ) ) {
352 return $content;
353 }
354
355 $charitable_preview_description = wp_strip_all_tags( $charitable_preview_description );
356
357 if ( '' === trim( $charitable_preview_description ) ) {
358 return $content;
359 }
360
361 return wp_trim_words( $charitable_preview_description, 55, '…' );
362 }
363
364 if ( ! isset( $this->campaign_data['id'] ) ) {
365 return '';
366 }
367
368 if ( ! charitable_current_user_can( 'edit_posts', $this->campaign_data['id'] ) ) {
369 return '';
370 }
371
372 $links = [];
373
374 if ( charitable_current_user_can( 'edit_posts', $this->campaign_data['id'] ) ) {
375 $links[] = [
376 'url' => esc_url(
377 add_query_arg(
378 [
379 'page' => 'charitable-campaign-builder',
380 'view' => 'design',
381 'campaign_id' => absint( $this->campaign_data['id'] ),
382 ],
383 admin_url( 'admin.php' )
384 )
385 ),
386 'text' => esc_html__( 'Edit Campaign', 'charitable' ),
387 ];
388 }
389
390 if ( charitable_current_user_can( 'read', $this->campaign_data['id'] ) ) {
391 $links[] = [
392 'url' => esc_url(
393 add_query_arg(
394 [
395 'post_type' => 'donation',
396 'campaign_id' => absint( $this->campaign_data['id'] ),
397 ],
398 admin_url( 'edit.php' )
399 )
400 ),
401 'text' => esc_html__( 'View Donations', 'charitable' ),
402 ];
403 }
404
405 $links[] = [
406 'url' => esc_url(
407 charitable_utm_link( 'https://www.wpcharitable.com/documentation/', 'Campaign Preview', 'Documentation' )
408 ),
409 'text' => esc_html__( 'Documentation', 'charitable' ),
410 ];
411
412 if ( ! empty( $_GET['new_window'] ) ) { // phpcs:ignore
413 $links[] = [
414 'url' => 'javascript:window.close();',
415 'text' => esc_html__( 'Close this window', 'charitable' ),
416 ];
417 }
418
419 $content = '<div class="charitable-preview-messages"><p>';
420
421 if ( 'publish' === get_post_status( $this->campaign_data['id'] ) ) :
422
423 if ( ! empty( $_GET['charitable_campaign_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
424
425 $content .= '<p>';
426
427 $content .= sprintf(
428 wp_kses(
429 /* translators: %s - Charitable doc link. */
430 __( 'You are viewing a preview of a <a href="%s" target="_blank">published campaign</a>. If this does not match your campaign page in the campaign builder, save your changes and then refresh this page. Your current theme can also effect the look of this page.', 'charitable' ),
431 [
432 'a' => [
433 'href' => [],
434 'target' => [],
435 'rel' => [],
436 ],
437 ]
438 ),
439 esc_url( get_permalink( $this->campaign_data['id'] ) )
440 );
441
442 $content .= '</p>';
443
444 $content .= '<p>' . esc_html__( 'Some functionality and elements (like donation buttons, forms, social sharing, etc.) are disabled.', 'charitable' ) . '</p>';
445
446 } else {
447
448 $content .= '<p>';
449
450 $content .= sprintf(
451 wp_kses(
452 /* translators: %s - Charitable doc link. */
453 __( 'This is a preview of the latest saved revision of your campaign page. If this does not match your campaign page in the campaign builder, save your changes and then refresh this page. Your current theme can also effect the look of this page.', 'charitable' ),
454 [
455 'a' => [
456 'href' => [],
457 'target' => [],
458 'rel' => [],
459 ],
460 ]
461 ),
462 esc_url( get_permalink( $this->campaign_data['id'] ) )
463 );
464
465 $content .= '</p>';
466
467 $content .= '<p>' . esc_html__( 'Some functionality and elements (like donation buttons, forms, social sharing, etc.) are disabled.', 'charitable' ) . '</p>';
468
469 }
470
471 array_unshift(
472 $links,
473 [
474 'url' => esc_url(
475 get_permalink( $this->campaign_data['id'] )
476 ),
477 'text' => esc_html__( 'View Live Campaign', 'charitable' ),
478 ]
479 );
480
481 elseif ( 'draft' === get_post_status( $this->campaign_data['id'] ) ) :
482
483 $content .= '<p>';
484
485 $content .= sprintf(
486 wp_kses(
487 /* translators: %s - Charitable doc link. */
488 __( 'This is a preview of the latest saved revision of your campaign page. If this does not match your campaign page in the campaign builder, save your changes and then refresh this page. Your current theme can also effect the look of this page.', 'charitable' ),
489 [
490 'a' => [
491 'href' => [],
492 'target' => [],
493 'rel' => [],
494 ],
495 ]
496 ),
497 esc_url( get_permalink( $this->campaign_data['id'] ) )
498 );
499
500 $content .= '</p>';
501
502 $content .= '<p>' . esc_html__( 'Some functionality and elements (like donation buttons, forms, social sharing, etc.) are disabled.', 'charitable' ) . '</p>';
503
504 endif;
505
506 if ( ! empty( $links ) ) {
507 $content .= '<span class="charitable-preview-notice-links">';
508
509 foreach ( $links as $key => $link ) {
510 $content .= '<a href="' . $link['url'] . '">' . $link['text'] . '</a>';
511 $l = array_keys( $links );
512
513 if ( end( $l ) !== $key ) {
514 $content .= ' <span style="display:inline-block;margin:0 6px;opacity: 0.5">|</span> ';
515 }
516 }
517
518 $content .= '</span>';
519 }
520 $content .= '</p>';
521
522 $content .= '</div>';
523
524 // If there is no campaign ID in the campaign_data, this MIGHT be an initial preview on a
525 // new campaign, in which case the ID is only in the URL. Same resolution the excerpt guard
526 // above uses, so the two cannot disagree about which campaign is being previewed.
527 $campaign_id = $this->get_preview_campaign_id();
528
529 // Mark a render as in progress for every instance of this class, not just this one, so a
530 // re-application of the_content from inside the render short-circuits at the guard above.
531 // This is the load-bearing part; the remove_filter() below is belt and braces that only
532 // covers this instance's own callback.
533 // Detach this handler from the content filters before rendering the campaign.
534 // The campaign render re-applies the_content (via the campaign's get_content), and
535 // with certain content-processing plugins active that re-application would re-enter
536 // this method and render the campaign again, looping until PHP's memory limit is
537 // exhausted (HelpScout #11576). The campaign endpoint already guards its own render
538 // the same way. The finally re-adds the filters unconditionally, so even if the
539 // render throws they are never left detached for the rest of the request.
540 remove_filter( 'the_content', [ $this, 'the_content' ], 999 );
541 remove_filter( 'get_the_excerpt', [ $this, 'the_content' ], 999 );
542
543 try {
544 // Inside the try so the finally below always clears it. Set outside, a throw between
545 // the assignment and the try would leave every later application short-circuiting.
546 self::$is_rendering = true;
547
548 $content .= do_shortcode( '[campaign version="2" id=' . $campaign_id . ']' );
549 } finally {
550 self::$is_rendering = false;
551 add_filter( 'the_content', [ $this, 'the_content' ], 999 );
552 // The 2 matters. Re-adding without it silently downgrades this hook to one argument
553 // for the rest of the request, so the excerpt guard above would stop receiving the
554 // WP_Post and would go back to answering every excerpt with the campaign render.
555 add_filter( 'get_the_excerpt', [ $this, 'the_content' ], 999, 2 );
556 }
557
558 return $content;
559 }
560
561 /**
562 * Force page template types.
563 *
564 * @since 1.8.0
565 *
566 * @param array $templates A list of template candidates, in descending order of priority.
567 *
568 * @return array
569 */
570 public function force_page_template_hierarchy( $templates ) { // phpcs:ignore
571
572 return [ 'page.php', 'single.php', 'index.php' ];
573 }
574
575 /**
576 * Adjust value of the {page_title} smart tag.
577 *
578 * @since 1.7.7
579 *
580 * @param string $content Content.
581 * @param array $campaign_data Campaign data.
582 * @param array $fields List of fields.
583 * @param string $entry_id Entry ID.
584 * @param object $smart_tag_object The smart tag object or the Generic object for those cases when class unregistered.
585 *
586 * @return string
587 */
588 public function smart_tags_process_page_title_value( $content, $campaign_data, $fields, $entry_id, $smart_tag_object ) { // phpcs:ignore
589
590 return sprintf( /* translators: %s - campaign title. */
591 esc_html__( '%s Preview', 'charitable' ),
592 ! empty( $campaign_data['settings']['campaign_title'] ) ? sanitize_text_field( $campaign_data['settings']['campaign_title'] ) : esc_html__( 'Campaign', 'charitable' )
593 );
594 }
595
596 /**
597 * Force page template types.
598 *
599 * @since 1.5.1
600 * @deprecated 1.8.0
601 *
602 * @return string
603 */
604 public function template_include() {
605
606 _deprecated_function( __METHOD__, '1.8.0 of the Charitable plugin' );
607
608 return locate_template( [ 'page.php', 'single.php', 'index.php' ] );
609 }
610 }
611
612 endif;
613
614 new Campaign_Builder_Preview();
615