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/AI/Operations/OperationValidator.php +51 -16 2.3.3 → 2.4.1 View file →
@@ -35,8 +35,17 @@
35 35 */
36 36 const SCOPED_OPERATIONS = [ 'update_block', 'replace_image' ];
37 37
38 38 /**
39 + * Longest element id a `set_layout` may carry through. Builder ids are 15
40 + * characters; the builder's REPLACE_LAYOUT applies the same cap before it
41 + * keeps an echoed id.
42 + *
43 + * @var int
44 + */
45 + const MAX_ID_LENGTH = 64;
46 +
47 + /**
39 48 * Validate a batch of operations, returning only the sanitised, valid ones.
40 49 *
41 50 * @param mixed $operations Raw operations from the model.
42 51 * @param array $context Optional trusted current state: [ 'layout' => [...], 'meta' => [...] ].
@@ -120,9 +129,9 @@
120 129 }
121 130
122 131 switch ( $name ) {
123 132 case 'set_layout':
124 - return self::validate_set_layout( $operation );
133 + return self::validate_set_layout( $operation, $id_type_map );
125 134 case 'insert_block':
126 135 return self::validate_insert_block( $operation );
127 136 case 'update_block':
128 137 return self::validate_update_block( $operation, $id_type_map );
@@ -146,9 +155,14 @@
146 155 }
147 156
148 157 // ------------------------------------------------------------------ per-op
149 158
150 - private static function validate_set_layout( array $op ) {
159 + /**
160 + * @param array $op Raw operation.
161 + * @param array<string, string> $id_type_map element_id => type of the current campaign.
162 + * @return array|null
163 + */
164 + private static function validate_set_layout( array $op, array $id_type_map = [] ) {
151 165 $layout = $op['layout'] ?? '';
152 166 // Some models JSON-encode the nested columns array; accept that too.
153 167 $columns = self::maybe_decode_json( $op['columns'] ?? [] );
154 168
@@ -158,8 +172,10 @@
158 172 if ( ! is_array( $columns ) ) {
159 173 return null;
160 174 }
161 175
176 + // Element ids already used in this layout — shared across columns.
177 + $seen_ids = [];
162 178 $clean_cols = [];
163 179 foreach ( $columns as $column ) {
164 180 if ( ! is_array( $column ) ) {
165 181 continue;
@@ -167,9 +183,9 @@
167 183 $clean_cols[] = [
168 184 'id' => self::sanitize_id( $column['id'] ?? '' ),
169 185 'label' => sanitize_text_field( $column['label'] ?? '' ),
170 186 'width' => self::sanitize_width( $column['width'] ?? '100%' ),
171 - 'elements' => self::sanitize_elements( $column['elements'] ?? [] ),
187 + 'elements' => self::sanitize_elements( $column['elements'] ?? [], $seen_ids, $id_type_map ),
172 188 ];
173 189 }
174 190
175 191 if ( empty( $clean_cols ) ) {
@@ -188,11 +204,12 @@
188 204 * including a Pro widget on a free install.
189 205 *
190 206 * A free user who explicitly asks the AI for FAQ, Video or Donors Wall gets
191 207 * that exact widget, not a free substitute: it lands on the canvas as the
192 - * locked preview a palette drop already produces, its settings are reset to the
193 - * schema defaults on save by
194 - * {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}, and
208 + * locked preview a palette drop already produces. A free install registers only
209 + * a lock stub for it, with no settings keys, so every setting the model wrote is
210 + * dropped here; {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}
211 + * saves a new one with the stub's empty defaults, and
195 212 * the builder renders it through {@see \Better_Payment\Lite\Campaign\Elements\ProElementPreview}.
196 213 * On the live page it renders nothing — the accepted, existing free-plugin
197 214 * behaviour for an unlicensed Pro widget. The assistant tells the user it is
198 215 * Pro-only via {@see CampaignSchema::locked_pro_types_in_operations()}.
@@ -394,10 +411,9 @@
394 411 /**
395 412 * Sanitise a full list of raw elements (used by set_layout).
396 413 *
397 414 * Note what this deliberately does NOT do: it does not drop Pro elements on a
398 - * non-Pro install, even though {@see self::validate_insert_block()} refuses to
399 - * create one. `set_layout` is a whole-page replacement, and an edit turn as
415 + * non-Pro install. `set_layout` is a whole-page replacement, and an edit turn as
400 416 * ordinary as "shorten the title" comes back with the entire page echoed in
401 417 * it. On a lapsed licence, filtering here would turn that turn into a silent
402 418 * deletion of every Pro element the user had built while subscribed — the same
403 419 * destruction {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}
@@ -402,16 +418,21 @@
402 418 * deletion of every Pro element the user had built while subscribed — the same
403 419 * destruction {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}
404 420 * exists to prevent by restoring settings rather than removing elements.
405 421 *
406 - * Entitlement is enforced where it is safe to enforce it: we do not offer Pro
407 - * types in the prompt, we refuse to insert new ones, and MetaBox neuters their
408 - * settings on save. A Pro element that survives here renders as nothing on the
422 + * Entitlement decides what is offered and disclosed, never what is recognised:
423 + * Pro types are not offered in a free install's prompt, a Pro widget the user
424 + * explicitly asks for is inserted locked ({@see self::validate_insert_block()})
425 + * with a disclosure AIService appends, and MetaBox resets a new one's settings
426 + * on save. A Pro element that survives here renders as nothing on the
409 427 * frontend, which is recoverable; deleting the user's work is not.
410 428 *
429 + * @param mixed $elements Raw elements (array or JSON string).
430 + * @param array<string, bool> $seen_ids Element ids already kept in this layout; updated.
431 + * @param array<string, string> $id_type_map element_id => type of the current campaign.
411 432 * @return array<int, array>
412 433 */
413 - private static function sanitize_elements( $elements ): array {
434 + private static function sanitize_elements( $elements, array &$seen_ids = [], array $id_type_map = [] ): array {
414 435 $elements = self::maybe_decode_json( $elements );
415 436 if ( ! is_array( $elements ) ) {
416 437 return [];
417 438 }
@@ -430,12 +451,26 @@
430 451 $entry = [
431 452 'type' => $type,
432 453 'settings' => self::sanitize_settings( $type, $element['settings'] ?? [] ),
433 454 ];
434 - // Preserve an incoming id only if it looks builder-generated; otherwise
435 - // the client mints a fresh one on apply.
436 - if ( ! empty( $element['id'] ) && is_string( $element['id'] ) ) {
437 - $entry['id'] = self::sanitize_id( $element['id'] );
455 + // An echoed id keeps the element the SAME element: the builder keeps it
456 + // on apply (REPLACE_LAYOUT), and MetaBox::enforce_pro_entitlement()
457 + // matches a locked Pro element to its stored settings by it. The id is
458 + // dropped — so the client mints a fresh one — when it is empty once
459 + // sanitised, too long, already used earlier in this layout (ids are
460 + // unique across columns), or names an element of ANOTHER type in the
461 + // current campaign (a reused id, not an echo).
462 + if ( isset( $element['id'] ) && is_string( $element['id'] ) ) {
463 + $id = self::sanitize_id( $element['id'] );
464 + if (
465 + '' !== $id
466 + && strlen( $id ) <= self::MAX_ID_LENGTH
467 + && ! isset( $seen_ids[ $id ] )
468 + && ( ! isset( $id_type_map[ $id ] ) || $id_type_map[ $id ] === $type )
469 + ) {
470 + $entry['id'] = $id;
471 + $seen_ids[ $id ] = true;
472 + }
438 473 }
439 474 $clean[] = $entry;
440 475 }
441 476 return $clean;