PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
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 / AI / Operations / OperationValidator.php

OperationValidator.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.0, at includes/AI/Operations/OperationValidator.php

808 lines 32.3 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\AI\Operations;
4
5 use Better_Payment\Lite\AI\Schema\CampaignSchema;
6 use Better_Payment\Lite\AI\Support\DateGuard;
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Validates and sanitises AI-produced operations before they reach the client.
14 *
15 * This is the security boundary for AI output. Model responses are never
16 * trusted: unknown operation types are dropped, unknown element types and
17 * setting keys are stripped, values are coerced/escaped by their control type,
18 * and enum-constrained values that fall outside the allowlist are removed.
19 *
20 * The wire shape of one operation is `{ "op": "<name>", ...params }`.
21 *
22 * @see OperationRegistry Operation catalog / param shapes.
23 * @see CampaignSchema Element + meta allowlists.
24 */
25 class OperationValidator {
26
27 /**
28 * Operations a single-widget turn is allowed to produce.
29 *
30 * `update_block` is the edit itself; `replace_image` is the same edit for a
31 * photo. Everything else either restructures the page or changes campaign-wide
32 * state, neither of which is what "rewrite this headline" asked for.
33 *
34 * @var array<int, string>
35 */
36 const SCOPED_OPERATIONS = [ 'update_block', 'replace_image' ];
37
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 /**
48 * Validate a batch of operations, returning only the sanitised, valid ones.
49 *
50 * @param mixed $operations Raw operations from the model.
51 * @param array $context Optional trusted current state: [ 'layout' => [...], 'meta' => [...] ].
52 * @param array $scope Optional [ 'element_id' => string ]. When set, the turn was
53 * launched against one selected widget and may only change that
54 * widget — see {@see self::in_scope()}.
55 * @return array<int, array> Sanitised operations, invalid ones removed.
56 */
57 public static function validate_batch( $operations, array $context = [], array $scope = [] ): array {
58 if ( ! is_array( $operations ) ) {
59 return [];
60 }
61
62 $id_type_map = self::build_id_type_map( $context['layout'] ?? [] );
63 $scope_id = isset( $scope['element_id'] ) ? trim( (string) $scope['element_id'] ) : '';
64 $clean = [];
65
66 foreach ( $operations as $operation ) {
67 $valid = self::validate_one( $operation, $id_type_map );
68 if ( null === $valid ) {
69 continue;
70 }
71 if ( '' !== $scope_id && ! self::in_scope( $valid, $scope_id ) ) {
72 continue;
73 }
74 $clean[] = $valid;
75 }
76
77 return $clean;
78 }
79
80 /**
81 * Whether a validated operation stays inside a single-widget turn.
82 *
83 * The builder's per-element AI buttons act on the widget the user has selected.
84 * Before this existed, the only thing tying the model to that widget was a
85 * sentence in the prompt naming its id — so a "shorten this" on one headline
86 * could come back as a `set_layout` rebuilding the page, or an `update_meta`
87 * changing the fundraising goal, and it would be applied. The user asked to
88 * edit one widget; anything else is a change they did not request and did not
89 * see coming, on a page they may have spent an hour arranging.
90 *
91 * Dropping is the right failure: the widget stays as it was, which is the same
92 * outcome as the model declining, and the free-form AI panel remains available
93 * for genuinely page-wide requests.
94 *
95 * @param array $operation Already-validated operation.
96 * @param string $scope_id The element id this turn is pinned to.
97 * @return bool
98 */
99 private static function in_scope( array $operation, string $scope_id ): bool {
100 $name = isset( $operation['op'] ) ? (string) $operation['op'] : '';
101
102 if ( ! in_array( $name, self::SCOPED_OPERATIONS, true ) ) {
103 return false;
104 }
105
106 $element_id = isset( $operation['element_id'] ) ? (string) $operation['element_id'] : '';
107
108 return $element_id === $scope_id;
109 }
110
111 /**
112 * Validate a single operation.
113 *
114 * @param mixed $operation
115 * @param array<string, string> $id_type_map element_id => type
116 * @return array|null Sanitised operation or null if invalid.
117 */
118 public static function validate_one( $operation, array $id_type_map = [] ) {
119 if ( ! is_array( $operation ) ) {
120 return null;
121 }
122
123 // Accept `op` (canonical) or `type`/`operation` as aliases.
124 $name = $operation['op'] ?? ( $operation['operation'] ?? ( $operation['type'] ?? '' ) );
125 $name = is_string( $name ) ? $name : '';
126
127 if ( ! OperationRegistry::exists( $name ) ) {
128 return null;
129 }
130
131 switch ( $name ) {
132 case 'set_layout':
133 return self::validate_set_layout( $operation, $id_type_map );
134 case 'insert_block':
135 return self::validate_insert_block( $operation );
136 case 'update_block':
137 return self::validate_update_block( $operation, $id_type_map );
138 case 'delete_block':
139 return self::require_element_id( 'delete_block', $operation, $id_type_map );
140 case 'move_block':
141 return self::validate_move_block( $operation, $id_type_map );
142 case 'update_meta':
143 return self::validate_update_meta( $operation );
144 case 'set_colors':
145 return self::validate_set_colors( $operation );
146 case 'set_donation_amounts':
147 return self::validate_donation_amounts( $operation );
148 case 'replace_image':
149 return self::validate_replace_image( $operation, $id_type_map );
150 case 'generate_image':
151 return self::validate_generate_image( $operation );
152 }
153
154 return null;
155 }
156
157 // ------------------------------------------------------------------ per-op
158
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 = [] ) {
165 $layout = $op['layout'] ?? '';
166 // Some models JSON-encode the nested columns array; accept that too.
167 $columns = self::maybe_decode_json( $op['columns'] ?? [] );
168
169 if ( ! in_array( $layout, CampaignSchema::layout_presets(), true ) ) {
170 $layout = '1-column';
171 }
172 if ( ! is_array( $columns ) ) {
173 return null;
174 }
175
176 // Element ids already used in this layout — shared across columns.
177 $seen_ids = [];
178 $clean_cols = [];
179 foreach ( $columns as $column ) {
180 if ( ! is_array( $column ) ) {
181 continue;
182 }
183 $clean_cols[] = [
184 'id' => self::sanitize_id( $column['id'] ?? '' ),
185 'label' => sanitize_text_field( $column['label'] ?? '' ),
186 'width' => self::sanitize_width( $column['width'] ?? '100%' ),
187 'elements' => self::sanitize_elements( $column['elements'] ?? [], $seen_ids, $id_type_map ),
188 ];
189 }
190
191 if ( empty( $clean_cols ) ) {
192 return null;
193 }
194
195 return [
196 'op' => 'set_layout',
197 'layout' => $layout,
198 'columns' => $clean_cols,
199 ];
200 }
201
202 /**
203 * `insert_block` creates a NEW element. Any registered type is accepted —
204 * including a Pro widget on a free install.
205 *
206 * A free user who explicitly asks the AI for FAQ, Video or Donors Wall gets
207 * that exact widget, not a free substitute: it lands on the canvas as the
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
212 * the builder renders it through {@see \Better_Payment\Lite\Campaign\Elements\ProElementPreview}.
213 * On the live page it renders nothing — the accepted, existing free-plugin
214 * behaviour for an unlicensed Pro widget. The assistant tells the user it is
215 * Pro-only via {@see CampaignSchema::locked_pro_types_in_operations()}.
216 *
217 * This used to refuse an unentitled Pro type outright, which left the two
218 * insertion paths inconsistent — `set_layout` kept Pro elements (see
219 * {@see self::sanitize_elements()}) while `insert_block` dropped them — so
220 * "add an FAQ" silently did nothing or got answered with a free widget. Only a
221 * truly unknown type is rejected now.
222 */
223 private static function validate_insert_block( array $op ) {
224 $type = CampaignSchema::resolve_element_type( is_string( $op['type'] ?? null ) ? $op['type'] : '' );
225 if ( '' === $type ) {
226 return null;
227 }
228
229 $clean = [
230 'op' => 'insert_block',
231 'type' => $type,
232 'settings' => self::sanitize_settings( $type, $op['settings'] ?? [] ),
233 ];
234 if ( isset( $op['column_id'] ) && is_string( $op['column_id'] ) && '' !== $op['column_id'] ) {
235 $clean['column_id'] = self::sanitize_id( $op['column_id'] );
236 }
237 if ( isset( $op['index'] ) && is_numeric( $op['index'] ) ) {
238 $clean['index'] = max( 0, (int) $op['index'] );
239 }
240 return $clean;
241 }
242
243 private static function validate_update_block( array $op, array $id_type_map ) {
244 $element_id = is_string( $op['element_id'] ?? null ) ? $op['element_id'] : '';
245 if ( '' === $element_id ) {
246 return null;
247 }
248 // If we know the element's type from context, sanitise settings against it.
249 // Otherwise keep a shallow-sanitised object; the client executor re-checks
250 // against live state and drops unknown keys.
251 $type = $id_type_map[ $element_id ] ?? '';
252 $settings = '' !== $type
253 ? self::sanitize_settings( $type, $op['settings'] ?? [] )
254 : self::shallow_sanitize_settings( $op['settings'] ?? [] );
255
256 if ( empty( $settings ) ) {
257 return null;
258 }
259
260 return [
261 'op' => 'update_block',
262 'element_id' => self::sanitize_id( $element_id ),
263 'settings' => $settings,
264 ];
265 }
266
267 private static function validate_move_block( array $op, array $id_type_map ) {
268 $element_id = is_string( $op['element_id'] ?? null ) ? $op['element_id'] : '';
269 $to_column_id = is_string( $op['to_column_id'] ?? null ) ? $op['to_column_id'] : '';
270 if ( '' === $element_id || '' === $to_column_id ) {
271 return null;
272 }
273 $clean = [
274 'op' => 'move_block',
275 'element_id' => self::sanitize_id( $element_id ),
276 'to_column_id' => self::sanitize_id( $to_column_id ),
277 ];
278 if ( isset( $op['index'] ) && is_numeric( $op['index'] ) ) {
279 $clean['index'] = max( 0, (int) $op['index'] );
280 }
281 return $clean;
282 }
283
284 private static function validate_update_meta( array $op ) {
285 $key = is_string( $op['key'] ?? null ) ? $op['key'] : '';
286 if ( ! CampaignSchema::is_writable_meta_key( $key ) ) {
287 return null;
288 }
289
290 // An AI-produced end date is always in the future, in every mode. This is
291 // the one place every model-produced operation passes through, whatever
292 // the route — generation, a conversational edit, an applied analysis
293 // suggestion — which is why the rule lives here and not in any one
294 // service. A past or unparseable date drops the whole operation, leaving
295 // the field unset: no deadline is a valid campaign, an elapsed one is a
296 // page that opens already closed. See {@see DateGuard}.
297 if ( 'bpc_end_date' === $key ) {
298 $date = DateGuard::usable_end_date( $op['value'] ?? '' );
299 if ( '' === $date ) {
300 return null;
301 }
302 return [
303 'op' => 'update_meta',
304 'key' => $key,
305 'value' => $date,
306 ];
307 }
308
309 return [
310 'op' => 'update_meta',
311 'key' => $key,
312 'value' => self::sanitize_meta_value( $key, $op['value'] ?? '' ),
313 ];
314 }
315
316 private static function validate_set_colors( array $op ) {
317 $clean = [ 'op' => 'set_colors' ];
318 if ( isset( $op['primary'] ) ) {
319 $primary = sanitize_hex_color( (string) $op['primary'] );
320 if ( $primary ) {
321 $clean['primary'] = $primary;
322 }
323 }
324 if ( isset( $op['background'] ) ) {
325 $bg = sanitize_hex_color( (string) $op['background'] );
326 if ( $bg ) {
327 $clean['background'] = $bg;
328 }
329 }
330 if ( ! isset( $clean['primary'] ) && ! isset( $clean['background'] ) ) {
331 return null;
332 }
333 return $clean;
334 }
335
336 private static function validate_donation_amounts( array $op ) {
337 $amounts = $op['amounts'] ?? [];
338 if ( ! is_array( $amounts ) ) {
339 return null;
340 }
341 $clean = [];
342 $i = 0;
343 foreach ( $amounts as $item ) {
344 if ( ! is_array( $item ) ) {
345 continue;
346 }
347 $i++;
348 $clean[] = [
349 'id' => 'sa_' . $i,
350 'amount' => (string) floatval( $item['amount'] ?? 0 ),
351 'description' => sanitize_text_field( $item['description'] ?? '' ),
352 'is_default' => ! empty( $item['is_default'] ),
353 ];
354 }
355 if ( empty( $clean ) ) {
356 return null;
357 }
358 return [
359 'op' => 'set_donation_amounts',
360 'amounts' => $clean,
361 ];
362 }
363
364 private static function validate_replace_image( array $op, array $id_type_map ) {
365 $element_id = is_string( $op['element_id'] ?? null ) ? $op['element_id'] : '';
366 $src = esc_url_raw( (string) ( $op['src'] ?? '' ) );
367 if ( '' === $element_id || '' === $src ) {
368 return null;
369 }
370 return [
371 'op' => 'replace_image',
372 'element_id' => self::sanitize_id( $element_id ),
373 'src' => $src,
374 'src_id' => isset( $op['src_id'] ) ? absint( $op['src_id'] ) : 0,
375 'src_sizes' => is_array( $op['src_sizes'] ?? null ) ? $op['src_sizes'] : [],
376 'alt' => sanitize_text_field( $op['alt'] ?? '' ),
377 ];
378 }
379
380 private static function validate_generate_image( array $op ) {
381 $prompt = sanitize_textarea_field( (string) ( $op['prompt'] ?? '' ) );
382 if ( '' === trim( $prompt ) ) {
383 return null;
384 }
385 $clean = [
386 'op' => 'generate_image',
387 'prompt' => $prompt,
388 ];
389 if ( ! empty( $op['element_id'] ) && is_string( $op['element_id'] ) ) {
390 $clean['element_id'] = self::sanitize_id( $op['element_id'] );
391 }
392 if ( ! empty( $op['column_id'] ) && is_string( $op['column_id'] ) ) {
393 $clean['column_id'] = self::sanitize_id( $op['column_id'] );
394 }
395 return $clean;
396 }
397
398 private static function require_element_id( string $name, array $op, array $id_type_map ) {
399 $element_id = is_string( $op['element_id'] ?? null ) ? $op['element_id'] : '';
400 if ( '' === $element_id ) {
401 return null;
402 }
403 return [
404 'op' => $name,
405 'element_id' => self::sanitize_id( $element_id ),
406 ];
407 }
408
409 // ------------------------------------------------------------------ helpers
410
411 /**
412 * Sanitise a full list of raw elements (used by set_layout).
413 *
414 * Note what this deliberately does NOT do: it does not drop Pro elements on a
415 * non-Pro install. `set_layout` is a whole-page replacement, and an edit turn as
416 * ordinary as "shorten the title" comes back with the entire page echoed in
417 * it. On a lapsed licence, filtering here would turn that turn into a silent
418 * deletion of every Pro element the user had built while subscribed — the same
419 * destruction {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}
420 * exists to prevent by restoring settings rather than removing elements.
421 *
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
427 * frontend, which is recoverable; deleting the user's work is not.
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.
432 * @return array<int, array>
433 */
434 private static function sanitize_elements( $elements, array &$seen_ids = [], array $id_type_map = [] ): array {
435 $elements = self::maybe_decode_json( $elements );
436 if ( ! is_array( $elements ) ) {
437 return [];
438 }
439 $clean = [];
440 foreach ( $elements as $element ) {
441 $element = self::maybe_decode_json( $element );
442 if ( ! is_array( $element ) ) {
443 continue;
444 }
445 // Resolve aliased type names (heading → campaign_title, etc.) so a
446 // mis-named element is recovered rather than dropped.
447 $type = CampaignSchema::resolve_element_type( is_string( $element['type'] ?? null ) ? $element['type'] : '' );
448 if ( '' === $type ) {
449 continue;
450 }
451 $entry = [
452 'type' => $type,
453 'settings' => self::sanitize_settings( $type, $element['settings'] ?? [] ),
454 ];
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 }
473 }
474 $clean[] = $entry;
475 }
476 return $clean;
477 }
478
479 /**
480 * Label settings the renderer appends a live value to.
481 *
482 * These are **prefixes**: `RendererService` prints `$goal_label . ' ' .
483 * $currency_symbol . $goal` and `$donate_label . ' ' . $progress . '%'`. A model
484 * handed a free-text field called "Goal Label:" writes the whole phrase —
485 * `"Our Goal: $5,000"` — and the page then reads `"Our Goal: $5,000 €5,000"`:
486 * the amount twice, in two currencies, because the model guesses USD while the
487 * site is EUR. Worse, the model's number is invented, so editing the real goal
488 * later leaves the label contradicting it rather than merely repeating it.
489 *
490 * This list is deliberately NOT "every key ending in `_label`".
491 * `donation_form`'s `button_label` is echoed on its own, so "Donate $50" is a
492 * legitimate CTA there; stripping it would be the bug, not the fix. Only labels
493 * the renderer follows with a value belong here — check the renderer before
494 * adding one.
495 *
496 * @return array<string, string[]> element type => label setting keys.
497 */
498 private static function value_suffixed_labels(): array {
499 /**
500 * Filter the label settings that must not contain a value.
501 *
502 * @param array<string, string[]> $map Element type => setting keys.
503 */
504 return apply_filters(
505 'better_payment/ai/value_suffixed_labels',
506 [
507 'progress_bar' => [ 'goal_label', 'donate_label' ],
508 ]
509 );
510 }
511
512 /**
513 * Strip an embedded amount / percentage / currency symbol from a label.
514 *
515 * The prompt tells the model not to write these ({@see Prompt/Templates/rules.php})
516 * and gives it the site currency, but a prompt is a request. This is the part
517 * that holds.
518 *
519 * Conservative on purpose — it removes only what unambiguously reads as a
520 * value: a currency symbol with digits, comma-grouped thousands, a percentage,
521 * or a stray currency symbol. A bare short number is left alone, because
522 * "Season 2024 Goal" is a real label and mangling an author's text is worse
523 * than the duplication this fixes. `\p{Sc}` covers $ € £ ¥ ₹ and the rest.
524 *
525 * @param string $label
526 * @return string Cleaned label; '' if nothing survived (caller drops it and the
527 * schema default applies).
528 */
529 public static function strip_value_from_label( string $label ): string {
530 $patterns = [
531 '/\p{Sc}\s*\d[\d.,]*/u', // $5,000 / € 5.000
532 '/\d{1,3}(?:,\d{3})+(?:\.\d+)?/u', // 5,000 — grouped thousands
533 '/\d[\d.,]*\s*%/u', // 40%
534 '/\p{Sc}/u', // stray symbol
535 ];
536
537 $clean = (string) preg_replace( $patterns, '', $label );
538
539 // Collapse the whitespace the removals left behind, then tidy trailing
540 // separators — "Our Goal: $5,000" must not come back as "Our Goal: ".
541 $clean = (string) preg_replace( '/\s{2,}/u', ' ', $clean );
542 $clean = trim( $clean );
543 $clean = (string) preg_replace( '/[\s\-–—]+$/u', '', $clean );
544
545 return trim( $clean );
546 }
547
548 /**
549 * Apply {@see self::strip_value_from_label()} where the type says it applies.
550 *
551 * @param string $type Element type, or '' when unknown.
552 * @param string $key Canonical setting key.
553 * @param mixed $value
554 * @return mixed
555 */
556 private static function guard_label_value( string $type, string $key, $value ) {
557 if ( ! is_string( $value ) || '' === $value ) {
558 return $value;
559 }
560
561 $map = self::value_suffixed_labels();
562
563 if ( '' !== $type ) {
564 $keys = isset( $map[ $type ] ) ? (array) $map[ $type ] : [];
565 } else {
566 // No type context (a bare update_block). Fall back to the union of
567 // every guarded key — these names exist only on the elements listed
568 // above, so matching by key alone cannot hit an unrelated setting.
569 $keys = array_unique( array_merge( [], ...array_values( $map ) ) );
570 }
571
572 if ( ! in_array( $key, $keys, true ) ) {
573 return $value;
574 }
575
576 return self::strip_value_from_label( $value );
577 }
578
579 /**
580 * Sanitise a settings object against a known element type's allowlist.
581 *
582 * @return array<string, mixed>
583 */
584 public static function sanitize_settings( string $type, $settings ): array {
585 $settings = self::maybe_decode_json( $settings );
586 if ( ! is_array( $settings ) ) {
587 return [];
588 }
589 $allowed = CampaignSchema::allowed_settings_keys( $type );
590 $clean = [];
591 foreach ( $settings as $key => $value ) {
592 // Map aliased content keys (text → title/content/button_label, …).
593 $canon = CampaignSchema::resolve_setting_key( $type, (string) $key );
594 if ( ! in_array( $canon, $allowed, true ) ) {
595 continue;
596 }
597 $sanitized = self::sanitize_setting_value( $type, $canon, $value );
598 if ( null !== $sanitized ) {
599 $guarded = self::guard_label_value( $type, $canon, $sanitized );
600
601 // Drop only when the GUARD emptied it — a label that was nothing
602 // but an amount. Then the renderer's `! empty()` restores the
603 // schema default ("Goal:") instead of printing a bare figure.
604 //
605 // Testing `'' === $guarded` alone would also swallow a value the
606 // model deliberately cleared, and in this codebase emptied means
607 // empty — see the "use isset(), never ! empty()" note in
608 // docs/features/campaign-builder/frontend-rendering.md.
609 if ( '' === $guarded && '' !== $sanitized ) {
610 continue;
611 }
612
613 $clean[ $canon ] = $guarded;
614 }
615 }
616 return $clean;
617 }
618
619 /**
620 * Shallow sanitise when the element type is unknown (no context).
621 * Keys are kept but values are coerced to safe scalars/arrays; the client
622 * executor performs the type-aware allowlist check against live state.
623 *
624 * @return array<string, mixed>
625 */
626 private static function shallow_sanitize_settings( $settings ): array {
627 if ( ! is_array( $settings ) ) {
628 return [];
629 }
630 $clean = [];
631 foreach ( $settings as $key => $value ) {
632 if ( ! is_string( $key ) ) {
633 continue;
634 }
635 if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) {
636 $clean[ $key ] = $value;
637 } elseif ( is_string( $value ) ) {
638 // The label guard runs here too. This path handles update_block
639 // with no type context, which is exactly the op an "edit the
640 // progress bar" turn produces — guarding only the typed path
641 // above would leave the common case open.
642 $safe = wp_kses_post( $value );
643 $guarded = self::guard_label_value( '', $key, $safe );
644
645 // As above: drop only when the guard emptied it, never when the
646 // model deliberately sent an empty string.
647 if ( '' === $guarded && '' !== $safe ) {
648 continue;
649 }
650
651 $clean[ $key ] = $guarded;
652 } elseif ( is_array( $value ) ) {
653 $clean[ $key ] = $value;
654 }
655 }
656 return $clean;
657 }
658
659 /**
660 * Coerce/escape a single setting value based on its control type.
661 *
662 * @param mixed $value
663 * @return mixed|null Null drops the key (e.g. invalid enum value).
664 */
665 public static function sanitize_setting_value( string $type, string $key, $value ) {
666 $control = CampaignSchema::find_control( $type, $key );
667 $ctype = $control['type'] ?? 'text';
668
669 switch ( $ctype ) {
670 case 'color':
671 $hex = sanitize_hex_color( (string) $value );
672 return $hex ? $hex : '';
673
674 case 'number':
675 case 'range':
676 case 'currency':
677 // Drop empty / non-numeric values so the element's own default
678 // applies. Emitting '' here would override a meaningful default
679 // (e.g. width 100) and collapse the element on render.
680 if ( '' === $value || null === $value || ! is_numeric( $value ) ) {
681 return null;
682 }
683 return 0 + $value;
684
685 case 'toggle':
686 case 'switch':
687 return (bool) $value;
688
689 case 'select':
690 case 'align':
691 $enum = CampaignSchema::enum_values( $type, $key );
692 $val = (string) $value;
693 if ( is_array( $enum ) && ! in_array( $val, $enum, true ) ) {
694 return null; // drop out-of-enum value
695 }
696 return $val;
697
698 case 'url':
699 return esc_url_raw( (string) $value );
700
701 case 'rich_text':
702 return wp_kses_post( (string) $value );
703
704 case 'textarea':
705 return sanitize_textarea_field( (string) $value );
706
707 case 'image_upload':
708 // src is a URL; other image sub-keys handled by defaultSettings coercion below.
709 return is_array( $value ) ? $value : esc_url_raw( (string) $value );
710
711 default:
712 // Preserve arrays (e.g. src_sizes), ints, bools; text-sanitise strings.
713 if ( is_array( $value ) ) {
714 return $value;
715 }
716 if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) {
717 return $value;
718 }
719 return sanitize_text_field( (string) $value );
720 }
721 }
722
723 /**
724 * Sanitise a campaign meta value by key.
725 *
726 * @param mixed $value
727 * @return mixed
728 */
729 public static function sanitize_meta_value( string $key, $value ) {
730 switch ( $key ) {
731 case 'bpc_goal_amount':
732 case 'bpc_minimum_amount':
733 return is_numeric( $value ) ? (float) $value : '';
734 case 'bpc_allow_custom_amount':
735 return (int) ( ! empty( $value ) );
736 case 'bpc_color_primary':
737 case 'bpc_color_background':
738 $hex = sanitize_hex_color( (string) $value );
739 return $hex ? $hex : '';
740 case 'bpc_css_class':
741 return sanitize_html_class( (string) $value );
742 case 'bpc_suggested_amounts':
743 return is_array( $value ) ? $value : [];
744 default:
745 return sanitize_text_field( (string) $value );
746 }
747 }
748
749 /**
750 * If a value is a JSON-encoded array/object string, decode it; otherwise
751 * return it unchanged. Guards against models that stringify nested tool-call
752 * arguments (e.g. columns/elements/settings as a JSON string).
753 *
754 * @param mixed $value
755 * @return mixed
756 */
757 private static function maybe_decode_json( $value ) {
758 if ( ! is_string( $value ) ) {
759 return $value;
760 }
761 $trimmed = trim( $value );
762 if ( '' === $trimmed || ( '[' !== $trimmed[0] && '{' !== $trimmed[0] ) ) {
763 return $value;
764 }
765 $decoded = json_decode( $trimmed, true );
766 return is_array( $decoded ) ? $decoded : $value;
767 }
768
769 private static function sanitize_id( $id ): string {
770 // Builder ids are like `el_ab12cd34ef56`, `col_abcd1234`, or template names.
771 return preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $id );
772 }
773
774 private static function sanitize_width( $width ): string {
775 $width = (string) $width;
776 if ( preg_match( '/^\d{1,3}(\.\d+)?%$/', $width ) ) {
777 return $width;
778 }
779 if ( is_numeric( $width ) ) {
780 return ( 0 + $width ) . '%';
781 }
782 return '100%';
783 }
784
785 /**
786 * Map element_id => type from a trusted layout context.
787 *
788 * @return array<string, string>
789 */
790 private static function build_id_type_map( $layout ): array {
791 $map = [];
792 if ( ! is_array( $layout ) || empty( $layout['columns'] ) || ! is_array( $layout['columns'] ) ) {
793 return $map;
794 }
795 foreach ( $layout['columns'] as $column ) {
796 if ( ! is_array( $column ) || empty( $column['elements'] ) ) {
797 continue;
798 }
799 foreach ( $column['elements'] as $element ) {
800 if ( is_array( $element ) && ! empty( $element['id'] ) && ! empty( $element['type'] ) ) {
801 $map[ (string) $element['id'] ] = (string) $element['type'];
802 }
803 }
804 }
805 return $map;
806 }
807 }
808