| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* License Gate — single server-side authority for premium feature enforcement. |
| 5 |
* |
| 6 |
* Every premium-gated setting (cursor AND background) must be registered here. |
| 7 |
* The gate is applied at three choke points: |
| 8 |
* 1. REST writes (class-rest.php) — via strip_premium_input(): premium |
| 9 |
* fields are removed from the INCOMING payload only. Premium data |
| 10 |
* already stored in the DB stays dormant across a license lapse and |
| 11 |
* comes back when the license returns; a free save never destroys it. |
| 12 |
* 2. Frontend enqueues (class-assets.php) — via sanitize(): full strip on |
| 13 |
* output, so dormant values never reach the public site unlicensed. |
| 14 |
* 3. Admin data output (class-assets.php) — via sanitize(): same full strip. |
| 15 |
* |
| 16 |
* Client-side (React) gating is presentation only and must never be the sole guard. |
| 17 |
* |
| 18 |
* @package ultimate-cursor |
| 19 |
*/ |
| 20 |
|
| 21 |
if ( ! defined( 'ABSPATH' ) ) { |
| 22 |
exit; |
| 23 |
} |
| 24 |
|
| 25 |
/** |
| 26 |
* Class Ultimate_Cursor_License_Gate |
| 27 |
*/ |
| 28 |
class Ultimate_Cursor_License_Gate { |
| 29 |
|
| 30 |
/** |
| 31 |
* Per-request cache of is_premium_active(). |
| 32 |
* |
| 33 |
* A static property (not a `static` local) so it can be flushed when the |
| 34 |
* license state changes mid-process — e.g. right after license activation, |
| 35 |
* or between unit tests. |
| 36 |
* |
| 37 |
* @var bool|null |
| 38 |
*/ |
| 39 |
private static $premium_active = null; |
| 40 |
|
| 41 |
/** |
| 42 |
* Flush the cached is_premium_active() result. |
| 43 |
*/ |
| 44 |
public static function reset_premium_active_cache() { |
| 45 |
self::$premium_active = null; |
| 46 |
} |
| 47 |
|
| 48 |
/** |
| 49 |
* Check if the user has a valid premium license. |
| 50 |
* |
| 51 |
* This is the SINGLE SOURCE OF TRUTH for premium feature gating. |
| 52 |
* It verifies BOTH conditions: |
| 53 |
* 1. The pro plugin class is loaded (plugin is active) |
| 54 |
* 2. Freemius reports a valid license or trial |
| 55 |
* |
| 56 |
* @return bool True only if pro plugin is active AND license is valid. |
| 57 |
*/ |
| 58 |
public static function is_premium_active() { |
| 59 |
// Cache the result to avoid repeated Freemius calls within a single request. |
| 60 |
if ( self::$premium_active !== null ) { |
| 61 |
return self::$premium_active; |
| 62 |
} |
| 63 |
$result = null; |
| 64 |
|
| 65 |
/** |
| 66 |
* Fast-path filter so a verified pro build can short-circuit the |
| 67 |
* Freemius lookups (and unit tests can flip the gate). |
| 68 |
* |
| 69 |
* @param bool $active Whether premium is active. Default false. |
| 70 |
*/ |
| 71 |
if ( apply_filters( 'ultimate_cursor_is_premium_active', false ) ) { |
| 72 |
self::$premium_active = true; |
| 73 |
return self::$premium_active; |
| 74 |
} |
| 75 |
|
| 76 |
// Condition 1: Pro plugin must be active. |
| 77 |
if ( ! class_exists( 'Ultimate_Cursor_Pro' ) ) { |
| 78 |
self::$premium_active = false; |
| 79 |
return self::$premium_active; |
| 80 |
} |
| 81 |
|
| 82 |
// Condition 2: Freemius must confirm a valid license. |
| 83 |
try { |
| 84 |
$fs = null; |
| 85 |
|
| 86 |
if ( function_exists( 'ultimate_cursor_pro_fs' ) ) { |
| 87 |
$fs = ultimate_cursor_pro_fs(); |
| 88 |
} |
| 89 |
|
| 90 |
if ( ! $fs && function_exists( 'ultimate_cursor_fs' ) ) { |
| 91 |
$fs = ultimate_cursor_fs(); |
| 92 |
} |
| 93 |
|
| 94 |
if ( ! $fs ) { |
| 95 |
self::$premium_active = false; |
| 96 |
return self::$premium_active; |
| 97 |
} |
| 98 |
|
| 99 |
// can_use_premium_code() covers both paid licenses and trials. |
| 100 |
$result = (bool) $fs->can_use_premium_code(); |
| 101 |
} catch ( \Exception $e ) { |
| 102 |
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 103 |
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Error logging gated behind WP_DEBUG for diagnostics only. |
| 104 |
error_log( 'Ultimate Cursor: Premium validation error - ' . $e->getMessage() ); |
| 105 |
} |
| 106 |
$result = false; |
| 107 |
} |
| 108 |
|
| 109 |
self::$premium_active = $result; |
| 110 |
|
| 111 |
return self::$premium_active; |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Premium-only setting keys per settings group. |
| 116 |
* |
| 117 |
* These keys are stripped entirely when no valid license exists. |
| 118 |
* |
| 119 |
* @param string $group Settings group: 'cursor' or 'background'. |
| 120 |
* @return array |
| 121 |
*/ |
| 122 |
public static function get_premium_keys( $group ) { |
| 123 |
$keys = array( |
| 124 |
'cursor' => array( |
| 125 |
'enableMultipleCursors', |
| 126 |
'cursorConfigurations', |
| 127 |
// Image cursor hotspot (tip alignment) is a premium feature. |
| 128 |
'imageHotspotPreset', |
| 129 |
'imageHotspotX', |
| 130 |
'imageHotspotY', |
| 131 |
// Circular text background is a premium text-cursor feature |
| 132 |
// (locked in the admin UI; absent values fall back to the |
| 133 |
// frontend component defaults). |
| 134 |
'circularBackground', |
| 135 |
'circularBackgroundSize', |
| 136 |
// Circular text (rotating badge) is a premium text-cursor |
| 137 |
// style. The style itself is blocked via get_premium_values() |
| 138 |
// (textStyle => circular); these are its customization knobs. |
| 139 |
// Absent values fall back to the frontend component defaults. |
| 140 |
'circularTextPreset', |
| 141 |
'circularTextRadius', |
| 142 |
'circularTextRotate', |
| 143 |
'circularTextDirection', |
| 144 |
'circularTextShowCircle', |
| 145 |
'circularTextCircleSize', |
| 146 |
'circularTextAutoRotate', |
| 147 |
'circularTextRotationSpeed', |
| 148 |
// Interactive Hover is a fully premium feature: the enable |
| 149 |
// toggle, scale, speed and colors are all premium, alongside |
| 150 |
// the advanced knobs below. Absent values = feature off in the |
| 151 |
// JS runtime (never reverted, so dormant values survive a lapse). |
| 152 |
'hoverEnabled', |
| 153 |
'hoverScale', |
| 154 |
'hoverSpeed', |
| 155 |
'hoverColor', |
| 156 |
'hoverTextColor', |
| 157 |
'hoverMagneticStrength', |
| 158 |
'hoverGlowSize', |
| 159 |
'hoverGlowColor', |
| 160 |
'hoverLabelsEnabled', |
| 161 |
'hoverBlendMode', |
| 162 |
'hoverCustomSelectors', |
| 163 |
), |
| 164 |
'background' => array( |
| 165 |
'enableMultipleBackgrounds', |
| 166 |
'backgroundConfigurations', |
| 167 |
// The "Display Settings" card (position / z-index / opacity) |
| 168 |
// is premium. The frontend renderer falls back to |
| 169 |
// fixed / -1 / 1 when these are absent (renderer.js). |
| 170 |
'position', |
| 171 |
'zIndex', |
| 172 |
'opacity', |
| 173 |
), |
| 174 |
); |
| 175 |
|
| 176 |
$group_keys = isset( $keys[ $group ] ) ? $keys[ $group ] : array(); |
| 177 |
|
| 178 |
/** |
| 179 |
* Filter the premium-only setting keys for a settings group. |
| 180 |
* |
| 181 |
* @param array $group_keys Premium-only keys. |
| 182 |
* @param string $group Settings group. |
| 183 |
*/ |
| 184 |
return apply_filters( 'ultimate_cursor_premium_keys', $group_keys, $group ); |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* Premium-only setting values per settings group. |
| 189 |
* |
| 190 |
* Format: field => array( 'blocked' => array of blocked values, 'default' => safe replacement ). |
| 191 |
* |
| 192 |
* @param string $group Settings group: 'cursor' or 'background'. |
| 193 |
* @return array |
| 194 |
*/ |
| 195 |
public static function get_premium_values( $group ) { |
| 196 |
$values = array( |
| 197 |
'cursor' => array( |
| 198 |
'cursorScope' => array( |
| 199 |
'blocked' => array( 'specific-pages', 'css-selectors', 'html-elements' ), |
| 200 |
'default' => 'entire-website', |
| 201 |
), |
| 202 |
// Circular text style is premium; free text cursors render |
| 203 |
// the normal (straight) style. |
| 204 |
'textStyle' => array( |
| 205 |
'blocked' => array( 'circular' ), |
| 206 |
'default' => 'normal', |
| 207 |
), |
| 208 |
// Shapes 6-25 are premium; 1-5 are free. |
| 209 |
'cursorShape' => array( |
| 210 |
'blocked' => array( '6.svg', '7.svg', '8.svg', '9.svg', '10.svg', '11.svg', '12.svg', '13.svg', '14.svg', '15.svg', '16.svg', '17.svg', '18.svg', '19.svg', '20.svg', '21.svg', '22.svg', '23.svg', '24.svg', '25.svg' ), |
| 211 |
'default' => '1.svg', |
| 212 |
), |
| 213 |
), |
| 214 |
'background' => array( |
| 215 |
'scope' => array( |
| 216 |
'blocked' => array( 'specific-pages', 'css-selector' ), |
| 217 |
'default' => 'entire-website', |
| 218 |
), |
| 219 |
), |
| 220 |
); |
| 221 |
|
| 222 |
$group_values = isset( $values[ $group ] ) ? $values[ $group ] : array(); |
| 223 |
|
| 224 |
/** |
| 225 |
* Filter the premium-only setting values for a settings group. |
| 226 |
* |
| 227 |
* @param array $group_values Premium-only value rules. |
| 228 |
* @param string $group Settings group. |
| 229 |
*/ |
| 230 |
return apply_filters( 'ultimate_cursor_premium_values', $group_values, $group ); |
| 231 |
} |
| 232 |
|
| 233 |
/** |
| 234 |
* Premium-only per-effect cursor config fields. |
| 235 |
* |
| 236 |
* Every animated-effect customization control in the admin UI is |
| 237 |
* premium-locked (free users run each effect with its built-in |
| 238 |
* defaults); this map is the server-side mirror of those locks. |
| 239 |
* Field names were collision-checked against the free UI: text |
| 240 |
* cursors use `fontSize` (not `size`), BubbleCursor styling and |
| 241 |
* `imageSize` are free and intentionally absent here. |
| 242 |
* |
| 243 |
* Both gates strip the UNION of these fields (not just the payload's |
| 244 |
* effect) — a payload that omits `effect` must not sneak premium |
| 245 |
* knobs past the gate. Unset (never revert) so frontend components |
| 246 |
* fall back to their built-in free defaults and dormant stored pro |
| 247 |
* values survive a license lapse. |
| 248 |
* |
| 249 |
* @return array effect => array of premium field names. |
| 250 |
*/ |
| 251 |
public static function get_premium_cursor_effect_fields() { |
| 252 |
$fields = array( |
| 253 |
'CharacterCursor' => array( 'characters', 'speed', 'delay', 'charactersColors' ), |
| 254 |
'RainbowCursor' => array( 'length', 'size', 'trailSpeed', 'blur', 'colors' ), |
| 255 |
'SnowFlake' => array( 'snowflakeEmojis', 'snowflakeSize', 'snowflakeCount', 'snowflakeLifespan' ), |
| 256 |
'TrailCursor' => array( 'trailEmoji', 'trailEmojiSize', 'trailParticleCount', 'trailSpeed', 'trailOpacity' ), |
| 257 |
'SplashCursor' => array( 'splashRadius', 'splashForce', 'splashColors' ), |
| 258 |
'ClickSpark' => array( 'sparkSize', 'sparkCount', 'duration', 'sparkColor', 'easing', 'extraScale', 'sparkRadius' ), |
| 259 |
'ClickParticles' => array( 'particleSpeed', 'particleColor' ), |
| 260 |
); |
| 261 |
|
| 262 |
/** |
| 263 |
* Filter the premium-only per-effect cursor config fields. |
| 264 |
* |
| 265 |
* @param array $fields effect => array of field names. |
| 266 |
*/ |
| 267 |
return apply_filters( 'ultimate_cursor_premium_cursor_effect_fields', $fields ); |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* Premium-only per-animation-type background config fields. |
| 272 |
* |
| 273 |
* When no valid license exists these fields are reset to their free defaults |
| 274 |
* (mirrors the disabled controls in the React background editor). |
| 275 |
* |
| 276 |
* @return array animationType => array( field => free default ). |
| 277 |
*/ |
| 278 |
public static function get_premium_background_type_fields() { |
| 279 |
$fields = array( |
| 280 |
'antigravity' => array( |
| 281 |
'magnetRadius' => 10, |
| 282 |
'ringRadius' => 10, |
| 283 |
'fieldStrength' => 10, |
| 284 |
'waveSpeed' => 0.4, |
| 285 |
'waveAmplitude' => 1, |
| 286 |
'lerpSpeed' => 0.1, |
| 287 |
'pulseSpeed' => 3, |
| 288 |
'depthFactor' => 1, |
| 289 |
'rotationSpeed' => 0, |
| 290 |
'autoAnimate' => false, |
| 291 |
), |
| 292 |
'light-pillar' => array( |
| 293 |
'intensity' => 1, |
| 294 |
'rotationSpeed' => 0.3, |
| 295 |
'glowAmount' => 0.005, |
| 296 |
'noiseIntensity' => 0.5, |
| 297 |
'interactive' => false, |
| 298 |
'mixBlendMode' => 'screen', |
| 299 |
'quality' => 'high', |
| 300 |
), |
| 301 |
); |
| 302 |
|
| 303 |
/** |
| 304 |
* Filter the premium-only per-type background config fields. |
| 305 |
* |
| 306 |
* @param array $fields animationType => array( field => free default ). |
| 307 |
*/ |
| 308 |
return apply_filters( 'ultimate_cursor_premium_background_type_fields', $fields ); |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* Drop premium-only fields from an incoming REST payload when no valid |
| 313 |
* license is active. |
| 314 |
* |
| 315 |
* Blocks injection via direct API calls WITHOUT touching premium values |
| 316 |
* already stored in the DB (those merge through untouched and stay |
| 317 |
* dormant until the license returns). Blocked-value fields (scopes, |
| 318 |
* premium shapes) are unset rather than reverted so a free save never |
| 319 |
* overwrites a stored pro value; premium per-type background knobs are |
| 320 |
* likewise unset rather than reset. |
| 321 |
* |
| 322 |
* @param array $settings Incoming (already schema-sanitized) payload. |
| 323 |
* @param string $group Settings group: 'cursor' or 'background'. |
| 324 |
* @return array |
| 325 |
*/ |
| 326 |
public static function strip_premium_input( $settings, $group = 'cursor' ) { |
| 327 |
if ( ! is_array( $settings ) || self::is_premium_active() ) { |
| 328 |
return $settings; |
| 329 |
} |
| 330 |
|
| 331 |
// Premium-only keys: never enter storage from an unlicensed request. |
| 332 |
foreach ( self::get_premium_keys( $group ) as $key ) { |
| 333 |
unset( $settings[ $key ] ); |
| 334 |
} |
| 335 |
|
| 336 |
// Premium-only values: unset (not revert) so the stored value survives. |
| 337 |
foreach ( self::get_premium_values( $group ) as $field => $rule ) { |
| 338 |
if ( isset( $settings[ $field ] ) && in_array( $settings[ $field ], $rule['blocked'], true ) ) { |
| 339 |
unset( $settings[ $field ] ); |
| 340 |
} |
| 341 |
} |
| 342 |
|
| 343 |
// Premium per-type background config fields: same unset-not-reset rule. |
| 344 |
// The UNION across all types is stripped (not just the payload's |
| 345 |
// animationType) — otherwise a payload that omits animationType could |
| 346 |
// sneak premium knobs past the gate into a stored config. |
| 347 |
if ( 'background' === $group ) { |
| 348 |
foreach ( self::get_premium_background_type_fields() as $type_fields ) { |
| 349 |
foreach ( array_keys( $type_fields ) as $field ) { |
| 350 |
unset( $settings[ $field ] ); |
| 351 |
} |
| 352 |
} |
| 353 |
} |
| 354 |
|
| 355 |
// Premium per-effect cursor config fields: union unset, same rationale. |
| 356 |
if ( 'cursor' === $group ) { |
| 357 |
foreach ( self::get_premium_cursor_effect_fields() as $effect_fields ) { |
| 358 |
foreach ( $effect_fields as $field ) { |
| 359 |
unset( $settings[ $field ] ); |
| 360 |
} |
| 361 |
} |
| 362 |
} |
| 363 |
|
| 364 |
return $settings; |
| 365 |
} |
| 366 |
|
| 367 |
/** |
| 368 |
* Sanitize a settings array by stripping/reverting premium-only data |
| 369 |
* when no valid license exists. |
| 370 |
* |
| 371 |
* OUTPUT gate: used on admin localize + frontend enqueue paths. For REST |
| 372 |
* input use strip_premium_input() instead — this method reverts values |
| 373 |
* and would clobber dormant premium data if run against storage. |
| 374 |
* |
| 375 |
* @param array $settings The settings array to sanitize. |
| 376 |
* @param string $group Settings group: 'cursor' or 'background'. |
| 377 |
* @return array Sanitized settings. |
| 378 |
*/ |
| 379 |
public static function sanitize( $settings, $group = 'cursor' ) { |
| 380 |
if ( ! is_array( $settings ) ) { |
| 381 |
return $settings; |
| 382 |
} |
| 383 |
|
| 384 |
// If premium is active, allow everything. |
| 385 |
if ( self::is_premium_active() ) { |
| 386 |
return $settings; |
| 387 |
} |
| 388 |
|
| 389 |
// Strip premium-only keys. |
| 390 |
foreach ( self::get_premium_keys( $group ) as $key ) { |
| 391 |
if ( isset( $settings[ $key ] ) ) { |
| 392 |
unset( $settings[ $key ] ); |
| 393 |
} |
| 394 |
} |
| 395 |
|
| 396 |
// Revert premium-only values to their safe defaults. |
| 397 |
foreach ( self::get_premium_values( $group ) as $field => $rule ) { |
| 398 |
if ( isset( $settings[ $field ] ) && in_array( $settings[ $field ], $rule['blocked'], true ) ) { |
| 399 |
$settings[ $field ] = $rule['default']; |
| 400 |
} |
| 401 |
} |
| 402 |
|
| 403 |
if ( 'cursor' === $group ) { |
| 404 |
// Force disable multiple cursors. |
| 405 |
$settings['enableMultipleCursors'] = false; |
| 406 |
|
| 407 |
// Unset premium per-effect fields (union across effects) so the |
| 408 |
// frontend components fall back to their built-in free defaults. |
| 409 |
// Unset — not revert — because the free defaults live in the JS |
| 410 |
// components; duplicating them here would rot. |
| 411 |
foreach ( self::get_premium_cursor_effect_fields() as $effect_fields ) { |
| 412 |
foreach ( $effect_fields as $field ) { |
| 413 |
unset( $settings[ $field ] ); |
| 414 |
} |
| 415 |
} |
| 416 |
} |
| 417 |
|
| 418 |
if ( 'background' === $group ) { |
| 419 |
// Force disable multiple backgrounds. |
| 420 |
$settings['enableMultipleBackgrounds'] = false; |
| 421 |
|
| 422 |
// Reset premium per-type config fields to their free defaults. |
| 423 |
$type_fields = self::get_premium_background_type_fields(); |
| 424 |
$type = isset( $settings['animationType'] ) ? $settings['animationType'] : ''; |
| 425 |
|
| 426 |
if ( isset( $type_fields[ $type ] ) ) { |
| 427 |
foreach ( $type_fields[ $type ] as $field => $default ) { |
| 428 |
if ( isset( $settings[ $field ] ) ) { |
| 429 |
$settings[ $field ] = $default; |
| 430 |
} |
| 431 |
} |
| 432 |
} |
| 433 |
} |
| 434 |
|
| 435 |
return $settings; |
| 436 |
} |
| 437 |
} |
| 438 |
|