assets
1 week ago
BuilderPanel.php
1 week ago
Compiler.php
1 week ago
Engine.php
1 week ago
FrontendEnqueue.php
1 week ago
Migrator.php
1 week ago
Palettes.php
1 week ago
PreviewDraft.php
1 week ago
RestController.php
1 week ago
Sanitizer.php
1 week ago
Schema.php
1 week ago
Templates.php
1 week ago
Sanitizer.php
559 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Style Customizer v2 — Sanitizer. |
| 4 | * |
| 5 | * The single write-path guard: no value reaches the stored record (and therefore the |
| 6 | * compiler) unless it is valid for its token's type. Driven entirely by {@see Schema} |
| 7 | * so it can never drift from the control set. |
| 8 | * |
| 9 | * Record shape: |
| 10 | * array( |
| 11 | * 'schema_version' => 1, |
| 12 | * 'tokens' => array( '<key>' => array( 'desktop' => v, 'tablet' => v, 'mobile' => v ) ), |
| 13 | * 'palette' => '<palette-id>', |
| 14 | * 'template' => '<template-id>', |
| 15 | * 'custom_css' => '<css>', |
| 16 | * '_updated_at' => <timestamp>, |
| 17 | * ) |
| 18 | * Only `desktop` is kept for non-responsive tokens; `tablet`/`mobile` survive only on the |
| 19 | * responsive (spacing) tokens. |
| 20 | * |
| 21 | * @package EverestForms\Addons\StyleCustomizer\V2 |
| 22 | * @since x.x.x |
| 23 | */ |
| 24 | |
| 25 | namespace EverestForms\Addons\StyleCustomizer\V2; |
| 26 | |
| 27 | defined( 'ABSPATH' ) || exit; |
| 28 | |
| 29 | /** |
| 30 | * Schema-driven sanitizer. |
| 31 | */ |
| 32 | final class Sanitizer { |
| 33 | |
| 34 | /** |
| 35 | * Sanitize a full v2 record. |
| 36 | * |
| 37 | * @param array $record Raw (untrusted) record. |
| 38 | * @param bool $check_contrast Also run {@see ensure_text_contrast()}. Only the AI style |
| 39 | * launcher opts in; a normal save must never silently revert a |
| 40 | * colour a person deliberately chose. |
| 41 | * @return array Clean record, always stamped with the current schema version. |
| 42 | */ |
| 43 | public static function sanitize_record( $record, $check_contrast = false ) { |
| 44 | $record = is_array( $record ) ? $record : array(); |
| 45 | $clean = array( 'schema_version' => Schema::version() ); |
| 46 | |
| 47 | $pro_active = Engine::pro_active(); |
| 48 | |
| 49 | // Palette first: on a non-Pro site the palette-driven ("free") tokens below are DERIVED |
| 50 | // from this id, never trusted raw from the client — see the loop below for why. |
| 51 | $palette_id = isset( $record['palette'] ) ? self::sanitize_palette_id( $record['palette'] ) : ''; |
| 52 | if ( isset( $record['palette'] ) ) { |
| 53 | $clean['palette'] = $palette_id; |
| 54 | } |
| 55 | $free_derived = ( ! $pro_active && '' !== $palette_id ) ? Schema::palette_token_values( $palette_id ) : array(); |
| 56 | |
| 57 | $in_tokens = isset( $record['tokens'] ) && is_array( $record['tokens'] ) ? $record['tokens'] : array(); |
| 58 | $out_tokens = array(); |
| 59 | foreach ( Schema::tokens() as $token ) { |
| 60 | $key = $token['key']; |
| 61 | |
| 62 | if ( ! $pro_active && isset( $token['tier'] ) && 'free' === $token['tier'] ) { |
| 63 | // Free tier: the ONLY sanctioned customisation is picking one of the 2 free |
| 64 | // palettes — never trust a raw client-submitted value for these keys, or a |
| 65 | // crafted request (an AI response, a hand-built REST call) could paint an |
| 66 | // arbitrary custom colour scheme through keys that exist only to let the |
| 67 | // palette picker render at all (see EVF-2708). |
| 68 | if ( isset( $free_derived[ $key ] ) ) { |
| 69 | $out_tokens[ $key ] = self::sanitize_token( $token, $free_derived[ $key ] ); |
| 70 | } |
| 71 | continue; |
| 72 | } |
| 73 | |
| 74 | if ( ! array_key_exists( $key, $in_tokens ) ) { |
| 75 | continue; // Absent → the compiler falls back to the schema default. |
| 76 | } |
| 77 | // A pro-tier value can never be persisted on a site without Pro. |
| 78 | if ( ! $pro_active && isset( $token['tier'] ) && 'pro' === $token['tier'] ) { |
| 79 | continue; |
| 80 | } |
| 81 | $out_tokens[ $key ] = self::sanitize_token( $token, $in_tokens[ $key ] ); |
| 82 | } |
| 83 | if ( $check_contrast ) { |
| 84 | self::ensure_text_contrast( $out_tokens ); |
| 85 | } |
| 86 | |
| 87 | $clean['tokens'] = $out_tokens; |
| 88 | |
| 89 | if ( isset( $record['template'] ) ) { |
| 90 | $template = sanitize_key( $record['template'] ); |
| 91 | // A Pro template (or a user/custom template) can't be selected without Pro. |
| 92 | if ( ! $pro_active && ! Templates::is_free_template_id( $template ) ) { |
| 93 | $template = ''; |
| 94 | } |
| 95 | $clean['template'] = $template; |
| 96 | } |
| 97 | if ( isset( $record['custom_css'] ) ) { |
| 98 | $clean['custom_css'] = self::sanitize_css( $record['custom_css'] ); |
| 99 | } |
| 100 | |
| 101 | $clean['_updated_at'] = time(); |
| 102 | |
| 103 | return $clean; |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Re-attach any token the OLD record already implied but this save's clean output doesn't, |
| 108 | * if Pro is currently inactive — so a save made while Pro is merely undetected doesn't |
| 109 | * silently erase pro-tier customisation the form already had. The old record may still be |
| 110 | * legacy shape, so it is re-derived through {@see Migrator::migrate_record()} when needed. |
| 111 | * |
| 112 | * This restores stale FREE-tier (palette-driven) tokens too, not just pro-tier ones: since |
| 113 | * {@see self::sanitize_record()} now only ever DERIVES those from a registered free palette id |
| 114 | * (never trusting a raw client value — see EVF-2708), a legacy form migrating to v2 with a |
| 115 | * bespoke v1 custom colour set that matches no registered palette would otherwise have that |
| 116 | * migrated colour dropped on its very first v2 save. Restoring it here (from the same |
| 117 | * migrated-legacy `$old_record`) keeps that one-time migration lossless without reopening the |
| 118 | * free/pro bypass, since this only ever re-attaches a value the form ALREADY had — it can |
| 119 | * never introduce one a fresh crafted request invented this turn. |
| 120 | * |
| 121 | * @param array $clean Freshly sanitized record (from self::sanitize_record()). |
| 122 | * @param array $old_record The record as it was stored before this save (legacy or v2 shape). |
| 123 | * @return array $clean, with any stale token the new save doesn't mention restored. |
| 124 | */ |
| 125 | public static function preserve_stale_pro_tokens( array $clean, array $old_record ) { |
| 126 | if ( Engine::pro_active() ) { |
| 127 | return $clean; |
| 128 | } |
| 129 | |
| 130 | if ( Engine::is_v2_record( $old_record ) ) { |
| 131 | $old_tokens = isset( $old_record['tokens'] ) && is_array( $old_record['tokens'] ) ? $old_record['tokens'] : array(); |
| 132 | } elseif ( ! empty( $old_record ) ) { |
| 133 | $migrated = Migrator::migrate_record( $old_record ); |
| 134 | $old_tokens = isset( $migrated['tokens'] ) && is_array( $migrated['tokens'] ) ? $migrated['tokens'] : array(); |
| 135 | } else { |
| 136 | $old_tokens = array(); |
| 137 | } |
| 138 | |
| 139 | foreach ( $old_tokens as $old_key => $old_value ) { |
| 140 | if ( ! isset( $clean['tokens'][ $old_key ] ) ) { |
| 141 | $clean['tokens'][ $old_key ] = $old_value; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | return $clean; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Drop an AI-generated text-colour override that would be unreadable against the form's own |
| 150 | * background (e.g. white label.color with no matching dark wrap.bg). Threshold is low (2:1) |
| 151 | * to catch only "vanishes entirely" pairings — a human's deliberate low-but-visible choice |
| 152 | * must never be reverted, so only {@see RestController::ai_style()} opts into this. |
| 153 | * |
| 154 | * @param array $out_tokens Sanitized token map, keyed by token key, modified in place. |
| 155 | */ |
| 156 | private static function ensure_text_contrast( array &$out_tokens ) { |
| 157 | // A background image covers wrap.bg visually, so skip the check rather than judge a |
| 158 | // colour against a background it isn't actually rendered against. |
| 159 | if ( ! empty( $out_tokens['wrap.bgImage']['desktop'] ) ) { |
| 160 | return; |
| 161 | } |
| 162 | |
| 163 | $bg = isset( $out_tokens['wrap.bg']['desktop'] ) ? $out_tokens['wrap.bg']['desktop'] : '#ffffff'; |
| 164 | |
| 165 | foreach ( array( 'label.color', 'sub.color', 'desc.color', 'title.color' ) as $key ) { |
| 166 | if ( ! isset( $out_tokens[ $key ]['desktop'] ) ) { |
| 167 | continue; |
| 168 | } |
| 169 | $fg = $out_tokens[ $key ]['desktop']; |
| 170 | if ( ! is_string( $fg ) || ! is_string( $bg ) ) { |
| 171 | continue; |
| 172 | } |
| 173 | if ( self::contrast_ratio( $fg, $bg ) < 2.0 ) { |
| 174 | unset( $out_tokens[ $key ] ); // Falls back to the token's own (coherent) schema default. |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * WCAG relative-luminance contrast ratio between two hex colours (1 = identical, 21 = max). |
| 181 | * |
| 182 | * @param string $hex1 First colour, e.g. "#ffffff". |
| 183 | * @param string $hex2 Second colour. |
| 184 | * @return float |
| 185 | */ |
| 186 | private static function contrast_ratio( $hex1, $hex2 ) { |
| 187 | $l1 = self::relative_luminance( $hex1 ); |
| 188 | $l2 = self::relative_luminance( $hex2 ); |
| 189 | if ( null === $l1 || null === $l2 ) { |
| 190 | return 21.0; // Unparseable colour (e.g. a CSS var/keyword) — don't second-guess it. |
| 191 | } |
| 192 | $lighter = max( $l1, $l2 ); |
| 193 | $darker = min( $l1, $l2 ); |
| 194 | return ( $lighter + 0.05 ) / ( $darker + 0.05 ); |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * WCAG relative luminance of a hex colour, or null if it isn't a parseable #rrggbb/#rgb value. |
| 199 | * |
| 200 | * @param string $hex Colour, e.g. "#1a1a2e". |
| 201 | * @return float|null |
| 202 | */ |
| 203 | private static function relative_luminance( $hex ) { |
| 204 | $hex = ltrim( trim( (string) $hex ), '#' ); |
| 205 | if ( 3 === strlen( $hex ) ) { |
| 206 | $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; |
| 207 | } |
| 208 | if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) { |
| 209 | return null; |
| 210 | } |
| 211 | $channels = array(); |
| 212 | foreach ( array( 0, 2, 4 ) as $i ) { |
| 213 | $c = hexdec( substr( $hex, $i, 2 ) ) / 255; |
| 214 | $channels[] = $c <= 0.03928 ? $c / 12.92 : pow( ( $c + 0.055 ) / 1.055, 2.4 ); |
| 215 | } |
| 216 | return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Sanitize one token's per-device value bag. |
| 221 | * |
| 222 | * @param array $token Token definition. |
| 223 | * @param mixed $stored Stored value (device bag, or a bare value we wrap into desktop). |
| 224 | * @return array Device bag with at least `desktop`. |
| 225 | */ |
| 226 | public static function sanitize_token( $token, $stored ) { |
| 227 | // Accept a bare value (e.g. legacy import) by treating it as the desktop base. |
| 228 | if ( ! is_array( $stored ) || ! self::is_device_bag( $stored ) ) { |
| 229 | $stored = array( 'desktop' => $stored ); |
| 230 | } |
| 231 | |
| 232 | $devices = ! empty( $token['responsive'] ) ? array( 'desktop', 'tablet', 'mobile' ) : array( 'desktop' ); |
| 233 | $out = array(); |
| 234 | foreach ( $devices as $device ) { |
| 235 | if ( ! array_key_exists( $device, $stored ) ) { |
| 236 | continue; // Missing device → inherits desktop via the CSS cascade. |
| 237 | } |
| 238 | $out[ $device ] = self::sanitize_scalar( $token, $stored[ $device ] ); |
| 239 | } |
| 240 | |
| 241 | if ( ! isset( $out['desktop'] ) ) { |
| 242 | $out['desktop'] = $token['default']; |
| 243 | } |
| 244 | |
| 245 | return $out; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Sanitize a single value according to the token type. |
| 250 | * |
| 251 | * @param array $token Token definition. |
| 252 | * @param mixed $value Raw value. |
| 253 | * @return mixed Clean value (token default on anything invalid). |
| 254 | */ |
| 255 | public static function sanitize_scalar( $token, $value ) { |
| 256 | // The font-family select is data-driven (Google Fonts, not baked into schema options), |
| 257 | // so validate as a font-family string rather than against the fallback option list. |
| 258 | if ( ! empty( $token['source'] ) && 'google_fonts' === $token['source'] ) { |
| 259 | return self::sanitize_font_family( $value ); |
| 260 | } |
| 261 | |
| 262 | switch ( $token['type'] ) { |
| 263 | case 'slider': |
| 264 | return self::sanitize_number( $token, $value ); |
| 265 | |
| 266 | case 'color': |
| 267 | return self::sanitize_color( $value, $token['default'], ! empty( $token['gradientable'] ) ); |
| 268 | |
| 269 | case 'box4': |
| 270 | return self::sanitize_box4( $token, $value ); |
| 271 | |
| 272 | case 'select': |
| 273 | case 'align': |
| 274 | return self::sanitize_choice( $token, $value ); |
| 275 | |
| 276 | case 'fontstyle': |
| 277 | return self::sanitize_fontstyle( $token, $value ); |
| 278 | |
| 279 | case 'toggle': |
| 280 | return (bool) $value; |
| 281 | |
| 282 | case 'media': |
| 283 | return $value ? esc_url_raw( (string) $value ) : ''; |
| 284 | |
| 285 | default: |
| 286 | return sanitize_text_field( (string) $value ); |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | /* --------------------------------------------------------------------- * |
| 291 | * Type handlers |
| 292 | * --------------------------------------------------------------------- */ |
| 293 | |
| 294 | /** |
| 295 | * Clamp a slider value to [min,max]; integer unless the step is fractional. |
| 296 | * |
| 297 | * @param array $token Token definition. |
| 298 | * @param mixed $value Raw value. |
| 299 | * @return int|float |
| 300 | */ |
| 301 | protected static function sanitize_number( $token, $value ) { |
| 302 | if ( ! is_numeric( $value ) ) { |
| 303 | return $token['default']; |
| 304 | } |
| 305 | $min = isset( $token['min'] ) ? $token['min'] : 0; |
| 306 | $max = isset( $token['max'] ) ? $token['max'] : 9999; |
| 307 | $step = isset( $token['step'] ) ? $token['step'] : 1; |
| 308 | $n = ( $step < 1 ) ? (float) $value : (int) round( $value ); |
| 309 | return max( $min, min( $max, $n ) ); |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Public façade over {@see self::sanitize_color()} for other engine classes (e.g. {@see Palettes}). |
| 314 | * |
| 315 | * @param mixed $value Raw value. |
| 316 | * @param string $default Fallback. |
| 317 | * @return string |
| 318 | */ |
| 319 | public static function color( $value, $default = '#000000' ) { |
| 320 | return self::sanitize_color( $value, $default ); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Validate a colour: #rgb / #rrggbb (+ 8-digit alpha) or rgb()/rgba(). Falls back to |
| 325 | * the default; alpha is preserved so legacy `rgba()` borders survive. When `$gradientable` |
| 326 | * is true (only set on background tokens whose CSS rule uses the `background` shorthand, |
| 327 | * not `background-color` — see frontend.css), a `linear-gradient()`/`radial-gradient()` |
| 328 | * value built from solid colour stops is accepted too. |
| 329 | * |
| 330 | * @param mixed $value Raw value. |
| 331 | * @param string $default Fallback. |
| 332 | * @param bool $gradientable Whether a gradient value is acceptable for this token. |
| 333 | * @return string |
| 334 | */ |
| 335 | protected static function sanitize_color( $value, $default, $gradientable = false ) { |
| 336 | $value = is_string( $value ) ? trim( $value ) : ''; |
| 337 | if ( preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value ) ) { |
| 338 | return strtolower( $value ); |
| 339 | } |
| 340 | if ( preg_match( '/^rgba?\(\s*[\d.,%\s\/]+\)$/i', $value ) ) { |
| 341 | return $value; |
| 342 | } |
| 343 | if ( $gradientable && self::is_valid_gradient( $value ) ) { |
| 344 | return $value; |
| 345 | } |
| 346 | return $default; |
| 347 | } |
| 348 | |
| 349 | /** |
| 350 | * Whether `$value` is a `linear-gradient()`/`radial-gradient()` built entirely from solid |
| 351 | * colour stops (each stop optionally followed by a `<percentage>` position) — e.g. |
| 352 | * `linear-gradient(135deg, #3366cc, #7a5cff)`. Deliberately strict: this string is emitted |
| 353 | * verbatim into an inline `<style>` block ({@see Compiler::css_safe}), so anything short of |
| 354 | * "every part matches a known-safe shape" is rejected rather than guessed at. |
| 355 | * |
| 356 | * @param string $value Raw value. |
| 357 | * @return bool |
| 358 | */ |
| 359 | protected static function is_valid_gradient( $value ) { |
| 360 | if ( ! preg_match( '/^(linear|radial)-gradient\((.*)\)$/is', $value, $m ) ) { |
| 361 | return false; |
| 362 | } |
| 363 | $type = strtolower( $m[1] ); |
| 364 | $parts = self::split_top_level_commas( $m[2] ); |
| 365 | if ( count( $parts ) < 3 ) { // header + at least 2 colour stops. |
| 366 | return false; |
| 367 | } |
| 368 | $header = trim( $parts[0] ); |
| 369 | $is_header = ( 'linear' === $type && ( |
| 370 | preg_match( '/^-?\d+(\.\d+)?deg$/i', $header ) || |
| 371 | preg_match( '/^to\s+(top|bottom|left|right)(\s+(top|bottom|left|right))?$/i', $header ) |
| 372 | ) ) || ( 'radial' === $type && preg_match( '/^(circle|ellipse)(\s+at\s+.+)?$/i', $header ) ); |
| 373 | $stops = $is_header ? array_slice( $parts, 1 ) : $parts; |
| 374 | if ( count( $stops ) < 2 ) { |
| 375 | return false; |
| 376 | } |
| 377 | foreach ( $stops as $stop ) { |
| 378 | $stop = trim( $stop ); |
| 379 | if ( ! preg_match( |
| 380 | '/^(#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([\d.,%\s\/]+\))(\s+-?\d+(\.\d+)?%)?$/i', |
| 381 | $stop |
| 382 | ) ) { |
| 383 | return false; |
| 384 | } |
| 385 | } |
| 386 | return true; |
| 387 | } |
| 388 | |
| 389 | /** |
| 390 | * Split on top-level commas only — skips commas nested inside an `rgba()`/`hsla()` stop |
| 391 | * (e.g. `rgba(0,0,0,.5)` inside a gradient's stop list must not be split apart). |
| 392 | * |
| 393 | * @param string $str String to split. |
| 394 | * @return string[] |
| 395 | */ |
| 396 | protected static function split_top_level_commas( $str ) { |
| 397 | $parts = array(); |
| 398 | $depth = 0; |
| 399 | $cur = ''; |
| 400 | $len = strlen( $str ); |
| 401 | for ( $i = 0; $i < $len; $i++ ) { |
| 402 | $ch = $str[ $i ]; |
| 403 | if ( '(' === $ch ) { |
| 404 | $depth++; |
| 405 | } elseif ( ')' === $ch ) { |
| 406 | $depth--; |
| 407 | } |
| 408 | if ( ',' === $ch && 0 === $depth ) { |
| 409 | $parts[] = $cur; |
| 410 | $cur = ''; |
| 411 | continue; |
| 412 | } |
| 413 | $cur .= $ch; |
| 414 | } |
| 415 | $parts[] = $cur; |
| 416 | return $parts; |
| 417 | } |
| 418 | |
| 419 | /** |
| 420 | * Clean a 4-side box, keeping the legacy associative shape {top,right,bottom,left} |
| 421 | * (+ `unit` for a radius). Margins may be negative; everything else is clamped at 0. |
| 422 | * |
| 423 | * @param array $token Token definition. |
| 424 | * @param mixed $value Raw value. |
| 425 | * @return array |
| 426 | */ |
| 427 | protected static function sanitize_box4( $token, $value ) { |
| 428 | if ( ! is_array( $value ) ) { |
| 429 | return $token['default']; |
| 430 | } |
| 431 | $allow_neg = false !== strpos( $token['key'], 'margin' ); |
| 432 | $floor = isset( $token['min'] ) ? (int) $token['min'] : ( $allow_neg ? -1000 : 0 ); |
| 433 | $max = isset( $token['max'] ) ? $token['max'] : 1000; |
| 434 | |
| 435 | $out = array(); |
| 436 | foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) { |
| 437 | $n = isset( $value[ $side ] ) && is_numeric( $value[ $side ] ) ? (int) $value[ $side ] : 0; |
| 438 | $out[ $side ] = max( $floor, min( $max, $n ) ); |
| 439 | } |
| 440 | |
| 441 | if ( ! empty( $token['units'] ) ) { |
| 442 | $out['unit'] = ( isset( $value['unit'] ) && in_array( $value['unit'], $token['units'], true ) ) ? $value['unit'] : $token['units'][0]; |
| 443 | } |
| 444 | return $out; |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * Ensure a select/align value is one of the allowed options. |
| 449 | * |
| 450 | * @param array $token Token definition. |
| 451 | * @param mixed $value Raw value. |
| 452 | * @return string |
| 453 | */ |
| 454 | protected static function sanitize_choice( $token, $value ) { |
| 455 | $value = (string) $value; |
| 456 | if ( 'align' === $token['type'] ) { |
| 457 | return in_array( $value, array( 'left', 'center', 'right' ), true ) ? $value : $token['default']; |
| 458 | } |
| 459 | $allowed = array(); |
| 460 | foreach ( ( isset( $token['options'] ) ? $token['options'] : array() ) as $opt ) { |
| 461 | $allowed[] = (string) $opt['value']; |
| 462 | } |
| 463 | return in_array( $value, $allowed, true ) ? $value : $token['default']; |
| 464 | } |
| 465 | |
| 466 | /** |
| 467 | * Sanitize a font-family value: the empty string (theme font) or a plain family name. |
| 468 | * |
| 469 | * @param mixed $value Raw value. |
| 470 | * @return string |
| 471 | */ |
| 472 | protected static function sanitize_font_family( $value ) { |
| 473 | $value = sanitize_text_field( (string) $value ); |
| 474 | $value = preg_replace( '/[^A-Za-z0-9 ,\-\'"]/', '', $value ); |
| 475 | return trim( $value ); |
| 476 | } |
| 477 | |
| 478 | /** |
| 479 | * Normalize a font-style flag set to four booleans (old customizer keys), plus an optional |
| 480 | * explicit `weight` — validated against the token's own `weight_options`; anything else |
| 481 | * (unknown value, or simply absent, true for every pre-existing stored value) becomes '' |
| 482 | * ("Auto"), which keeps deriving font-weight from `bold` exactly as before this was added. |
| 483 | * |
| 484 | * @param array $token Token definition (for weight_options). |
| 485 | * @param mixed $value Raw value. |
| 486 | * @return array |
| 487 | */ |
| 488 | protected static function sanitize_fontstyle( $token, $value ) { |
| 489 | $value = is_array( $value ) ? $value : array(); |
| 490 | $out = array(); |
| 491 | foreach ( array( 'bold', 'italic', 'underline', 'uppercase' ) as $flag ) { |
| 492 | $out[ $flag ] = ! empty( $value[ $flag ] ); |
| 493 | } |
| 494 | $allowed_weights = array(); |
| 495 | foreach ( ( isset( $token['weight_options'] ) ? $token['weight_options'] : array() ) as $opt ) { |
| 496 | $allowed_weights[] = (string) $opt['value']; |
| 497 | } |
| 498 | $weight = isset( $value['weight'] ) ? (string) $value['weight'] : ''; |
| 499 | $out['weight'] = in_array( $weight, $allowed_weights, true ) ? $weight : ''; |
| 500 | return $out; |
| 501 | } |
| 502 | |
| 503 | /* --------------------------------------------------------------------- * |
| 504 | * Record-level helpers |
| 505 | * --------------------------------------------------------------------- */ |
| 506 | |
| 507 | /** |
| 508 | * A palette id must match one of the registered palettes, else empty (custom/none). A Pro |
| 509 | * palette is rejected unless the Pro tier is active. |
| 510 | * |
| 511 | * @param mixed $value Raw value. |
| 512 | * @return string |
| 513 | */ |
| 514 | protected static function sanitize_palette_id( $value ) { |
| 515 | $value = sanitize_key( $value ); |
| 516 | $pro_active = Engine::pro_active(); |
| 517 | foreach ( Schema::palettes() as $palette ) { |
| 518 | if ( $palette['id'] === $value ) { |
| 519 | if ( ! empty( $palette['is_pro'] ) && ! $pro_active ) { |
| 520 | return ''; |
| 521 | } |
| 522 | return $value; |
| 523 | } |
| 524 | } |
| 525 | return ''; |
| 526 | } |
| 527 | |
| 528 | /** |
| 529 | * Strip anything dangerous from user CSS ({@see Compiler::scope_custom_css()} scopes it). |
| 530 | * |
| 531 | * @param mixed $css Raw CSS. |
| 532 | * @return string |
| 533 | */ |
| 534 | protected static function sanitize_css( $css ) { |
| 535 | $css = (string) $css; |
| 536 | $css = wp_strip_all_tags( $css ); |
| 537 | $css = preg_replace( '/@import\b[^;]+;?/i', '', $css ); |
| 538 | $css = preg_replace( '/expression\s*\(/i', '', $css ); |
| 539 | $css = preg_replace( '/url\(\s*[\'"]?\s*javascript:/i', 'url(', $css ); |
| 540 | return trim( $css ); |
| 541 | } |
| 542 | |
| 543 | /** |
| 544 | * Is this array a per-device bag (has desktop/tablet/mobile keys) rather than a value |
| 545 | * that just happens to be an array (like a box4)? |
| 546 | * |
| 547 | * @param array $value Value. |
| 548 | * @return bool |
| 549 | */ |
| 550 | protected static function is_device_bag( $value ) { |
| 551 | foreach ( array( 'desktop', 'tablet', 'mobile' ) as $device ) { |
| 552 | if ( array_key_exists( $device, $value ) ) { |
| 553 | return true; |
| 554 | } |
| 555 | } |
| 556 | return false; |
| 557 | } |
| 558 | } |
| 559 |