| @@ -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; |