PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / Campaign / MetaBox.php

MetaBox.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/Campaign/MetaBox.php

416 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Campaign;
4
5 use Better_Payment\Lite\Controller;
6 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Handles saving and reading _bpc_* post meta for bp_campaign posts.
14 * The builder save path goes through the REST API (CampaignAPI).
15 * This class handles the rare case of direct post saves (e.g., autosave, bulk actions).
16 */
17 class MetaBox extends Controller {
18
19 /**
20 * Meta fields and their sanitize callbacks.
21 */
22 private static function meta_fields(): array {
23 return apply_filters( 'better_payment/campaign/meta_fields', [
24 '_bpc_goal_amount' => 'floatval',
25 '_bpc_end_date' => 'sanitize_text_field',
26 '_bpc_suggested_amounts' => [ self::class, 'sanitize_amounts_array' ],
27 '_bpc_allow_custom_amount' => 'absint',
28 '_bpc_minimum_amount' => 'floatval',
29 '_bpc_form_page_id' => 'absint',
30 '_bpc_fields_layout' => [ self::class, 'sanitize_json' ],
31 '_bpc_status' => 'sanitize_text_field',
32 '_bpc_color_primary' => 'sanitize_hex_color',
33 '_bpc_color_background' => 'sanitize_hex_color',
34 '_bpc_css_class' => 'sanitize_html_class',
35 '_bpc_template_key' => 'sanitize_text_field',
36 ] );
37 }
38
39 /**
40 * Save post meta on save_post_bp_campaign.
41 */
42 public function save( int $post_id ) {
43 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
44 return;
45 }
46
47 if ( ! current_user_can( 'edit_post', $post_id ) ) {
48 return;
49 }
50
51 // Only handle REST saves that explicitly post _bpc nonce; direct post saves are rare.
52 if ( ! isset( $_POST['bpc_meta_nonce'] ) ) {
53 return;
54 }
55
56 if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['bpc_meta_nonce'] ) ), 'bpc_save_meta_' . $post_id ) ) {
57 return;
58 }
59
60 foreach ( self::meta_fields() as $key => $sanitize ) {
61 if ( ! isset( $_POST[ $key ] ) ) {
62 continue;
63 }
64
65 $value = wp_unslash( $_POST[ $key ] );
66 $value = is_callable( $sanitize ) ? call_user_func( $sanitize, $value ) : $value;
67
68 if ( '_bpc_fields_layout' === $key ) {
69 $value = self::enforce_pro_entitlement( $post_id, $value );
70 }
71
72 update_post_meta( $post_id, $key, $value );
73 }
74 }
75
76 /**
77 * Save meta directly from an array (used by CampaignAPI).
78 */
79 public static function save_from_array( int $post_id, array $data ) {
80 foreach ( self::meta_fields() as $key => $sanitize ) {
81 $short_key = ltrim( $key, '_' );
82
83 if ( array_key_exists( $short_key, $data ) ) {
84 $raw = $data[ $short_key ];
85 $value = is_callable( $sanitize ) ? call_user_func( $sanitize, $raw ) : sanitize_text_field( $raw );
86
87 if ( '_bpc_fields_layout' === $key ) {
88 $value = self::enforce_pro_entitlement( $post_id, $value );
89 }
90
91 // wp_unslash() is applied inside update_metadata, which strips backslashes from strings.
92 // wp_slash() pre-escapes so the round-trip leaves the value intact (standard WP REST API pattern).
93 if ( is_string( $value ) ) {
94 $value = wp_slash( $value );
95 }
96 update_post_meta( $post_id, $key, $value );
97 }
98 }
99 }
100
101 /**
102 * Read all meta for a campaign post as a plain array.
103 */
104 public static function get_all( int $post_id ): array {
105 $result = [];
106 foreach ( array_keys( self::meta_fields() ) as $key ) {
107 $short = ltrim( $key, '_' );
108 $result[ $short ] = get_post_meta( $post_id, $key, true );
109 }
110
111 // Cast integer/float meta so JS receives 0/1 (not "0"/"1" strings) —
112 // get_post_meta always returns strings; "0" is truthy in JS which breaks boolean checks.
113 $result['bpc_allow_custom_amount'] = (int) ( $result['bpc_allow_custom_amount'] ?? 1 );
114 $result['bpc_goal_amount'] = (float) ( $result['bpc_goal_amount'] ?? 0 );
115 $result['bpc_minimum_amount'] = '' !== ( $result['bpc_minimum_amount'] ?? '' )
116 ? (float) $result['bpc_minimum_amount']
117 : '';
118 $result['bpc_form_page_id'] = (int) ( $result['bpc_form_page_id'] ?? 0 );
119 $result['bpc_css_class'] = (string) ( $result['bpc_css_class'] ?? '' );
120
121 // Parse JSON fields.
122 if ( ! empty( $result['bpc_fields_layout'] ) && is_string( $result['bpc_fields_layout'] ) ) {
123 $decoded = json_decode( $result['bpc_fields_layout'], true );
124 $result['bpc_fields_layout'] = is_array( $decoded ) ? $decoded : [];
125 }
126
127 // Parse suggested amounts — stored as JSON array of {id,amount,description,is_default}.
128 $sa = $result['bpc_suggested_amounts'] ?? '';
129 if ( is_string( $sa ) && '' !== $sa ) {
130 $decoded = json_decode( $sa, true );
131 if ( is_array( $decoded ) ) {
132 $result['bpc_suggested_amounts'] = $decoded;
133 } else {
134 // Legacy comma-separated — migrate on read.
135 $parts = array_filter( array_map( 'trim', explode( ',', $sa ) ) );
136 $result['bpc_suggested_amounts'] = array_values( array_map(
137 function ( $amount, $i ) {
138 return [
139 'id' => 'sa_' . ( $i + 1 ),
140 'amount' => (string) floatval( $amount ),
141 'description' => '',
142 'is_default' => false,
143 ];
144 },
145 $parts,
146 array_keys( $parts )
147 ) );
148 }
149 } else {
150 $result['bpc_suggested_amounts'] = [];
151 }
152
153 return $result;
154 }
155
156 // ------------------------------------------------------------------ helpers
157
158 public static function sanitize_amounts_array( $value ): string {
159 if ( is_array( $value ) ) {
160 // PHP array from REST API JSON body — sanitize each item and re-encode.
161 $clean = array_values( array_map( function ( $item ) {
162 return [
163 'id' => sanitize_text_field( $item['id'] ?? '' ),
164 'amount' => (string) floatval( $item['amount'] ?? 0 ),
165 'description' => sanitize_text_field( $item['description'] ?? '' ),
166 'is_default' => ! empty( $item['is_default'] ),
167 ];
168 }, $value ) );
169 return wp_json_encode( $clean );
170 }
171 if ( is_string( $value ) ) {
172 $decoded = json_decode( $value, true );
173 if ( is_array( $decoded ) ) {
174 return self::sanitize_amounts_array( $decoded );
175 }
176 // Legacy comma-separated — migrate.
177 $parts = array_filter( array_map( 'trim', explode( ',', $value ) ) );
178 if ( ! empty( $parts ) ) {
179 $items = array_values( array_map( function ( $a, $i ) {
180 return [
181 'id' => 'sa_' . ( $i + 1 ),
182 'amount' => (string) floatval( $a ),
183 'description' => '',
184 'is_default' => false,
185 ];
186 }, $parts, array_keys( $parts ) ) );
187 return wp_json_encode( $items );
188 }
189 return '[]';
190 }
191 return '[]';
192 }
193
194 /**
195 * Refuse changes to Pro-element settings from a site without Pro.
196 *
197 * The builder shows a locked Pro element's upgrade banner instead of its
198 * controls, but that is a courtesy, not a control — nothing stops a crafted
199 * `POST` to the REST route or the classic editor (or an AI turn) from carrying
200 * arbitrary `donors_wall` settings. This is the actual enforcement.
201 *
202 * **Existing values are preserved, never stripped.** The tempting fix is to
203 * delete Pro elements (or blank their settings) on save, but a user whose
204 * licence lapsed for a week would come back to find their configuration
205 * destroyed by an autosave. Instead:
206 *
207 * - element exists already → its stored settings are restored verbatim, so
208 * the campaign is byte-identical on the Pro elements and reactivating Pro
209 * brings everything back exactly as it was;
210 * - element is new → its settings are reset to the registered defaults, so a
211 * free user can't hand-craft values for an element they can't configure.
212 * A free install registers only a lock stub
213 * ({@see \Better_Payment\Lite\Campaign\Elements\ProElementStubs}), whose
214 * defaults are empty, so a new Pro element saves with `[]` settings.
215 *
216 * **"Exists already" is decided in two passes, and the second one is the one
217 * that protects the user.** An element id is not a stable identity: an AI
218 * whole-page edit (`set_layout`), an undo to a snapshot from before a save, or
219 * any client that re-mints ids hands back the SAME FAQ under a NEW id. Matching
220 * by id alone made that FAQ "new", saved it as `[]`, and destroyed the
221 * configuration this guard exists to keep — permanently, since reactivating Pro
222 * has nothing left to restore. So:
223 *
224 * 1. an incoming Pro element whose id AND type match a stored one takes that
225 * element's settings;
226 * 2. every other incoming Pro element takes the settings of the next stored
227 * element of the SAME type that step 1 did not claim, in document order;
228 * 3. only when no unclaimed stored element of that type remains is it new.
229 *
230 * Settings never cross types, and each stored element is claimed at most once,
231 * so an extra Pro element beyond what was stored still gets the defaults. The
232 * values restored always come from storage, never from the request.
233 *
234 * Structure (order, position, insertion, deletion) is deliberately NOT
235 * guarded — moving or removing a leftover Pro element is legitimate, and the
236 * canvas element toolbar's delete action is there for exactly that.
237 *
238 * Structure (order, position, insertion, deletion) is deliberately NOT
239 * guarded — moving or removing a leftover Pro element is legitimate, and the
240 * canvas element toolbar's delete action is there for exactly that.
241 *
242 * No-ops entirely when Pro is active. Entitlement is read live from
243 * `better_payment/pro_enabled` on every save, so a previously-saved campaign
244 * grants nothing.
245 *
246 * @param int $post_id Campaign post ID.
247 * @param mixed $layout_json Sanitized layout JSON about to be stored. Typed
248 * loosely because the meta_fields() sanitizer is
249 * filterable — a third party can swap in one that
250 * returns something else.
251 * @return string Layout JSON with Pro-element settings enforced.
252 */
253 public static function enforce_pro_entitlement( int $post_id, $layout_json ): string {
254 if ( ! is_string( $layout_json ) ) {
255 return '[]';
256 }
257
258 if ( apply_filters( 'better_payment/pro_enabled', false ) ) {
259 return $layout_json;
260 }
261
262 $incoming = json_decode( $layout_json, true );
263 if ( ! is_array( $incoming ) ) {
264 return $layout_json;
265 }
266
267 $locked = [];
268 foreach ( ElementRegistry::get_all() as $type => $schema ) {
269 if ( ! empty( $schema['pro'] ) ) {
270 $locked[ $type ] = isset( $schema['defaultSettings'] ) && is_array( $schema['defaultSettings'] )
271 ? $schema['defaultSettings']
272 : [];
273 }
274 }
275
276 if ( empty( $locked ) ) {
277 return $layout_json;
278 }
279
280 $stored = json_decode( (string) get_post_meta( $post_id, '_bpc_fields_layout', true ), true );
281 $pool = self::collect_locked_elements( is_array( $stored ) ? $stored : [], $locked );
282 $claims = self::collect_locked_elements( $incoming, $locked );
283 $resolved = self::resolve_locked_settings( $claims, $pool, $locked );
284
285 $ordinal = 0;
286 $guarded = self::restore_locked_settings( $incoming, $locked, $resolved, $ordinal );
287
288 $encoded = wp_json_encode( $guarded );
289
290 return is_string( $encoded ) ? $encoded : $layout_json;
291 }
292
293 /**
294 * Every locked (Pro) element in a layout, in document order.
295 *
296 * Walks recursively rather than assuming `columns[].elements[]` so the legacy
297 * flat-array layout format is covered too. It stops at a locked element, and
298 * {@see self::restore_locked_settings()} walks identically, so the Nth entry
299 * here is the Nth element that walk rewrites.
300 *
301 * @param array $node
302 * @param array<string, array> $locked Type => default settings.
303 * @param array $acc
304 * @return array<int, array{id: string, type: string, settings: array}>
305 */
306 private static function collect_locked_elements( array $node, array $locked, array $acc = [] ): array {
307 if ( isset( $node['type'] ) && is_string( $node['type'] ) && isset( $locked[ $node['type'] ] ) ) {
308 $acc[] = [
309 'id' => isset( $node['id'] ) && is_scalar( $node['id'] ) ? (string) $node['id'] : '',
310 'type' => $node['type'],
311 'settings' => isset( $node['settings'] ) && is_array( $node['settings'] ) ? $node['settings'] : [],
312 ];
313
314 return $acc;
315 }
316
317 foreach ( $node as $child ) {
318 if ( is_array( $child ) ) {
319 $acc = self::collect_locked_elements( $child, $locked, $acc );
320 }
321 }
322
323 return $acc;
324 }
325
326 /**
327 * Decide the settings each incoming locked element is saved with.
328 *
329 * Pass 1 claims stored elements by id AND type. Pass 2 gives each still
330 * unresolved incoming element the next unclaimed stored element of the same
331 * type, in document order — the same element under a re-minted id (an AI
332 * `set_layout`, an undo, any client that regenerates ids). Whatever is left
333 * over is genuinely new and gets the registered defaults. A stored element is
334 * claimed at most once and never by another type.
335 *
336 * @param array<int, array{id: string, type: string, settings: array}> $incoming Locked elements being saved.
337 * @param array<int, array{id: string, type: string, settings: array}> $stored Locked elements already stored.
338 * @param array<string, array> $locked Type => default settings.
339 * @return array<int, array> Incoming ordinal => settings to save.
340 */
341 private static function resolve_locked_settings( array $incoming, array $stored, array $locked ): array {
342 $resolved = [];
343 $claimed = [];
344
345 foreach ( $incoming as $i => $element ) {
346 if ( '' === $element['id'] ) {
347 continue;
348 }
349 foreach ( $stored as $j => $candidate ) {
350 if ( ! isset( $claimed[ $j ] ) && $candidate['id'] === $element['id'] && $candidate['type'] === $element['type'] ) {
351 $resolved[ $i ] = $candidate['settings'];
352 $claimed[ $j ] = true;
353 break;
354 }
355 }
356 }
357
358 foreach ( $incoming as $i => $element ) {
359 if ( array_key_exists( $i, $resolved ) ) {
360 continue;
361 }
362
363 $resolved[ $i ] = $locked[ $element['type'] ];
364
365 foreach ( $stored as $j => $candidate ) {
366 if ( ! isset( $claimed[ $j ] ) && $candidate['type'] === $element['type'] ) {
367 $resolved[ $i ] = $candidate['settings'];
368 $claimed[ $j ] = true;
369 break;
370 }
371 }
372 }
373
374 return $resolved;
375 }
376
377 /**
378 * Replace the settings of every locked element with the ones resolved for it.
379 * Walks exactly like {@see self::collect_locked_elements()}; `$ordinal` counts
380 * the locked elements seen so far.
381 *
382 * @param array $node
383 * @param array<string, array> $locked Type => default settings.
384 * @param array<int, array> $resolved Ordinal => settings.
385 * @param int $ordinal Running locked-element counter.
386 * @return array
387 */
388 private static function restore_locked_settings( array $node, array $locked, array $resolved, int &$ordinal ): array {
389 if ( isset( $node['type'] ) && is_string( $node['type'] ) && isset( $locked[ $node['type'] ] ) ) {
390 $node['settings'] = $resolved[ $ordinal ] ?? $locked[ $node['type'] ];
391 ++$ordinal;
392
393 return $node;
394 }
395
396 foreach ( $node as $key => $child ) {
397 if ( is_array( $child ) ) {
398 $node[ $key ] = self::restore_locked_settings( $child, $locked, $resolved, $ordinal );
399 }
400 }
401
402 return $node;
403 }
404
405 public static function sanitize_json( $value ): string {
406 if ( is_array( $value ) ) {
407 return wp_json_encode( $value );
408 }
409 if ( is_string( $value ) ) {
410 $decoded = json_decode( $value, true );
411 return is_array( $decoded ) ? wp_json_encode( $decoded ) : '[]';
412 }
413 return '[]';
414 }
415 }
416