← All changes
|
includes/shortcodes/class-charitable-campaign-shortcode.php
+100
-1
1.8.12.1
→
1.8.13
View file →
| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | * @copyright Copyright (c) 2023, WP Charitable LLC |
| 8 | 8 | * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License |
| 9 | 9 | * @since 1.8.0 |
| 10 | 10 | * @version. 1.8.0 |
| 11 | + * @version 1.8.12.2 Add an infinite-recursion guard so a self-referencing [campaign] shortcode cannot exhaust memory. | |
| 11 | 12 | */ |
| 12 | 13 | |
| 13 | 14 | // Exit if accessed directly. |
| 14 | 15 | if ( ! defined( 'ABSPATH' ) ) { |
| @@ -29,8 +30,36 @@ | ||
| 29 | 30 | */ |
| 30 | 31 | private $campaign_data; |
| 31 | 32 | |
| 32 | 33 | /** |
| 34 | + * Campaign IDs that are currently mid-render (the recursion stack). | |
| 35 | + * | |
| 36 | + * Used to stop a campaign from rendering itself forever. A campaign whose | |
| 37 | + * content embeds a [campaign] shortcode pointing back at the same campaign | |
| 38 | + * (directly, or via a mutual A->B->A reference) would otherwise re-enter | |
| 39 | + * self::display() on every level, nesting an output buffer each time until | |
| 40 | + * PHP's memory limit is exhausted and the request fatals. Keyed by campaign | |
| 41 | + * ID for O(1) lookups; a set (not a bool) so mutual references are caught too. | |
| 42 | + * | |
| 43 | + * @since 1.8.12.2 | |
| 44 | + * | |
| 45 | + * @var array<int,bool> | |
| 46 | + */ | |
| 47 | + private static $rendering = array(); | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * Whether the given campaign is already being rendered further up the stack. | |
| 51 | + * | |
| 52 | + * @since 1.8.12.2 | |
| 53 | + * | |
| 54 | + * @param int $campaign_id The campaign ID. | |
| 55 | + * @return bool | |
| 56 | + */ | |
| 57 | + public static function is_rendering( $campaign_id ) { | |
| 58 | + return isset( self::$rendering[ (int) $campaign_id ] ); | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** | |
| 33 | 62 | * Display the shortcode output. This is the callback method for the campaigns shortcode. |
| 34 | 63 | * |
| 35 | 64 | * @since 1.8.0 |
| 36 | 65 | * |
| @@ -44,8 +73,64 @@ | ||
| 44 | 73 | ); |
| 45 | 74 | |
| 46 | 75 | $args = shortcode_atts( $default, $atts, 'campaign' ); |
| 47 | 76 | |
| 77 | + // Guard against infinite recursion. If this campaign is already being rendered | |
| 78 | + // higher up the stack, a field inside it embeds a [campaign] shortcode that points | |
| 79 | + // back at the same campaign (directly, or via a mutual A->B->A reference). Rendering | |
| 80 | + // it again here would loop until the memory limit is exhausted, fatalling the request. | |
| 81 | + // Bail with a harmless placeholder. | |
| 82 | + $recursion_guard_id = absint( $args['id'] ); | |
| 83 | + | |
| 84 | + /* | |
| 85 | + * Defence in depth for the id-less form, which absint( '' ) would otherwise | |
| 86 | + * reduce to 0 and exclude from both the check below and the flagging further | |
| 87 | + * down. | |
| 88 | + * | |
| 89 | + * A bare [campaign] does resolve to the current post -- get_campaign() passes | |
| 90 | + * the empty string through to charitable_get_campaign(), and WP's get_post() | |
| 91 | + * falls back to $GLOBALS['post'] on an empty argument (verified: it returns | |
| 92 | + * the surrounding campaign). It cannot recurse today only because | |
| 93 | + * get_campaign_data() reads campaign_settings_v2 via get_post_meta( '' ), | |
| 94 | + * which gets no such fallback and returns empty, so no fields render and no | |
| 95 | + * nested shortcode ever executes -- a bare [campaign] emits an empty wrapper. | |
| 96 | + * | |
| 97 | + * That makes this belt and braces rather than a live fix: the only thing | |
| 98 | + * preventing the loop is an asymmetry between get_post() and get_post_meta(). | |
| 99 | + * Give get_campaign_data() the same fallback and the vector opens, so resolve | |
| 100 | + * the ID the guard would need before that can happen. | |
| 101 | + */ | |
| 102 | + if ( ! $recursion_guard_id ) { | |
| 103 | + $recursion_guard_id = absint( get_the_ID() ); | |
| 104 | + } | |
| 105 | + | |
| 106 | + if ( $recursion_guard_id && self::is_rendering( $recursion_guard_id ) ) { | |
| 107 | + | |
| 108 | + // Invisible in the rendered page, but leaves a trace in the source for debugging. | |
| 109 | + $notice = '<!-- charitable: skipped self-referencing [campaign] shortcode for campaign ' . $recursion_guard_id . ' to prevent an infinite render loop. -->'; | |
| 110 | + | |
| 111 | + // In the builder preview / admin, surface a visible notice so the author understands | |
| 112 | + // why the embedded campaign did not render, instead of silently dropping it. | |
| 113 | + $is_preview = is_admin() || ! empty( $_GET['charitable_campaign_preview'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 114 | + | |
| 115 | + if ( $is_preview ) { | |
| 116 | + $notice .= '<div class="charitable-notice charitable-notice-info"><p style="margin:0;">' . | |
| 117 | + esc_html__( 'This campaign embeds a shortcode that points back at itself, so it was not rendered here to prevent an infinite loop. Remove or change that shortcode field.', 'charitable' ) . | |
| 118 | + '</p></div>'; | |
| 119 | + } | |
| 120 | + | |
| 121 | + /** | |
| 122 | + * Filter the placeholder returned when a self-referencing [campaign] shortcode is skipped. | |
| 123 | + * | |
| 124 | + * @since 1.8.12.2 | |
| 125 | + * | |
| 126 | + * @param string $notice The placeholder markup. | |
| 127 | + * @param int $campaign_id The campaign ID that would have recursed. | |
| 128 | + * @param array $args The parsed shortcode arguments. | |
| 129 | + */ | |
| 130 | + return apply_filters( 'charitable_campaign_shortcode_recursion_notice', $notice, $recursion_guard_id, $args ); | |
| 131 | + } | |
| 132 | + | |
| 48 | 133 | // Get the campaign data from the ID that should be passed along in the shortcode. |
| 49 | 134 | $args['campaign'] = self::get_campaign( $args ); |
| 50 | 135 | $args['campaign_data'] = self::get_campaign_data( $args ); |
| 51 | 136 | |
| @@ -90,9 +175,23 @@ | ||
| 90 | 175 | $template->set_view_args( $view_args ); |
| 91 | 176 | |
| 92 | 177 | ob_start(); |
| 93 | 178 | |
| 94 | - $template->render(); | |
| 179 | + try { | |
| 180 | + // Flag this campaign as rendering so any nested [campaign] shortcode that points | |
| 181 | + // back at it short-circuits in the recursion guard above instead of looping. The | |
| 182 | + // matching unset in the finally below runs even if rendering throws, so a campaign | |
| 183 | + // is never left flagged as rendering for the rest of the request. | |
| 184 | + if ( $recursion_guard_id ) { | |
| 185 | + self::$rendering[ $recursion_guard_id ] = true; | |
| 186 | + } | |
| 187 | + | |
| 188 | + $template->render(); | |
| 189 | + } finally { | |
| 190 | + if ( $recursion_guard_id ) { | |
| 191 | + unset( self::$rendering[ $recursion_guard_id ] ); | |
| 192 | + } | |
| 193 | + } | |
| 95 | 194 | |
| 96 | 195 | $html = ob_get_clean(); |
| 97 | 196 | |
| 98 | 197 | // If the $args['elementor'] is true, then replace the campaign-loop with the elementor campaign-loop. |