| 1 |
<?php |
| 2 |
|
| 3 |
|
| 4 |
namespace Jet_Form_Builder\Form_Messages; |
| 5 |
|
| 6 |
use Jet_Form_Builder\Classes\Tools; |
| 7 |
use JFB_Modules\Rich_Content\Macros_Parser; |
| 8 |
use JFB_Modules\Rich_Content\Module as Rich_Content_Module; |
| 9 |
|
| 10 |
// If this file is called directly, abort. |
| 11 |
if ( ! defined( 'WPINC' ) ) { |
| 12 |
die; |
| 13 |
} |
| 14 |
|
| 15 |
class Message_Content_Processor { |
| 16 |
|
| 17 |
/** |
| 18 |
* @var Rich_Content_Module |
| 19 |
*/ |
| 20 |
private $rich_content; |
| 21 |
|
| 22 |
/** |
| 23 |
* @param Rich_Content_Module|null $rich_content |
| 24 |
*/ |
| 25 |
public function __construct( $rich_content = null ) { |
| 26 |
$this->rich_content = $rich_content instanceof Rich_Content_Module |
| 27 |
? $rich_content |
| 28 |
: new Rich_Content_Module(); |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Process a message template loaded from saved form/action settings. |
| 33 |
* |
| 34 |
* Runtime values are always treated as data. Only active syntax present in the |
| 35 |
* original template is interpreted, and preset/shortcode modes are mutually |
| 36 |
* exclusive to prevent cross-parser injection. |
| 37 |
*/ |
| 38 |
public function prepare_trusted( string $message, array $replacements = array() ): string { |
| 39 |
if ( $this->is_dynamic_preset_template( $message ) ) { |
| 40 |
$content = $this->rich_content->apply_dynamic_preset( $message ); |
| 41 |
} else { |
| 42 |
$runtime_values = array(); |
| 43 |
$content = $this->tokenize_macros( $message, $replacements, $runtime_values ); |
| 44 |
$content = $this->apply_shortcodes( $content, $runtime_values ); |
| 45 |
$content = $this->restore_runtime_values( $content, $runtime_values ); |
| 46 |
} |
| 47 |
|
| 48 |
return $this->finalize( $content ); |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* Process a dynamic or unknown status body. |
| 53 |
* |
| 54 |
* The body may originate in the request or an external action, so it must not |
| 55 |
* enter any active parser. |
| 56 |
*/ |
| 57 |
public function prepare_untrusted( string $message ): string { |
| 58 |
return $this->finalize( $message ); |
| 59 |
} |
| 60 |
|
| 61 |
public function prepare_macros( string $message, array $replacements = array() ): string { |
| 62 |
$runtime_values = array(); |
| 63 |
$content = $this->tokenize_macros( $message, $replacements, $runtime_values ); |
| 64 |
|
| 65 |
return $this->restore_runtime_values( $content, $runtime_values ); |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Replace every runtime macro with a unique token before shortcode parsing. |
| 70 |
* |
| 71 |
* A token is bound to its value only after WordPress has fixed the shortcode |
| 72 |
* tag and attribute structure. This prevents values from completing a tag name |
| 73 |
* or escaping a quoted attribute while preserving macros used as attribute data. |
| 74 |
*/ |
| 75 |
private function tokenize_macros( |
| 76 |
string $message, |
| 77 |
array $replacements, |
| 78 |
array &$runtime_values |
| 79 |
): string { |
| 80 |
$parser = new Macros_Parser(); |
| 81 |
$content = preg_replace_callback( |
| 82 |
'/%([\w\-].*?\S?)%/', |
| 83 |
function ( array $match ) use ( $parser, $replacements, $message, &$runtime_values ) { |
| 84 |
$token = $this->generate_runtime_token( $message, $runtime_values ); |
| 85 |
$value = $parser->parse_macros( $match[0], $replacements ); |
| 86 |
|
| 87 |
$runtime_values[ $token ] = $this->neutralize_shortcodes( $value ); |
| 88 |
|
| 89 |
return $token; |
| 90 |
}, |
| 91 |
$message |
| 92 |
); |
| 93 |
|
| 94 |
return is_string( $content ) ? $content : ''; |
| 95 |
} |
| 96 |
|
| 97 |
private function generate_runtime_token( string $message, array $runtime_values ): string { |
| 98 |
do { |
| 99 |
$token = 'jfbmacro' . str_replace( '-', '', wp_generate_uuid4() ); |
| 100 |
} while ( false !== strpos( $message, $token ) || isset( $runtime_values[ $token ] ) ); |
| 101 |
|
| 102 |
return $token; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Execute shortcodes while runtime values remain outside shortcode grammar. |
| 107 |
* |
| 108 |
* WordPress has already parsed the tag and its attributes when this late |
| 109 |
* pre_do_shortcode_tag callback runs. The callback therefore receives restored |
| 110 |
* values as data, not as source text that can add tags or attributes. |
| 111 |
*/ |
| 112 |
private function apply_shortcodes( string $content, array $runtime_values ): string { |
| 113 |
if ( ! $runtime_values ) { |
| 114 |
return $this->rich_content->apply_shortcodes( $content ); |
| 115 |
} |
| 116 |
|
| 117 |
$interceptor = function ( $return, $tag, $attributes, $match ) use ( $runtime_values ) { |
| 118 |
if ( false !== $return || ! $this->contains_runtime_token( $match, $runtime_values ) ) { |
| 119 |
return $return; |
| 120 |
} |
| 121 |
|
| 122 |
global $shortcode_tags; |
| 123 |
|
| 124 |
if ( empty( $shortcode_tags[ $tag ] ) || ! is_callable( $shortcode_tags[ $tag ] ) ) { |
| 125 |
return $return; |
| 126 |
} |
| 127 |
|
| 128 |
$resolved_attributes = $this->restore_callback_values( $attributes, $runtime_values ); |
| 129 |
$content = $match[5] ?? null; |
| 130 |
$resolved_content = is_null( $content ) |
| 131 |
? null |
| 132 |
: $this->restore_callback_values( $content, $runtime_values ); |
| 133 |
$output = $match[1] . call_user_func( |
| 134 |
$shortcode_tags[ $tag ], |
| 135 |
$resolved_attributes, |
| 136 |
$resolved_content, |
| 137 |
$tag |
| 138 |
) . $match[6]; |
| 139 |
|
| 140 |
// Core skips do_shortcode_tag after a pre_do_shortcode_tag short circuit. |
| 141 |
return apply_filters( |
| 142 |
'do_shortcode_tag', |
| 143 |
$output, |
| 144 |
$tag, |
| 145 |
$resolved_attributes, |
| 146 |
$match |
| 147 |
); |
| 148 |
}; |
| 149 |
|
| 150 |
add_filter( 'pre_do_shortcode_tag', $interceptor, PHP_INT_MAX, 4 ); |
| 151 |
|
| 152 |
try { |
| 153 |
return $this->rich_content->apply_shortcodes( $content ); |
| 154 |
} finally { |
| 155 |
remove_filter( 'pre_do_shortcode_tag', $interceptor, PHP_INT_MAX ); |
| 156 |
} |
| 157 |
} |
| 158 |
|
| 159 |
private function contains_runtime_token( $value, array $runtime_values ): bool { |
| 160 |
if ( is_array( $value ) ) { |
| 161 |
foreach ( $value as $item ) { |
| 162 |
if ( $this->contains_runtime_token( $item, $runtime_values ) ) { |
| 163 |
return true; |
| 164 |
} |
| 165 |
} |
| 166 |
|
| 167 |
return false; |
| 168 |
} |
| 169 |
|
| 170 |
if ( ! is_string( $value ) ) { |
| 171 |
return false; |
| 172 |
} |
| 173 |
|
| 174 |
foreach ( $runtime_values as $token => $runtime_value ) { |
| 175 |
if ( false !== strpos( $value, $token ) ) { |
| 176 |
return true; |
| 177 |
} |
| 178 |
} |
| 179 |
|
| 180 |
return false; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* Restore tokens only in parsed values, never in shortcode tag/attribute names. |
| 185 |
* |
| 186 |
* If restoration would assemble another registered shortcode inside a value, |
| 187 |
* keep that value tokenized until the outer shortcode has finished. It is then |
| 188 |
* restored as text and neutralized by finalize(). |
| 189 |
*/ |
| 190 |
private function restore_callback_values( $value, array $runtime_values ) { |
| 191 |
if ( is_array( $value ) ) { |
| 192 |
foreach ( $value as $key => $item ) { |
| 193 |
$value[ $key ] = $this->restore_callback_values( $item, $runtime_values ); |
| 194 |
} |
| 195 |
|
| 196 |
return $value; |
| 197 |
} |
| 198 |
|
| 199 |
if ( ! is_string( $value ) ) { |
| 200 |
return $value; |
| 201 |
} |
| 202 |
|
| 203 |
$resolved = $this->restore_runtime_values( $value, $runtime_values ); |
| 204 |
|
| 205 |
return $this->introduces_registered_shortcode( $value, $resolved ) ? $value : $resolved; |
| 206 |
} |
| 207 |
|
| 208 |
private function restore_runtime_values( string $content, array $runtime_values ): string { |
| 209 |
return $runtime_values ? strtr( $content, $runtime_values ) : $content; |
| 210 |
} |
| 211 |
|
| 212 |
private function introduces_registered_shortcode( string $before, string $after ): bool { |
| 213 |
$before_tags = $this->get_registered_shortcode_counts( $before ); |
| 214 |
$after_tags = $this->get_registered_shortcode_counts( $after ); |
| 215 |
|
| 216 |
foreach ( $after_tags as $tag => $count ) { |
| 217 |
if ( $count > ( $before_tags[ $tag ] ?? 0 ) ) { |
| 218 |
return true; |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
return false; |
| 223 |
} |
| 224 |
|
| 225 |
private function get_registered_shortcode_counts( string $content ): array { |
| 226 |
global $shortcode_tags; |
| 227 |
|
| 228 |
if ( false === strpos( $content, '[' ) || empty( $shortcode_tags ) ) { |
| 229 |
return array(); |
| 230 |
} |
| 231 |
|
| 232 |
preg_match_all( |
| 233 |
'@\[([^<>&/\[\]\x00-\x20=]++)@', |
| 234 |
$content, |
| 235 |
$matches |
| 236 |
); |
| 237 |
|
| 238 |
$tags = array_intersect( array_keys( $shortcode_tags ), $matches[1] ?? array() ); |
| 239 |
|
| 240 |
return array_count_values( $tags ); |
| 241 |
} |
| 242 |
|
| 243 |
private function is_dynamic_preset_template( string $message ): bool { |
| 244 |
$decoded = json_decode( $message, true ); |
| 245 |
|
| 246 |
return is_array( $decoded ) && ! empty( $decoded['jet_preset'] ); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* Keep literal brackets visible while preventing this value from introducing |
| 251 |
* shortcode syntax into a trusted template. |
| 252 |
*/ |
| 253 |
private function neutralize_shortcodes( string $content ): string { |
| 254 |
return str_replace( |
| 255 |
array( '[', ']' ), |
| 256 |
array( '[', ']' ), |
| 257 |
$content |
| 258 |
); |
| 259 |
} |
| 260 |
|
| 261 |
private function finalize( $content ): string { |
| 262 |
$content = Tools::to_string( $content ); |
| 263 |
$content = wp_kses_post( $content ); |
| 264 |
|
| 265 |
return $this->neutralize_shortcodes( $content ); |
| 266 |
} |
| 267 |
} |
| 268 |
|