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
← All changes | includes/Campaign/MetaBox.php +222 -0 2.2.2 → 2.4.1 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace Better_Payment\Lite\Campaign;
4 4
5 5 use Better_Payment\Lite\Controller;
6 +use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
6 7
7 8 if ( ! defined( 'ABSPATH' ) ) {
8 9 exit;
9 10 }
@@ -62,8 +63,13 @@
62 63 }
63 64
64 65 $value = wp_unslash( $_POST[ $key ] );
65 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 +
66 72 update_post_meta( $post_id, $key, $value );
67 73 }
68 74 }
69 75
@@ -76,8 +82,13 @@
76 82
77 83 if ( array_key_exists( $short_key, $data ) ) {
78 84 $raw = $data[ $short_key ];
79 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 +
80 91 // wp_unslash() is applied inside update_metadata, which strips backslashes from strings.
81 92 // wp_slash() pre-escapes so the round-trip leaves the value intact (standard WP REST API pattern).
82 93 if ( is_string( $value ) ) {
83 94 $value = wp_slash( $value );
@@ -177,8 +188,219 @@
177 188 }
178 189 return '[]';
179 190 }
180 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;
181 403 }
182 404
183 405 public static function sanitize_json( $value ): string {
184 406 if ( is_array( $value ) ) {