晋太元中,武陵人捕鱼为业。缘溪行,忘路之远近。忽逢桃花林,夹岸数百步,中无杂树,芳草鲜美,落英缤纷。渔人甚异之,复前行,欲穷其林。   林尽水源,便得一山,山有小口,仿佛若有光。便舍船,从口入。初极狭,才通人。复行数十步,豁然开朗。土地平旷,屋舍俨然,有良田、美池、桑竹之属。阡陌交通,鸡犬相闻。其中往来种作,男女衣着,悉如外人。黄发垂髫,并怡然自乐。   见渔人,乃大惊,问所从来。具答之。便要还家,设酒杀鸡作食。村中闻有此人,咸来问讯。自云先世避秦时乱,率妻子邑人来此绝境,不复出焉,遂与外人间隔。问今是何世,乃不知有汉,无论魏晋。此人一一为具言所闻,皆叹惋。余人各复延至其家,皆出酒食。停数日,辞去。此中人语云:“不足为外人道也。”(间隔 一作:隔绝)   既出,得其船,便扶向路,处处志之。及郡下,诣太守,说如此。太守即遣人随其往,寻向所志,遂迷,不复得路。   南阳刘子骥,高尚士也,闻之,欣然规往。未果,寻病终。后遂无问津者。 .
Prv8 Shell
Server : Apache
System : Linux srv.rainic.com 4.18.0-553.47.1.el8_10.x86_64 #1 SMP Wed Apr 2 05:45:37 EDT 2025 x86_64
User : rainic ( 1014)
PHP Version : 7.4.33
Disable Function : exec,passthru,shell_exec,system
Directory :  /home/stando/www/wp-content/plugins/wpmudev-updates/includes/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Current File : //home/stando/www/wp-content/plugins/wpmudev-updates/includes/class-wpmudev-dashboard-api.php
<?php
/**
 * API module.
 * Handles all functions that are doing or processing remote calls.
 *
 * @since   4.0.0
 * @package WPMUDEV_Dashboard
 */

/**
 * The main API class.
 */
class WPMUDEV_Dashboard_Api {

	/**
	 * Expiry time of the token used for Single SignOn, in seconds.
	 * If the token returned from the DEV site is older than that, the user won't be logged in.
	 */
	const SSO_TOKEN_EXPIRY_TIME = 30.0;

	/**
	 * The WPMUDEV API server.
	 *
	 * @var string (URL)
	 */
	protected $server_root = 'https://wpmudev.com/';

	/**
	 * Path to the REST API on the server.
	 *
	 * @var string (URL)
	 */
	protected $rest_api = 'api/dashboard/v2/';

	/**
	 * Path to the Analytics REST API on the server.
	 *
	 * @var string (URL)
	 */
	protected $rest_api_analytics = 'api/analytics/v1/';

	/**
	 * Path to the Analytics REST API on the server.
	 *
	 * @var string (URL)
	 */
	protected $rest_api_translation = 'api/translations/v1/';

	/**
	 * Path to the hosting site endpoints.
	 *
	 * @var string
	 */
	protected $rest_api_hub = 'api/hub/v1/';

	/**
	 * The complete WPMUDEV REST API endpoint. Defined in constructor.
	 *
	 * @var string (URL)
	 */
	protected $server_url = '';

	/**
	 * Stores the API key used for authentication.
	 *
	 * @var string
	 */
	protected $api_key = '';

	/**
	 * Stores the site_id from the API.
	 *
	 * @var int
	 */
	protected $api_site_id = '';

	/**
	 * Holds the last API error that occured (if any)
	 *
	 * @var string
	 */
	public $api_error = '';

	/**
	 * Set up the API module.
	 *
	 * @since 4.0.0
	 * @internal
	 */
	public function __construct() {
		if ( WPMUDEV_CUSTOM_API_SERVER ) {
			$this->server_root = trailingslashit( WPMUDEV_CUSTOM_API_SERVER );
		}
		$this->server_url = $this->server_root . $this->rest_api;

		if ( defined( 'WPMUDEV_APIKEY' ) && WPMUDEV_APIKEY ) {
			$this->api_key = WPMUDEV_APIKEY;
		} else {
			// If 'clear_key' is present in URL then do not load the key from DB.
			$this->api_key = get_site_option( 'wpmudev_apikey' );
		}

		// Schedule automatic data update on the main site of the network.
		if ( is_main_site() ) {
			if ( ! wp_next_scheduled( 'wpmudev_scheduled_jobs' ) ) {
				wp_schedule_event( time(), 'twicedaily', 'wpmudev_scheduled_jobs' );
			}

			// Run action on wpmudev admin actions.
			add_action( 'wpmudev_dashboard_admin_request', array( $this, 'run_admin_cron' ) );

			add_action( 'wpmudev_scheduled_jobs', array( $this, 'cron_hub_sync' ) );
			add_action( 'wpmudev_scheduled_jobs', array( $this, 'refresh_projects_data' ) );
			add_action( 'wpmudev_scheduled_jobs', array( $this, 'maybe_update_translations' ) );

		} elseif ( wp_next_scheduled( 'wpmudev_scheduled_jobs' ) ) {
			// In case the cron job was already installed in a sub-site...
			wp_clear_scheduled_hook( 'wpmudev_scheduled_jobs' );
		}

		/**
		 * Run custom initialization code for the API module.
		 *
		 * @since  4.0.0
		 * @var  WPMUDEV_Dashboard_Api The dashboards API module.
		 */
		do_action( 'wpmudev_dashboard_api_init', $this );
	}


	/*
	 * *********************************************************************** *
	 * *     PUBLIC INTERFACE FOR OTHER MODULES
	 * *********************************************************************** *
	 */


	/**
	 * Returns true if the API key is defined.
	 *
	 * @since  4.0.0
	 * @return bool
	 */
	public function has_key() {
		return ! empty( $this->api_key );
	}

	/**
	 * Returns the API key.
	 *
	 * @since  1.0.0
	 * @return string
	 */
	public function get_key() {
		return $this->api_key;
	}

	/**
	 * Updates the API key in the database.
	 *
	 * @since 4.0.0
	 *
	 * @param string $key The new API key to store.
	 */
	public function set_key( $key ) {
		$this->api_key = $key;
		update_site_option( 'wpmudev_apikey', $key );
	}

	/**
	 * Returns the Hub Site ID.
	 *
	 * We just need this get method for this because
	 * it comes with membershipdata which is handled
	 * set/cleared on hubsync.
	 *
	 * @since  4.7.4
	 *
	 * @return int
	 */
	public function get_site_id() {
		// Do this here since we don't need it in construct.
		if ( ! $this->api_site_id ) {
			// Careful while using this.
			// Manually changing site ID could break your site and hub connection.
			// This is only for advance usage.
			if ( defined( 'WPMUDEV_SITE_ID' ) && WPMUDEV_SITE_ID ) {
				$this->api_site_id = WPMUDEV_SITE_ID;
			} else {
				$membership = $this->get_membership_data();
				if ( ! empty( $membership ) && isset( $membership['hub_site_id'] ) ) {
					$this->api_site_id = $membership['hub_site_id'];
				}
			}
		}

		return $this->api_site_id;
	}

	/**
	 * Returns the canonical site_url that should be used for the site in the hub.
	 *
	 * Define WPMUDEV_HUB_SITE_URL to override or make static the url it should show as
	 *  in the hub. Defaults to network_site_url() which may be dynamically filtered
	 *  by some plugins and hosting providers.
	 *
	 * @since  4.6.0
	 *
	 * @return string
	 */
	public function network_site_url() {
		return defined( 'WPMUDEV_HUB_SITE_URL' ) ? WPMUDEV_HUB_SITE_URL : network_site_url();
	}

	/**
	 * Returns the canonical home_url that should be used for the site in the hub.
	 *
	 * Define WPMUDEV_HUB_HOME_URL to override or make static the url it should show as
	 *  in the hub. Defaults to WPMUDEV_HUB_SITE_URL if set, or network_home_url() which may be dynamically filtered
	 *  by some plugins and hosting providers.
	 *
	 * @since  4.6.0
	 *
	 * @return string
	 */
	public function network_home_url() {
		if ( defined( 'WPMUDEV_HUB_HOME_URL' ) ) {
			return WPMUDEV_HUB_HOME_URL;
		} elseif ( defined( 'WPMUDEV_HUB_SITE_URL' ) ) {
			return WPMUDEV_HUB_SITE_URL;
		} else {
			return network_home_url();
		}
	}

	/**
	 * Returns the canonical home_url that should be used for the site in the hub.
	 *
	 * Define WPMUDEV_HUB_ADMIN_URL to override or make static the url it should show as
	 *  in the hub. Defaults to deriving from WPMUDEV_HUB_SITE_URL if set, or network_admin_url() which may be dynamically filtered
	 *  by some plugins and hosting providers.
	 *
	 * @since  4.6.0
	 *
	 * @return string
	 */
	public function network_admin_url() {
		if ( defined( 'WPMUDEV_HUB_ADMIN_URL' ) ) {
			return WPMUDEV_HUB_ADMIN_URL;
		} elseif ( defined( 'WPMUDEV_HUB_SITE_URL' ) ) {
			return is_multisite() ? trailingslashit( WPMUDEV_HUB_SITE_URL ) . 'wp-admin/network/' : trailingslashit( WPMUDEV_HUB_SITE_URL ) . 'wp-admin/';
		} else {
			return network_admin_url();
		}
	}

	/**
	 * Returns a URL we use to validate connection to server. This is not an
	 * API endpoint and does not return any defined information. Only the
	 * HTTP-Status of the GET/POST response is validated.
	 *
	 * @since  4.0.0
	 * @return string
	 */
	public function get_test_url() {
		return $this->rest_url( 'test' );
	}

	/**
	 * Returns the full URL to the specified REST API endpoint.
	 *
	 * This is a function instead of making the property $server_url public so
	 * we have better control and overview of the requested pages:
	 * It's easy to add a filter or add extra URL params to all URLs this way.
	 *
	 * @since  4.0.0
	 *
	 * @param  string $endpoint The endpoint to call on the server.
	 *
	 * @return string The full URL to the requested endpoint.
	 */
	public function rest_url( $endpoint ) {
		if ( preg_match( '!^https?://!', $endpoint ) ) {
			$url = $endpoint;
		} else {
			$url = $this->server_url . $endpoint;
		}

		return $url;
	}

	/**
	 * Returns the full URL to the specified REST API endpoint and includes
	 * the API key as last element in URL.
	 *
	 * Uses the function `rest_url()` to build the URL.
	 *
	 * @since  4.0.0
	 *
	 * @param  string $endpoint The endpoint to call on the server.
	 *
	 * @return string The full URL to the requested endpoint.
	 */
	public function rest_url_auth( $endpoint ) {
		$api_key = $this->get_key();
		if ( false === strpos( $endpoint, '/' . $api_key ) ) {
			$endpoint .= '/' . $api_key;
		}

		$membership_data = $this->get_membership_data();
		if ( isset( $membership_data['hub_site_id'] ) ) {
			$endpoint .= '?site_id=';
			$endpoint .= $membership_data['hub_site_id'];
		}

		return $this->rest_url( $endpoint );
	}

	/**
	 * Checks if the specified URL is on our remote server.
	 *
	 * @since  4.0.0
	 *
	 * @param  string $url The full URL to evaluate.
	 *
	 * @return bool True if the URL is on our remote server.
	 */
	public function is_server_url( $url ) {
		return false !== strpos( $url, $this->server_url );
	}

	/**
	 * Process admin side actions if it's from cron.
	 *
	 * @since  4.11.6
	 * @access public
	 *
	 * @param array $data Request data.
	 *
	 * @return void
	 */
	public function run_admin_cron( $data ) {
		if (
			isset( $data['action'], $data['from'] ) &&
			'cron' === $data['from'] &&
			'hub_sync' === $data['action']
		) {
			// Run hub sync.
			$this->hub_sync();
		}
	}

	/**
	 * Run cron hub sync using admin HTTP request.
	 *
	 * @since  4.11.6
	 * @access public
	 *
	 * @return void
	 */
	public function cron_hub_sync() {
		WPMUDEV_Dashboard::$utils->send_admin_request(
			array(
				'from'   => 'cron',
				'action' => 'hub_sync',
			)
		);
	}

	/**
	 * Makes an API call and returns the results.
	 *
	 * The remote_path can be either relative to the server_url or it can be
	 * an absolute URL to any server.
	 *
	 * If remote_path is a relative path then the API-Key is automatically
	 * added the URL.
	 *
	 * @since  4.0.0
	 *
	 * @param  string $remote_path The API function to call.
	 * @param  array  $data        Optional. GET or POST data to send.
	 * @param  string $method      Optional. GET or POST.
	 * @param  array  $options     Optional. Array of request options.
	 *
	 * @return array Results of the wp_remote_get/post call.
	 */
	public function call( $remote_path, $data = false, $method = 'GET', $options = array() ) {
		$link = $this->rest_url( $remote_path );

		$options = wp_parse_args(
			$options,
			array(
				'timeout'    => 15,
				'sslverify'  => WPMUDEV_API_SSLVERIFY,
				'user-agent' => 'WPMUDEV Dashboard Client/' . WPMUDEV_Dashboard::$version . ' (+' . network_site_url() . ')',
			)
		);

		// Solve the annoying WordPress warning: "gzinflate(): data error".
		if ( WPMUDEV_API_UNCOMPRESSED ) {
			$options['decompress'] = false;
		}

		if ( WPMUDEV_API_AUTHORIZATION ) {
			if ( ! isset( $options['headers'] ) ) {
				$options['headers'] = array();
			}
			$options['headers']['Authorization'] = WPMUDEV_API_AUTHORIZATION;
		}

		if ( 'GET' === $method ) {
			if ( ! empty( $data ) ) {
				$link = add_query_arg( $data, $link );
			}
			$response = wp_remote_get( $link, $options );
		} elseif ( 'POST' === $method ) {
			$options['body'] = $data;
			$response        = wp_remote_post( $link, $options );
		} elseif ( 'DELETE' === $method ) {
			$options['method'] = 'DELETE';
			$response          = wp_remote_request( $link, $options );
		}

		// Add the request-URL to the response data.
		if ( $response && is_array( $response ) ) {
			$response['request_url'] = $link;
		}

		if ( WPMUDEV_API_DEBUG ) {
			$log = '[WPMUDEV API call] %s | %s: %s (%s)';
			if ( WPMUDEV_API_DEBUG_ALL ) {
				$log .= "\nRequest options: %s\nResponse: %s";
			}

			// strip down big vars unless WPMUDEV_API_DEBUG_CRAZY is defined
			$resp_body = wp_remote_retrieve_body( $response );
			if ( ! defined( 'WPMUDEV_API_DEBUG_CRAZY' ) ) {
				$req_body = isset( $options['body'] ) ? $options['body'] : '';
				if ( isset( $req_body['projects'] ) ) {
					/**
					 * this contains 2 keys: plugins and themes
					 *
					 * @see self::get_repo_updates_infos()
					 */
					$repo_updates = json_decode( $req_body['repo_updates'], true );
					$req_body['projects']     = count( (array) json_decode( $req_body['projects'] ) ) . ' PROJECTS';
					// TODO: subject for code/implementation improvement
					$req_body['repo_updates'] = array(
						'plugins' => ( isset( $repo_updates['plugins'] ) && $repo_updates['plugins'] ) ? count( $repo_updates['plugins'] ) : 0,
						'themes'  => ( isset( $repo_updates['themes'] ) && $repo_updates['themes'] ) ? count( $repo_updates['themes'] ) : 0,
					);
					$packages                 = (object) json_decode( $req_body['packages'] );
					$packages->plugins        = count( (array) $packages->plugins ) . ' PLUGINS';
					$packages->themes         = count( (array) $packages->themes ) . ' THEMES';
					$req_body['packages']     = wp_json_encode( $packages );
				}
				$options['body'] = $req_body;

				$resp_body = json_decode( wp_remote_retrieve_body( $response ) );
				if ( is_object( $resp_body ) ) {
					if ( isset( $resp_body->projects ) ) {
						$resp_body->projects = '[...]';
					}
					if ( isset( $resp_body->plugin_tags ) ) {
						$resp_body->plugin_tags = '[...]';
					}
				}
				$resp_body = wp_json_encode( $resp_body );
			}

			if ( $response && is_array( $response ) ) {
				$debug_data  = sprintf( "%s %s\n", wp_remote_retrieve_response_code( $response ), wp_remote_retrieve_response_message( $response ) );
				$debug_data .= var_export( wp_remote_retrieve_headers( $response ), true ) . PHP_EOL; // WPCS: var_export() ok.
				$debug_data .= $resp_body;
			} else {
				$debug_data = '';
			}

			$msg = sprintf(
				$log,
				WPMUDEV_Dashboard::$version,
				$method,
				$link,
				wp_remote_retrieve_response_code( $response ),
				wp_json_encode( $options ),
				$debug_data
			);
			error_log( $msg );
		}

		return $response;
	}

	/**
	 * Makes an API call and includes the API key in the REST URL and returns
	 * the results.
	 *
	 * Uses `call()` to get the results.
	 *
	 * @since  4.0.0
	 *
	 * @param  string $remote_path The API function to call.
	 * @param  array  $data        Optional. GET or POST data to send.
	 * @param  string $method      Optional. GET or POST.
	 * @param  array  $options     Optional. List of Request options.
	 *
	 * @return array Results of the wp_remote_get/post call.
	 */
	public function call_auth( $remote_path, $data = false, $method = 'GET', $options = array() ) {
		if ( 'GET' == $method ) {
			$remote_path = $this->rest_url_auth( $remote_path );
		} elseif ( 'POST' == $method ) {
			if ( ! is_array( $data ) ) {
				$data = array();
			}

			$key_data            = array();
			$key_data['api_key'] = $this->get_key();

			// make sure api key is first
			$data = array_merge( $key_data, $data );
		}

		$response = $this->call( $remote_path, $data, $method, $options );

		return $response;
	}

	/**
	 * In WP Engine hosting only requests from logged in users with auth cookies are given filesystem
	 *  write access. So we need to send those to Hub to allow for remote updates, backups, etc. Encrypted
	 *  for extra safety. Workaround inspired by ManageWP.
	 *
	 * @return array $cookies
	 */
	public function get_encrypted_cookies() {

		$crypt_file = WPMUDEV_Dashboard::$site->plugin_path . 'lib/PHPSecLib/Crypt/RSA.php';

		// we only need to run this in WP Engine environment.
		if ( ! defined( 'WPE_APIKEY' ) || ! is_readable( $crypt_file ) ) {
			return array();
		}

		// Make sure the constants are set.
		$this->define_cookie_constants();

		// figure out the first admin.
		if ( is_multisite() ) {
			$supers = get_super_admins();
			$user   = get_user_by( 'login', $supers[0] );
		} else {
			$admins = get_users(
				array(
					'role'   => 'administrator',
					'number' => 1,
				)
			);
			$user   = $admins[0];
		}
		$user_id = $user->ID;

		$cookies = array();
		$secure  = is_ssl();
		$secure  = apply_filters( 'secure_auth_cookie', $secure, $user_id );

		if ( $secure ) {
			$auth_cookie_name = SECURE_AUTH_COOKIE;
			$scheme           = 'secure_auth';
		} else {
			$auth_cookie_name = AUTH_COOKIE;
			$scheme           = 'auth';
		}

		$expiration = time() + ( DAY_IN_SECONDS * 14 ); // we expire sites from the hub after 14 days, so long enough for these cookies

		$cookies[ $auth_cookie_name ] = wp_generate_auth_cookie( $user_id, $expiration, $scheme );
		$cookies[ LOGGED_IN_COOKIE ]  = wp_generate_auth_cookie( $user_id, $expiration, 'logged_in' );
		$cookies['wpe-auth']          = md5( 'wpe_auth_salty_dog|' . WPE_APIKEY ); // this is WP Engine's proprietary auth cookie

		if ( empty( $cookies ) ) {
			return $cookies;
		}

		if ( ! class_exists( 'Crypt_RSA', false ) ) {
			require_once WPMUDEV_Dashboard::$site->plugin_path . 'lib/PHPSecLib/Crypt/RSA.php';
		}

		$rsa = new Crypt_RSA();
		$rsa->setEncryptionMode( CRYPT_RSA_SIGNATURE_PKCS1 );
		$rsa->loadKey( file_get_contents( WPMUDEV_Dashboard::$site->plugin_path . 'keys/dashboard.pub' ), CRYPT_RSA_PUBLIC_FORMAT_PKCS1 ); // public key

		foreach ( $cookies as &$cookieValue ) {
			$cookieValue = base64_encode( $rsa->encrypt( $cookieValue ) );
		}

		return $cookies;
	}

	/**
	 * Defines cookie-related WordPress constants if required.
	 *
	 * In WP Engine, sometimes there is a delay so we try to access
	 * the constants before it's defined.
	 *
	 * @see https://incsub.atlassian.net/browse/WDD-140
	 *
	 * @since 4.11.1
	 */
	private function define_cookie_constants() {
		// Include required file.
		if ( ! function_exists( 'wp_cookie_constants' ) ) {
			include_once ABSPATH . 'wp-includes/default-constants.php';
		}

		// Make sure the constants are defined by WP.
		wp_cookie_constants();
	}

	/**
	 * The proper way to get details about the current projects on DEV.
	 *
	 * @since  1.0.0
	 * @return array {
	 *         Details about current projects on DEV.
	 *
	 * @type string $downloads         [disabled|enabled]
	 * @type array  $free_notice       Array with 'key' and 'msg'
	 * @type array  $full_notice       Array with 'key' and 'msg'
	 * @type array  $single_notice     Array with 'key' and 'msg'
	 * @type int    $latest_release    A Project-ID
	 * @type array  $latest_plugins    Array of latest 5 project-IDs
	 * @type array  $latest_themes     Array of latest 5 project-IDs
	 * @type array  $plugin_tags       List of all plugin tags with list of tagged projects
	 * @type array  $theme_tags        List of all theme tags with list of tagged projects
	 * @type array  $projects          Complete list of all available projects (plugins and themes)
	 * @type string $text_admin_notice HTML text for display
	 * @type string $text_page_head    HTML text for display
	 * }
	 */
	public function get_projects_data() {
		$expire = time() - ( HOUR_IN_SECONDS * 12 );
		$flag   = WPMUDEV_Dashboard::$settings->get( 'refresh_remote', 'flags' );

		if ( $flag ) {
			WPMUDEV_Dashboard::$settings->set( 'updates_data', false );
			$res      = false;
			$last_run = 0;
		} else {
			$res      = WPMUDEV_Dashboard::$settings->get( 'updates_data' );
			$last_run = intval( WPMUDEV_Dashboard::$settings->get( 'last_run_updates', 'general' ) );
		}

		if ( $flag || ! is_array( $res ) || ! $last_run || $expire > $last_run ) {
			// This condition prevents race condition in case of network error
			// or problems on API side.
			if ( $last_run < time() ) {
				$res = $this->refresh_projects_data();
			}
		}

		// Basic sanitation, to avoid incompatible return values.
		if ( ! is_array( $res ) ) {
			$res = array();
		}
		$res = wp_parse_args(
			$res,
			array(
				'latest_release' => 0,
				'latest_plugins' => array(),
				'latest_themes'  => array(),
				'plugin_tags'    => array(),
				'theme_tags'     => array(),
				'projects'       => array(),
			)
		);

		return apply_filters( 'wpmudev_dashboard_get_projects_data', $res );
	}

	/**
	 * The proper way to get details about the current membership
	 *
	 * @since  4.4.1
	 * @return array {
	 *         Details about current membership.
	 *
	 * @type string $membership            [free|single|unit|full]
	 * @type string $membership_full_level [gold|bronze|silver]
	 * }
	 */
	public function get_membership_data() {
		$res = WPMUDEV_Dashboard::$settings->get( 'membership_data' );
		// Basic sanitation, to avoid incompatible return values.
		if ( ! is_array( $res ) ) {
			$res = array();
		}
		$res = wp_parse_args(
			$res,
			array(
				'membership' => '',
			)
		);

		return apply_filters( 'wpmudev_dashboard_get_membership_data', $res );
	}

	/**
	 * Get the current membership type/status (deprecated).
	 *
	 * Possible return values:
	 * 'free'    - expired/not signed up yet.
	 * 'single'  - Single membership (i.e. only 1 project is licensed)
	 * 'unit'    - One or more projects licensed
	 * 'full'    - Full membership, no restrictions.
	 * 'free_hub'  - Free hub membership.
	 *
	 * @since  4.0.0
	 * @deprecated 4.11.9 Use WPMUDEV_Dashboard::$api->get_membership_status()
	 *
	 * @param mixed $legacy_param - Not to be used! Remains as part of public facing
	 *                              API, but does not affect anything.
	 *
	 * @return string The membership type.
	 */
	public function get_membership_type( &$legacy_param = null ) {
		// Get the current membership status.
		$type = $this->get_membership_status();

		// Available membership types.
		$types = array( 'full', 'unit', 'single' );

		if ( ! in_array( $type, $types, true ) ) {
			/**
			 * For backward compatibility.
			 * We previously considered expired, paused etc. types as free.
			 * But now we have a separate `free` type. But for backward compat
			 * we need to use different name for `free` type.
			 */
			$type = 'free' === $type ? 'free_hub' : 'free';
		}

		return $type;
	}

	/**
	 * Get current membership type/status.
	 *
	 * Possible return values:
	 * 'free'    - Free hub membership.
	 * 'single'  - Single membership (i.e. only 1 project is licensed)
	 * 'unit'    - One or more projects licensed
	 * 'full'    - Full membership, no restrictions.
	 * 'paused'  - Membership access is paused.
	 * 'expired' - Expired membership.
	 * ''        - (empty string) If user is not logged in or with an unknown type.
	 *
	 * @since 4.11.9
	 *
	 * @return string The membership type.
	 */
	public function get_membership_status() {
		$data = $this->get_membership_data();

		// Available membership types.
		$types = array(
			'full',
			'unit',
			'free',
			'paused',
			'expired',
		);

		// Default type is empty.
		$type = '';

		// All possible string values.
		if ( is_string( $data['membership'] ) && in_array( $data['membership'], $types, true ) ) {
			$type = $data['membership'];
		} elseif (
			is_numeric( $data['membership'] ) ||
			( is_bool( $data['membership'] ) && isset( $data['membership_full_level'] ) && is_numeric( $data['membership_full_level'] ) )
		) {
			$type = 'single';
		}

		return $type;
	}

	/**
	 * Returns a numeric id or array of numeric ids or projects available on plan.
	 * Numeric id is returned only if "single" plan is active, for backwards compatibility.
	 *
	 * This method does not account for membership_excluded_projects!!! Use:
	 * WPMUDEV_Dashboard::$api->get_excluded_projects()
	 * to exclude projects where needed.
	 *
	 * @since  4.9.0
	 *
	 * @return mixed Numeric id or available project for "single" plan or array or
	 *               numeric ids for "unit" plans.
	 */
	public function get_membership_projects() {
		$data = $this->get_membership_data();

		if ( 'full' === $data['membership'] ) {
			return array();
		}
		// For free and unit memberships.
		if ( in_array( $data['membership'], array( 'free', 'unit' ), true ) ) {
			$projects = is_array( $data['membership_projects'] ) ? $data['membership_projects'] : array();
			foreach ( $projects as $i => $p ) {
				$projects[ $i ] = intval( $p );
			}
			return $projects;
		}
		if ( is_numeric( $data['membership'] ) ) {
			return intval( $data['membership'] );
		}
		if ( is_bool( $data['membership'] ) && is_numeric( $data['membership_full_level'] ) ) {
			return intval( $data['membership_full_level'] );
		}

		return array();
	}

	/**
	 * Get projects that are strictly forbidden to be installed or updated for
	 * current membership level.
	 *
	 * @return array[int] List of excluded project ids as numeric values.
	 */
	public function get_excluded_projects() {
		$key      = 'membership_excluded_projects';
		$defaults = array( $key => array() );
		$data     = wp_parse_args( $this->get_membership_data(), $defaults );

		$projects = array();
		if ( false === empty( $data[ $key ] ) ) {
			foreach ( $data[ $key ] as $pid ) {
				$projects[] = intval( $pid );
			}
		}

		return $projects;
	}

	/**
	 * Checks if feature is allowed for membership plan by feature string.
	 *
	 * @param string $feature Feature string.
	 * @return boolean is allowed.
	 */
	private function is_feature_allowed( $feature ) {
		$data     = $this->get_membership_data();
		$features = isset( $data['membership_access'] ) ? $data['membership_access'] : array();

		// The membership_access can be boolean true for full accesss, or array with allowed features strings.
		if ( true === $features ) {
			return true;
		}

		if ( false === is_array( $features ) ) {
			return false;
		}

		return in_array( $feature, $features, true );
	}

	/**
	 * Checks if feature is allowed for membership plan by feature string.
	 *
	 * This is here for other plugins to check feature availability.
	 *
	 * @param string $feature Feature string.
	 *
	 * @since 4.11.9
	 *
	 * @return boolean is allowed.
	 */
	public function has_access( $feature ) {
		return $this->is_feature_allowed( $feature );
	}

	/**
	 * Checks if whitelabel is allowed by membership plan.
	 *
	 * @return boolean is allowed.
	 */
	public function is_whitelabel_allowed() {
		return $this->is_feature_allowed( 'whitelabel-dashboard' );
	}

	/**
	 * Checks if analytics is allowed by membership plan.
	 *
	 * @since 4.11
	 *
	 * @return boolean is allowed.
	 */
	public function is_analytics_allowed() {
		return $this->is_feature_allowed( 'whitelabel-basic-analytics' );
	}

	/**
	 * Checks if support forum is allowed by membership plan.
	 *
	 * @since 4.11.8
	 *
	 * @return boolean is allowed.
	 */
	public function is_support_allowed() {
		return $this->is_feature_allowed( 'support-forums' );
	}

	/**
	 * Checks if tickets are hidden on UI.
	 *
	 * @since 4.11.4
	 *
	 * @return bool is hidden.
	 */
	public function is_tickets_hidden() {
		// Get membership data.
		$data = $this->get_membership_data();
		// Check tickets visibility.
		$hidden = isset( $data['is_tickets_hidden'] ) && (bool) $data['is_tickets_hidden'];

		/**
		 * Filter hook to change tickets visibility.
		 *
		 * @param bool $visible Is hidden.
		 *
		 * @since 4.11.4
		 */
		return apply_filters( 'wpmudev_dashboard_is_tickets_hidden', $hidden );
	}

	/**
	 * Returns the details of a single project from the API.
	 *
	 * @since  4.0.0
	 *
	 * @param  int $project_id The project to return.
	 *
	 * @return array Project details.
	 */
	public function get_project_data( $project_id ) {
		static $all_projects = null;
		$item                = false;

		if ( null === $all_projects ) {
			$data = $this->get_projects_data();
			if ( isset( $data['projects'] ) ) {
				$all_projects = $data['projects'];
			}
		}

		if ( $all_projects && isset( $all_projects[ $project_id ] ) ) {
			$item = wp_parse_args(
				$all_projects[ $project_id ],
				array(
					'id'                => 0,
					'paid'              => 'paid',
					'type'              => 'plugin',
					'name'              => '',
					'released'          => 0,
					'updated'           => 0,
					'downloads'         => 0,
					'popularity'        => 0,
					'short_description' => '',
					'features'          => array(),
					'active'            => true,
					'version'           => '1.0.0',
					'autoupdate'        => 1,
					'requires'          => 'wp',
					'requires_min_php'  => '5.6',
					'compatible'        => '',
					'url'               => '',
					'thumbnail'         => '',
					'video'             => false,
					'wp_config_url'     => '',
					'ms_config_url'     => '',
					'package'           => 0,
					'screenshots'       => array(),
					'free_version_slug' => '',
				)
			);
		} else {
			if ( WPMUDEV_API_DEBUG && defined( 'WPMUDEV_API_DEBUG_CRAZY' ) ) {
				error_log(
					sprintf(
						'[WPMUDEV API Warning] No remote data found for project %s',
						$project_id
					)
				);
			}
		}

		return $item;
	}

	/**
	 * Get available teams for the authenticated user.
	 *
	 * @param string $key User API key.
	 *
	 * @since  4.11.10
	 *
	 * @return array|bool
	 */
	public function get_user_teams( $key ) {
		// Sets up special auth header.
		$options['headers']                  = array();
		$options['headers']['Authorization'] = $key;

		// Send API request.
		$response = WPMUDEV_Dashboard::$api->call(
			'site-authenticate-teams',
			false,
			'GET',
			$options
		);

		// Error.
		if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
			$this->parse_api_error( $response );

			return false;
		} else {
			// Get team list.
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( isset( $data['data'] ) ) {
				return $data['data'];
			}
		}

		return array();
	}

	/**
	 * Returns a list of all plugins and themes installed the WordPress site
	 * WPMU DEV projects are not included here.
	 *
	 * @since  4.3.0
	 * @return array Array that contains 2 sub-arrays: 'plugins' and 'themes'.
	 */
	public function get_repo_packages() {
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
		$packages = array(
			'plugins' => array(),
			'themes'  => array(),
		);

		$plugins = get_plugins();
		$themes  = wp_get_themes();

		// First remove WPMUDEV plugins from the WP update data (for slug conflicts like
		$local_projects = WPMUDEV_Dashboard::$site->get_cached_projects();
		foreach ( $local_projects as $id => $update ) {
			if ( isset( $plugins[ $update['filename'] ] ) ) {
				unset( $plugins[ $update['filename'] ] );
			}

			$theme_slug = dirname( $update['filename'] );
			if ( isset( $themes[ $theme_slug ] ) ) {
				unset( $themes[ $theme_slug ] );
			}
		}

		// Extract and collect details we need.
		foreach ( $plugins as $slug => $data ) {
			// Only network active plugin should be considered as active.
			$active = is_multisite() ? is_plugin_active_for_network( $slug ) : is_plugin_active( $slug );

			$packages['plugins'][ $slug ] = array(
				'name'       => $data['Name'],
				'version'    => $data['Version'],
				'plugin_url' => $data['PluginURI'],
				'author'     => $data['Author'],
				'author_url' => $data['AuthorURI'],
				'network'    => $data['Network'],
				'active'     => $active,
			);
		}

		foreach ( $themes as $slug => $theme ) {
			if ( is_multisite() ) {
				$active = $theme->is_allowed() || get_stylesheet() == $slug; // network enabled or on main site
			} else {
				// If the theme is available on main site it's "active".
				$active = get_stylesheet() == $slug;
			}

			$parent                      = $theme->parent() ? $theme->get_template() : false;
			$packages['themes'][ $slug ] = array(
				'name'       => $theme->display( 'Name', false ),
				'version'    => $theme->display( 'Version', false ),
				'author'     => $theme->display( 'Author', false ),
				'author_url' => $theme->display( 'AuthorURI', false ),
				'screenshot' => $theme->get_screenshot(),
				'parent'     => $parent,
				'active'     => $active,
			);
		}

		return $packages;
	}

	/**
	 * Returns a list of all plugins and themes on the WordPress site that have
	 * an pending update. WPMU DEV projects are not included here.
	 *
	 * @since  4.1.0
	 * @return array Array that contains 2 sub-arrays: 'plugins' and 'themes'.
	 */
	public function get_repo_updates_infos() {
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
		$core_updates = array(
			'plugins' => array(),
			'themes'  => array(),
		);

		// Remove our custom filters, so we get the original updates list.
		remove_filter(
			'site_transient_update_plugins',
			array( WPMUDEV_Dashboard::$site, 'filter_plugin_update_count' )
		);
		remove_filter(
			'site_transient_update_themes',
			array( WPMUDEV_Dashboard::$site, 'filter_theme_update_count' )
		);

		// Get the available updates list.
		$plugin_data = get_site_transient( 'update_plugins' );
		$theme_data  = get_site_transient( 'update_themes' );

		// Restore our filters to include WPMU DEV projects in the updates list.
		add_filter(
			'site_transient_update_plugins',
			array( WPMUDEV_Dashboard::$site, 'filter_plugin_update_count' )
		);
		add_filter(
			'site_transient_update_themes',
			array( WPMUDEV_Dashboard::$site, 'filter_theme_update_count' )
		);

		// First remove WPMUDEV plugins from the WP update data (for slug conflicts like
		$local_projects = WPMUDEV_Dashboard::$site->get_cached_projects();
		foreach ( $local_projects as $id => $update ) {
			if ( isset( $plugin_data->response[ $update['filename'] ] ) ) {
				unset( $plugin_data->response[ $update['filename'] ] );
			}
			if ( isset( $plugin_data->no_update[ $update['filename'] ] ) ) {
				unset( $plugin_data->no_update[ $update['filename'] ] );
			}

			$theme_slug = dirname( $update['filename'] );
			if ( isset( $theme_data->response[ $theme_slug ] ) ) {
				unset( $theme_data->response[ $theme_slug ] );
			}
			if ( isset( $theme_data->no_update[ $theme_slug ] ) ) {
				unset( $theme_data->no_update[ $theme_slug ] );
			}
		}

		// Extract and collect details we need.
		if ( isset( $plugin_data->response ) && is_array( $plugin_data->response ) ) {
			foreach ( $plugin_data->response as $slug => $infos ) {
				$item                             = get_plugin_data( WP_PLUGIN_DIR . '/' . $slug );
				$core_updates['plugins'][ $slug ] = array(
					'name'        => $item['Name'],
					'version'     => $item['Version'],
					'new_version' => $infos->new_version,
					'upgradable'  => ! empty( $infos->package ),
				);
			}
		}

		if ( isset( $theme_data->response ) && is_array( $theme_data->response ) ) {
			foreach ( $theme_data->response as $slug => $infos ) {
				$item                            = wp_get_theme( $slug );
				$core_updates['themes'][ $slug ] = array(
					'name'        => $item->Name,
					'version'     => $item->Version,
					'new_version' => $infos['new_version'],
					'upgradable'  => ! empty( $infos['package'] ),
				);
			}
		}

		return $core_updates;
	}

	/**
	 * The proper way to get the array of profile data from cache/Api.
	 *
	 * @since  1.0.0
	 * @return array
	 */
	public function get_profile() {
		$expire = time() - ( MINUTE_IN_SECONDS * 10 );
		$flag   = WPMUDEV_Dashboard::$settings->get( 'refresh_profile', 'flags' );

		if ( $flag ) {
			WPMUDEV_Dashboard::$settings->set( 'profile_data', false );
			$res      = false;
			$last_run = 0;
		} else {
			$res      = WPMUDEV_Dashboard::$settings->get( 'profile_data' );
			$last_run = intval( WPMUDEV_Dashboard::$settings->get( 'last_run_profile', 'general' ) );
		}

		if ( $flag || ! $res || ! $last_run || $expire > $last_run ) {
			// This condition prevents race condition in case of network error
			// or problems on API side.
			if ( $last_run < time() ) {
				$res = $this->refresh_profile();
			}
		}

		// Basic sanitation, to avoid incompatible return values.
		if ( ! is_array( $res ) ) {
			$res = array();
		}
		if ( empty( $res['profile'] ) || ! is_array( $res['profile'] ) ) {
			$res['profile'] = array();
		}
		if ( empty( $res['points'] ) || ! is_array( $res['points'] ) ) {
			$res['points'] = array();
		}
		if ( empty( $res['forum'] ) ) {
			$res['forum'] = array();
		}
		if ( empty( $res['forum']['support_threads'] ) ) {
			$res['forum']['support_threads'] = array();
		}

		$res['profile'] = wp_parse_args(
			$res['profile'],
			array(
				'avatar'       => '',
				'member_since' => time(),
				'name'         => '[name]',
				'title'        => '[title]',
				'user_name'    => '[username]',
			)
		);
		$res['points']  = wp_parse_args(
			$res['points'],
			array(
				'hero_points' => 0,
				'history'     => array(),
				'rank'        => 0,
				'rep_points'  => 0,
			)
		);

		return $res;
	}

	/**
	 * The proper way to get a projects changelog from cache/Api.
	 * The changelog is stored in transients with expire date of 7 days.
	 *
	 * @since  4.0.0
	 *
	 * @param  int    $pid          The Project ID.
	 * @param  string $last_version Optional. The last version that must appear
	 *                              in the changelog; used to refresh cached changelog data before
	 *                              the cache expires.
	 *
	 * @return array
	 */
	public function get_changelog( $pid, $last_version = false ) {
		$res = WPMUDEV_Dashboard::$settings->get_transient( 'changelog_' . $pid );

		if ( $last_version && is_array( $res ) && ! empty( $res[0] ) ) {
			$retry_stamp = time() - MINUTE_IN_SECONDS;

			if ( empty( $res['timestamp'] ) ) {
				$res = false;
			} elseif ( ! empty( $res['timestamp'] ) && $res['timestamp'] <= $retry_stamp ) {
				// Check if version in cache is less then the latest version.
				if ( version_compare( $res[0]['version'], $last_version, 'lt' ) ) {
					$res = false; // Cache is outdated and needs to be refreshed.
				}
			}
		}

		if ( empty( $res ) || ! is_array( $res ) ) {
			$res = $this->refresh_changelog( $pid );
		}

		// Basic sanitation, to avoid incompatible return values.
		if ( ! is_array( $res ) ) {
			$res = array();
		}

		return $res;
	}


	/*
	 * *********************************************************************** *
	 * *     FETCH AND REFRESH DATA FROM API
	 * *********************************************************************** *
	 */


	/**
	 * Generates the stats data about the site and installed products
	 *
	 * @param  bool       $encoded        Whether to json encode the fields that are arrays
	 * @param  bool|array $local_projects Optional array of local projects, pass if you have it to save time
	 *
	 * @return array
	 */
	public function build_api_data( $encoded = false, $local_projects = false ) {
		global $wp_version;

		if ( ! is_array( $local_projects ) ) {
			$local_projects = WPMUDEV_Dashboard::$site->get_cached_projects();
		}

		if ( ! function_exists( 'is_plugin_active' ) ) {
			include_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$projects   = array();
		$theme      = wp_get_theme();
		$ms_allowed = $theme->get_allowed();
		foreach ( $local_projects as $pid => $item ) {
			if ( 'theme' == $item['type'] ) {
				$slug = dirname( $item['filename'] );
				if ( is_multisite() ) {
					$active = ! empty( $ms_allowed[ $slug ] ) || ( $theme->stylesheet == $slug || $theme->template == $slug ); // network enabled or on main site
				} else {
					// If the theme is available on main site it's "active".
					$active = ( $theme->stylesheet == $slug || $theme->template == $slug );
				}
			} else {
				// On multisite, only consider network active plugins as active.
				$active = is_multisite() ? is_plugin_active_for_network( $item['filename'] ) : is_plugin_active( $item['filename'] );
			}

			$extra = '';

			/**
			 * Collect extra data from individual plugins.
			 *
			 * @since  4.0.0
			 * @api    wpmudev_api_project_extra_data-$pid
			 *
			 * @param  string $extra Default extra data is an empty string.
			 */
			$extra = apply_filters( "wpmudev_api_project_extra_data-$pid", $extra );
			$extra = apply_filters( 'wpmudev_api_project_extra_data', $extra, $pid );

			$projects[ $pid ] = array(
				'version' => $item['version'],
				'active'  => $active ? true : false,
				'extra'   => $extra,
			);
		}

		/**
		 * Allows modification of the plugin data that is sent to the server.
		 *
		 * @since  4.0.0
		 * @api    wpmudev_api_project_data
		 *
		 * @param  array $projects The whole array of project details.
		 */
		$projects = apply_filters( 'wpmudev_api_project_data', $projects );

		// Get WP/BP version string to help with support.
		if ( is_multisite() ) {
			$wp_ver     = "WordPress Multisite $wp_version";
			$blog_count = get_blog_count();
		} else {
			$wp_ver     = "WordPress $wp_version";
			$blog_count = 1;
		}
		if ( defined( 'BP_VERSION' ) ) {
			$wp_ver .= ', BuddyPress ' . BP_VERSION;
		}

		// Prepare site info.
		$site_info = WPMUDEV_Dashboard::$utils->get_site_info();

		// Get a list of pending WP updates of non-WPMUDEV themes/plugins.
		$repo_updates = $this->get_repo_updates_infos();

		$packages = $this->get_repo_packages();

		// get auth cookies if in WP Engine
		$auth_cookies = $this->get_encrypted_cookies();

		$call_version = WPMUDEV_Dashboard::$version;

		$data = array(
			'call_version' => $call_version,
			'domain'       => $this->network_site_url(),
			'blog_count'   => $blog_count,
			'wp_version'   => $wp_ver,
			'projects'     => $projects,
			'admin_url'    => $this->network_admin_url(),
			'home_url'     => $this->network_home_url(),
			'sso_status'   => WPMUDEV_Dashboard::$settings->get( 'enabled', 'sso' ),
			'repo_updates' => $repo_updates,
			'packages'     => $packages,
			'auth_cookies' => $auth_cookies,
			'site_info'    => $site_info,
		);

		// Report the hosting site_id if in WPMUDEV Hosting environment.
		if ( WPMUDEV_Dashboard::$api->is_wpmu_dev_hosting() ) {
			$data['hosting_site_id'] = defined( 'WPMUDEV_HOSTING_SITE_ID' ) ? WPMUDEV_HOSTING_SITE_ID : gethostname();
		}

		if ( $encoded ) {
			$data['projects']     = json_encode( $data['projects'] );
			$data['repo_updates'] = json_encode( $data['repo_updates'] );
			$data['packages']     = json_encode( $data['packages'] );
			$data['auth_cookies'] = json_encode( $data['auth_cookies'] );
			$data['site_info']    = json_encode( $data['site_info'] );
		}

		return $data;
	}

	/**
	 * Checks if site is hosted on WPMU Dev hosting.
	 *
	 * @since 4.9.0
	 * @since 4.11.15 Added extra checks.
	 *
	 * @return bool Is site hosted on WPMU Dev, true if it is.
	 */
	public function is_wpmu_dev_hosting() {
		return defined( 'WPMUDEV_HOSTING_SITE_ID' ) || isset( $_SERVER['WPMUDEV_HOSTED'] );
	}

	/**
	 * Checks if site is hosted on WPMU Dev hosting with standalone hosting plan.
	 *
	 * @since 4.11.15 Added extra checks.
	 *
	 * @return bool
	 */
	public function is_standalone_hosting_plan() {
		// Get membership data.
		$data = $this->get_membership_data();
		// For standalone hosting there should be active products.
		if ( isset( $data['membership_active_products'] ) && is_array( $data['membership_active_products'] ) ) {
			foreach ( $data['membership_active_products'] as $product ) {
				// If hosting plan found return early.
				if ( strpos( $product, 'hosting-' ) === 0 ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Checks if current site is a third party site with standalone hosting plan.
	 *
	 * @since 4.11.15
	 *
	 * @return bool
	 */
	public function is_hosted_third_party() {
		return $this->is_standalone_hosting_plan() && ! $this->is_wpmu_dev_hosting();
	}

	/**
	 * Contacts the API to sync the latest data from this site.
	 *
	 * Returns the membership status if things are working out.
	 * In case the API call fails the function returns boolean false and does
	 * not update the update
	 *
	 * @since    1.0.0
	 * @internal Function only is public because it's an action handler.
	 *
	 * @param  bool|array $local_projects Optional array of local projects.
	 * @param  bool       $force          Optional forces a sync
	 *
	 * @return array|bool
	 */
	public function hub_sync( $local_projects = false, $force = false ) {
		$res = false;

		/*
		Note: This endpoint does not require an API key.
		 */

		if ( defined( 'WP_INSTALLING' ) ) {
			return false;
		}

		// Clear the "Force data update" flag to avoid infinite loop.
		WPMUDEV_Dashboard::$settings->set( 'refresh_remote', false, 'flags' );

		$stats_data = $hash_data = $this->build_api_data( true, $local_projects );

		unset( $hash_data['auth_cookies'] );
		$data_hash = md5( json_encode( $hash_data ) ); // get a hash of the data to see if it changed (minus auth cookies)
		unset( $hash_data );

		$last_run = (array) WPMUDEV_Dashboard::$settings->get( 'last_run_sync', 'general', array() );


		if ( ! $force && ! empty( $last_run ) ) {
			// this is the main check to prevent pinging unless the data is changed or 6 hrs have passed
			if ( ( $last_run['hash'] ?? '' ) == $data_hash && ( $last_run['time'] ?? 0 ) > ( time() - ( HOUR_IN_SECONDS * 6 ) ) ) {
				if ( WPMUDEV_API_DEBUG ) {
					error_log( '[WPMUDEV API] Skipped sync due to unchanged local data.' );
				}

				return $this->get_membership_data();
			} elseif ( ( $last_run['fails'] ?? 0 ) ) { // check for exponential backoff
				$backoff = min( pow( 5, ( $last_run['fails'] ?? 0 ) ), HOUR_IN_SECONDS ); // 5, 25, 125, 625, 3125, 3600 max
				if ( $last_run['time'] > ( time() - $backoff ) ) {
					if ( WPMUDEV_API_DEBUG ) {
						error_log( '[WPMUDEV API] Skipped sync due to API error exponential backoff.' );
					}

					return $this->get_membership_data();
				}
			}
		}

		$stats_data['sync_version'] = WPMUDEV_Dashboard::$version;

		$response = WPMUDEV_Dashboard::$api->call_auth(
			'hub-sync',
			$stats_data,
			'POST'
		);

		if ( 200 == wp_remote_retrieve_response_code( $response ) ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( is_array( $data ) ) {
				if ( isset( $data['membership'] ) && empty( $data['membership'] ) && ! defined( 'WPMUDEV_APIKEY' ) && WPMUDEV_Dashboard::$api->has_key() ) {
					if ( WPMUDEV_API_DEBUG ) {
						error_log( '[WPMUDEV API Warning] Invalid API key, logging out.' );
					}
					WPMUDEV_Dashboard::$api->set_key( '' );
				}

				WPMUDEV_Dashboard::$settings->set( 'membership_data', $data );
				WPMUDEV_Dashboard::$settings->set(
					'last_run_sync',
					array(
						'time'  => time(),
						'hash'  => $data_hash,
						'fails' => 0,
					),
					'general'
				);

				// Sync analytics state
				$prev_analytics_data = [
					'site_id'    => (int) WPMUDEV_Dashboard::$settings->get( 'site_id', 'analytics' ),
					'tracker'    => (string) WPMUDEV_Dashboard::$settings->get( 'tracker', 'analytics' ),
					'enabled'    => wp_validate_boolean( WPMUDEV_Dashboard::$settings->get( 'enabled', 'analytics' ) ),
					'script_url' => (string) WPMUDEV_Dashboard::$settings->get( 'script_url', 'analytics' ),
				];

				$new_analytics_data = [
					'site_id'    => (int) ( $data['analytics_site_id'] ?? $prev_analytics_data['site_id'] ),
					'tracker'    => (string) ( $data['analytics_tracker'] ?? $prev_analytics_data['tracker'] ),
					'enabled'    => wp_validate_boolean( $data['analytics_enabled'] ?? $prev_analytics_data['enabled'] ),
					'script_url' => (string) ( $data['analytics_script_url'] ?? $prev_analytics_data['script_url'] ),
				];

				$analytics_updated = false;
				foreach ( $new_analytics_data as $key => $value ) {
					WPMUDEV_Dashboard::$settings->set( $key, $value, 'analytics' );
					if ( ! $analytics_updated && ( $prev_analytics_data[ $key ] ?? null ) !== $value ) {
						$analytics_updated = true;
					}
				}

				if ( $analytics_updated ) {
					$this->maybe_clear_hosting_static_cache();
				}

				$res = $data;
			} else {
				$this->parse_api_error( 'Error unserializing remote response.' );
			}
		} else {
			$this->parse_api_error( $response );

			// Check specifically for whether the user has exceeded the sites they can add to the Hub, due to being on the single site plan.
			$body = is_array( $response )
				? wp_remote_retrieve_body( $response )
				: false;

			$data = array();
			if ( is_array( $response ) && ! empty( $body ) ) {
				$data = json_decode( $body, true );
			}

			if ( isset( $data['code'] ) ) {
				if ( 'limit_exceeded_no_hosting_sites' === $data['code'] ) {
					$res['limit_exceeded_no_hosting_sites'] = true;
				}
				$res['limit_data'] = $data['data'];
			}

			/*
			 * For network errors, perform exponential backoff
			 */
			$last_run['time']  = time();
			$last_run['fails'] = $last_run['fails'] + 1;
			WPMUDEV_Dashboard::$settings->set( 'last_run_sync', $last_run, 'general' );
		}

		return $res;
	}

	/**
	 * Contacts the API to get the latest API updates data.
	 *
	 * Returns the available update details if things are working out.
	 * In case the API call fails the function returns boolean false and does
	 * not update the update
	 *
	 * @since    4.4.1
	 * @internal Function only is public because it's an action handler.
	 *
	 * @return array|bool
	 */
	public function refresh_projects_data() {
		$res = false;

		/*
		Note: This endpoint does not require an API key.
		 */

		if ( defined( 'WP_INSTALLING' ) ) {
			return false;
		}

		// Clear the "Force data update" flag to avoid infinite loop.
		WPMUDEV_Dashboard::$settings->set( 'refresh_remote', false, 'flags' );

		// we don't want/need to add apikey to this as we pass no data, and want CDN to cache it as a whole
		$response = WPMUDEV_Dashboard::$api->call(
			'projects',
			false,
			'GET'
		);

		if ( 200 == wp_remote_retrieve_response_code( $response ) ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( is_array( $data ) ) {

				// Default order to display plugins is the order in the array.
				if ( isset( $data['projects'] ) ) {
					$pos = 1;
					foreach ( $data['projects'] as $id => $project ) {
						$data['projects'][ $id ]['_order'] = $pos;
						$pos                              += 1;
					}
				}

				// Remove projects that are not accessible for current member.
				$data = $this->strip_unavailable_projects( $data );

				WPMUDEV_Dashboard::$settings->set( 'updates_data', $data );
				WPMUDEV_Dashboard::$settings->set( 'last_run_updates', time(), 'general' );
				$this->calculate_upgrades();
				$this->calculate_translation_upgrades( true );
				$this->enqueue_notices( $data );

				$res = $data;
			} else {
				$this->parse_api_error( 'Error unserializing remote response.' );
			}
		} else {
			$this->parse_api_error( $response );

			/*
			 * For network errors, set last run to 1 hour in future so it
			 * doesn't retry every single pageload (in case of server
			 * connection issues)
			 */
			WPMUDEV_Dashboard::$settings->set(
				'last_run_updates',
				time() + HOUR_IN_SECONDS,
				'general'
			);
		}

		return $res;
	}

	/**
	 * Refresh the user profile in the local cache and return it.
	 *
	 * If there is any error while loading the current profile from the API
	 * the function will return boolean false and not update the cache.
	 *
	 * @since  1.0.0
	 * @return array|bool
	 */
	public function refresh_profile() {
		$res = false;

		/*
		Note: We need a VALID API KEY to access this endpoint.
		 */

		if ( defined( 'WP_INSTALLING' ) ) {
			return false;
		}
		if ( ! $this->has_key() ) {
			return false;
		}

		WPMUDEV_Dashboard::$settings->set( 'refresh_profile', false, 'flags' );

		$response = WPMUDEV_Dashboard::$api->call_auth(
			'user-info',
			false,
			'GET'
		);

		if ( 200 == wp_remote_retrieve_response_code( $response ) ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( is_array( $data ) ) {
				// 3.1.2 - 2012-06-26 PaulM Convert image urls for ssl admin
				if ( is_ssl() && isset( $data['profile']['gravatar'] ) ) {
					$data['profile']['gravatar'] = str_replace(
						'http://',
						'https://',
						$data['profile']['gravatar']
					);
				}

				WPMUDEV_Dashboard::$settings->set( 'profile_data', $data );
				WPMUDEV_Dashboard::$settings->set( 'last_run_profile', time(), 'general' );

				if ( ! empty( $data['profile']['user_name'] ) ) {
					// The only place we use this, is the login form.
					WPMUDEV_Dashboard::$settings->set(
						'auth_user',
						$data['profile']['user_name'],
						'general'
					);
				}

				$res = $data;
			} else {
				$this->parse_api_error( 'Error unserializing remote response.' );
			}
		} else {
			$this->parse_api_error( $response );
		}

		/*
		 * For network errors, set last run to 1 hour in future so it
		 * doesn't retry every single pageload (in case of server
		 * connection issues)
		 */
		WPMUDEV_Dashboard::$settings->set(
			'last_run_profile',
			time() + HOUR_IN_SECONDS,
			'general'
		);

		return $res;
	}

	/**
	 * Refresh a single projects changelog in the local cache and return it.
	 *
	 * If there is any error while loading the changelog from the API the
	 * function will return boolean false and not update the cache.
	 *
	 * The changlog is cached in a transient for 7 days.
	 *
	 * @since  4.0.0
	 *
	 * @param  int $pid Refresh changelog of this project-ID.
	 *
	 * @return array|bool
	 */
	public function refresh_changelog( $pid ) {
		$res = false;

		/*
		Note: This endpoint does not require an API key.
		 */

		if ( defined( 'WP_INSTALLING' ) ) {
			return false;
		}

		$response = WPMUDEV_Dashboard::$api->call(
			'changelog/' . $pid,
			false,
			'GET'
		);

		if ( wp_remote_retrieve_response_code( $response ) == 200 ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( is_array( $data ) ) {
				$data['timestamp'] = time();
				WPMUDEV_Dashboard::$settings->set_transient(
					'changelog_' . $pid,
					$data,
					WEEK_IN_SECONDS
				);
				$res = $data;
			} else {
				$this->parse_api_error( 'Error unserializing remote response' );
			}
		} else {
			$this->parse_api_error( $response );
		}

		return $res;
	}

	/*
	 * *********************************************************************** *
	 * *     TRANSLATION UPDATE FUNCTIONS
	 * *********************************************************************** *
	 */

	/**
	 * Get translation details from the API.
	 * The API usually returns specific data of all projects
	 * so this is parsed and sorted here.
	 *
	 * @since  4.8.0
	 *
	 * @param  string $locale Locale to search translations for.
	 * @param  bool   $force Forcing will update the data and ignore cache.
	 */
	public function get_project_locale_translations( $locale, $force = false ) {
		$res = false;

		/*
		Note: This endpoint requires an API key.
		 */

		if ( defined( 'WP_INSTALLING' ) ) {
			return false;
		}

		// if no locale is present return.
		if ( ! $locale ) {
			return false;
		}

		// return from cache if possible. Get locale baset cache.
		$cached = WPMUDEV_Dashboard::$settings->get_transient( 'translations_all_' . $locale );
		// Return from cache.
		if ( false !== $cached && ! $force ) {
			return $cached;
		}

		// Get last check time.
		$last_checked = WPMUDEV_Dashboard::$settings->get( 'last_run_translation', 'general', 0 );
		// Already checked in within last 12 hours. Skip API call.
		if ( false !== $cached && ! empty( $last_checked ) && $last_checked > ( time() - DAY_IN_SECONDS ) ) {
			return $cached;
		}

		// Do not continue if no API is set.
		if ( ! $this->has_key() ) {
			return false;
		}

		// set api base.
		$api_base = $this->server_root . $this->rest_api_translation;

		// sets up special auth header.
		$options['headers']                  = array();
		$options['headers']['Authorization'] = $this->get_key();

		$response = WPMUDEV_Dashboard::$api->call(
			$api_base . 'sets/' . $locale . '/projects',
			false,
			'GET',
			$options
		);

		if ( wp_remote_retrieve_response_code( $response ) == 200 ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( is_array( $data ) ) {
				$res = $data;
			} else {
				$this->parse_api_error( 'Error unserializing remote response' );
			}
		} else {
			$this->parse_api_error( $response );
		}

		if ( is_array( $res ) ) {
			$res = $this->sort_translation_projects( $res );
		}
		$data['timestamp'] = time();
		WPMUDEV_Dashboard::$settings->set_transient( 'translations_all_' . $locale, $res, WEEK_IN_SECONDS );
		// Set last checked time.
		WPMUDEV_Dashboard::$settings->set( 'last_run_translation', time(), 'general' );

		return $res;
	}

	/**
	 * Parses the Translation API response data and sort active premium projects
	 *
	 * @since  4.8.0
	 *
	 * @param  array $translations Response data from Translation API call to parse.
	 */
	public function sort_translation_projects( $translations ) {
		$data                = WPMUDEV_Dashboard::$api->get_projects_data();
		$projects            = wp_list_pluck( $data['projects'], 'id' );
		$project_translation = array();

		foreach ( $translations as $key => $project ) {
			if ( is_wp_error( $project ) ) {
				continue;
			}
			if ( in_array( $project['dev_project_id'], $projects, true ) ) {
				$project_translation[] = $project;
			}
		}
		return $project_translation;
	}

	/**
	 * Calculate if the translation files need update.
	 *
	 * @since  4.8.0
	 */
	public function calculate_translation_upgrades( $force = false ) {
		$available_translation = wp_get_installed_translations( 'plugins' );
		$projects              = array();
		$translation_needed    = array();
		$locale                = WPMUDEV_Dashboard::$settings->get( 'translation_locale', 'general' );
		$auto_update           = WPMUDEV_Dashboard::$settings->get( 'enable_auto_translation', 'flags' );
		$update_available      = WPMUDEV_Dashboard::$settings->get( 'translation_updates_available' );
		$translations          = $this->get_project_locale_translations( $locale, $force );

		// set api base.
		$api_base = $this->server_root . $this->rest_api_translation;

		// cache
		if ( ! $force && ! empty( $update_available ) ) {
			return $update_available;
		}

		if ( $translations ) {
			// sort installed plugins
			foreach ( $translations as $key => $value ) {
				$project = WPMUDEV_Dashboard::$site->get_project_info( $value['dev_project_id'] );
				if ( ! empty( $project->is_installed ) ) {
					// Handle Snapshot translation slug.
					// https://incsub.atlassian.net/browse/WDD-187
					$value['translation_slug'] = 3760011 === (int) $value['dev_project_id'] ? 'snapshot' : $value['slug'];
					$value['version']          = $project->version_installed;
					$value['name']             = $project->name;
					$projects[]                = $value;
				}
			}
		}

		// check if translation is not installed and if is installed check if is available.
		foreach ( $projects as $key => $updates ) {

			if (
				! array_key_exists( $updates['translation_slug'], $available_translation ) ||
				! array_key_exists( $locale, $available_translation[ $updates['translation_slug'] ] ) ||
					(
						array_key_exists( $updates['translation_slug'], $available_translation ) &&
						array_key_exists( $locale, $available_translation[ $updates['translation_slug'] ] ) &&
						strtotime( $available_translation[ $updates['translation_slug'] ][ $locale ]['PO-Revision-Date'] ) < strtotime( $updates['sets'][0]['last_modified_utc'] )
					)
				) {

				// package url
				$package = $this->rest_url_auth( $updates['sets'][0]['download_url'] );
				$package = add_query_arg(
					array(
						'format' => 'pomo_zip',
					),
					$package
				);

				$translation_needed[] = array(
					'type'       => 'plugin',
					'slug'       => $updates['translation_slug'],
					'language'   => $locale,
					'version'    => $updates['version'],
					'updated'    => $updates['sets'][0]['last_modified_utc'],
					'package'    => $package,
					'autoupdate' => (bool) $auto_update,
					'name'       => $updates['name'],
				);
			}
		}

		WPMUDEV_Dashboard::$settings->set( 'translation_updates_available', $translation_needed );
		return $translation_needed;
	}

	/**
	 * Auto update the translation files.
	 *
	 * @since  4.8.0
	 */
	public function maybe_update_translations() {
		if ( WPMUDEV_Dashboard::$settings->get( 'enable_auto_translation', 'flags' ) ) {
			// upgrade all the translations
			WPMUDEV_Dashboard::$upgrader->upgrade_translation();
			return true;
		}
		return false;
	}
	/**
	 * Parses the API response data and enqueues the correct message for the
	 * current member.
	 *
	 * @since  4.0.0
	 *
	 * @param  array $api_response Response data from API call to parse.
	 */
	public function enqueue_notices( $api_response ) {
		if ( ! $this->has_key() ) {
			return false;
		}
		if ( ! is_array( $api_response ) ) {
			return false;
		}
		if ( empty( $api_response['membership'] ) ) {
			return false;
		}
		$membership_type = $this->get_membership_status();

		$field = false;

		if ( 'full' == $membership_type ) {
			$field = 'full_notice';
		} elseif ( 'single' == $membership_type ) {
			$field = 'single_notice';
		} elseif ( in_array( $membership_type, array( 'expired', 'paused', 'free' ), true ) ) {
			$field = 'free_notice';
		}

		if ( $field && isset( $api_response[ $field ] ) ) {
			$notice = $api_response[ $field ];

			if ( is_array( $notice ) && ! empty( $notice['time'] ) ) {
				WPMUDEV_Dashboard::$notice->enqueue(
					$notice['time'],
					$notice['msg']
				);

				return true;
			}
		}
	}

	/**
	 * Compares the list of local plugins/themes against Api data to determine
	 * available updates. Save the details to wdp_un_updates_available site
	 * option for later use.
	 *
	 * @since  1.0.0
	 *
	 * @param  array $local_projects List of local projects from the transient.
	 * @param  int   $force_update   Optional. A single project ID that is marked
	 *                               for update, regardless of the version-check.
	 *
	 * @return array
	 */
	public function calculate_upgrades( $local_projects = false, $force_update = 0 ) {
		$updates = array();

		if ( ! is_array( $local_projects ) ) {
			$local_projects = WPMUDEV_Dashboard::$site->get_cached_projects();
		}

		// Check for updates.
		foreach ( $local_projects as $pid => $dummy ) {
			// Skip if the project is not installed on current site.
			$item = WPMUDEV_Dashboard::$site->get_project_info( $pid );
			if ( ! $item || empty( $item->name ) ) {
				continue;
			}
			if ( ! $item->is_installed ) {
				continue;
			}

			if ( $pid != $force_update ) {
				if ( ! $item->has_update ) {
					continue;
				}

				/**
				 * Allows excluding certain projects from update notifications.
				 *
				 * Basically just check the ID and return true if you want to
				 * silence updates.
				 *
				 * Filter result is only used if the remote-project `autoupdate`
				 * attribute does not have value 2.
				 *
				 * @since  1.0.0
				 * @api    wpmudev_project_ignore_updates
				 *
				 * @param  bool $flag Defaults to false, return true to silence.
				 * @param  int  $pid  The WDP ID of the plugin/theme
				 */
				$silence = apply_filters(
					'wpmudev_project_ignore_updates',
					false,
					$pid
				);

				// Handle WP auto-upgrades.
				if ( $silence ) {
					continue;
				}
			}

			// Fallback image is main thumbnail.
			$icon = $item->url->thumbnail;
			if ( ! empty( $item->url->icon ) ) {
				// Use icon if available.
				$icon = $item->url->icon;
			} elseif ( ! empty( $item->url->thumbnail_square ) ) {
				// If icon not available, check if we can use square thumb.
				$icon = $item->url->thumbnail_square;
			}

			// Add to array.
			$updates[ $pid ] = array(
				'url'              => $item->url->website,
				'type'             => $item->type,
				'instructions_url' => $item->url->instructions,
				'name'             => $item->name,
				'filename'         => $item->filename,
				'thumbnail'        => $icon,
				'version'          => $item->version_installed,
				'new_version'      => $item->version_latest,
				'changelog'        => $item->changelog,
				'autoupdate'       => $item->can_autoupdate ? 1 : 0,
			);
		}

		// Record results.
		WPMUDEV_Dashboard::$settings->set( 'updates_available', $updates );

		return $updates;
	}

	/**
	 * Remove projects from the data array that are not available for the
	 * current users membership-plan.
	 *
	 * This means:
	 * - FULL members will NOT see any LITE projects.
	 *
	 * @since  4.0.0
	 *
	 * @param  array $data Response from the API.
	 *
	 * @return array Modified response from the API.
	 */
	protected function strip_unavailable_projects( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}
		if ( empty( $data['projects'] ) ) {
			return $data;
		}

		$my_level = $this->get_membership_status();

		foreach ( $data['projects'] as $id => $project ) {
			if ( 'full' == $my_level ) {
				// Remove lite from the projects list.
				if ( 'lite' == $project['paid'] ) {
					unset( $data['projects'][ $id ] );
				}
			}
		}

		return $data;
	}

	/**
	 * Uses usermeta cache to store gravatar validity flag,
	 * in order to tighten up outgoing requests.
	 *
	 * @since  1.0.0
	 * @return bool True if the user has a gravatar.
	 */
	public function current_user_has_dev_gravatar() {
		$res = (int) WPMUDEV_Dashboard::$site->get_usermeta( '_wdp_un_has_gravatar' );

		// If user has a confirmed gravatar we're good already.
		if ( $res ) {
			return true;
		}

		$profile = $this->get_profile();

		// Check if the user has a valid gravatar.
		$gravatar = $profile['profile']['gravatar'];
		$res      = true;
		$link     = false;

		// Extract clean gravatar URL.
		if ( preg_match_all( '/src=[\'"](https?:\/\/.+\.gravatar.com\/avatar\/.+?\b)/', $gravatar, $parts ) ) {
			$link = isset( $parts[1][0] ) ? $parts[1][0] : false;
		} else {
			$res = false;
		}

		// Check if the gravatar URL is valid.
		if ( $res && $link ) {
			// Construct a special, 404-fallback URL format
			// @see https://en.gravatar.com/site/implement/images/ .
			$link    .= '?d=404';
			$options  = array(
				'sslverify' => true,
				'timeout'   => 5,
			);
			$response = WPMUDEV_Dashboard::$api->call(
				$link,
				false,
				'GET',
				$options
			);

			if ( wp_remote_retrieve_response_code( $response ) != 200 ) {
				$res = false;
			}
		} else {
			$res = false;
		}

		// Only remember the result if the user has a valid gravatar.
		if ( $res ) {
			WPMUDEV_Dashboard::$site->set_usermeta( '_wdp_un_has_gravatar', 1 );
		}

		return $res;
	}


	/*
	 * *********************************************************************** *
	 * *     REMOTE ACCESS FUNCTIONS
	 * *********************************************************************** *
	 */

	/**
	 * Returns details about the remote access permission.
	 *
	 * If no param is specified the function will return a list of all access
	 * details. If a valid param is specified, the function will return a single
	 * string/value of the detail, or false if the detail-name is invalid.
	 *
	 * Details:
	 *   enabled (bool)
	 *   granted (int/timestamp)
	 *   expires (int/timestamp)
	 *   user (int/user-ID)
	 *
	 * @since  4.0.0
	 *
	 * @param  string $detail Optional. Specify the requested detail.
	 *
	 * @return object|scalar The requested detail or all details.
	 */
	public function remote_access_details( $detail = null ) {
		static $Remote_Details = null;

		if ( null === $Remote_Details ) {
			$Remote_Details = array();

			$Remote_Details['enabled'] = false;
			$Remote_Details['expires'] = 0;
			$Remote_Details['granted'] = 0;
			$Remote_Details['user']    = 0;

			$option_val = WPMUDEV_Dashboard::$settings->get( 'remote_access' );

			if ( ! WPMUDEV_DISABLE_REMOTE_ACCESS ) {
				$access = true;
				if ( ! $option_val ) {
					$access = false;
				} elseif ( ! is_array( $option_val ) ) {
					$access = false;
				}

				if ( $access ) {
					if ( isset( $option_val['expire'] ) ) {
						$Remote_Details['expires'] = (int) $option_val['expire'];
					}
					if ( isset( $option_val['granted'] ) ) {
						$Remote_Details['granted'] = (int) $option_val['granted'];
					}
					if ( isset( $option_val['userid'] ) ) {
						$Remote_Details['user'] = (int) $option_val['userid'];
					}
				}

				if ( $Remote_Details['expires'] <= time() ) {
					$access = false;
				}

				$Remote_Details['enabled'] = $access;
			}
		}

		// Reset access details for security if remote access is disabled.
		if ( WPMUDEV_DISABLE_REMOTE_ACCESS || ! $Remote_Details['enabled'] ) {
			$Remote_Details['enabled'] = false;
			$Remote_Details['expires'] = 0;
			$Remote_Details['granted'] = 0;
			$Remote_Details['user']    = 0;
		}

		if ( empty( $detail ) ) {
			return (object) $Remote_Details;
		} elseif ( isset( $Remote_Details[ $detail ] ) ) {
			return $Remote_Details[ $detail ];
		} else {
			return false;
		}
	}

	/**
	 * Enable WPMUDEV staff remote access login.
	 *
	 * @since  1.0.0
	 *
	 * @param  string $action Optional. Can either be 'start' or 'extend'.
	 *                        start .. Will grant access for 5 days from now.
	 *                        extend .. Will grant access for additional 3 days to the current
	 *                        expiration date. This option only works if support access is
	 *                        granted already.
	 */
	public function enable_remote_access( $action = 'start' ) {
		global $current_user;

		if ( ! current_user_can( 'edit_users' ) ) {
			return false;
		}

		if ( WPMUDEV_DISABLE_REMOTE_ACCESS ) {
			return false;
		}

		$details   = $this->remote_access_details();
		$time_base = time();
		$span      = '+5 Days'; // By default grant 5 days from now.

		if ( $details->enabled && $details->expires > $time_base && 'extend' == $action ) {
			// When extending add 3 days to previous expire date.
			$time_base = $details->expires;
			$span      = '+3 Days';
		}

		// We will always create a new access key, even if we only extend!
		$access_key = wp_generate_password( 64, true );
		$expiration = strtotime( $span, $time_base );

		$response = WPMUDEV_Dashboard::$api->call_auth(
			'grant-access',
			array(
				'domain'      => $this->network_site_url(),
				'auth_key'    => $access_key,
				'auth_expire' => $expiration,
				'auth_url'    => admin_url( 'admin-ajax.php?action=wdpunauth' ),
			),
			'POST'
		);

		if ( 200 != wp_remote_retrieve_response_code( $response ) || 'true' != wp_remote_retrieve_body( $response ) ) {
			$this->parse_api_error( $response );

			return false;
		}

		// Save the access details.
		$access = array(
			'userid'  => $current_user->ID,
			'key'     => $access_key,
			'expire'  => $expiration,
			'granted' => time(),
		);
		WPMUDEV_Dashboard::$settings->set( 'remote_access', $access );

		return true;
	}

	/**
	 * Removes access ability for support staff.
	 *
	 * @since 1.0.0
	 */
	public function revoke_remote_access() {
		// Do this whether or not we can update the API.
		WPMUDEV_Dashboard::$settings->set( 'remote_access', '' );

		$response = $this->call_auth(
			'revoke-access',
			array(
				'domain' => $this->network_site_url(),
			),
			'POST'
		);

		if ( 200 != wp_remote_retrieve_response_code( $response ) ) {
			$this->parse_api_error( $response );

			return false;
		}

		return true;
	}

	/**
	 * Listener for WPMU DEV staff remote access login.
	 *
	 * @since    1.0.0
	 * @internal Ajax handler
	 */
	public function authenticate_remote_access() {
		if ( WPMUDEV_DISABLE_REMOTE_ACCESS ) {
			wp_die( 'Error: Remote access disabled in wp-config' );
		}

		$access = WPMUDEV_Dashboard::$settings->get( 'remote_access' );

		// @codingStandardsIgnoreStart: We have own validation, not using nonce!
		$_REQUEST = $_POST;
		// @codingStandardsIgnoreEnd

		$error = false;
		if ( ! $access ) {
			$error = 'no token';
		} elseif ( ! is_array( $access ) ) {
			$error = 'no token';
		} elseif ( empty( $_REQUEST['wdpunkey'] ) ) {
			$error = 'invalid';
		} elseif ( ! hash_equals( $_REQUEST['wdpunkey'], $access['key'] ) ) { // timing attack safe key comparison.
			$error = 'invalid';
		} elseif ( (int) $access['expire'] <= current_time( 'timestamp' ) ) {
			$error = 'expired';
		}

		if ( ! $error ) {
			/* Authentication was successful, log in our support user. */

			// Force 1 hour cookie timeout.
			add_filter( 'auth_cookie_expiration', array( $this, 'auth_cookie_expiration' ) );

			/**
			 * Filter access user_id to be used on remote_access.
			 *
			 * @param int   $usser_id User ID to be used on remote_access.
			 * @param array $access   Remote access details.
			 *
			 * @since 4.11.29
			 */
			$access['userid'] = apply_filters( 'wpmudev_remote_access_set_current_user_id', $access['userid'], $access );

			wp_clear_auth_cookie();
			wp_set_auth_cookie( $access['userid'], false );
			wp_set_current_user( $access['userid'] );

			/**
			 * Do action after successful remote access login..
			 *
			 * @param int   $usser_id User ID being used on remote_access..
			 * @param array $access   Remote access details..
			 *
			 * @since 4.11.29
			 */
			do_action( 'wpmudev_remote_access_set_current_user', $access['userid'], $access );

			$secure_cookie = 'https' === wp_parse_url( get_option( 'home' ), PHP_URL_SCHEME );
			setcookie( 'wpmudev_is_staff', '1', time() + 3600, COOKIEPATH, COOKIE_DOMAIN, $secure_cookie, true );

			// Record login info.
			$access['logins'][ time() ] = array(
				'name'  => $_REQUEST['staff'],
				'image' => $_REQUEST['gravatar_hash'],
			);

			WPMUDEV_Dashboard::$settings->set( 'remote_access', $access );

			// Send to dashboard.
			$url = WPMUDEV_Dashboard::$ui->page_urls->support_url . '#access';
			wp_redirect( $url );
			exit;
		} else {
			// There was an error. Display the error message.
			switch ( $error ) {
				case 'no token':
					wp_die( 'The admin did not enable remote access. Please ask the user to grant access.' );

				case 'expired':
					wp_die( 'This access token has expired. Please ask the user to renew it.' );

				case 'invalid':
				default:
					wp_die( 'This is an invalid access token. Please ask the user to grant access.' );
			}
		}
	}

	/**
	 * Listener for SSO through the Hub - 1st step.
	 * This step will check if the Dashboard user is logged in and the SSO is enabled.
	 * If so, it will redirect to the auth endpoint in the Hub to try the first hmac verification.
	 *
	 * @param string $redirect        Where to redirect after a successful SSO.
	 * @param string $nonce           Nonce coming from the DEV site, to later check if user is logged in.
	 * @param string $jwttoken        JWT Token coming from the DEV site, to later check if user is logged in.
	 * @param string $dev_user_apikey User API Key coming from the DEV site, to later check if user is logged in.
	 * @param string $hubteam         Arbitrary team ID, to later check if user has valid access to specified Hub Team.
	 *
	 * @since    4.7.3
	 * @internal Ajax handler
	 */
	public function authenticate_sso_access_step1( $redirect, $nonce, $jwttoken = '', $dev_user_apikey = '', $hubteam = '' ) {
		// If user is already logged in, let's bypass the whole auth process.
		if ( is_user_logged_in() ) {
			$redirect = urldecode( $redirect );
			wp_safe_redirect( $redirect );
			exit;
		}

		if ( WPMUDEV_DISABLE_SSO ) {
			wp_die( 'Error: Single Signon is disabled in wp-config' );
		}

		$access = WPMUDEV_Dashboard::$settings->get( 'enabled', 'sso' );
		$user   = $this->refresh_profile();

		/**
		 * Checking if user is logged in.
		 * This could have been checked by
		 * just checking if api key is present
		 * but an extra layer has been added here
		 * to check if the api key can actually
		 * fetch proper data
		 */
		$logged = ! empty( $user ) && $this->has_key();

		$error = false;
		if ( ! $access ) {
			$error = 'sso_disabled';
		} elseif ( ! $logged ) {
			$error = 'no_logged_in_dashboard_user';
		}

		if ( ! $error ) {
			/* SSO is enabled and Dashboard user is logged in. */

			$token = uniqid() . '-' . microtime( true );
			WPMUDEV_Dashboard::$settings->set( 'active_token', $token, 'sso' );

			// Create state session cookie.
			$api_key = $this->get_key();

			$pre_sso_state = uniqid( '', true );
			$secure_cookie = 'https' === wp_parse_url( get_option( 'home' ), PHP_URL_SCHEME );

			setcookie( 'wdp-pre-sso-state', $pre_sso_state, time() + 3600, COOKIEPATH, COOKIE_DOMAIN, $secure_cookie, true );

			$hashed_pre_sso_state = hash_hmac( 'sha256', $pre_sso_state, $api_key );
			// Build hmac for OAuth.
			$domain  = $this->network_site_url();
			$profile = $this->get_profile();

			$outgoing_hmac = hash_hmac( 'sha256', $token . $hashed_pre_sso_state . $redirect . $domain, $api_key );

			$auth_endpoint = $this->rest_url( 'sso-hub' );
			$auth_params   = array(
				'domain'        => $domain,
				'hmac'          => $outgoing_hmac,
				'token'         => $token,
				'pre_sso_state' => $hashed_pre_sso_state,
				'redirect'      => $redirect,
				'_hubteam'      => $hubteam,
			);

			// Use user id if available.
			if ( isset( $profile['profile']['id'] ) ) {
				$auth_params['user_id'] = (int) $profile['profile']['id'];
			} else {
				// Fallback to email in case we are still on old cache.
				$auth_params['email'] = rawurlencode( $profile['profile']['user_name'] );
			}

			if ( $jwttoken ) {
				$auth_params['_jwttoken'] = $jwttoken;
			} elseif ( $dev_user_apikey ) {
				$auth_params['_apikey'] = $dev_user_apikey;
			} else {
				// always fallback to nonce as default auth
				$auth_params['_wpnonce'] = $nonce;
			}

			$auth_endpoint = add_query_arg( $auth_params, $auth_endpoint );

			wp_redirect( $auth_endpoint );
			exit;

		} else {
			// There was an error. Display the error message.
			switch ( $error ) {
				case 'sso_disabled':
					$redirect_upon_failure = add_query_arg(
						array(
							'wdp_sso_fail' => 'sso_disabled',
						),
						wp_login_url( urldecode( $redirect ) )
					);

					wp_redirect( $redirect_upon_failure );
					exit;

				case 'no_logged_in_dashboard_user':
					$redirect_upon_failure = add_query_arg(
						array(
							'wdp_sso_fail' => 'no_logged_in_dashboard_user',
						),
						wp_login_url( urldecode( $redirect ) )
					);

					wp_redirect( $redirect_upon_failure );
					exit;
				default:
					$redirect_upon_failure = add_query_arg(
						array(
							'wdp_sso_fail' => 'unkown_reasons',
						),
						wp_login_url( urldecode( $redirect ) )
					);

					wp_redirect( $redirect_upon_failure );
					exit;
			}
		}
	}

	/**
	 * Listener for SSO through the Hub - 2nd step.
	 * This step will verify the hmac coming from the Hub.
	 * If the verification works, it should log in the user and redirect him.
	 *
	 * @param array $sso_access_data {
	 *                               incoming_hmac: string, The hmac coming from the Hub.
	 *                               token: string, The one-time passcode to prevent replay attacks.
	 *                               pre_sso_state: string, The state value that has been saved in a session cookie, in the previous step.
	 *                               redirect: string, The URL that the user needs to be redirected to.
	 *                               dev_user_id: int, The WPMU DEV User ID coming from the Hub.
	 *                               dev_user_email: string, The WPMU DEV User email address coming from the Hub.
	 *                               } An array of SSO Access data
	 *
	 * @since    4.7.3
	 * @since    4.11.29 Simplify parameter format into an array.
	 * @internal Ajax handler
	 */
	public function authenticate_sso_access_step2( $sso_access_data ) {
		if ( WPMUDEV_DISABLE_SSO ) {
			wp_die( 'Error: Single Signon is disabled in wp-config' );
		}

		$incoming_hmac = $sso_access_data['incoming_hmac'] ?? '';
		$token         = $sso_access_data['token'] ?? '';
		$pre_sso_state = $sso_access_data['pre_sso_state'] ?? '';
		$redirect      = $sso_access_data['redirect'] ?? '';

		$api_key        = $this->get_key();
		$verifying_hmac = hash_hmac( 'sha256', $token . $pre_sso_state . $redirect, $api_key );
		$redirect       = urldecode( $redirect );

		$userid = WPMUDEV_Dashboard::$settings->get( 'userid', 'sso' );
		$user   = $this->refresh_profile();

		$is_valid = hash_equals( $incoming_hmac, $verifying_hmac );

		if ( $is_valid && ! empty( $user ) ) {

			list( $req_id, $token_timestamp ) = explode( '-', $token );
			$token_timestamp_float            = floatval( $token_timestamp );

			// Check if the token has expired.
			$current_time = microtime( true );
			if ( number_format( floatval( $current_time ) - $token_timestamp_float, 2 ) > self::SSO_TOKEN_EXPIRY_TIME ) {
				wp_die( 'The SSO token has expired.' );
			}

			// Check if the session cookie of the state value exists in the user's browser.
			if ( isset( $_COOKIE['wdp-pre-sso-state'] ) ) {
				// Check that the state value is the same with what was passed through the endpoint.
				$hmac_state_value = hash_hmac( 'sha256', $_COOKIE['wdp-pre-sso-state'], $api_key );

				if ( hash_equals( $hmac_state_value, $pre_sso_state ) ) {

					// Check if the token has been used in the past, to prevent replay attacks.
					$previous_sso_token = WPMUDEV_Dashboard::$settings->get( 'previous_token', 'sso', 0 );
					if ( $token_timestamp_float > $previous_sso_token ) {
						WPMUDEV_Dashboard::$settings->set( 'previous_token', $token_timestamp_float, 'sso' );
					} else {
						wp_die( 'The SSO token has been used in the past.' );
					}

					// Finally, check if the passed token is the same that was saved in the first place.
					$active_sso_token = WPMUDEV_Dashboard::$settings->get( 'active_token', 'sso' );
					if ( $token !== $active_sso_token ) {
						wp_die( 'The SSO token could not be verified.' );
					} else {
						WPMUDEV_Dashboard::$settings->set( 'active_token', uniqid(), 'sso' );
					}

					/**
					 * Filter access user_id to be used on SSO.
					 *
					 * @param int   $userid          User ID to be used on SSO.
					 * @param array $sso_access_data SSO access details.
					 *
					 * @since 4.11.29
					 */
					$userid = apply_filters( 'wpmudev_sso_set_current_user_id', $userid, $sso_access_data );

					// If everything checks out, log in the user.
					wp_clear_auth_cookie();
					wp_set_auth_cookie( $userid, false );
					wp_set_current_user( $userid );

					/**
					 * Do action after successful SSO login.
					 *
					 * @param int    $usser_id User ID being used on remote_access..
					 * @param string $redirect Where to redirect after a successful SSO.
					 *
					 * @since 4.11.29
					 */
					do_action( 'wpmudev_sso_set_current_user', $userid, $sso_access_data );

					wp_safe_redirect( $redirect );
					exit;
				} else {
					if ( defined( 'WPMUDEV_API_DEBUG' ) && WPMUDEV_API_DEBUG ) {
						error_log(
							sprintf(
								'WPMU DEV Dashboard Error: SSO failed. Expected: %s / Recieved: %s',
								$hmac_state_value,
								$pre_sso_state
							)
						);
					}
					wp_die( 'Passed state value does not match with the session cookie.' );
				}
			} else {
				wp_die( 'Session cookie of the state value does not exist.' );
			}
		} else {
			wp_die( 'Key mismatch.' );
		}
	}

	/**
	 * Clear WPMUDEV hosting static cache if possible.
	 *
	 * Using Internal `wpmudev_hosting_purge_static_cache` Hosting function
	 *
	 * @return void
	 */
	public function maybe_clear_hosting_static_cache() {
		// Only if WPMUDEV hosting.
		if ( ! $this->is_wpmu_dev_hosting() ) {
			return;
		}

		if ( function_exists( 'wpmudev_hosting_purge_static_cache' ) ) {
			wpmudev_hosting_purge_static_cache();
		}
	}

	/**
	 * Enable and configure analytics to collect data for the site.
	 *
	 * @since  4.6
	 *
	 * @return bool
	 */
	public function analytics_enable() {
		$api_base = $this->server_root . $this->rest_api_analytics;

		// sets up special auth header.
		$options['headers']                  = array();
		$options['headers']['Authorization'] = $this->get_key();

		$response = WPMUDEV_Dashboard::$api->call(
			$api_base . 'enable',
			array(
				'domain'       => $this->network_site_url(),
				'sync_version' => WPMUDEV_Dashboard::$version,
			),
			'POST',
			$options
		);
		if ( wp_remote_retrieve_response_code( $response ) == 200 ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );

			if ( isset( $data['site_id'], $data['tracker'] ) ) {
				WPMUDEV_Dashboard::$settings->set( 'site_id', $data['site_id'], 'analytics' );
				WPMUDEV_Dashboard::$settings->set( 'tracker', $data['tracker'], 'analytics' );
				if ( isset( $data['script_url'] ) ) {
					WPMUDEV_Dashboard::$settings->set( 'script_url', $data['script_url'], 'analytics' );
				}

				// Clear WPMUDEV static cache.
				$this->maybe_clear_hosting_static_cache();

				return true;
			}
		} else {
			$this->parse_api_error( $response );
		}

		return false;
	}

	/**
	 * Enable and configure analytics to collect data for the site.
	 *
	 * @since  4.6
	 *
	 * @return bool
	 */
	public function analytics_disable() {
		$api_base = $this->server_root . $this->rest_api_analytics;

		// sets up special auth header.
		$options['headers']                  = array();
		$options['headers']['Authorization'] = $this->get_key();

		$response = WPMUDEV_Dashboard::$api->call(
			$api_base . 'disable',
			array( 'domain' => $this->network_site_url() ),
			'POST',
			$options
		);

		if ( wp_remote_retrieve_response_code( $response ) == 200 ) {
			// Clear WPMUDEV static cache.
			$this->maybe_clear_hosting_static_cache();

			return true;
		} else {
			$this->parse_api_error( $response );
		}

		return false;
	}

	/**
	 * Get overall analytics data for the given site.
	 *  Cached in a transient for 24hrs.
	 *
	 * @since  4.6
	 *
	 * @param int $days_ago How many days in the past to look back.
	 * @param int $subsite  If filtering to a subsite pass the blog_id of it.
	 *
	 * @return mixed
	 */
	public function analytics_stats_overall( $days_ago = 7, $subsite = 0 ) {
		$site_id = WPMUDEV_Dashboard::$settings->get( 'site_id', 'analytics' );
		$tracker = WPMUDEV_Dashboard::$settings->get( 'tracker', 'analytics' );

		// Analytics site id is needed.
		if ( empty( $site_id ) ) {
			return false;
		}

		// figure out what widget view we want.
		if ( is_multisite() ) {
			if ( $subsite ) {
				$type = 'subsite';
			} else {
				$type = 'network';
			}
		} else {
			$type = 'normal';
		}

		$api_base    = $this->server_root . $this->rest_api_analytics;
		$remote_path = add_query_arg( 'days_ago', $days_ago, "{$api_base}site/{$site_id}/overall/{$type}" );
		if ( $subsite ) {
			$remote_path = add_query_arg( 'subsite', $subsite, $remote_path );
		}

		// Add hub site ID.
		$remote_path = add_query_arg( 'domain', $this->network_site_url(), $remote_path );

		// version to update the logic completely ( typically on plugin update )
		// include tracker url in cache key, as id can be collided between tracker
		$transient_key = 'analytics_data_v1_' . md5( $remote_path . $tracker );
		// Get from transient.
		$cached = WPMUDEV_Dashboard::$settings->get_transient( $transient_key );

		// return from cache if possible. We don't use *_site_transient() to avoid unnecessary autoloading. ( we use it behind the scene though ?)
		if ( false !== $cached ) {
			$cached = $this->_analytics_overall_filter_metrics( $cached );

			// Temporary fix to make data format in autocomplete format.
			if ( ! empty( $cached['autocomplete'][0]['value'] ) && is_array( $cached['autocomplete'][0]['value'] ) ) {
				foreach ( $cached['autocomplete'] as $index => $item ) {
					$cached['autocomplete'][ $index ] = array(
						'label'  => $item['label'],
						'value'  => $item['label'],
						'filter' => $item['value']['filter'],
						'type'   => $item['value']['type'],
					);
				}
			}

			return $cached;
		}

		// sets up special auth header.
		$options['headers']                  = array();
		$options['headers']['Authorization'] = $this->get_key();

		$response = WPMUDEV_Dashboard::$api->call(
			$remote_path,
			false,
			'GET',
			$options
		);

		if ( wp_remote_retrieve_response_code( $response ) == 200 ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
		} else {
			$this->parse_api_error( $response );
			return false;
		}

		// parse the data into a format best for our needs
		$final_data                 = array();
		$final_data['autocomplete'] = array();
		$comparison_data            = isset( $data['comparision_overall'] ) ? $data['comparision_overall'] : array();
		// overall data for charts and totals.
		if ( isset( $data['overall'] ) ) {

			// available fields are a bit different when filtered to subsite
			$to_process = array(
				'bounce_rate'      => array(
					'orig_key' => 'bounce_rate',
					'label'    => __( 'Bounce Rate', 'wpmudev' ),
					'callback' => '_analytics_format_pcnt',
				),
				'exit_rate'        => array(
					'orig_key' => 'exit_rate',
					'label'    => __( 'Exit Rate', 'wpmudev' ),
					'callback' => '_analytics_format_pcnt',
				),
				'visit_time'       => array(
					'orig_key' => 'avg_time_on_site',
					'label'    => __( 'Visit Time', 'wpmudev' ),
					'callback' => '_analytics_format_time',
				),
				'visits'           => array(
					'orig_key' => 'nb_visits',
					'label'    => __( 'Entrances', 'wpmudev' ),
					'callback' => '_analytics_format_num',
				),
				'unique_visits'    => array(
					'orig_key' => 'nb_uniq_visitors',
					'label'    => __( 'Unique Visits', 'wpmudev' ),
					'callback' => '_analytics_format_num',
				),
				'pageviews'        => array(
					'orig_key' => 'nb_pageviews',
					'label'    => __( 'Page Views', 'wpmudev' ),
					'callback' => '_analytics_format_num',
				),
				'unique_pageviews' => array(
					'orig_key' => 'nb_uniq_pageviews',
					'label'    => __( 'Unique Page Views', 'wpmudev' ),
					'callback' => '_analytics_format_num',
				),
			);
			if ( $subsite ) {
				unset( $to_process['visits'] );
				unset( $to_process['unique_visits'] );
				unset( $to_process['visit_time'] );
				$to_process['pageviews']        = array(
					'orig_key' => 'nb_hits',
					'label'    => __( 'Pageviews', 'wpmudev' ),
					'callback' => '_analytics_format_num',
				);
				$to_process['unique_pageviews'] = array(
					'orig_key' => 'nb_visits',
					'label'    => __( 'Unique Pageviews', 'wpmudev' ),
					'callback' => '_analytics_format_num',
				);
				$to_process['page_time']        = array(
					'orig_key' => 'avg_time_on_page',
					'label'    => __( 'Page Time', 'wpmudev' ),
					'callback' => '_analytics_format_time',
				);
			}

			foreach ( $data['overall'] as $date => $day ) {
				if ( isset( $day[0] ) ) {
					$day = $day[0];
				}

				// this helps data appear on correct day in x axis.
				$timestamp = date( 'c', strtotime( '+1 day', strtotime( $date ) ) );
				foreach ( $to_process as $key => $process ) {
					$y_value = isset( $day[ $process['orig_key'] ] ) ? $day[ $process['orig_key'] ] : null;
					$final_data['overall']['chart'][ $key ]['label']  = $process['label'];
					$final_data['overall']['chart'][ $key ]['data'][] = array(
						't' => $timestamp,
						'y' => $y_value,
					);
				}
			}

			foreach ( $comparison_data as $date => $day ) {
				if ( isset( $day[0] ) ) {
					$day = $day[0];
				}
				// this helps data appear on correct day in x axis.
				$timestamp = date( 'c', strtotime( '+1 day', strtotime( $date ) ) );
				foreach ( $to_process as $key => $process ) {
					$y_value                          = isset( $day[ $process['orig_key'] ] ) ? $day[ $process['orig_key'] ] : null;
					$comparing_data[ $key ]['label']  = $process['label'];
					$comparing_data[ $key ]['data'][] = array(
						't' => $timestamp,
						'y' => $y_value,
					);

				}
			}

			// for totals, we only wants if any of the days ( keys ) has page views
			// note: 1 visits can have multiple page views
			$data_count_with_page_views = 0;
			if ( isset( $final_data['overall']['chart']['pageviews']['data'] ) ) {
				foreach ( $final_data['overall']['chart']['pageviews']['data'] as $key => $value ) {
					if ( isset( $value['y'] ) && $value['y'] > 0 ) {
						++$data_count_with_page_views;
					}
				}
			}
			$data_compare_count_with_page_views = 0;
			if ( isset( $comparing_data['pageviews']['data'] ) ) {
				foreach ( $comparing_data['pageviews']['data'] as $key => $value ) {
					if ( isset( $value['y'] ) && $value['y'] > 0 ) {
						++$data_compare_count_with_page_views;
					}
				}
			}

			foreach ( $to_process as $key => $process ) {
				if ( isset( $final_data['overall']['chart'][ $key ] ) ) {
					$totals        = 0;
					$compare_total = 0;
					$compare_avg   = false;
					$compare_data  = array();
					if ( isset( $comparing_data[ $key ] ) ) {
						$compare_data = wp_list_pluck( $comparing_data[ $key ]['data'], 'y' );
					}

					$list = wp_list_pluck( $final_data['overall']['chart'][ $key ]['data'], 'y' );

					// for number we want total, others mean
					if ( '_analytics_format_num' === $process['callback'] ) {
						$totals        = array_sum( $list );
						$compare_total = array_sum( $compare_data );
					} else {
						$avg         = 0;
						$compare_avg = 0;
						if ( $data_count_with_page_views > 0 ) {
							$avg = array_sum( $list ) / $data_count_with_page_views;
						}
						if ( $data_compare_count_with_page_views > 0 ) {
							$compare_avg = array_sum( $compare_data ) / $data_compare_count_with_page_views;
						}
						$totals        = $avg;
						$compare_total = $compare_avg;
					}

					if ( count( $list ) ) {

						// assume 1 if no data found.
						$start = $compare_total > 0 ? abs( $compare_total ) : 0;

						if ( 0 === $start && 0 === abs( $totals ) ) {
							$end = 0;
						} else {
							$end = $totals > 0 ? abs( $totals ) : 1;
						}

						// if no data found the current data is the increment.
						if ( $start <= 0 && $end <= 0 ) {
							$change = 0;
						} elseif ( $start <= 0 ) {
							$change = round( $end, 1 );
						} else {
							$change = round( ( ( $end - $start ) / $start * 100 ), 1 );
						}
					} else {
						$change = 0;
					}

					$final_data['overall']['totals'][ $key ] = array(
						'change'    => number_format_i18n( abs( $change ) ) . '%',
						'direction' => ( $change == 0 ) ? 'none' : ( $change > 0 ? 'up' : 'down' ),
						'value'     => call_user_func( array( $this, $process['callback'] ), $totals ),
					);

				}
			}
		}

		$to_process = array(
			'pageviews'        => array(
				'orig_key' => 'nb_hits',
				'callback' => '_analytics_format_num',
			),
			'unique_pageviews' => array(
				'orig_key' => 'nb_visits',
				'callback' => '_analytics_format_num',
			),
			'bounce_rate'      => array(
				'orig_key' => 'bounce_rate',
				'callback' => '_analytics_format_pcnt',
			),
			'visits'           => array(
				'orig_key' => 'entry_nb_visits',
				'callback' => '_analytics_format_num',
			),
			'exit_rate'        => array(
				'orig_key' => 'exit_rate',
				'callback' => '_analytics_format_pcnt',
			),
			'gen_time'         => array(
				'orig_key' => 'avg_page_load_time',
				'callback' => '_analytics_format_time',
			),
			'page_time'        => array(
				'orig_key' => 'avg_time_on_page',
				'callback' => '_analytics_format_time',
			),
		);

		// top pages & posts list.
		if ( isset( $data['pages'] ) ) {
			foreach ( $data['pages'] as $page ) {

				$new_page = array(
					'filter' => isset( $page['url'] ) ? $page['url'] : '',
					'name'   => isset( $page['label'] ) ? trim( $page['label'] ) : '',
				);

				// get desired categories.
				foreach ( $to_process as $key => $process ) {
					if ( isset( $page[ $process['orig_key'] ] ) ) {
						$new_page[ $key ] = array(
							'value' => call_user_func( array( $this, $process['callback'] ), $page[ $process['orig_key'] ] ),
							'sort'  => $page[ $process['orig_key'] ],
						);
					}
				}
				$final_data['pages'][]        = $new_page;
				$final_data['autocomplete'][] = array(
					'label'  => sprintf( __( 'Page: %s', 'wpmudev' ), $new_page['name'] ),
					'value'  => sprintf( __( 'Page: %s', 'wpmudev' ), $new_page['name'] ),
					'type'   => 'page',
					'filter' => $new_page['filter'],
				);
			}
		}

		// sites list.
		if ( isset( $data['sites'] ) && is_multisite() ) {
			$blog_ids = array();
			foreach ( $data['sites'] as $site ) {

				$new_site = array(
					'filter' => urlencode( $site['label'] ),
				);

				// try to get the blog domain from blog_id
				$blog_id = trim( $site['label'] );
				if ( $blog_id && is_numeric( $blog_id ) && absint( $blog_id ) && ! in_array( absint( $blog_id ), $blog_ids, true ) ) {
					$blog = get_blog_details( absint( $blog_id ), true );
					if ( $blog ) {
						$blog_ids[]       = absint( $blog_id ); // save to make sure we only see each blog once (first with most data) in case of tracking bugs.
						$new_site['name'] = untrailingslashit( $blog->domain . $blog->path ) . ' - ' . $blog->blogname;
					} else {
						continue;
					}
				} else {
					continue;
				}

				// get desired categories.
				foreach ( $to_process as $key => $process ) {
					if ( isset( $site[ $process['orig_key'] ] ) ) {
						$new_site[ $key ] = array(
							'value' => call_user_func( array( $this, $process['callback'] ), $site[ $process['orig_key'] ] ),
							'sort'  => $site[ $process['orig_key'] ],
						);
					}
				}
				$final_data['sites'][]        = $new_site;
				$final_data['autocomplete'][] = array(
					'label'  => sprintf( __( 'Site: %s', 'wpmudev' ), $new_site['name'] ),
					'value'  => sprintf( __( 'Site: %s', 'wpmudev' ), $new_site['name'] ),
					'type'   => 'subsite',
					'filter' => $new_site['filter'],
				);
			}
		}

		// authors list.
		if ( isset( $data['authors'] ) ) {
			// page_time key is different for custom dimension.
			$to_process['page_time'] = array(
				'orig_key' => 'avg_time_on_dimension',
				'callback' => '_analytics_format_time',
			);

			foreach ( $data['authors'] as $author ) {

				// attempt to decode author json object.
				$author_object = json_decode( trim( $author['label'] ) );
				if ( ! isset( $author_object->ID ) ) {
					continue;
				}

				$new_author = array();
				$user       = get_userdata( $author_object->ID );
				if ( $user ) {
					$new_author['name']     = $user->display_name;
					$new_author['gravatar'] = get_avatar_url( $author_object->ID, array( 'size' => 25 ) );
				} else {
					$new_author['name']     = $author_object->name;
					$new_author['gravatar'] = get_avatar_url( $author_object->avatar, array( 'size' => 25 ) );
				}

				$new_author['filter'] = urlencode( $author['label'] );

				// get desired categories.
				foreach ( $to_process as $key => $process ) {
					if ( isset( $author[ $process['orig_key'] ] ) ) {
						$new_author[ $key ] = array(
							'value' => call_user_func( array( $this, $process['callback'] ), $author[ $process['orig_key'] ] ),
							'sort'  => $author[ $process['orig_key'] ],
						);
					}
				}
				$final_data['authors'][]      = $new_author;
				$final_data['autocomplete'][] = array(
					'label'  => sprintf( __( 'Author: %s', 'wpmudev' ), $new_author['name'] ),
					'value'  => sprintf( __( 'Author: %s', 'wpmudev' ), $new_author['name'] ),
					'type'   => 'author',
					'filter' => $new_author['filter'],
				);
			}
		}

		// Cache for later.
		WPMUDEV_Dashboard::$settings->set_transient( $transient_key, $final_data, DAY_IN_SECONDS );

		return $this->_analytics_overall_filter_metrics( $final_data );
	}

	/**
	 * Get analytics data for a specific dimension query for the given site.
	 *  Not cached due to the vast number of possible args.
	 *
	 * @since  4.6
	 *
	 * @param int    $days_ago How many days in the past to look back
	 * @param string $type     Can be page|author|subsite.
	 * @param string $filter   Page, author, or blog_id to filter to.
	 *
	 * @return mixed
	 */
	public function analytics_stats_single( $days_ago, $type, $filter ) {
		$site_id  = WPMUDEV_Dashboard::$settings->get( 'site_id', 'analytics' );
		$metrics  = WPMUDEV_Dashboard::$site->get_metrics_on_analytics();
		$days_ago = isset( $days_ago ) ? $days_ago : 7;

		$api_base    = $this->server_root . $this->rest_api_analytics;
		$remote_path = add_query_arg(
			array(
				'filter'   => $filter,
				'days_ago' => $days_ago,
				'domain'   => $this->network_site_url(),
			),
			"{$api_base}site/{$site_id}/{$type}"
		);

		// sets up special auth header.
		$options['headers']                  = array();
		$options['headers']['Authorization'] = $this->get_key();

		$response = WPMUDEV_Dashboard::$api->call(
			$remote_path,
			false,
			'GET',
			$options
		);

		if ( wp_remote_retrieve_response_code( $response ) == 200 ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
		} else {
			$this->parse_api_error( $response );
			return false;
		}

		// parse the data into a format best for our needs
		$final_data      = array();
		$comparison_data = isset( $data['comparisions'] ) ? $data['comparisions'] : array();

		// available fields are a bit different when filtered to subsite
		$to_process = array(
			'bounce_rate'      => array(
				'orig_key' => 'bounce_rate',
				'label'    => __( 'Bounce Rate', 'wpmudev' ),
				'callback' => '_analytics_format_pcnt',
			),
			'exit_rate'        => array(
				'orig_key' => 'exit_rate',
				'label'    => __( 'Exit Rate', 'wpmudev' ),
				'callback' => '_analytics_format_pcnt',
			),
			'visits'        => array(
				'orig_key' => 'entry_nb_visits',
				'label'    => __( 'Entrances', 'wpmudev' ),
				'callback' => '_analytics_format_num',
			),
			'page_time'        => array(
				'orig_key' => 'avg_time_on_page',
				'label'    => __( 'Page Time', 'wpmudev' ),
				'callback' => '_analytics_format_time',
			),
			'pageviews'        => array(
				'orig_key' => 'nb_hits',
				'label'    => __( 'Pageviews', 'wpmudev' ),
				'callback' => '_analytics_format_num',
			),
			'unique_pageviews' => array(
				'orig_key' => 'nb_visits',
				'label'    => __( 'Unique Pageviews', 'wpmudev' ),
				'callback' => '_analytics_format_num',
			),
		);

		// limit metrics
		if ( ! in_array( 'pageviews', $metrics, true ) ) {
			unset( $to_process['pageviews'] );
		}
		if ( ! in_array( 'unique_pageviews', $metrics, true ) ) {
			unset( $to_process['unique_pageviews'] );
		}
		if ( ! in_array( 'page_time', $metrics, true ) ) {
			unset( $to_process['page_time'] );
		}
		if ( ! in_array( 'bounce_rate', $metrics, true ) ) {
			unset( $to_process['bounce_rate'] );
		}
		if ( ! in_array( 'exit_rate', $metrics, true ) ) {
			unset( $to_process['exit_rate'] );
		}
		if ( ! in_array( 'visits', $metrics, true ) ) {
			unset( $to_process['visits'] );
		}

		// key is different for authors
		if ( 'author' === $type ) {
			if ( in_array( 'page_time', $metrics, true ) ) {
				$to_process['page_time']['orig_key'] = 'avg_time_on_dimension';
			}
		}

		foreach ( $data as $date => $day ) {
			// Do not convert non-date values.
			if ( 'comparisions' === $date ) {
				continue;
			}

			if ( isset( $day[0] ) ) {
				$day = $day[0];
			}

			// this helps data appear on correct day in x axis.
			$timestamp = date( 'c', strtotime( '+1 day', strtotime( $date ) ) );
			foreach ( $to_process as $key => $process ) {
				$y_value                               = isset( $day[ $process['orig_key'] ] ) ? $day[ $process['orig_key'] ] : null;
				$final_data['chart'][ $key ]['label']  = $process['label'];
				$final_data['chart'][ $key ]['data'][] = array(
					't' => $timestamp,
					'y' => $y_value,
				);
			}
		}

		foreach ( $comparison_data as $date => $day ) {
			// Do not convert non-date values.
			if ( 'comparisions' === $date ) {
				continue;
			}

			if ( isset( $day[0] ) ) {
				$day = $day[0];
			}
			// this helps data appear on correct day in x axis.
			$timestamp = date( 'c', strtotime( '+1 day', strtotime( $date ) ) );
			foreach ( $to_process as $key => $process ) {
				$y_value                                   = isset( $day[ $process['orig_key'] ] ) ? $day[ $process['orig_key'] ] : null;
				$comparing_data['chart'][ $key ]['label']  = $process['label'];
				$comparing_data['chart'][ $key ]['data'][] = array(
					't' => $timestamp,
					'y' => $y_value,
				);
			}
		}
		foreach ( $to_process as $key => $process ) {

			if ( isset( $final_data['chart'][ $key ] ) ) {
				$list          = array_filter( wp_list_pluck( $final_data['chart'][ $key ]['data'], 'y' ) );
				$compare_data  = array();
				$totals        = 0;
				$compare_total = 0;
				$compare_avg   = false;

				if ( isset( $comparing_data['chart'][ $key ] ) ) {
					$compare_data = wp_list_pluck( $comparing_data['chart'][ $key ]['data'], 'y' );
				}

				// for number we want total, others mean
				if ( '_analytics_format_num' === $process['callback'] ) {
					$totals        = array_sum( $list );
					$compare_total = array_sum( $compare_data );
				} else {
					if ( count( $list ) ) {
						$avg = array_sum( $list ) / count( $list );
						if ( ! empty( $compare_data ) && count( $compare_data ) ) {
							$compare_avg = array_sum( $compare_data ) / count( $compare_data );
						}
					} else {
						$avg         = false;
						$compare_avg = false;
					}
					$totals        = $avg;
					$compare_total = $compare_avg;
				}

				if ( count( $list ) ) {

					// assume 1 if no data found.
					$start = $compare_total > 0 ? abs( $compare_total ) : 0;

					if ( 0 === $start && 0 === abs( $totals ) ) {
						$end = 0;
					} else {
						$end = $totals > 0 ? abs( $totals ) : 1;
					}

					// if no data found the current data is the increment.
					if ( $start <= 0 && $end <= 0 ) {
						$change = 0;
					} elseif ( $start <= 0 ) {
						$change = round( $end, 1 );
					} else {
						$change = round( ( ( $end - $start ) / $start * 100 ), 1 );
					}
				} else {
					$change = 0;
				}

				$final_data['totals'][ $key ] = array(
					'change'    => number_format_i18n( abs( $change ) ) . '%',
					'direction' => ( $change == 0 ) ? 'none' : ( $change > 0 ? 'up' : 'down' ),
					'value'     => call_user_func( array( $this, $process['callback'] ), $totals ),
				);

			}
		}

		return $final_data;
	}

	/*
	 * *********************************************************************** *
	 * *     INTERNAL ACTION HANDLERS
	 * *********************************************************************** *
	 */

	/**
	 * Callback to format percentage for analytics widget.
	 *
	 * @since  4.6
	 *
	 * @param  int|float $decimal Number to format.
	 *
	 * @return string
	 */
	public function _analytics_format_pcnt( $decimal ) {
		if ( false === $decimal ) {
			return '-';
		}
		return round( $decimal * 100, 2 ) . '%';
	}

	/**
	 * Callback to format time for analytics widget.
	 *
	 * @since  4.6
	 *
	 * @param  int|float $seconds Seconds to format.
	 *
	 * @return string
	 */
	public function _analytics_format_time( $seconds ) {
		if ( false === $seconds ) {
			return '-';
		}
		if ( $seconds >= 60 ) {
			$mins = round( ( $seconds / 60 ), 2 );
			return sprintf( _n( '%s min', '%s mins', $seconds, 'wpmudev' ), $mins );
		} elseif ( $seconds >= 1 ) {
			$seconds = round( $seconds, 2 );
			return sprintf( _n( '%s sec', '%s secs', $seconds, 'wpmudev' ), $seconds );
		} else {
			$milliseconds = round( $seconds * 1000 );
			return sprintf( __( '%s ms', 'wpmudev' ), $milliseconds );
		}
	}

	/**
	 * Callback to format number for analytics widget.
	 *
	 * @since  4.6
	 *
	 * @param  int|float $number Number to format.
	 *
	 * @return string
	 */
	public function _analytics_format_num( $number ) {
		return number_format_i18n( round( $number ) );
	}

	/**
	 * Filter overall analytics data
	 *
	 * @since 4.7
	 *
	 * @param $data
	 *
	 * @return mixed
	 */
	public function _analytics_overall_filter_metrics( $data ) {
		$metrics = WPMUDEV_Dashboard::$site->get_metrics_on_analytics();
		// filter metrics
		if ( isset( $data['overall'] ) && is_array( $data['overall'] ) ) {
			if ( isset( $data['overall']['chart'] ) && is_array( $data['overall']['chart'] ) ) {
				// limit metrics
				if ( ! in_array( 'pageviews', $metrics, true ) ) {
					unset( $data['overall']['chart']['pageviews'] );
				}
				if ( ! in_array( 'unique_pageviews', $metrics, true ) ) {
					unset( $data['overall']['chart']['unique_pageviews'] );
				}
				if ( ! in_array( 'page_time', $metrics, true ) ) {
					unset( $data['overall']['chart']['page_time'] );
					unset( $data['overall']['chart']['visit_time'] );
				}
				if ( ! in_array( 'bounce_rate', $metrics, true ) ) {
					unset( $data['overall']['chart']['bounce_rate'] );
				}
				if ( ! in_array( 'exit_rate', $metrics, true ) ) {
					unset( $data['overall']['chart']['exit_rate'] );
				}
				if ( ! in_array( 'visits', $metrics, true ) ) {
					unset( $data['overall']['chart']['visits'] );
				}
			}
			if ( isset( $data['overall']['totals'] ) && is_array( $data['overall']['totals'] ) ) {
				// limit metrics
				if ( ! in_array( 'pageviews', $metrics, true ) ) {
					unset( $data['overall']['totals']['pageviews'] );
				}
				if ( ! in_array( 'unique_pageviews', $metrics, true ) ) {
					unset( $data['overall']['totals']['unique_pageviews'] );
				}
				if ( ! in_array( 'page_time', $metrics, true ) ) {
					unset( $data['overall']['totals']['page_time'] );
					unset( $data['overall']['totals']['visit_time'] );
				}
				if ( ! in_array( 'bounce_rate', $metrics, true ) ) {
					unset( $data['overall']['totals']['bounce_rate'] );
				}
				if ( ! in_array( 'exit_rate', $metrics, true ) ) {
					unset( $data['overall']['totals']['exit_rate'] );
				}
				if ( ! in_array( 'visits', $metrics, true ) ) {
					unset( $data['overall']['totals']['visits'] );
				}
			}
		}

		if ( isset( $data['pages'] ) && is_array( $data['pages'] ) ) {
			foreach ( $data['pages'] as $key => $page ) {
				// limit metrics
				if ( ! in_array( 'pageviews', $metrics, true ) ) {
					unset( $data['pages'][ $key ]['pageviews'] );
				}
				if ( ! in_array( 'unique_pageviews', $metrics, true ) ) {
					unset( $data['pages'][ $key ]['unique_pageviews'] );
				}
				if ( ! in_array( 'page_time', $metrics, true ) ) {
					unset( $data['pages'][ $key ]['page_time'] );
					unset( $data['pages'][ $key ]['visit_time'] );
				}
				if ( ! in_array( 'bounce_rate', $metrics, true ) ) {
					unset( $data['pages'][ $key ]['bounce_rate'] );
				}
				if ( ! in_array( 'exit_rate', $metrics, true ) ) {
					unset( $data['pages'][ $key ]['exit_rate'] );
				}
				if ( ! in_array( 'visits', $metrics, true ) ) {
					unset( $data['pages'][ $key ]['visits'] );
				}
			}
		}

		if ( isset( $data['sites'] ) && is_array( $data['sites'] ) ) {
			foreach ( $data['sites'] as $key => $site ) {
				// limit metrics
				if ( ! in_array( 'pageviews', $metrics, true ) ) {
					unset( $data['sites'][ $key ]['pageviews'] );
				}
				if ( ! in_array( 'unique_pageviews', $metrics, true ) ) {
					unset( $data['sites'][ $key ]['unique_pageviews'] );
				}
				if ( ! in_array( 'page_time', $metrics, true ) ) {
					unset( $data['sites'][ $key ]['page_time'] );
					unset( $data['sites'][ $key ]['visit_time'] );
				}
				if ( ! in_array( 'bounce_rate', $metrics, true ) ) {
					unset( $data['sites'][ $key ]['bounce_rate'] );
				}
				if ( ! in_array( 'exit_rate', $metrics, true ) ) {
					unset( $data['sites'][ $key ]['exit_rate'] );
				}
				if ( ! in_array( 'visits', $metrics, true ) ) {
					unset( $data['sites'][ $key ]['visits'] );
				}
			}
		}

		if ( isset( $data['authors'] ) && is_array( $data['authors'] ) ) {
			foreach ( $data['authors'] as $key => $author ) {
				// limit metrics
				if ( ! in_array( 'pageviews', $metrics, true ) ) {
					unset( $data['authors'][ $key ]['pageviews'] );
				}
				if ( ! in_array( 'unique_pageviews', $metrics, true ) ) {
					unset( $data['authors'][ $key ]['unique_pageviews'] );
				}
				if ( ! in_array( 'page_time', $metrics, true ) ) {
					unset( $data['authors'][ $key ]['page_time'] );
					unset( $data['authors'][ $key ]['visit_time'] );
				}
				if ( ! in_array( 'bounce_rate', $metrics, true ) ) {
					unset( $data['authors'][ $key ]['bounce_rate'] );
				}
				if ( ! in_array( 'exit_rate', $metrics, true ) ) {
					unset( $data['authors'][ $key ]['exit_rate'] );
				}
				if ( ! in_array( 'visits', $metrics, true ) ) {
					unset( $data['authors'][ $key ]['visits'] );
				}
			}
		}

		return $data;
	}

	/**
	 * Used to filter auth cookie expiration.
	 *
	 * @since  4.5
	 *
	 * @param  int $timeout Time in seconds.
	 *
	 * @return int $timeout
	 */
	public function auth_cookie_expiration( $timeout ) {
		return HOUR_IN_SECONDS;
	}

	/**
	 * Parses an HTTP response object (or other value) to determine an error
	 * reason. The error reason is added to the PHP error log.
	 *
	 * @since  4.0.0
	 *
	 * @param mixed $response String, WP_Error object, HTTP response array.
	 */
	protected function parse_api_error( $response ) {
		$error_code = wp_remote_retrieve_response_code( $response );
		if ( ! $error_code ) {
			$error_code = 500;
		}
		$this->api_error = '';

		$body = is_array( $response )
			? wp_remote_retrieve_body( $response )
			: false;

		if ( is_scalar( $response ) ) {
			$this->api_error = $response;
		} elseif ( is_wp_error( $response ) ) {
			$this->api_error = $response->get_error_message();
		} elseif ( is_array( $response ) && ! empty( $body ) ) {
			$data = json_decode( wp_remote_retrieve_body( $response ), true );
			if ( is_array( $data ) && ! empty( $data['message'] ) ) {
				$this->api_error = $data['message'];
			}
		}

		$url = '(unknown URL)';
		if ( is_array( $response ) && isset( $response['request_url'] ) ) {
			$url = $response['request_url'];
		}

		if ( empty( $this->api_error ) ) {
			$this->api_error = sprintf(
				'HTTP Error: %s "%s"',
				$error_code,
				wp_remote_retrieve_response_message( $response )
			);
		}

		// Collect back-trace information for the logfile.
		$caller_dump = '';
		if ( defined( 'WPMUDEV_API_DEBUG' ) && WPMUDEV_API_DEBUG ) {
			$trace     = debug_backtrace();
			$caller    = array();
			$last_line = '';
			foreach ( $trace as $level => $item ) {
				if ( ! isset( $item['class'] ) ) {
					$item['class'] = '';
				}
				if ( ! isset( $item['type'] ) ) {
					$item['type'] = '';
				}
				if ( ! isset( $item['function'] ) ) {
					$item['function'] = '<function>';
				}
				if ( ! isset( $item['line'] ) ) {
					$item['line'] = '?';
				}

				if ( $level > 0 ) {
					$caller[] = $item['class'] .
					            $item['type'] .
					            $item['function'] .
					            ':' . $last_line;
				}
				$last_line = $item['line'];
			}
			$caller_dump = "\n\t# " . implode( "\n\t# ", $caller );

			if ( is_array( $response ) && isset( $response['request_url'] ) ) {
				$caller_dump = "\n\tURL: " . $response['request_url'] . $caller_dump;
			}

			// Log the error to PHP error log.
			error_log(
				sprintf(
					'[WPMUDEV API Error] %s | %s (%s [%s]) %s',
					WPMUDEV_Dashboard::$version,
					$this->api_error,
					$url,
					$error_code,
					$caller_dump
				),
				0
			);
		}

		// If error was "invalid API key" then log out the user. (we don't call logout here to avoid infinite loop)
		if ( 401 == $error_code && ! defined( 'WPMUDEV_APIKEY' ) && ! defined( 'WPMUDEV_OVERRIDE_LOGOUT' ) ) {
			WPMUDEV_Dashboard::$api->set_key( '' );
		}
	}
}

/**
 * Returns the correct network_site_url to use for API calls to the hub.
 *
 * For use by other WPMU DEV plugins.
 *
 * @since  4.6.0
 *
 * @return string
 */
function wpmudev_api_url() {
	return WPMUDEV_Dashboard::$api->network_site_url();
}

haha - 2025