--- a/google-analytics-dashboard-for-wp/gadwp.php
+++ b/google-analytics-dashboard-for-wp/gadwp.php
@@ -5,7 +5,7 @@
* Plugin URI: https://exactmetrics.com
* Description: Displays Google Analytics Reports and Real-Time Statistics in your Dashboard. Automatically inserts the tracking code in every page of your website.
* Author: ExactMetrics
- * Version: 9.0.2
+ * Version: 9.0.3
* Requires at least: 5.6.0
* Requires PHP: 7.2
* Author URI: https://exactmetrics.com/lite/?utm_source=liteplugin&utm_medium=pluginheader&utm_campaign=authoruri&utm_content=7%2E0%2E0
@@ -55,7 +55,7 @@
* @var string $version Plugin version.
*/
- public $version = '9.0.2';
+ public $version = '9.0.3';
/**
* Plugin file.
--- a/google-analytics-dashboard-for-wp/includes/admin/admin-assets.php
+++ b/google-analytics-dashboard-for-wp/includes/admin/admin-assets.php
@@ -248,6 +248,15 @@
$site_auth = $auth->get_viewname();
$ms_auth = is_multisite() && $auth->get_network_viewname();
+ // Get bearer token for direct browser-to-API requests.
+ $bearer_token_data = ExactMetrics_API_Token::get_token( is_network_admin() );
+ $bearer_token = '';
+ $bearer_expires = 0;
+ if ( ! is_wp_error( $bearer_token_data ) ) {
+ $bearer_token = $bearer_token_data['token'];
+ $bearer_expires = $bearer_token_data['expires_at'];
+ }
+
wp_localize_script(
$handle,
'exactmetrics',
@@ -265,6 +274,10 @@
'wizard_url' => exactmetrics_get_onboarding_url(),
'rest_url' => get_rest_url(),
'rest_nonce' => wp_create_nonce( 'wp_rest' ),
+ // Direct API access (bypasses WordPress for performance).
+ 'relay_api_url' => apply_filters( 'exactmetrics_api_url_custom_dashboard', 'https://app.exactmetrics.com/' ),
+ 'bearer_token' => $bearer_token,
+ 'bearer_expires' => $bearer_expires,
)
);
--- a/google-analytics-dashboard-for-wp/includes/admin/admin.php
+++ b/google-analytics-dashboard-for-wp/includes/admin/admin.php
@@ -62,9 +62,6 @@
add_submenu_page( $parent_slug, __( 'General Reports:', 'google-analytics-dashboard-for-wp' ), __( 'Reports', 'google-analytics-dashboard-for-wp' ), 'exactmetrics_view_dashboard', 'exactmetrics_reports', 'exactmetrics_reports_page' );
}
- // then settings page
- add_submenu_page( $parent_slug, __( 'ExactMetrics', 'google-analytics-dashboard-for-wp' ), __( 'Settings', 'google-analytics-dashboard-for-wp' ), 'exactmetrics_save_settings', 'exactmetrics_settings', 'exactmetrics_settings_page' );
-
/**
* Output the Custom Dashboard app mount node.
*
@@ -72,9 +69,27 @@
*/
function exactmetrics_custom_dashboard_page() {
do_action( 'exactmetrics_head' );
- echo '<div id="exactmetrics-custom-dashboard-app" class="mi-custom-dashboard-app">Loading</div>';
+ // Hide WordPress admin notices on this page - Vue app handles its own notifications
+ echo '<style>.exactmetrics_page_exactmetrics_custom_dashboard .notice:not(.exactmetrics-notice),.exactmetrics_page_exactmetrics_custom_dashboard .error:not(.exactmetrics-notice),.exactmetrics_page_exactmetrics_custom_dashboard .updated:not(.exactmetrics-notice){display:none !important;}</style>';
+ echo '<div id="exactmetrics-custom-dashboard-app" class="mi-custom-dashboard-app">';
+ echo '<div class="mi-app-loading"><span class="dashicons dashicons-update mi-spin"></span></div>';
+ echo '<style>.mi-app-loading{display:flex;align-items:center;justify-content:center;min-height:400px;}.mi-spin{animation:mi-spin 1s linear infinite;font-size:40px;width:40px;height:40px;color:#338eef;}@keyframes mi-spin{to{transform:rotate(360deg);}}</style>';
+ echo '</div>';
}
+// // Add Dashboard page (Vue 3 app)
+// add_submenu_page(
+// $parent_slug,
+// __( 'Dashboard:', 'google-analytics-dashboard-for-wp' ),
+// __( 'Dashboard', 'google-analytics-dashboard-for-wp' ) . $new_indicator,
+// 'exactmetrics_view_dashboard',
+// 'exactmetrics_custom_dashboard',
+// 'exactmetrics_custom_dashboard_page'
+// );
+
+ // then settings page
+ add_submenu_page( $parent_slug, __( 'ExactMetrics', 'google-analytics-dashboard-for-wp' ), __( 'Settings', 'google-analytics-dashboard-for-wp' ), 'exactmetrics_save_settings', 'exactmetrics_settings', 'exactmetrics_settings_page' );
+
// Add dashboard submenu.
add_submenu_page( 'index.php', __( 'General Reports:', 'google-analytics-dashboard-for-wp' ), 'ExactMetrics', 'exactmetrics_view_dashboard', 'admin.php?page=exactmetrics_reports' );
--- a/google-analytics-dashboard-for-wp/includes/admin/class-exactmetrics-onboarding.php
+++ b/google-analytics-dashboard-for-wp/includes/admin/class-exactmetrics-onboarding.php
@@ -225,9 +225,26 @@
$is_network = boolval( $request->get_param( 'is_network' ) );
// Process settings
$settings = $request->get_param( 'settings' );
+ $onboarding_user_id = exactmetrics_get_onboarding_user_id();
if ( ! empty( $settings ) ) {
+ $allowed_settings = apply_filters( 'exactmetrics_onboarding_allowed_settings', array(
+ 'site_type',
+ 'extensions_of_files',
+ 'affiliate_links',
+ 'view_reports',
+ 'automatic_updates',
+ 'anonymous_data',
+ 'verified_automatic',
+ ) );
foreach ( $settings as $key => $value ) {
+ if ( ! in_array( $key, $allowed_settings, true ) ) {
+ continue;
+ }
+ // Skip admin-only settings for non-admin users.
+ if ( exactmetrics_is_admin_only_setting( $key ) && ! user_can( $onboarding_user_id, 'manage_options' ) ) {
+ continue;
+ }
exactmetrics_update_option( $key, $value );
}
}
@@ -269,8 +286,7 @@
);
$is_network ? ExactMetrics()->auth->set_network_analytics_profile( $profile ) : ExactMetrics()->auth->set_analytics_profile( $profile );
}
- $triggered_by_user = $request->get_param( 'triggered_by' );
- $can_install = exactmetrics_can_install_plugins( $triggered_by_user );
+ $can_install = exactmetrics_can_install_plugins( $onboarding_user_id ?: null );
if ( $can_install && ! empty( $settings['addons_to_install'] ) ) {
$plugins = $settings['addons_to_install'];
@@ -502,6 +518,7 @@
*/
public function delete_onboarding_key( $request ) {
delete_transient( 'exactmetrics_onboarding_key' );
+ delete_transient( 'exactmetrics_onboarding_user_id' );
return new WP_REST_Response(
array(
'success' => true,
--- a/google-analytics-dashboard-for-wp/includes/admin/exclude-page-metabox.php
+++ b/google-analytics-dashboard-for-wp/includes/admin/exclude-page-metabox.php
@@ -35,10 +35,6 @@
return false;
}
- private function posttype_supports_gutenberg() {
- return post_type_supports( exactmetrics_get_current_post_type(), 'custom-fields' );
- }
-
private function get_current_post_type() {
global $post;
@@ -72,7 +68,7 @@
}
add_action( 'admin_enqueue_scripts', array( $this, 'load_metabox_styles' ) );
- if ( $this->is_gutenberg_editor() && $this->posttype_supports_gutenberg() ) {
+ if ( $this->is_gutenberg_editor() ) {
return;
}
if ( 'attachment' !== $post_type ) {
--- a/google-analytics-dashboard-for-wp/includes/admin/routes.php
+++ b/google-analytics-dashboard-for-wp/includes/admin/routes.php
@@ -203,6 +203,14 @@
if ( isset( $_POST['setting'] ) ) {
$setting = sanitize_text_field( wp_unslash( $_POST['setting'] ) );
+
+ // Prevent non-admin users from modifying access-control settings.
+ if ( exactmetrics_is_admin_only_setting( $setting ) && ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( array(
+ 'message' => esc_html__( 'You do not have permission to update this setting.', 'google-analytics-dashboard-for-wp' ),
+ ) );
+ }
+
if ( isset( $_POST['value'] ) ) {
$value = $this->handle_sanitization( $setting, $_POST['value'] ); // phpcs:ignore
exactmetrics_update_option( $setting, $value );
@@ -232,6 +240,10 @@
if ( isset( $_POST['settings'] ) ) {
$settings = json_decode( sanitize_text_field( wp_unslash( $_POST['settings'] ) ), true );
foreach ( $settings as $setting => $value ) {
+ // Skip admin-only settings for non-admin users.
+ if ( exactmetrics_is_admin_only_setting( $setting ) && ! current_user_can( 'manage_options' ) ) {
+ continue;
+ }
$value = $this->handle_sanitization( $setting, $value );
exactmetrics_update_option( $setting, $value );
do_action( 'exactmetrics_after_update_settings', $setting, $value );
@@ -1067,7 +1079,18 @@
foreach ( $exclude as $e ) {
if ( ! empty( $settings[ $e ] ) ) {
- $new_settings = $settings[ $e ];
+ $new_settings[ $e ] = $settings[ $e ];
+ }
+ }
+
+ // Prevent non-admin users from importing access-control settings.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ $admin_only_settings = exactmetrics_get_admin_only_settings();
+ foreach ( $admin_only_settings as $admin_setting ) {
+ unset( $new_settings[ $admin_setting ] );
+ if ( isset( $settings[ $admin_setting ] ) ) {
+ $new_settings[ $admin_setting ] = $settings[ $admin_setting ];
+ }
}
}
--- a/google-analytics-dashboard-for-wp/includes/admin/site-notes/Controller.php
+++ b/google-analytics-dashboard-for-wp/includes/admin/site-notes/Controller.php
@@ -1158,6 +1158,11 @@
}
public function load_metabox_assets() {
+ // Don't load classic editor assets on block editor
+ if ( $this->is_gutenberg_editor() ) {
+ return;
+ }
+
wp_register_style('exactmetrics-admin-metabox-sitenotes-style', plugins_url('assets/css/admin-metabox-sitenotes.css', EXACTMETRICS_PLUGIN_FILE), array(), exactmetrics_get_asset_version());
wp_enqueue_style('exactmetrics-admin-metabox-sitenotes-style');
@@ -1166,6 +1171,24 @@
}
/**
+ * Check if the current screen is the Gutenberg (block) editor.
+ *
+ * @return bool True if on block editor, false otherwise.
+ */
+ private function is_gutenberg_editor() {
+ if ( function_exists( 'is_gutenberg_page' ) && is_gutenberg_page() ) {
+ return true;
+ }
+
+ $current_screen = get_current_screen();
+ if ( method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor() ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
* Add site-note to traffic sessions chart.
*
* @param array $data
--- a/google-analytics-dashboard-for-wp/includes/api/class-exactmetrics-api-token.php
+++ b/google-analytics-dashboard-for-wp/includes/api/class-exactmetrics-api-token.php
@@ -0,0 +1,274 @@
+<?php
+/**
+ * API Token class for ExactMetrics.
+ *
+ * Generates encrypted tokens for secure browser-to-API communication.
+ * Used by Custom Dashboard, AI Chat, and any feature needing direct
+ * browser-to-Laravel requests without proxying through WordPress.
+ *
+ * Token Format: {publickey}.{base64(hmac + iv + ciphertext)}
+ *
+ * Security Features:
+ * - AES-256-CBC encryption
+ * - HMAC-SHA256 integrity verification
+ * - 30-minute token expiration
+ * - Site-specific encryption key (relay token)
+ *
+ * @since 9.x.x
+ * @package ExactMetrics
+ */
+
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * ExactMetrics API Token class.
+ *
+ * @since 9.x.x
+ */
+class ExactMetrics_API_Token {
+
+ /**
+ * Token expiration time in seconds (30 minutes).
+ *
+ * @var int
+ */
+ const TOKEN_EXPIRATION = 1800;
+
+ /**
+ * Cache group for storing generated tokens.
+ *
+ * @var string
+ */
+ const CACHE_GROUP = 'api_tokens';
+
+ /**
+ * Encryption cipher.
+ *
+ * @var string
+ */
+ const CIPHER = 'AES-256-CBC';
+
+ /**
+ * Generate an encrypted token for browser-to-API communication.
+ *
+ * The token contains encrypted user and site context that can be
+ * validated by Laravel/Python without calling back to WordPress.
+ *
+ * @since 9.x.x
+ *
+ * @param bool $network Whether to use network credentials.
+ * @return array|WP_Error Token data array or WP_Error on failure.
+ * {
+ * 'token' => string, // The encrypted token
+ * 'expires_at' => int, // Unix timestamp
+ * }
+ */
+ public static function generate( $network = false ) {
+ $auth = ExactMetrics()->auth;
+
+ // Get relay credentials.
+ $public_key = $network ? $auth->get_network_key() : $auth->get_key();
+ $token_key = $network ? $auth->get_network_token() : $auth->get_token();
+
+ if ( empty( $public_key ) || empty( $token_key ) ) {
+ return new WP_Error(
+ 'not_authenticated',
+ __( 'Site is not authenticated with ExactMetrics.', 'google-analytics-dashboard-for-wp' )
+ );
+ }
+
+ // Build payload - minimal data needed for Laravel validation.
+ $timestamp = time();
+ $expires_at = $timestamp + self::TOKEN_EXPIRATION;
+
+ $payload = array(
+ 'site_url' => $network ? network_admin_url() : home_url(),
+ 'issued_at' => $timestamp,
+ 'expires_at' => $expires_at,
+ );
+
+ // Encrypt the payload.
+ $encrypted = self::encrypt_payload( $payload, $token_key );
+
+ if ( is_wp_error( $encrypted ) ) {
+ return $encrypted;
+ }
+
+ // Final token format: publickey.encrypted_payload
+ $token = $public_key . '.' . $encrypted;
+
+ return array(
+ 'token' => $token,
+ 'expires_at' => $expires_at,
+ );
+ }
+
+ /**
+ * Get a cached token or generate a new one.
+ *
+ * Tokens are cached at site-level since all users see the same analytics data.
+ * A 5-minute buffer is used to refresh tokens before they expire.
+ *
+ * @since 9.x.x
+ *
+ * @param bool $network Whether to use network credentials.
+ * @return array|WP_Error Token data array or WP_Error on failure.
+ */
+ public static function get_token( $network = false ) {
+ // Ensure user has permission to view dashboard data.
+ if ( ! current_user_can( 'exactmetrics_view_dashboard' ) ) {
+ return new WP_Error(
+ 'unauthorized',
+ __( 'You do not have permission to access this data.', 'google-analytics-dashboard-for-wp' )
+ );
+ }
+
+ $cache_key = 'api_token_site' . ( $network ? '_network' : '' );
+
+ // Try to get cached token.
+ $cached = exactmetrics_cache_get( $cache_key, self::CACHE_GROUP );
+
+ // Check if cached token is still valid (with 5-minute buffer).
+ if ( $cached && isset( $cached['expires_at'] ) ) {
+ $buffer = 300; // 5 minutes.
+ if ( $cached['expires_at'] > ( time() + $buffer ) ) {
+ return $cached;
+ }
+ }
+
+ // Generate new token.
+ $token_data = self::generate( $network );
+
+ if ( is_wp_error( $token_data ) ) {
+ return $token_data;
+ }
+
+ // Cache the token (TTL = expiration - buffer).
+ $ttl = ( $token_data['expires_at'] - time() ) - 300;
+ if ( $ttl > 0 ) {
+ exactmetrics_cache_set( $cache_key, $token_data, self::CACHE_GROUP, $ttl );
+ }
+
+ return $token_data;
+ }
+
+ /**
+ * Get just the token string (convenience method for wp_localize_script).
+ *
+ * @since 9.x.x
+ *
+ * @param bool $network Whether to use network credentials.
+ * @return string The token string or empty string on failure.
+ */
+ public static function get_token_string( $network = false ) {
+ $token_data = self::get_token( $network );
+
+ if ( is_wp_error( $token_data ) ) {
+ return '';
+ }
+
+ return $token_data['token'];
+ }
+
+ /**
+ * Get the token expiration timestamp.
+ *
+ * @since 9.x.x
+ *
+ * @param bool $network Whether to use network credentials.
+ * @return int Unix timestamp or 0 on failure.
+ */
+ public static function get_expiration( $network = false ) {
+ $token_data = self::get_token( $network );
+
+ if ( is_wp_error( $token_data ) ) {
+ return 0;
+ }
+
+ return $token_data['expires_at'];
+ }
+
+ /**
+ * Invalidate cached token for the site.
+ *
+ * Call this when relay credentials change or are deauthenticated.
+ *
+ * @since 9.x.x
+ *
+ * @param bool $network Whether to invalidate network token.
+ * @return bool True on success.
+ */
+ public static function invalidate( $network = false ) {
+ $cache_key = 'api_token_site' . ( $network ? '_network' : '' );
+
+ return exactmetrics_cache_delete( $cache_key, self::CACHE_GROUP );
+ }
+
+ /**
+ * Encrypt the payload using AES-256-CBC.
+ *
+ * @since 9.x.x
+ *
+ * @param array $payload The data to encrypt.
+ * @param string $key_seed The seed for deriving the encryption key (relay token).
+ * @return string|WP_Error Base64-encoded encrypted data or WP_Error.
+ */
+ private static function encrypt_payload( $payload, $key_seed ) {
+ // Check if OpenSSL is available.
+ if ( ! function_exists( 'openssl_encrypt' ) ) {
+ return new WP_Error(
+ 'openssl_missing',
+ __( 'OpenSSL extension is required for secure API tokens.', 'google-analytics-dashboard-for-wp' )
+ );
+ }
+
+ $payload_json = wp_json_encode( $payload );
+
+ if ( false === $payload_json ) {
+ return new WP_Error(
+ 'json_encode_failed',
+ __( 'Failed to encode token payload.', 'google-analytics-dashboard-for-wp' )
+ );
+ }
+
+ // Derive encryption key (SHA256 of the relay token).
+ $key = hash( 'sha256', $key_seed, true ); // 32 bytes.
+
+ // Generate random IV.
+ $iv = openssl_random_pseudo_bytes( 16 );
+
+ if ( false === $iv ) {
+ return new WP_Error(
+ 'iv_generation_failed',
+ __( 'Failed to generate secure IV.', 'google-analytics-dashboard-for-wp' )
+ );
+ }
+
+ // Encrypt with AES-256-CBC.
+ $ciphertext = openssl_encrypt(
+ $payload_json,
+ self::CIPHER,
+ $key,
+ OPENSSL_RAW_DATA,
+ $iv
+ );
+
+ if ( false === $ciphertext ) {
+ return new WP_Error(
+ 'encryption_failed',
+ __( 'Failed to encrypt token payload.', 'google-analytics-dashboard-for-wp' )
+ );
+ }
+
+ // Create HMAC for integrity (sign: iv + ciphertext).
+ $hmac = hash_hmac( 'sha256', $iv . $ciphertext, $key_seed, true ); // 32 bytes.
+
+ // Combine: hmac(32) + iv(16) + ciphertext.
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ return base64_encode( $hmac . $iv . $ciphertext );
+ }
+
+}
--- a/google-analytics-dashboard-for-wp/includes/capabilities.php
+++ b/google-analytics-dashboard-for-wp/includes/capabilities.php
@@ -91,3 +91,36 @@
}
add_filter( 'map_meta_cap', 'exactmetrics_add_capabilities', 10, 4 );
+
+/**
+ * Get the list of settings that only users with manage_options can modify.
+ *
+ * These are access-control settings that, if modified by a delegated user,
+ * could lead to privilege escalation.
+ *
+ * @since 9.5.2
+ *
+ * @return array Array of admin-only setting keys.
+ */
+function exactmetrics_get_admin_only_settings() {
+ $settings = array(
+ 'save_settings',
+ 'view_reports',
+ 'ignore_users',
+ );
+
+ return apply_filters( 'exactmetrics_admin_only_settings', $settings );
+}
+
+/**
+ * Check if a given setting key is an admin-only setting.
+ *
+ * @since 9.5.2
+ *
+ * @param string $setting The setting key to check.
+ *
+ * @return bool True if the setting is admin-only, false otherwise.
+ */
+function exactmetrics_is_admin_only_setting( $setting ) {
+ return in_array( $setting, exactmetrics_get_admin_only_settings(), true );
+}
--- a/google-analytics-dashboard-for-wp/includes/frontend/frontend.php
+++ b/google-analytics-dashboard-for-wp/includes/frontend/frontend.php
@@ -218,6 +218,7 @@
return;
}
+ // phpcs:ignore PHPCS_SecurityAudit.Misc.IncludeMismatch.ErrMiscIncludeMismatchNoExt -- File path is validated with file_exists() above.
$asset_data = require $asset_file;
// Enqueue styles
--- a/google-analytics-dashboard-for-wp/includes/helpers.php
+++ b/google-analytics-dashboard-for-wp/includes/helpers.php
@@ -1033,9 +1033,14 @@
if ( empty( $key ) ) {
$key = wp_generate_password( 32, false );
set_transient( 'exactmetrics_onboarding_key', $key, 30 * MINUTE_IN_SECONDS );
+ set_transient( 'exactmetrics_onboarding_user_id', get_current_user_id(), 30 * MINUTE_IN_SECONDS );
}
return $key;
}
+
+function exactmetrics_get_onboarding_user_id() {
+ return (int) get_transient( 'exactmetrics_onboarding_user_id' );
+}
/**
* Clears the onboarding key
*
--- a/google-analytics-dashboard-for-wp/lite/includes/load.php
+++ b/google-analytics-dashboard-for-wp/lite/includes/load.php
@@ -71,6 +71,7 @@
require_once EXACTMETRICS_PLUGIN_DIR . 'includes/api/class-exactmetrics-api-reports.php';
require_once EXACTMETRICS_PLUGIN_DIR . 'includes/api/class-exactmetrics-api-tracking.php';
require_once EXACTMETRICS_PLUGIN_DIR . 'includes/api/class-exactmetrics-api-ads.php';
+ require_once EXACTMETRICS_PLUGIN_DIR . 'includes/api/class-exactmetrics-api-token.php';
// Load Google Ads admin classes
require_once EXACTMETRICS_PLUGIN_DIR . 'includes/ppc/google/class-exactmetrics-google-ads.php';