PluginProbe
Sky Login Redirect / trunk
Sky Login Redirect vtrunk
sky-login-redirect / sky-login-redirect.php

sky-login-redirect.php in Sky Login Redirect trunk, at sky-login-redirect.php

781 lines 35.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Name: Sky Login Redirect
5 * Plugin URI: https://utopique.net/products/sky-login-redirect-premium/
6 * Description: Advanced login/logout redirects with user/role rules, content restriction, login customizer, and WooCommerce integration.
7 * Version: 4.2.9
8 * Author: Utopique
9 * Author URI: https://utopique.net/
10 * Developer: Utopique
11 * Developer URI: https://utopique.net/
12 * Copyright: (c) 2009-2026 Utopique
13 * Text Domain: sky-login-redirect
14 * License: GPLv3 or later
15 * Requires at least: 5.6
16 * Tested up to: 7.1
17 * Requires PHP: 8.1
18 * WC requires at least: 3.3
19 * WC tested up to: 11
20 *
21 * Modern PHP 8.1+ implementation with strict types and enums.
22 *
23 * @category Login_Redirect
24 * @package Sky_Login_Redirect
25 * @author Utopique <support@utopique.net>
26 * @license GPL https://utopique.net
27 * @link https://utopique.net
28 */
29 declare (strict_types = 1);
30 namespace SkyLoginRedirect;
31
32 if ( !defined( 'ABSPATH' ) ) {
33 exit;
34 }
35 // Current version.
36 define( 'SLR_VERSION', '4.2.9' );
37 // Plugin root path.
38 define( 'SLR_ROOT', trailingslashit( plugin_dir_path( __FILE__ ) ) );
39 // Composer autoloader — must be loaded unconditionally so that:
40 // 1. Freemius SDK global functions (fs_dynamic_init, etc.) are available at plugin load time.
41 // 2. CF classes (Carbon_Fields\Widget, etc.) are available for class inheritance site-wide.
42 require_once SLR_ROOT . 'vendor/autoload.php';
43 /** Plugin admin screen IDs. */
44 const PLUGIN_SCREENS = [
45 'toplevel_page_sky-login-redirect',
46 'login-redirect_page_sky-login-redirect-account',
47 'login-redirect_page_sky-login-redirect-contact',
48 'login-redirect_page_sky-login-redirect-pricing'
49 ];
50 /**
51 * Freemius
52 */
53 if ( function_exists( __NAMESPACE__ . '\\sky_login_redirect_fs' ) ) {
54 sky_login_redirect_fs()->set_basename( false, __FILE__ );
55 } else {
56 // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found -- Required by the native Freemius bootstrap.
57 /**
58 * DO NOT REMOVE THIS IF, IT IS ESSENTIAL FOR THE
59 * `function_exists` CALL ABOVE TO PROPERLY WORK.
60 */
61 if ( !function_exists( __NAMESPACE__ . '\\sky_login_redirect_fs' ) ) {
62 /**
63 * Create a helper function for easy SDK access.
64 *
65 * @return object Freemius SDK instance.
66 */
67 function sky_login_redirect_fs() {
68 global $sky_login_redirect_fs;
69 if ( !isset( $sky_login_redirect_fs ) ) {
70 // Include Freemius SDK.
71 // SDK is auto-loaded through Composer
72 $sky_login_redirect_fs = \fs_dynamic_init( [
73 'id' => '3088',
74 'slug' => 'sky-login-redirect',
75 'type' => 'plugin',
76 'public_key' => 'pk_f0e9c9d4e383120cf38d5b44b586b',
77 'is_premium' => false,
78 'premium_suffix' => 'Pro',
79 'has_addons' => false,
80 'has_paid_plans' => true,
81 'is_org_compliant' => true,
82 'menu' => [
83 'slug' => 'sky-login-redirect',
84 ],
85 'is_live' => true,
86 ] );
87 }
88 return $sky_login_redirect_fs;
89 }
90
91 /**
92 * Return the local plugin icon path for Freemius.
93 *
94 * @return string Icon file path.
95 */
96 function get_plugin_icon() : string {
97 return __DIR__ . '/assets/img/sky-login-redirect.png';
98 }
99
100 if ( is_admin() ) {
101 // Init Freemius.
102 sky_login_redirect_fs();
103 // Ensure the plugin icon resolves to the committed local asset
104 // regardless of the Freemius SDK assets folder copy.
105 sky_login_redirect_fs()->add_filter( 'plugin_icon', __NAMESPACE__ . '\\get_plugin_icon' );
106 // Signal that SDK was initiated.
107 do_action( 'sky_login_redirect_fs_loaded' );
108 }
109 /**
110 * Clean up all plugin data on uninstall.
111 * Hooked to Freemius after_uninstall to allow uninstall tracking.
112 *
113 * @return void
114 */
115 function sky_login_redirect_fs_uninstall_cleanup() : void {
116 global $wpdb;
117 // Delete main plugin options.
118 delete_option( 'sky_login_redirect' );
119 delete_option( 'SLR_BFCM' );
120 // Delete all Carbon Fields options (prefixed with _slr_).
121 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Bulk wildcard deletion has no equivalent options API.
122 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( '_slr_' ) . '%' ) );
123 // Delete transients.
124 delete_transient( 'sky_login_redirect' );
125 // Clear object cache.
126 wp_cache_delete( 'sky_login_redirect', 'slr' );
127 }
128
129 // Hook uninstall cleanup to Freemius after_uninstall action — admin only.
130 // Freemius uninstall flows always run in an admin context; registering this
131 // hook on frontend or WP-CLI would trigger a needless FS initialisation.
132 if ( is_admin() ) {
133 sky_login_redirect_fs()->add_action( 'after_uninstall', __NAMESPACE__ . '\\sky_login_redirect_fs_uninstall_cleanup' );
134 }
135 // Translations are automatically loaded by WordPress for plugins hosted on WordPress.org
136 /**
137 * Load Carbon Fields dependency via Composer.
138 *
139 * Boots CF on every admin request and on CF REST saves so that:
140 * - The admin sidebar menu item is registered on every admin page load
141 * (CF's Container::make() registers the WP menu — if CF does not boot,
142 * the menu item never appears anywhere in the admin).
143 * - Field values can be saved via the CF REST API.
144 *
145 * CF is intentionally NOT booted on frontend or WP-CLI requests — that is
146 * where the performance win lives (5–15 ms PHP time, ~1–2 MB memory saved
147 * per public page load).
148 *
149 * REST_REQUEST is not yet defined at after_setup_theme, so CF REST requests
150 * are detected by inspecting the raw REQUEST_URI instead.
151 *
152 * @return void
153 */
154 function load_carbon_fields() : void {
155 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
156 $request_uri = ( isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' );
157 $is_cf_rest = str_contains( $request_uri, '/carbon-fields/' );
158 // Boot on all admin requests and on CF REST saves.
159 // Skip everything else (frontend, WP-CLI).
160 if ( !is_admin() && !$is_cf_rest ) {
161 return;
162 }
163 \Carbon_Fields\Carbon_Fields::boot();
164 // Remove CF's sidebar widget manager scripts — not needed for theme options.
165 $sidebar_manager = \Carbon_Fields\Carbon_Fields::resolve( 'sidebar_manager' );
166 remove_action( 'admin_enqueue_scripts', [$sidebar_manager, 'enqueue_scripts'] );
167 }
168
169 add_action( 'after_setup_theme', __NAMESPACE__ . '\\load_carbon_fields' );
170 /**
171 * Load plugin options file.
172 *
173 * @return void
174 */
175 function load_plugin() : void {
176 include_once plugin_dir_path( __FILE__ ) . 'includes/options.php';
177 }
178
179 add_action( 'plugins_loaded', __NAMESPACE__ . '\\load_plugin' );
180 include_once plugin_dir_path( __FILE__ ) . 'includes/meta.php';
181 //include_once plugin_dir_path( __FILE__ ) . 'includes/notices.php';
182 include_once plugin_dir_path( __FILE__ ) . 'includes/class-redirect-manager.php';
183 require_once plugin_dir_path( __FILE__ ) . 'includes/class-css-builder.php';
184 /**
185 * Determines if the current page is the WordPress login page.
186 *
187 * @return bool True if inside WordPress login page.
188 */
189 function is_login_page() : bool {
190 global $pagenow;
191 // Check if current page is wp-login.php
192 if ( 'wp-login.php' === $pagenow ) {
193 return true;
194 }
195 // Check if current page contains the [login-form] shortcode
196 if ( function_exists( 'is_singular' ) && is_singular() ) {
197 $post = get_post();
198 if ( $post && function_exists( 'has_shortcode' ) && has_shortcode( (string) $post->post_content, 'login-form' ) ) {
199 return true;
200 }
201 }
202 return false;
203 }
204
205 /**
206 * Get the canonical URL of the current page.
207 * Uses get_permalink() for singular pages, falls back to REQUEST_URI.
208 *
209 * @return string Current page URL.
210 */
211 function get_current_url() : string {
212 if ( function_exists( 'is_singular' ) && is_singular() ) {
213 $permalink = get_permalink( get_the_ID() );
214 if ( $permalink ) {
215 return esc_url_raw( $permalink );
216 }
217 }
218 $req = ( isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : '/' );
219 return esc_url_raw( home_url( $req ) );
220 }
221
222 /**
223 * Check whether any configured redirect rule needs prior-page tracking.
224 *
225 * @return bool Whether the tracking assets are required.
226 */
227 function slr_needs_prior_tracking() : bool {
228 static $needed;
229 if ( null !== $needed ) {
230 return $needed;
231 }
232 $needed = false;
233 foreach ( carbonade_pipe( 'slr_xlogin_logout' ) as $rule ) {
234 if ( 'prior' === ($rule['slr_xselect_login'] ?? '') || 'prior' === ($rule['slr_xselect_logout'] ?? '') ) {
235 $needed = true;
236 break;
237 }
238 }
239 return $needed;
240 }
241
242 // Track the prior page and inject it into supported login forms with one asset.
243 $slr_suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min' );
244 $enqueue_prior_page_script = static function () use($slr_suffix) : void {
245 if ( !slr_needs_prior_tracking() ) {
246 return;
247 }
248 wp_enqueue_script(
249 'slr-local-tracker',
250 plugins_url( "assets/js/slr-local-tracker{$slr_suffix}.js", __FILE__ ),
251 [],
252 ( defined( 'SLR_VERSION' ) ? SLR_VERSION : null ),
253 true
254 );
255 };
256 add_action( 'login_enqueue_scripts', $enqueue_prior_page_script );
257 // Load on all frontend pages so ordinary pages are tracked and embedded
258 // WordPress, WooCommerce, and EDD login forms receive the stored referrer.
259 add_action( 'wp_enqueue_scripts', static function () use($enqueue_prior_page_script) : void {
260 if ( is_admin() ) {
261 return;
262 }
263 $enqueue_prior_page_script();
264 } );
265 // Load PERF-005 migration for Select2 to association fields
266 require_once SLR_ROOT . 'includes/migration-perf005.php';
267 // Load security header manager
268 require_once SLR_ROOT . 'includes/class-security-header-manager.php';
269 $security_manager = new SecurityHeaderManager();
270 add_action( 'send_headers', $security_manager->addSecurityHeaders( ... ) );
271 /**
272 * Clear login cookies
273 *
274 * @return void
275 */
276 function clear_cookies_on_logout() : void {
277 global $wpdb;
278 // Set the cookie expiration time to a year ago
279 $expiration_time = time() - YEAR_IN_SECONDS;
280 // Define the cookie prefixes to clear
281 $cookie_prefixes = [
282 'wordpress_',
283 'woocommerce_',
284 $wpdb->prefix . 'woocommerce_',
285 'comment_',
286 'wp-postpass_',
287 'wp-settings-',
288 'wp-lang'
289 ];
290 // Loop through the cookie prefixes and clear any matching cookies
291 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Keys are sanitized below.
292 $cookie_keys = array_keys( $_COOKIE );
293 foreach ( $cookie_prefixes as $prefix ) {
294 foreach ( $cookie_keys as $cookie_key ) {
295 // Sanitize cookie key to prevent injection attacks
296 $sanitized_key = sanitize_text_field( wp_unslash( $cookie_key ) );
297 if ( strpos( $sanitized_key, $prefix ) === 0 ) {
298 $opts = [
299 'expires' => $expiration_time,
300 'path' => '/',
301 'secure' => is_ssl(),
302 'httponly' => true,
303 'samesite' => 'Lax',
304 ];
305 if ( defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ) {
306 $opts['domain'] = COOKIE_DOMAIN;
307 }
308 setcookie( $sanitized_key, '', $opts );
309 }
310 }
311 }
312 // Clear-Site-Data header
313 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data
314 if ( !headers_sent() && apply_filters( 'slr_use_clear_site_data_on_logout', false ) ) {
315 header( 'Clear-Site-Data: "cookies"' );
316 }
317 }
318
319 add_action( 'wp_logout', __NAMESPACE__ . '\\clear_cookies_on_logout', PHP_INT_MAX );
320 /**
321 * Get the real client IP address, checking for proxy/CDN headers.
322 *
323 * By default ONLY REMOTE_ADDR is trusted. Proxy headers like X-Forwarded-For
324 * and CF-Connecting-IP are attacker-controllable on sites not behind the
325 * corresponding proxy/CDN, so trusting them by default would let an attacker
326 * rotate the rate-limit key and bypass brute-force protection.
327 *
328 * Opt in via the 'slr_rate_limit_ip' filter, e.g.:
329 * add_filter( 'slr_rate_limit_ip', fn() => [ 'HTTP_CF_CONNECTING_IP', 'REMOTE_ADDR' ] );
330 *
331 * @return string Validated IP address, or empty string if not found.
332 */
333 function get_client_ip() : string {
334 $ip_headers = apply_filters( 'slr_rate_limit_ip', ['REMOTE_ADDR'] );
335 foreach ( $ip_headers as $header ) {
336 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below
337 $ip = ( isset( $_SERVER[$header] ) ? sanitize_text_field( wp_unslash( $_SERVER[$header] ) ) : '' );
338 if ( empty( $ip ) ) {
339 continue;
340 }
341 if ( 'HTTP_X_FORWARDED_FOR' === $header ) {
342 $ips = explode( ',', $ip );
343 $ip = trim( $ips[0] );
344 }
345 $ip = filter_var( trim( $ip ), FILTER_VALIDATE_IP );
346 if ( false !== $ip ) {
347 return $ip;
348 }
349 }
350 return '';
351 }
352
353 /**
354 * Get the URL the user was on before reaching the login page.
355 *
356 * Reads the slr_referrer POST field injected by slr-local-tracker.js (localStorage),
357 * with a fallback to wp_get_referer().
358 *
359 * @return string Pre-login URL, or empty string if unavailable.
360 */
361 function get_pre_login_url() : string {
362 $referrer = filter_input( INPUT_POST, 'slr_referrer', FILTER_DEFAULT );
363 if ( $referrer ) {
364 return esc_url_raw( wp_unslash( (string) $referrer ) );
365 }
366 $referer = wp_get_referer();
367 return ( $referer ? (string) $referer : '' );
368 }
369
370 /**
371 * Removes the 'redirect_to' parameter from the logout URL.
372 *
373 * This function filters the WordPress logout URL to remove the 'redirect_to'
374 * GET parameter, resulting in a cleaner URL.
375 *
376 * @param string $logout_url The original logout URL.
377 * @param string $_redirect The redirect URL (not used in this function).
378 * @return string The modified logout URL without the 'redirect_to' parameter.
379 */
380 function clean_logout_url( $logout_url, $_redirect ) {
381 // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress supplies both logout_url filter arguments.
382 return remove_query_arg( 'redirect_to', $logout_url );
383 }
384
385 add_filter(
386 'logout_url',
387 __NAMESPACE__ . '\\clean_logout_url',
388 10,
389 2
390 );
391 /**
392 * Redirection for login and logout using RedirectManager.
393 *
394 * @param mixed $redirect_to Default redirect URL.
395 * @param mixed $requested_redirect_to Requested redirect URL.
396 * @param mixed $user Current user object.
397 * @return string Validated redirect URL.
398 */
399 function process_redirection( $redirect_to, $requested_redirect_to, $user ) : string {
400 static $redirect_manager = null;
401 if ( null === $redirect_manager ) {
402 $redirect_manager = new RedirectManager();
403 }
404 // WordPress and third-party plugins sometimes pass bool/int as
405 // $redirect_to via the login_redirect filter — normalise before
406 // passing to the strictly-typed RedirectManager.
407 $redirect_to = ( is_string( $redirect_to ) ? $redirect_to : null );
408 $requested_redirect_to = ( is_string( $requested_redirect_to ) ? $requested_redirect_to : null );
409 $user_obj = ( $user instanceof \WP_User ? $user : null );
410 return $redirect_manager->processRedirect( $redirect_to, $requested_redirect_to, $user_obj );
411 }
412
413 add_filter(
414 'login_redirect',
415 __NAMESPACE__ . '\\process_redirection',
416 PHP_INT_MAX,
417 3
418 );
419 add_filter(
420 'logout_redirect',
421 __NAMESPACE__ . '\\process_redirection',
422 PHP_INT_MAX,
423 3
424 );
425 /**
426 * Determine whether premium code may be used without triggering a
427 * Freemius initialisation on frontend or WP-CLI requests.
428 *
429 * Admin: FS is already initialised (see the is_admin() block above).
430 * We call the real FS methods and persist the result so that
431 * frontend requests can read it without touching FS.
432 *
433 * Frontend / WP-CLI: read the lightweight WP option written on the last
434 * admin load. No FS initialisation occurs.
435 *
436 * Result is statically cached so the DB / FS is only hit once per request.
437 *
438 * @return bool True when premium features may be loaded.
439 */
440 function slr_can_use_premium() : bool {
441 static $result = null;
442 if ( null !== $result ) {
443 return $result;
444 }
445 if ( is_admin() ) {
446 // FS is already initialised in the is_admin() block above.
447 $result = sky_login_redirect_fs()->is__premium_only() && sky_login_redirect_fs()->can_use_premium_code();
448 // Persist for frontend / WP-CLI where FS must not be initialised.
449 update_option( '_slr_premium_active', ( $result ? '1' : '0' ), true );
450 } else {
451 // Frontend / WP-CLI: read the cached option. No FS needed.
452 $result = '1' === get_option( '_slr_premium_active', '0' );
453 }
454 return $result;
455 }
456
457 // PREMIUM LOGIC : centralised in premium/bootstrap.php
458 if ( slr_can_use_premium() ) {
459 require_once plugin_dir_path( __FILE__ ) . 'premium/bootstrap.php';
460 }
461 /**
462 * Shortcode : [login-logout]
463 *
464 * @return string Login or logout link.
465 */
466 function login_logout_shortcode() : string {
467 if ( is_user_logged_in() ) {
468 return sprintf( '<a class="logout-btn slr-lilo-shortcode" href="%s">%s</a>', esc_url( wp_logout_url() ), esc_html__( 'Logout', 'sky-login-redirect' ) );
469 }
470 return sprintf( '<a class="login-btn slr-lilo-shortcode" href="%s">%s</a>', esc_url( wp_login_url() ), esc_html__( 'Login', 'sky-login-redirect' ) );
471 }
472
473 add_shortcode( 'login-logout', __NAMESPACE__ . '\\login_logout_shortcode' );
474 /**
475 * Register Login Customizer.
476 *
477 * Loaded directly on after_setup_theme (priority 20) so it is always
478 * available on the login page, regardless of whether CF has booted.
479 * The customizer reads options exclusively via carbonade() and does not
480 * register any Carbon Fields fields, so it has no CF boot dependency.
481 *
482 * @return void
483 */
484 function register_login_customizer() : void {
485 include_once plugin_dir_path( __FILE__ ) . 'includes/login-customizer.php';
486 }
487
488 add_action( 'after_setup_theme', __NAMESPACE__ . '\\register_login_customizer', 20 );
489 /**
490 * Get cached options from transient
491 *
492 * @return array
493 */
494 function get_cached_options() : array {
495 global $wpdb;
496 // Try object cache first to avoid direct DB queries when possible.
497 $cached = wp_cache_get( 'sky_login_redirect', 'slr' );
498 if ( false !== $cached && is_array( $cached ) ) {
499 return $cached;
500 }
501 // Try transient cache as second layer (survives object cache purges)
502 $transient_cache = get_transient( 'slr_options_cache' );
503 if ( false !== $transient_cache && is_array( $transient_cache ) ) {
504 wp_cache_set(
505 'sky_login_redirect',
506 $transient_cache,
507 'slr',
508 HOUR_IN_SECONDS
509 );
510 return $transient_cache;
511 }
512 // Only query database if both caches miss
513 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Results are cached in object and transient caches below.
514 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( '_slr_' ) . '%' ), ARRAY_A );
515 $cache = [];
516 foreach ( $rows as $row ) {
517 $cache[$row['option_name']] = maybe_unserialize( $row['option_value'] );
518 }
519 // Populate both cache layers — no DB write on read path (VIP compat).
520 wp_cache_set(
521 'sky_login_redirect',
522 $cache,
523 'slr',
524 HOUR_IN_SECONDS
525 );
526 set_transient( 'slr_options_cache', $cache, DAY_IN_SECONDS );
527 return $cache;
528 }
529
530 //add_action(
531 // 'carbon_fields_theme_options_container_saved',
532 // __NAMESPACE__ . '\\get_cached_options'
533 //);
534 /**
535 * Flush object cache keys when Carbon Fields options are saved.
536 * Ensures redirect/restrict rules and other cached data are refreshed.
537 *
538 * @return void
539 */
540 function flush_slr_object_cache() : void {
541 wp_cache_delete( 'sky_login_redirect', 'slr' );
542 delete_transient( 'slr_options_cache' );
543 wp_cache_delete( 'slr_redirect_rules', 'slr' );
544 wp_cache_delete( 'slr_restrict_rules', 'slr' );
545 wp_cache_delete( 'slr_menu_ids', 'slr' );
546 }
547
548 add_action( 'carbon_fields_theme_options_container_saved', __NAMESPACE__ . '\\flush_slr_object_cache', 5 );
549 /**
550 * Internal shared option-cache loader.
551 *
552 * Calls get_cached_options() once per request and memoises the result so
553 * both carbonade() and carbonade_pipe() share the same array without a
554 * second DB / transient / object-cache round-trip.
555 *
556 * @return array Flat map of every _slr_* wp_option row.
557 */
558 function slr_options_cache() : array {
559 static $cache;
560 if ( null === $cache ) {
561 $cache = get_cached_options();
562 }
563 return $cache;
564 }
565
566 /**
567 * Getter for scalar Carbon Fields theme options.
568 *
569 * Works for simple fields (text, select, checkbox, …) that Carbon Fields
570 * stores as a single wp_option row. For complex (repeater) fields use
571 * carbonade_pipe() instead.
572 *
573 * @param string $key Option key without leading underscore.
574 * @param mixed $default_value Returned when the key is not found.
575 * @return mixed Raw option value.
576 */
577 function carbonade( string $key, $default_value = false ) {
578 $k = '_' . $key;
579 // Return raw value - escaping should be done at output time, not retrieval
580 $cache = slr_options_cache();
581 return ( array_key_exists( $k, $cache ) ? $cache[$k] : $default_value );
582 }
583
584 /**
585 * Reconstruct a Carbon Fields complex (repeater) field from flat rows.
586 *
587 * Carbon Fields stores complex fields as individual wp_option rows with a
588 * pipe-separated hierarchy:
589 * _{field}|{sub_field}|{group_index}|{item_index}|{property}
590 *
591 * This function reassembles those rows into the same nested array that
592 * carbon_get_theme_option() would return, without requiring CF to be
593 * booted — safe to call on wp-login.php and any frontend context.
594 *
595 * Sub-field type detection rules:
596 * - property === '_empty' → empty array (empty multiselect/assoc)
597 * - item has > 1 property or 'id' → array of objects (association field)
598 * - multiple items, only 'value' → flat string array (multiselect)
599 * - single item, only 'value' → scalar string (select / text / …)
600 *
601 * @param string $key Complex field name without leading underscore.
602 * @param array $default_value Returned when no matching rows are found.
603 * @return array Reconstructed array of group entries.
604 */
605 function carbonade_pipe( string $key, array $default_value = [] ) : array {
606 $cache = slr_options_cache();
607 $prefix = '_' . $key . '|';
608 $prefix_len = strlen( $prefix );
609 $raw = [];
610 // [ group_idx => [ sub_field => [ item_idx => [ prop => val ] ] ] ]
611 foreach ( $cache as $option_name => $value ) {
612 if ( strncmp( $option_name, $prefix, $prefix_len ) !== 0 ) {
613 continue;
614 }
615 $parts = explode( '|', substr( $option_name, $prefix_len ), 4 );
616 if ( count( $parts ) !== 4 ) {
617 continue;
618 }
619 [
620 $sub_field,
621 $group_str,
622 $item_str,
623 $property
624 ] = $parts;
625 if ( '' === $sub_field ) {
626 continue;
627 // group-type marker row (e.g. |||0|value = _)
628 }
629 $raw[(int) $group_str][$sub_field][(int) $item_str][$property] = $value;
630 }
631 if ( empty( $raw ) ) {
632 return $default_value;
633 }
634 ksort( $raw );
635 $groups = [];
636 foreach ( $raw as $sub_fields ) {
637 $entry = [];
638 foreach ( $sub_fields as $sub_field => $items ) {
639 ksort( $items );
640 $first = reset( $items );
641 // Empty-array marker (empty multiselect / association)
642 if ( isset( $first['_empty'] ) ) {
643 $entry[$sub_field] = [];
644 continue;
645 }
646 // Association field: item carries more than just 'value'
647 if ( count( $first ) > 1 || isset( $first['id'] ) ) {
648 $entry[$sub_field] = array_values( $items );
649 continue;
650 }
651 // Multiselect: several items each with only 'value'
652 if ( count( $items ) > 1 ) {
653 $entry[$sub_field] = array_column( array_values( $items ), 'value' );
654 continue;
655 }
656 // Scalar: single item with only 'value' (select, text, textarea, …)
657 $entry[$sub_field] = $first['value'] ?? '';
658 }
659 $groups[] = $entry;
660 }
661 return $groups;
662 }
663
664 /**
665 * Declare HPOS (High-Performance Order Storage) compatibility for Sky Login Redirect plugin.
666 *
667 * This function declares that the plugin is compatible with WooCommerce's custom order tables (HPOS).
668 * It's important to declare compatibility even if the plugin doesn't directly interact with WooCommerce tables,
669 * as it informs WooCommerce and store owners that this plugin won't interfere with HPOS functionality.
670 *
671 * @since 3.7.5
672 *
673 * @return void
674 */
675 function declare_woocommerce_compatibility() : void {
676 $features_util = 'Automattic\\WooCommerce\\Utilities\\FeaturesUtil';
677 if ( class_exists( $features_util ) ) {
678 $features_util::declare_compatibility( 'custom_order_tables', __FILE__, true );
679 $features_util::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
680 $features_util::declare_compatibility( 'product_instance_caching', __FILE__, true );
681 }
682 }
683
684 add_action( 'before_woocommerce_init', __NAMESPACE__ . '\\declare_woocommerce_compatibility' );
685 /**
686 * Output the SVG sprite in the admin head.
687 *
688 * @return void
689 */
690 function output_admin_svg_sprite() : void {
691 $sprite = SLR_ROOT . 'assets/icons/icons-sprite.svg';
692 if ( is_readable( $sprite ) ) {
693 echo '<div aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">';
694 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
695 echo file_get_contents( $sprite );
696 // phpcs:ignore
697 echo '</div>';
698 }
699 }
700
701 /**
702 * Enqueue scripts
703 *
704 * @param mixed $hook The current admin hook.
705 *
706 * @return mixed styles and scripts
707 */
708 function enqueue_admin_scripts( $hook ) {
709 $svg = plugins_url( 'assets/img/sky-login-redirect.svg', __FILE__ );
710 $handle = 'slr-admin-css';
711 wp_register_style(
712 $handle,
713 '',
714 [],
715 SLR_VERSION
716 );
717 // empty style as a target for inline CSS
718 wp_enqueue_style( $handle );
719 wp_add_inline_style( $handle, "\n:root{\n\t--slr-accent:#a2ff37;\n\t--slr-icon-size:16px;\n}\n\n#toplevel_page_sky-login-redirect .wp-menu-image{\n\tbackground-color:var(--slr-accent);\n\tmask-image:url('{$svg}');\n\tmask-repeat:no-repeat;\n\tmask-position:center;\n\tmask-size:var(--slr-icon-size);\n\t-webkit-mask-image:url('{$svg}');\n\t-webkit-mask-repeat:no-repeat;\n\t-webkit-mask-position:center;\n\t-webkit-mask-size:var(--slr-icon-size);\n\ttransition:transform .9s ease;\n\twill-change:transform;\n}\n\n/* Hover / focus rotates the icon. */\n#toplevel_page_sky-login-redirect:hover .wp-menu-image,\n#toplevel_page_sky-login-redirect .wp-menu-image:hover,\na.toplevel_page_sky-login-redirect:hover .wp-menu-image{\n\ttransform:rotate(180deg);\n}\n\n/* Respect reduced motion: no transition. */\n@media (prefers-reduced-motion: reduce){\n\t#toplevel_page_sky-login-redirect .wp-menu-image{\n\t\ttransition:none;\n\t}\n}\n\n/* Fallback when CSS masks aren't supported. */\n@supports not ((mask-image:url('')) or (-webkit-mask-image:url(''))){\n\t#toplevel_page_sky-login-redirect .wp-menu-image{\n\t\tbackground-color:transparent;\n\t\tbackground-image:url('{$svg}');\n\t\tbackground-repeat:no-repeat;\n\t\tbackground-position:center;\n\t\tbackground-size:var(--slr-icon-size);\n\t}\n}" );
720 if ( in_array( $hook, PLUGIN_SCREENS, true ) ) {
721 // Only on our main plugin page
722 if ( PLUGIN_SCREENS[0] === $hook ) {
723 add_action( 'admin_head', __NAMESPACE__ . '\\output_admin_svg_sprite' );
724 wp_enqueue_style(
725 'utopique-elements',
726 plugins_url( 'assets/css/elements.css', __FILE__ ),
727 [],
728 SLR_VERSION,
729 'all'
730 );
731 wp_enqueue_style(
732 'slr',
733 plugins_url( 'assets/css/slr.css', __FILE__ ),
734 [],
735 SLR_VERSION,
736 'all'
737 );
738 wp_enqueue_script(
739 'slr-js',
740 plugins_url( 'assets/js/slr.js', __FILE__ ),
741 ['jquery', 'underscore', 'code-editor'],
742 SLR_VERSION,
743 true
744 );
745 wp_localize_script( 'slr-js', 'SLR', [
746 'upgrade_url' => sky_login_redirect_fs()->get_upgrade_url(),
747 'pro_feature' => __( 'unlock with Pro version', 'sky-login-redirect' ),
748 'business_feature' => __( 'unlock with Business version', 'sky-login-redirect' ),
749 ] );
750 wp_localize_script( 'slr-js', 'SLRPlugins', [
751 'catalogUrl' => plugins_url( 'assets/utopique-plugins.json', __FILE__ ),
752 'installUrl' => add_query_arg( [
753 's' => 'utopique',
754 'tab' => 'search',
755 'type' => 'term',
756 ], admin_url( 'plugin-install.php' ) ),
757 'installText' => __( 'Install', 'sky-login-redirect' ),
758 ] );
759 // Codemirror editor
760 $cm_css['codeEditor'] = wp_enqueue_code_editor( [
761 'type' => 'text/css',
762 ] );
763 wp_localize_script( 'jquery', 'cm_css', $cm_css );
764 $cm_js['codeEditor'] = wp_enqueue_code_editor( [
765 'type' => 'text/html',
766 ] );
767 wp_localize_script( 'jquery', 'cm_js', $cm_js );
768 wp_enqueue_script( 'wp-theme-plugin-editor' );
769 wp_enqueue_style( 'wp-codemirror' );
770 // remove WP emoji on our pages
771 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
772 remove_action( 'admin_print_styles', 'print_emoji_styles' );
773 }
774 return;
775 }
776 }
777
778 add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\\enqueue_admin_scripts' );
779 }
780 }
781 // end of freemius