PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.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 1.0.5 1.0.6 All 66 releases
better-payment / includes / Campaign / MetaBox.php

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

331 lines 13.0 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 renders a Pro element's controls disabled, but a disabled input
198 * is a courtesy, not a control — nothing stops a crafted `POST` to the REST
199 * route or the classic editor from carrying arbitrary `donors_wall` settings.
200 * This is the actual enforcement; the greyed-out inputs are the UI for it.
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 schema defaults, so a
211 * free user can't hand-craft values for an element they can't configure.
212 *
213 * Structure (order, position, insertion, deletion) is deliberately NOT
214 * guarded — moving or removing a leftover Pro element is legitimate and the
215 * builder offers a Remove button for exactly that.
216 *
217 * No-ops entirely when Pro is active. Entitlement is read live from
218 * `better_payment/pro_enabled` on every save, so a previously-saved campaign
219 * grants nothing.
220 *
221 * @param int $post_id Campaign post ID.
222 * @param mixed $layout_json Sanitized layout JSON about to be stored. Typed
223 * loosely because the meta_fields() sanitizer is
224 * filterable — a third party can swap in one that
225 * returns something else.
226 * @return string Layout JSON with Pro-element settings enforced.
227 */
228 public static function enforce_pro_entitlement( int $post_id, $layout_json ): string {
229 if ( ! is_string( $layout_json ) ) {
230 return '[]';
231 }
232
233 if ( apply_filters( 'better_payment/pro_enabled', false ) ) {
234 return $layout_json;
235 }
236
237 $incoming = json_decode( $layout_json, true );
238 if ( ! is_array( $incoming ) ) {
239 return $layout_json;
240 }
241
242 $locked = [];
243 foreach ( ElementRegistry::get_all() as $type => $schema ) {
244 if ( ! empty( $schema['pro'] ) ) {
245 $locked[ $type ] = isset( $schema['defaultSettings'] ) && is_array( $schema['defaultSettings'] )
246 ? $schema['defaultSettings']
247 : [];
248 }
249 }
250
251 if ( empty( $locked ) ) {
252 return $layout_json;
253 }
254
255 $stored = json_decode( (string) get_post_meta( $post_id, '_bpc_fields_layout', true ), true );
256 $known = self::index_element_settings( is_array( $stored ) ? $stored : [] );
257
258 $guarded = self::restore_locked_settings( $incoming, $locked, $known );
259
260 $encoded = wp_json_encode( $guarded );
261
262 return is_string( $encoded ) ? $encoded : $layout_json;
263 }
264
265 /**
266 * Flatten a layout into `element id => settings` for every element it holds.
267 *
268 * Walks recursively rather than assuming `columns[].elements[]` so the legacy
269 * flat-array layout format is covered too.
270 *
271 * @param array $node
272 * @param array $acc
273 * @return array<string, array>
274 */
275 private static function index_element_settings( array $node, array $acc = [] ): array {
276 if ( isset( $node['id'], $node['type'] ) && is_scalar( $node['id'] ) ) {
277 $acc[ (string) $node['id'] ] = isset( $node['settings'] ) && is_array( $node['settings'] )
278 ? $node['settings']
279 : [];
280 }
281
282 foreach ( $node as $child ) {
283 if ( is_array( $child ) ) {
284 $acc = self::index_element_settings( $child, $acc );
285 }
286 }
287
288 return $acc;
289 }
290
291 /**
292 * Replace the settings of every locked element with the stored ones (or the
293 * schema defaults when the element is new). Mirrors the walk above.
294 *
295 * @param array $node
296 * @param array<string, array> $locked Type => default settings.
297 * @param array<string, array> $known Element id => stored settings.
298 * @return array
299 */
300 private static function restore_locked_settings( array $node, array $locked, array $known ): array {
301 if ( isset( $node['type'] ) && is_string( $node['type'] ) && isset( $locked[ $node['type'] ] ) ) {
302 $id = isset( $node['id'] ) && is_scalar( $node['id'] ) ? (string) $node['id'] : '';
303
304 $node['settings'] = ( '' !== $id && isset( $known[ $id ] ) )
305 ? $known[ $id ]
306 : $locked[ $node['type'] ];
307
308 return $node;
309 }
310
311 foreach ( $node as $key => $child ) {
312 if ( is_array( $child ) ) {
313 $node[ $key ] = self::restore_locked_settings( $child, $locked, $known );
314 }
315 }
316
317 return $node;
318 }
319
320 public static function sanitize_json( $value ): string {
321 if ( is_array( $value ) ) {
322 return wp_json_encode( $value );
323 }
324 if ( is_string( $value ) ) {
325 $decoded = json_decode( $value, true );
326 return is_array( $decoded ) ? wp_json_encode( $decoded ) : '[]';
327 }
328 return '[]';
329 }
330 }
331