PluginProbe
Ultimate Cursor – Interactive and Animated Custom Cursor and Background Effects Toolkit / trunk
Ultimate Cursor – Interactive and Animated Custom Cursor and Background Effects Toolkit vtrunk
2.4.1 2.4.0 2.3.3 2.3.2 2.3.1 2.3.0 2.2.3 2.2.2 2.2.1 trunk 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 All 56 releases
ultimate-cursor / classes / class-license-gate.php

class-license-gate.php in Ultimate Cursor – Interactive and Animated Custom Cursor and Background Effects Toolkit trunk, at classes/class-license-gate.php

438 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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