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 / AI / Operations / OperationValidator.php

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

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