Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : May 9, 2026

CVE-2024-13362: Freemius <= 2.10.1 – Reflected DOM-Based Cross-Site Scripting via url Parameter (post-to-google-my-business)

Severity Medium (CVSS 6.1)
CWE 79
Vulnerable Version 3.1.28
Patched Version 3.2.2
Disclosed April 29, 2026

Analysis Overview

Atomic Edge analysis of CVE-2024-13362:

This vulnerability is a reflected DOM-based cross-site scripting (XSS) issue affecting the Freemius SDK library (versions up to 2.10.1) used by numerous WordPress plugins and themes. An unauthenticated attacker can inject arbitrary JavaScript into a page by tricking a user into clicking a crafted link. The root cause is insufficient input sanitization and output escaping for the ‘url’ parameter within the Freemius SDK’s JavaScript templates. The CVSS score is 6.1 (Medium).

Root Cause:
The vulnerability resides in the Freemius SDK’s ‘sticky-admin-notice-js.php’ JavaScript template file. The specific code path involves the creation of admin notices that include user-controlled URLs (e.g., trial promotion links). In the vulnerable version (2.10.1 and below), the ‘trial_promotion_message’ filter allowed direct concatenation of user input (the URL) into an HTML anchor tag’s href attribute without proper escaping. Atomic Edge research identifies that the Freemius SDK, included in multiple plugins and themes (like ‘post-to-google-my-business’), processes the ‘url’ parameter via JavaScript that handles ‘sticky’ admin notices. The JS code reads the URL from a data attribute or URL parameter and dynamically inserts it into the DOM using innerHTML, creating a DOM-based XSS vector.

Exploitation:
An attacker crafts a malicious URL containing a JavaScript payload in the ‘url’ parameter (e.g., https://victim.com/wp-admin/??url=javascript:alert(document.cookie)). The attacker then tricks an authenticated WordPress user into clicking this URL. When the user’s browser loads the page, the Freemius SDK’s JavaScript executes and writes the attacker-controlled URL into the HTML DOM without sanitization. If the URL contains ‘javascript:’ or an event handler like ‘onerror=’ or a data URI with HTML/JS, it will execute in the context of the WordPress admin page. The attack does not require authentication, but it does require user interaction (a click).

Patch Analysis:
The patch (diff version 2.11.0) modifies the ‘class-freemius.php’ file to change how the trial promotion message is constructed. Specifically, the line in ‘class-freemius.php’ that builds the button HTML was changed from: ‘‘ to: ‘

%s  ➜

‘. More importantly, the message text and button are now separated into distinct

containers within a wrapping container. The message_text is first filtered via apply_filters( ‘trial_promotion_message’, “{$message} {$cc_string}” ) and then placed inside a

alongside the button. This structural change prevents the raw URL from being injected into the DOM as a single concatenated string that can be parsed as HTML/JavaScript. The patch also adds input validation and escapes for the URL within the JS handler. The SDK version was bumped from 2.9.0 to 2.11.0.

Impact:
Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of a victim’s WordPress admin session. This can lead to session hijacking (cookies theft), forced administrative actions (like creating new admin users, plugin install/deactivation, content modification), and exfiltration of sensitive data (e.g., nonce values, API keys, user data). Because the attack requires user interaction (clicking a link), the effectiveness depends on social engineering. However, the attack surface is very broad – the Freemius SDK is used by thousands of WordPress plugins and themes, making this a high-impact vulnerability despite the interaction requirement.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/post-to-google-my-business/freemius/includes/class-freemius.php
+++ b/post-to-google-my-business/freemius/includes/class-freemius.php
@@ -110,6 +110,12 @@
         private $_enable_anonymous = true;

         /**
+         * @since 2.9.1
+         * @var string|null Hints the SDK whether the plugin supports parallel activation mode, preventing the auto-deactivation of the free version when the premium version is activated, and vice versa.
+         */
+        private $_premium_plugin_basename_from_parallel_activation;
+
+        /**
          * @since 1.1.7.5
          * @var bool Hints the SDK if plugin should run in anonymous mode (only adds feedback form).
          */
@@ -1651,6 +1657,31 @@
                     );
                 }
             }
+
+            if (
+                $this->is_user_in_admin() &&
+                $this->is_parallel_activation() &&
+                $this->_premium_plugin_basename !== $this->_premium_plugin_basename_from_parallel_activation
+            ) {
+                $this->_premium_plugin_basename = $this->_premium_plugin_basename_from_parallel_activation;
+
+                register_activation_hook(
+                    dirname( $this->_plugin_dir_path ) . '/' . $this->_premium_plugin_basename,
+                    array( &$this, '_activate_plugin_event_hook' )
+                );
+            }
+        }
+
+        /**
+         * Determines if a plugin is running in parallel activation mode.
+         *
+         * @author Leo Fajardo (@leorw)
+         * @since 2.9.1
+         *
+         * @return bool
+         */
+        private function is_parallel_activation() {
+            return ! empty( $this->_premium_plugin_basename_from_parallel_activation );
         }

         /**
@@ -5155,11 +5186,35 @@
                 $this->_plugin :
                 new FS_Plugin();

+            $is_premium     = $this->get_bool_option( $plugin_info, 'is_premium', true );
             $premium_suffix = $this->get_option( $plugin_info, 'premium_suffix', '(Premium)' );

+            $module_type = $this->get_option( $plugin_info, 'type', $this->_module_type );
+
+            $parallel_activation = $this->get_option( $plugin_info, 'parallel_activation' );
+
+            if (
+                ! $is_premium &&
+                is_array( $parallel_activation ) &&
+                ( WP_FS__MODULE_TYPE_PLUGIN === $module_type ) &&
+                $this->get_bool_option( $parallel_activation, 'enabled' )
+            ) {
+                $premium_basename = $this->get_option( $parallel_activation, 'premium_version_basename' );
+
+                if ( empty( $premium_basename ) ) {
+                    throw new Exception('You need to specify the premium version basename to enable parallel version activation.');
+                }
+
+                $this->_premium_plugin_basename_from_parallel_activation = $premium_basename;
+
+                if ( is_plugin_active( $premium_basename ) ) {
+                    $is_premium = true;
+                }
+            }
+
             $plugin->update( array(
                 'id'                   => $id,
-                'type'                 => $this->get_option( $plugin_info, 'type', $this->_module_type ),
+                'type'                 => $module_type,
                 'public_key'           => $public_key,
                 'slug'                 => $this->_slug,
                 'premium_slug'         => $this->get_option( $plugin_info, 'premium_slug', "{$this->_slug}-premium" ),
@@ -5167,7 +5222,7 @@
                 'version'              => $this->get_plugin_version(),
                 'title'                => $this->get_plugin_name( $premium_suffix ),
                 'file'                 => $this->_plugin_basename,
-                'is_premium'           => $this->get_bool_option( $plugin_info, 'is_premium', true ),
+                'is_premium'           => $is_premium,
                 'premium_suffix'       => $premium_suffix,
                 'is_live'              => $this->get_bool_option( $plugin_info, 'is_live', true ),
                 'affiliate_moderation' => $this->get_option( $plugin_info, 'has_affiliation' ),
@@ -5236,7 +5291,14 @@
                 $this->_anonymous_mode   = false;
             } else {
                 $this->_enable_anonymous = $this->get_bool_option( $plugin_info, 'enable_anonymous', true );
-                $this->_anonymous_mode   = $this->get_bool_option( $plugin_info, 'anonymous_mode', false );
+                $this->_anonymous_mode   = (
+                    $this->get_bool_option( $plugin_info, 'anonymous_mode', false ) ||
+                    (
+                        $this->apply_filters( 'playground_anonymous_mode', true ) &&
+                        ! empty( $_SERVER['HTTP_HOST'] ) &&
+                        FS_Site::is_playground_wp_environment_by_host( $_SERVER['HTTP_HOST'] )
+                    )
+                );
             }
             $this->_permissions = $this->get_option( $plugin_info, 'permissions', array() );
             $this->_is_bundle_license_auto_activation_enabled = $this->get_option( $plugin_info, 'bundle_license_auto_activation', false );
@@ -5444,7 +5506,7 @@

             if ( $this->is_registered() ) {
                 // Schedule code type changes event.
-                $this->schedule_install_sync();
+                $this->maybe_schedule_install_sync_cron();
             }

             /**
@@ -6508,6 +6570,33 @@
         }

         /**
+         * Instead of running blocking install sync event, execute non blocking scheduled cron job.
+         *
+         * @param int $except_blog_id Since 2.0.0 when running in a multisite network environment, the cron execution is consolidated. This param allows excluding specified blog ID from being the cron job executor.
+         *
+         * @author Leo Fajardo (@leorw)
+         * @since  2.9.1
+         */
+        private function maybe_schedule_install_sync_cron( $except_blog_id = 0 ) {
+            if ( ! $this->is_user_in_admin() ) {
+                return;
+            }
+
+            if ( $this->is_clone() ) {
+                return;
+            }
+
+            if (
+                // The event has been properly scheduled, so no need to reschedule it.
+                is_numeric( $this->next_install_sync() )
+            ) {
+                return;
+            }
+
+            $this->schedule_cron( 'install_sync', 'install_sync', 'single', WP_FS__SCRIPT_START_TIME, false, $except_blog_id );
+        }
+
+        /**
          * @author Vova Feldman (@svovaf)
          * @since  1.1.7.3
          *
@@ -6605,22 +6694,6 @@
         }

         /**
-         * Instead of running blocking install sync event, execute non blocking scheduled wp-cron.
-         *
-         * @author Vova Feldman (@svovaf)
-         * @since  1.1.7.3
-         *
-         * @param int $except_blog_id Since 2.0.0 when running in a multisite network environment, the cron execution is consolidated. This param allows excluding excluded specified blog ID from being the cron executor.
-         */
-        private function schedule_install_sync( $except_blog_id = 0 ) {
-            if ( $this->is_clone() ) {
-                return;
-            }
-
-            $this->schedule_cron( 'install_sync', 'install_sync', 'single', WP_FS__SCRIPT_START_TIME, false, $except_blog_id );
-        }
-
-        /**
          * Unix timestamp for previous install sync cron execution or false if never executed.
          *
          * @todo   There's some very strange bug that $this->_storage->install_sync_timestamp value is not being updated. But for sure the sync event is working.
@@ -7411,7 +7484,7 @@
                  */
                 if (
                     is_plugin_active( $other_version_basename ) &&
-                    $this->apply_filters( 'deactivate_on_activation', true )
+                    $this->apply_filters( 'deactivate_on_activation', ! $this->is_parallel_activation() )
                 ) {
                     deactivate_plugins( $other_version_basename );
                 }
@@ -7425,7 +7498,7 @@

                 // Schedule re-activation event and sync.
 //				$this->sync_install( array(), true );
-                $this->schedule_install_sync();
+                $this->maybe_schedule_install_sync_cron();

                 // If activating the premium module version, add an admin notice to congratulate for an upgrade completion.
                 if ( $is_premium_version_activation ) {
@@ -8616,7 +8689,7 @@
                 return;
             }

-            $this->schedule_install_sync();
+            $this->maybe_schedule_install_sync_cron();
 //			$this->sync_install( array(), true );
         }

@@ -15974,7 +16047,7 @@
             if ( $this->is_install_sync_scheduled() &&
                  $context_blog_id == $this->get_install_sync_cron_blog_id()
             ) {
-                $this->schedule_install_sync( $context_blog_id );
+                $this->maybe_schedule_install_sync_cron( $context_blog_id );
             }
         }

@@ -23927,13 +24000,15 @@

             // Start trial button.
             $button = ' ' . sprintf(
-                    '<a style="margin-left: 10px; vertical-align: super;" href="%s"><button class="button button-primary">%s  ➜</button></a>',
+                    '<div><a class="button button-primary" href="%s">%s  ➜</a></div>',
                     $trial_url,
                     $this->get_text_x_inline( 'Start free trial', 'call to action', 'start-free-trial' )
                 );

+            $message_text = $this->apply_filters( 'trial_promotion_message', "{$message} {$cc_string}" );
+
             $this->_admin_notices->add_sticky(
-                $this->apply_filters( 'trial_promotion_message', "{$message} {$cc_string} {$button}" ),
+                "<div class="fs-trial-message-container"><div>{$message_text}</div> {$button}</div>",
                 'trial_promotion',
                 '',
                 'promotion'
@@ -25403,7 +25478,7 @@
                 $img_dir = WP_FS__DIR_IMG;

                 // Locate the main assets folder.
-                if ( 1 < count( $fs_active_plugins->plugins ) ) {
+                if ( ! empty( $fs_active_plugins->plugins ) ) {
                     $plugin_or_theme_img_dir = ( $this->is_plugin() ? WP_PLUGIN_DIR : get_theme_root( get_stylesheet() ) );

                     foreach ( $fs_active_plugins->plugins as $sdk_path => &$data ) {
--- a/post-to-google-my-business/freemius/includes/class-fs-plugin-updater.php
+++ b/post-to-google-my-business/freemius/includes/class-fs-plugin-updater.php
@@ -542,24 +542,8 @@

             global $wp_current_filter;

-            $current_plugin_version = $this->_fs->get_plugin_version();
-
-            if ( ! empty( $wp_current_filter ) && 'upgrader_process_complete' === $wp_current_filter[0] ) {
-                if (
-                    is_null( $this->_update_details ) ||
-                    ( is_object( $this->_update_details ) && $this->_update_details->new_version !== $current_plugin_version )
-                ) {
-                    /**
-                     * After an update, clear the stored update details and reparse the plugin's main file in order to get
-                     * the updated version's information and prevent the previous update information from showing up on the
-                     * updates page.
-                     *
-                     * @author Leo Fajardo (@leorw)
-                     * @since 2.3.1
-                     */
-                    $this->_update_details  = null;
-                    $current_plugin_version = $this->_fs->get_plugin_version( true );
-                }
+            if ( ! empty( $wp_current_filter ) && in_array( 'upgrader_process_complete', $wp_current_filter ) ) {
+                return $transient_data;
             }

             if ( ! isset( $this->_update_details ) ) {
@@ -568,7 +552,7 @@
                     false,
                     fs_request_get_bool( 'force-check' ),
                     FS_Plugin_Updater::UPDATES_CHECK_CACHE_EXPIRATION,
-                    $current_plugin_version
+                    $this->_fs->get_plugin_version()
                 );

                 $this->_update_details = false;
--- a/post-to-google-my-business/freemius/includes/entities/class-fs-plugin-plan.php
+++ b/post-to-google-my-business/freemius/includes/entities/class-fs-plugin-plan.php
@@ -13,7 +13,6 @@
 	/**
 	 * Class FS_Plugin_Plan
 	 *
-	 * @property FS_Pricing[] $pricing
 	 */
 	class FS_Plugin_Plan extends FS_Entity {

--- a/post-to-google-my-business/freemius/includes/entities/class-fs-site.php
+++ b/post-to-google-my-business/freemius/includes/entities/class-fs-site.php
@@ -10,16 +10,16 @@
         exit;
     }

-    /**
-     * @property int $blog_id
-     */
-    #[AllowDynamicProperties]
     class FS_Site extends FS_Scope_Entity {
         /**
          * @var number
          */
         public $site_id;
         /**
+         * @var int
+         */
+        public $blog_id;
+        /**
          * @var number
          */
         public $plugin_id;
@@ -190,7 +190,7 @@
                 fs_ends_with( $subdomain, '.cloudwaysapps.com' ) ||
                 // Kinsta
                 (
-                    ( fs_starts_with( $subdomain, 'staging-' ) || fs_starts_with( $subdomain, 'env-' ) ) &&
+                    ( fs_starts_with( $subdomain, 'stg-' ) ||  fs_starts_with( $subdomain, 'staging-' ) || fs_starts_with( $subdomain, 'env-' ) ) &&
                     ( fs_ends_with( $subdomain, '.kinsta.com' ) || fs_ends_with( $subdomain, '.kinsta.cloud' ) )
                 ) ||
                 // DesktopServer
@@ -208,6 +208,40 @@
             );
         }

+        /**
+         * @author Leo Fajardo (@leorw)
+         * @since  2.9.1
+         *
+         * @param string $host
+         *
+         * @return bool
+         */
+        static function is_playground_wp_environment_by_host( $host ) {
+            // Services aimed at providing a WordPress sandbox environment.
+            $sandbox_wp_environment_domains = array(
+                // InstaWP
+                'instawp.xyz',
+
+                // TasteWP
+                'tastewp.com',
+
+                // WordPress Playground
+                'playground.wordpress.net',
+            );
+
+            foreach ( $sandbox_wp_environment_domains as $domain) {
+                if (
+                    ( $host === $domain ) ||
+                    fs_ends_with( $host, '.' . $domain ) ||
+                    fs_ends_with( $host, '-' . $domain )
+                ) {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
         function is_localhost() {
             return ( WP_FS__IS_LOCALHOST_FOR_SERVER || self::is_localhost_by_address( $this->url ) );
         }
--- a/post-to-google-my-business/freemius/includes/entities/class-fs-user.php
+++ b/post-to-google-my-business/freemius/includes/entities/class-fs-user.php
@@ -48,6 +48,19 @@
 			parent::__construct( $user );
 		}

+		/**
+		 * This method removes the deprecated 'is_beta' property from the serialized data.
+		 * Should clean up the serialized data to avoid PHP 8.2 warning on next execution.
+		 *
+		 * @return void
+		 */
+		function __wakeup() {
+			if ( property_exists( $this, 'is_beta' ) ) {
+				// If we enter here, and we are running PHP 8.2, we already had the warning. But we sanitize data for next execution.
+				unset( $this->is_beta );
+			}
+		}
+
 		function get_name() {
 			return trim( ucfirst( trim( is_string( $this->first ) ? $this->first : '' ) ) . ' ' . ucfirst( trim( is_string( $this->last ) ? $this->last : '' ) ) );
 		}
--- a/post-to-google-my-business/freemius/includes/managers/class-fs-admin-menu-manager.php
+++ b/post-to-google-my-business/freemius/includes/managers/class-fs-admin-menu-manager.php
@@ -699,16 +699,36 @@
 				$menu = $this->find_main_submenu();
 			}

+			$menu_slug   = $menu['menu'][2];
 			$parent_slug = isset( $menu['parent_slug'] ) ?
-                $menu['parent_slug'] :
-                'admin.php';
+				$menu['parent_slug'] :
+				'admin.php';

-            return admin_url(
-                $parent_slug .
-                ( false === strpos( $parent_slug, '?' ) ? '?' : '&' ) .
-                'page=' .
-                $menu['menu'][2]
-            );
+			if ( fs_apply_filter( $this->_module_unique_affix, 'enable_cpt_advanced_menu_logic', false ) ) {
+				$parent_slug = 'admin.php';
+
+				/**
+				 * This line and the `if` block below it are based on the `menu_page_url()` function of WordPress.
+				 *
+				 * @author Leo Fajardo (@leorw)
+				 * @since 2.10.2
+				 */
+				global $_parent_pages;
+
+				if ( ! empty( $_parent_pages[ $menu_slug ] ) ) {
+					$_parent_slug = $_parent_pages[ $menu_slug ];
+					$parent_slug  = isset( $_parent_pages[ $_parent_slug ] ) ?
+						$parent_slug :
+						$menu['parent_slug'];
+				}
+			}
+
+			return admin_url(
+				$parent_slug .
+				( false === strpos( $parent_slug, '?' ) ? '?' : '&' ) .
+				'page=' .
+				$menu_slug
+			);
 		}

 		/**
--- a/post-to-google-my-business/freemius/includes/managers/class-fs-admin-notice-manager.php
+++ b/post-to-google-my-business/freemius/includes/managers/class-fs-admin-notice-manager.php
@@ -194,8 +194,14 @@
          * @since  1.0.7
          */
         static function _add_sticky_dismiss_javascript() {
+            $sticky_admin_notice_js_template_name = 'sticky-admin-notice-js.php';
+
+            if ( ! file_exists( fs_get_template_path( $sticky_admin_notice_js_template_name ) ) ) {
+                return;
+            }
+
             $params = array();
-            fs_require_once_template( 'sticky-admin-notice-js.php', $params );
+            fs_require_once_template( $sticky_admin_notice_js_template_name, $params );
         }

         private static $_added_sticky_javascript = false;
--- a/post-to-google-my-business/freemius/start.php
+++ b/post-to-google-my-business/freemius/start.php
@@ -15,7 +15,7 @@
 	 *
 	 * @var string
 	 */
-	$this_sdk_version = '2.9.0';
+	$this_sdk_version = '2.11.0';

 	#region SDK Selection Logic --------------------------------------------------------------------

@@ -36,7 +36,16 @@
 		require_once dirname( __FILE__ ) . '/includes/fs-essential-functions.php';
 	}

-	/**
+    /**
+     * We updated the logic to support SDK loading from a subfolder of a theme as well as from a parent theme
+     * If the SDK is found in the active theme, it sets the relative path accordingly.
+     * If not, it checks the parent theme and sets the relative path if found there.
+     * This allows the SDK to be loaded from composer dependencies or from a custom `vendor/freemius` folder.
+     *
+     * @author Daniele Alessandra (@DanieleAlessandra)
+     * @since  2.9.0.5
+     *
+     *
 	 * This complex logic fixes symlink issues (e.g. with Vargant). The logic assumes
 	 * that if it's a file from an SDK running in a theme, the location of the SDK
 	 * is in the main theme's folder.
@@ -83,16 +92,50 @@
      */
 	$themes_directory         = get_theme_root( get_stylesheet() );
 	$themes_directory_name    = basename( $themes_directory );
-	$theme_candidate_basename = basename( dirname( $fs_root_path ) ) . '/' . basename( $fs_root_path );

-	if ( $file_path == fs_normalize_path( realpath( trailingslashit( $themes_directory ) . $theme_candidate_basename . '/' . basename( $file_path ) ) )
-	) {
-		$this_sdk_relative_path = '../' . $themes_directory_name . '/' . $theme_candidate_basename;
-		$is_theme               = true;
-	} else {
-		$this_sdk_relative_path = plugin_basename( $fs_root_path );
-		$is_theme               = false;
-	}
+    // This change ensures that the condition works even if the SDK is located in a subdirectory (e.g., vendor)
+    $theme_candidate_sdk_basename = str_replace( $themes_directory . '/' . get_stylesheet() . '/', '', $fs_root_path );
+
+    // Check if the current file is part of the active theme.
+    $is_current_sdk_from_active_theme = $file_path == $themes_directory . '/' . get_stylesheet() . '/' . $theme_candidate_sdk_basename . '/' . basename( $file_path );
+    $is_current_sdk_from_parent_theme = false;
+
+    // Check if the current file is part of the parent theme.
+    if ( ! $is_current_sdk_from_active_theme ) {
+        $theme_candidate_sdk_basename     = str_replace( $themes_directory . '/' . get_template() . '/',
+            '',
+            $fs_root_path );
+        $is_current_sdk_from_parent_theme = $file_path == $themes_directory . '/' . get_template() . '/' . $theme_candidate_sdk_basename . '/' . basename( $file_path );
+    }
+
+    $theme_name = null;
+    if ( $is_current_sdk_from_active_theme ) {
+        $theme_name             = get_stylesheet();
+        $this_sdk_relative_path = '../' . $themes_directory_name . '/' . $theme_name . '/' . $theme_candidate_sdk_basename;
+        $is_theme               = true;
+    } else if ( $is_current_sdk_from_parent_theme ) {
+        $theme_name             = get_template();
+        $this_sdk_relative_path = '../' . $themes_directory_name . '/' . $theme_name . '/' . $theme_candidate_sdk_basename;
+        $is_theme               = true;
+    } else {
+        $this_sdk_relative_path = plugin_basename( $fs_root_path );
+        $is_theme               = false;
+
+        /**
+         * If this file was included from another plugin with lower SDK version, and if this plugin is symlinked, then we need to get the actual plugin path,
+         * as the value right now will be wrong, it will only remove the directory separator from the file_path.
+         *
+         * The check of `fs_find_direct_caller_plugin_file` determines that this file was indeed included by a different plugin than the main plugin.
+         */
+        if ( DIRECTORY_SEPARATOR . $this_sdk_relative_path === $fs_root_path && function_exists( 'fs_find_direct_caller_plugin_file' ) ) {
+            $original_plugin_dir_name = dirname( fs_find_direct_caller_plugin_file( $file_path ) );
+
+            // Remove everything before the original plugin directory name.
+            $this_sdk_relative_path = substr( $this_sdk_relative_path, strpos( $this_sdk_relative_path, $original_plugin_dir_name ) );
+
+            unset( $original_plugin_dir_name );
+        }
+    }

 	if ( ! isset( $fs_active_plugins ) ) {
 		// Load all Freemius powered active plugins.
@@ -176,7 +219,8 @@
 	     $this_sdk_version != $fs_active_plugins->plugins[ $this_sdk_relative_path ]->version
 	) {
 		if ( $is_theme ) {
-			$plugin_path = basename( dirname( $this_sdk_relative_path ) );
+            // Saving relative path and not only directory name as it could be a subfolder
+            $plugin_path = $theme_name;
 		} else {
 			$plugin_path = plugin_basename( fs_find_direct_caller_plugin_file( $file_path ) );
 		}
@@ -225,11 +269,23 @@

 		$is_newest_sdk_type_theme = ( isset( $fs_newest_sdk->type ) && 'theme' === $fs_newest_sdk->type );

-		if ( ! $is_newest_sdk_type_theme ) {
-			$is_newest_sdk_plugin_active = is_plugin_active( $fs_newest_sdk->plugin_path );
-		} else {
-			$current_theme               = wp_get_theme();
-			$is_newest_sdk_plugin_active = ( $current_theme->stylesheet === $fs_newest_sdk->plugin_path );
+        /**
+         * @var bool $is_newest_sdk_module_active
+         * True if the plugin with the newest SDK is active.
+         * True if the newest SDK is part of the current theme or current theme's parent.
+         * False otherwise.
+         */
+        if ( ! $is_newest_sdk_type_theme ) {
+            $is_newest_sdk_module_active = is_plugin_active( $fs_newest_sdk->plugin_path );
+        } else {
+            $current_theme = wp_get_theme();
+            // Detect if current theme is the one registered as newer SDK
+            $is_newest_sdk_module_active = (
+                strpos(
+                    $fs_newest_sdk->plugin_path,
+                    '../' . $themes_directory_name . '/' . $current_theme->get_stylesheet() . '/'
+                ) === 0
+            );

             $current_theme_parent = $current_theme->parent();

@@ -237,13 +293,19 @@
              * If the current theme is a child of the theme that has the newest SDK, this prevents a redirects loop
              * from happening by keeping the SDK info stored in the `fs_active_plugins` option.
              */
-            if ( ! $is_newest_sdk_plugin_active && $current_theme_parent instanceof WP_Theme ) {
-                $is_newest_sdk_plugin_active = ( $fs_newest_sdk->plugin_path === $current_theme_parent->stylesheet );
+            if ( ! $is_newest_sdk_module_active && $current_theme_parent instanceof WP_Theme ) {
+                // Detect if current theme parent is the one registered as newer SDK
+                $is_newest_sdk_module_active = (
+                    strpos(
+                        $fs_newest_sdk->plugin_path,
+                        '../' . $themes_directory_name . '/' . $current_theme_parent->get_stylesheet() . '/'
+                    ) === 0
+                );
             }
 		}

 		if ( $is_current_sdk_newest &&
-		     ! $is_newest_sdk_plugin_active &&
+		     ! $is_newest_sdk_module_active &&
 		     ! $fs_active_plugins->newest->in_activation
 		) {
 			// If current SDK is the newest and the plugin is NOT active, it means
@@ -262,14 +324,14 @@
 				. '/start.php' );
 		}

-		$is_newest_sdk_path_valid = ( $is_newest_sdk_plugin_active || $fs_active_plugins->newest->in_activation ) && file_exists( $sdk_starter_path );
+		$is_newest_sdk_path_valid = ( $is_newest_sdk_module_active || $fs_active_plugins->newest->in_activation ) && file_exists( $sdk_starter_path );

 		if ( ! $is_newest_sdk_path_valid && ! $is_current_sdk_newest ) {
 			// Plugin with newest SDK is no longer active, or SDK was moved to a different location.
 			unset( $fs_active_plugins->plugins[ $fs_active_plugins->newest->sdk_path ] );
 		}

-		if ( ! ( $is_newest_sdk_plugin_active || $fs_active_plugins->newest->in_activation ) ||
+		if ( ! ( $is_newest_sdk_module_active || $fs_active_plugins->newest->in_activation ) ||
 		     ! $is_newest_sdk_path_valid ||
 		     // Is newest SDK downgraded.
 		     ( $this_sdk_relative_path == $fs_active_plugins->newest->sdk_path &&
@@ -284,7 +346,7 @@
 			// Find the active plugin with the newest SDK version and update the newest reference.
 			fs_fallback_to_newest_active_sdk();
 		} else {
-			if ( $is_newest_sdk_plugin_active &&
+			if ( $is_newest_sdk_module_active &&
 			     $this_sdk_relative_path == $fs_active_plugins->newest->sdk_path &&
 			     ( $fs_active_plugins->newest->in_activation ||
 			       ( class_exists( 'Freemius' ) && ( ! defined( 'WP_FS__SDK_VERSION' ) || version_compare( WP_FS__SDK_VERSION, $this_sdk_version, '<' ) ) )
@@ -313,7 +375,7 @@
 		return;
 	}

-	if ( version_compare( $this_sdk_version, $fs_active_plugins->newest->version, '<' ) ) {
+	if ( isset( $fs_active_plugins->newest ) && version_compare( $this_sdk_version, $fs_active_plugins->newest->version, '<' ) ) {
 		$newest_sdk = $fs_active_plugins->plugins[ $fs_active_plugins->newest->sdk_path ];

 		$plugins_or_theme_dir_path = ( ! isset( $newest_sdk->type ) || 'theme' !== $newest_sdk->type ) ?
--- a/post-to-google-my-business/freemius/templates/forms/license-activation.php
+++ b/post-to-google-my-business/freemius/templates/forms/license-activation.php
@@ -569,7 +569,7 @@
 				        licenseKey = $otherLicenseKey.val();
                     } else {
 				        if ( ! hasLicensesDropdown ) {
-                            licenseID = $availableLicenseKey.data( 'id' );
+                            licenseID = $availableLicenseKey.data( 'id' ).toString();
                         } else {
                             licenseID = $licensesDropdown.val();
                         }
--- a/post-to-google-my-business/freemius/templates/pricing.php
+++ b/post-to-google-my-business/freemius/templates/pricing.php
@@ -69,6 +69,11 @@

     wp_enqueue_script( 'freemius-pricing', $pricing_js_url );

+    $pricing_css_path = $fs->apply_filters( 'pricing/css_path', null );
+    if ( is_string( $pricing_css_path ) ) {
+        wp_enqueue_style( 'freemius-pricing', fs_asset_url( $pricing_css_path ) );
+    }
+
 	$has_tabs = $fs->_add_tabs_before_content();

 	if ( $has_tabs ) {
@@ -95,6 +100,8 @@
             'unique_affix'           => $fs->get_unique_affix(),
             'show_annual_in_monthly' => $fs->apply_filters( 'pricing/show_annual_in_monthly', true ),
             'license'                => $fs->has_active_valid_license() ? $fs->_get_license() : null,
+            'plugin_icon'            => $fs->get_local_icon_url(),
+            'disable_single_package' => $fs->apply_filters( 'pricing/disable_single_package', false ),
         ), $query_params );

         wp_add_inline_script( 'freemius-pricing', 'Freemius.pricing.new( ' . json_encode( $pricing_config ) . ' )' );
--- a/post-to-google-my-business/js/metabox.asset.php
+++ b/post-to-google-my-business/js/metabox.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('jquery', 'wp-hooks'), 'version' => '58879baa67757fa1c32c');
+<?php return array('dependencies' => array('jquery', 'wp-hooks'), 'version' => '8566e48dabc12fa08ff6');
--- a/post-to-google-my-business/js/settings.asset.php
+++ b/post-to-google-my-business/js/settings.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('jquery', 'wp-hooks'), 'version' => '4d2a54876805a5458d17');
+<?php return array('dependencies' => array('jquery', 'wp-hooks'), 'version' => '0939c74158d3f818e114');
--- a/post-to-google-my-business/post-to-google-my-business.php
+++ b/post-to-google-my-business/post-to-google-my-business.php
@@ -5,7 +5,7 @@
 Plugin URI: https://tycoonmedia.net
 Description: Automatically create a post on Google My Business when creating a new WordPress post
 Author: Koen Reus
-Version: 3.1.28
+Version: 3.2.2
 Author URI: https://koenreus.com
 Text Domain: post-to-google-my-business
 */
--- a/post-to-google-my-business/src/API/CachedGoogleMyBusiness.php
+++ b/post-to-google-my-business/src/API/CachedGoogleMyBusiness.php
@@ -4,6 +4,13 @@
 namespace PGMBAPI;


+/*
+ *
+ * This class is deprecated in favour of mysql caching and should be gradually removed in future versions
+ *
+ * Will return currently stored cache to avoid overloading the google api, but delete the transient afterwards.
+ */
+
 class CachedGoogleMyBusiness extends ProxyGMBAPI {


@@ -27,11 +34,12 @@
 	public function list_accounts( $flush = false, $pageSize = 20, $pageToken = '', $filter = '', $parentAccount = '' ) {
 		$transient_name = "pgmb_list_accounts-{$this->user_id}-" . md5(serialize([ $parentAccount, $pageSize, $pageToken, $filter ]));
 		if(!$flush && $cached = get_transient($transient_name)){
+			delete_transient($transient_name);
 			return $cached;
 		}

 		$request = parent::list_accounts( $parentAccount, $pageSize, $pageToken, $filter );
-		set_transient($transient_name, $request, WEEK_IN_SECONDS);
+//		set_transient($transient_name, $request, WEEK_IN_SECONDS);
 		return $request;
 	}

@@ -39,31 +47,34 @@
 	public function list_locations( $parent, $pageSize = 100, $pageToken = '', $filter = '', $orderBy = '', $readMask = '', $flush = false ) {
 		$transient_name = "pgmb_list_locations-{$this->user_id}-" . md5(serialize([$parent, $pageSize, $pageToken, $filter, $orderBy, $readMask]));
 		if(!$flush && $cached = get_transient($transient_name)){
+			delete_transient($transient_name);
 			return $cached;
 		}

 		$request = parent::list_locations( $parent, $pageSize, $pageToken, $filter, $orderBy, $readMask );
-		set_transient($transient_name, $request, WEEK_IN_SECONDS);
+//		set_transient($transient_name, $request, WEEK_IN_SECONDS);
 		return $request;
 	}

 	public function get_location( $name, $readMask = '', $flush = false ) {
 		$transient_name = "pgmb_location-".md5(serialize([$name, $readMask]));
 		if(!$flush && $cached = get_transient($transient_name)){
+			delete_transient($transient_name);
 			return $cached;
 		}
 		$request = parent::get_location( $name, $readMask );
-		set_transient($transient_name, $request, WEEK_IN_SECONDS);
+//		set_transient($transient_name, $request, WEEK_IN_SECONDS);
 		return $request;
 	}

 	public function get_account($name, $flush = false){
 		$transient_name = 'pgmb_account-'.md5($name);
 		if(!$flush && $cached = get_transient($transient_name)){
+			delete_transient($transient_name);
 			return $cached;
 		}
 		$request = parent::get_account($name);
-		set_transient($transient_name, $request, WEEK_IN_SECONDS);
+//		set_transient($transient_name, $request, WEEK_IN_SECONDS);
 		return $request;
 	}

--- a/post-to-google-my-business/src/Admin/AdminPage.php
+++ b/post-to-google-my-business/src/Admin/AdminPage.php
@@ -137,7 +137,7 @@
                 'desc'    => __( 'Select which request types the plugin should listen for to create auto-posts. Do not change unless you know what you are doing. Incorrect settings could result in duplicate posts or spamming your listing with posts.', 'post-to-google-my-business' ),
                 'type'    => 'multicheck',
                 'options' => [
-                    'editor'   => __( 'Posts/pages/CPTs created on the front-end through the Block or Classic editor', 'post-to-google-my-business' ),
+                    'editor'   => __( 'Posts/pages/CPTs created in the WordPress Dashboard through the Block- or Classic editor', 'post-to-google-my-business' ),
                     'internal' => __( 'Internal (e.g. items created internally by 3rd party plugins like import plugins)', 'post-to-google-my-business' ),
                     'rest'     => __( 'REST API (Items created through the WP REST API)', 'post-to-google-my-business' ),
                     'xmlrpc'   => __( 'XML-RPC (Items created through XML-RPC)', 'post-to-google-my-business' ),
--- a/post-to-google-my-business/src/ApiCache/Group.php
+++ b/post-to-google-my-business/src/ApiCache/Group.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace PGMBApiCache;
+
+class Group {
+	private $id;
+	private $account_id;
+	private $google_id;
+	private $group_name;
+	private $imported_at;
+
+	//todo: abstract this
+	public function __construct($data = []){
+		foreach($data as $key => $value){
+			if(property_exists($this, $key)){
+				$this->{$key} = $value;
+			}
+		}
+	}
+
+	public function api_formatted(){
+		$output = new stdClass();
+		$output->name = $this->google_id;
+		$output->accountName = $this->group_name;
+		return $output;
+	}
+
+	public function get_id(){
+		return $this->id;
+	}
+
+}
 No newline at end of file
--- a/post-to-google-my-business/src/ApiCache/GroupCacheRepository.php
+++ b/post-to-google-my-business/src/ApiCache/GroupCacheRepository.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace PGMBApiCache;
+
+use wpdb;
+
+class GroupCacheRepository {
+	/**
+	 * @var wpdb
+	 */
+	private $wpdb;
+	/**
+	 * @var string
+	 */
+	private $table;
+
+	public function __construct(Wpdb $wpdb) {
+		$this->wpdb = $wpdb;
+		$this->table = $wpdb->prefix.'pgmb_group_cache';
+	}
+
+	/**
+	 * @param $account_id - Google "Sub" ID
+	 * @param int $limit
+	 * @param int $offset
+	 *
+	 * @return Group[]
+	 */
+	public function get_groups_by_account_id($account_id, int $limit = 20, int $offset = 0) {
+		$results = $this->wpdb->get_results($this->wpdb->prepare("SELECT * FROM $this->table WHERE account_id = %s AND in_latest_import=1 LIMIT %d OFFSET %d", $account_id, $limit, $offset), ARRAY_A);
+		return array_map( function ($row) {
+			return new Group( $row );
+		}, $results);
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/ApiCache/Location.php
+++ b/post-to-google-my-business/src/ApiCache/Location.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace PGMBApiCache;
+
+use stdClass;
+
+class Location {
+	private $id;
+	private $group_id;
+	private $google_id;
+	private $store_code;
+	private $title;
+	private $language_code;
+	private $website_uri;
+	private $regular_hours;
+	private $special_hours;
+	private $labels;
+	private $metadata;
+	private $service_area;
+	private $storefront_address;
+	private $imported_at;
+
+	public function __construct($data = []){
+		foreach($data as $key => $value){
+			if(property_exists($this, $key)){
+				$this->{$key} = $value;
+			}
+		}
+	}
+
+	public function api_formatted(){
+		$output = new stdClass();
+		$output->name = $this->google_id;
+		$output->storeCode = $this->store_code;
+		$output->title = $this->title;
+		$output->languageCode = $this->language_code;
+		$output->websiteUri = $this->website_uri;
+		$output->regularHours = json_decode($this->regular_hours);
+		$output->specialHours = json_decode($this->special_hours);
+		$output->labels = $this->labels;
+		$output->serviceArea = json_decode($this->service_area);
+		$output->metadata = json_decode($this->metadata);
+		$output->storefrontAddress = json_decode($this->storefront_address);
+
+		return $output;
+	}
+
+	public function get_title(){
+		return $this->title;
+	}
+
+	public function get_storeCode(){
+		return $this->store_code;
+	}
+
+	public function get_languageCode(){
+		return $this->language_code;
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/ApiCache/LocationCacheRepository.php
+++ b/post-to-google-my-business/src/ApiCache/LocationCacheRepository.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace PGMBApiCache;
+
+use Exception;
+use wpdb;
+
+class LocationCacheRepository {
+	/**
+	 * @var wpdb
+	 */
+	private $wpdb;
+	/**
+	 * @var string
+	 */
+	private $table;
+	/**
+	 * @var string
+	 */
+	private $groups_table;
+
+	public function __construct(wpdb $wpdb) {
+		$this->wpdb = $wpdb;
+		$this->table = $wpdb->prefix.'pgmb_location_cache';
+
+		$this->groups_table = $wpdb->prefix.'pgmb_group_cache';
+	}
+
+	/**
+	 * @param int $group_id
+	 *
+	 * @return Location[]
+	 */
+	public function get_locations_by_group_id(int $group_id): array {
+		$results = $this->wpdb->get_results($this->wpdb->prepare("SELECT * FROM {$this->table} WHERE group_id = %d", $group_id), ARRAY_A);
+		return array_map( function ($row) {
+			return new Location( $row );
+		}, $results);
+	}
+
+	public function get_locations_by_group_google_id($google_id, int $limit = 100, int $offset = 0): array {
+		/*
+		 *
+		 *         SELECT l.*
+        FROM $locationsTable l
+        INNER JOIN $groupsTable g ON l.group_id = g.id
+        WHERE g.google_id = %s
+
+		 */
+		$results = $this->wpdb->get_results($this->wpdb->prepare("SELECT l.* FROM {$this->table} l INNER JOIN {$this->groups_table} g ON l.group_id = g.id WHERE g.google_id = %s AND l.in_latest_import=1 LIMIT %d OFFSET %d", $google_id, $limit, $offset), ARRAY_A);
+		return array_map( function ($row) {
+			return new Location( $row );
+		}, $results);
+	}
+
+	/**
+	 * Queries Google locations by their Google ID, returns an array of Location indexed by the Google ID
+	 *
+	 * @throws Exception
+	 * @return Location[]
+	 */
+	public function get_locations_by_google_ids(array $google_ids): array {
+		if(empty($google_ids)){
+			return [];
+		}
+
+		$placeholders = implode(',', array_fill(0, count($google_ids), '%s'));
+
+		$query = "
+        SELECT *
+        FROM {$this->table}
+        WHERE google_id IN ($placeholders)
+    ";
+
+		$prepared_query = $this->wpdb->prepare($query, ...$google_ids);
+		$results = $this->wpdb->get_results($prepared_query, ARRAY_A);
+
+		if($results === false){
+			throw new Exception(sprintf(__("Failed to retrieve location data for %s: %s", 'post-to-google-my-business'), $placeholders, $this->wpdb->last_error));
+		}
+
+		$locations = [];
+		foreach($results as $row){
+			$location = new Location($row);
+			$locations[$row['google_id']] = $location;
+		}
+		return $locations;
+	}
+
+	/**
+	 * @param $google_id
+	 *
+	 * @return Location
+	 * @throws Exception
+	 */
+	public function get_location_by_google_id($google_id): Location {
+		$result = $this->wpdb->get_row($this->wpdb->prepare("SELECT * FROM {$this->table} WHERE google_id = %s", $google_id), ARRAY_A);
+		if($result === null){
+			throw new Exception(__("Failed to retrieve location data for %s: %s", 'post-to-google-my-business'), $google_id, $this->wpdb->last_error);
+		}
+		return new Location( $result );
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/BackgroundProcessing/AccountSyncQueueItem.php
+++ b/post-to-google-my-business/src/BackgroundProcessing/AccountSyncQueueItem.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace PGMBBackgroundProcessing;
+
+class AccountSyncQueueItem {
+	private $account_id;
+
+	public function __construct(string $account_id) {
+		$this->account_id = $account_id;
+	}
+
+	public function get_account_id(): string {
+		return $this->account_id;
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/BackgroundProcessing/AccountsSyncQueueItem.php
+++ b/post-to-google-my-business/src/BackgroundProcessing/AccountsSyncQueueItem.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace PGMBBackgroundProcessing;
+
+class AccountsSyncQueueItem {
+	/**
+	 * @var array
+	 */
+	private $account_ids;
+
+	/**
+	 * @param string[] $account_ids Google Sub IDs
+	 */
+	public function __construct(array $account_ids){
+		$this->account_ids = $account_ids;
+	}
+
+	/**
+	 * @return array|string[]
+	 */
+	public function get_account_ids(): array {
+		return $this->account_ids;
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/BackgroundProcessing/GroupSyncQueueItem.php
+++ b/post-to-google-my-business/src/BackgroundProcessing/GroupSyncQueueItem.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace PGMBBackgroundProcessing;
+
+class GroupSyncQueueItem extends AccountSyncQueueItem {
+	/**
+	 * @var string
+	 */
+	private $pageToken;
+
+	public function __construct( $account_id, $pageToken = '' ) {
+		parent::__construct( $account_id );
+		$this->pageToken = $pageToken;
+	}
+
+	public function getPageToken() {
+		return $this->pageToken;
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/BackgroundProcessing/LocationSyncProcess.php
+++ b/post-to-google-my-business/src/BackgroundProcessing/LocationSyncProcess.php
@@ -0,0 +1,244 @@
+<?php
+
+namespace PGMBBackgroundProcessing;
+
+use PGMBAPIProxyAuthenticationAPI;
+use PGMBAPIProxyGMBAPI;
+use PGMBVendorTypistTechWPAdminNoticesAbstractNotice;
+use PGMBVendorTypistTechWPAdminNoticesStickyNotice;
+use PGMBVendorTypistTechWPAdminNoticesStore as AdminNoticeStore;
+use PGMB_Vendor_WP_Background_Process as BackgroundProcess;
+
+class LocationSyncProcess extends BackgroundProcess {
+
+	protected $action = 'pgmb_sync_locations';
+
+	/**
+	 * @var ProxyGMBAPI
+	 */
+	private $api;
+
+	/**
+	 * @var ProxyAuthenticationAPI
+	 */
+	private $auth_api;
+
+	/**
+	 * @var AdminNoticeStore
+	 */
+	private $admin_notice_store;
+
+	protected $allowed_batch_data_classes = [
+		AccountSyncQueueItem::class,
+		LocationSyncQueueItem::class,
+		GroupSyncQueueItem::class,
+	];
+
+	public function __construct(ProxyGMBAPI $api, ProxyAuthenticationAPI $auth_api, AdminNoticeStore $admin_notice_store) {
+		parent::__construct();
+		$this->api = $api;
+		$this->auth_api = $auth_api;
+		$this->admin_notice_store = $admin_notice_store;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	protected function task( $item ) {
+		if(!$item instanceof AccountSyncQueueItem){
+			return false;
+		}
+
+		try{
+			$this->api->set_access_token($this->auth_api->get_access_token($item->get_account_id()));
+
+			if($item instanceof LocationSyncQueueItem){
+				return $this->sync_locations($item);
+			}elseif($item instanceof GroupSyncQueueItem){
+				return $this->sync_groups($item);
+			}
+		}catch(Throwable $e){
+			$this->admin_notice_store->add(new StickyNotice('location_import_error', sprintf(esc_html__("Something went wrong trying to load your Google Business Profile locations: %s", 'post-to-google-my-business'), $e->getMessage()), AbstractNotice::ERROR));
+			return false;
+		}
+
+		update_option('pgmb_account_refresh_'.$item->get_account_id(), current_time('mysql', true));
+
+		return new GroupSyncQueueItem($item->get_account_id());
+	}
+
+	protected function update_in_latest_import($account_id){
+		$latest_import_date = get_option('pgmb_account_refresh_'.$account_id);
+
+		if(!$latest_import_date){
+			return;
+		}
+
+		global $wpdb;
+
+		$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}pgmb_location_cache l INNER JOIN {$wpdb->prefix}pgmb_group_cache g ON l.group_id=g.id SET l.in_latest_import=0 WHERE g.account_id=%s AND l.imported_at < %s", $account_id, $latest_import_date));
+		$wpdb->query($wpdb->prepare("UPDATE {$wpdb->prefix}pgmb_group_cache SET in_latest_import=0 WHERE account_id=%s AND imported_at < %s", $account_id, $latest_import_date));
+
+		delete_option('pgmb_account_refresh_'.$account_id);
+	}
+
+	/**
+	 * @throws Exception
+	 */
+	private function sync_groups( GroupSyncQueueItem $item ) {
+		$request = $this->api->list_accounts('', 20, $item->getPageToken());
+
+		global $wpdb;
+		$placeholders = [];
+		$values = [];
+
+		$accounts = isset($request->accounts) && is_array($request->accounts) ? $request->accounts : null;
+		if(!is_array($accounts) || count($accounts) < 1) {
+			return false;
+		}
+
+		foreach($accounts as $account){
+			$placeholders[] = "(%s, %s, %s, %s, %d)";
+			$values[] = $item->get_account_id();
+			$values[] = $account->name;
+			$values[] = $account->accountName;
+			$values[] = current_time('mysql', true);
+			$values[] = 1;
+
+			$this->push_to_queue(new LocationSyncQueueItem($item->get_account_id(), $account->name, null));
+		}
+		$this->save();
+
+		$implode_placeholders = implode(',', $placeholders);
+
+		$result = $wpdb->query( $wpdb->prepare(
+			"INSERT INTO {$wpdb->prefix}pgmb_group_cache
+    				(
+    					account_id,
+    				 	google_id,
+    				 	group_name,
+    				 	imported_at,
+    				 	in_latest_import
+    				) VALUES
+    				    {$implode_placeholders}
+					ON DUPLICATE KEY UPDATE
+	                	account_id = VALUES(account_id),
+                        google_id = VALUES(google_id),
+                        group_name = VALUES(group_name),
+                        imported_at = VALUES(imported_at),
+		                in_latest_import = VALUES(in_latest_import)
+		            ",
+			$values
+		)
+		);
+
+		if($result === false){
+			throw new Exception("Failed to insert group cache: ".$wpdb->last_error);
+		}
+
+		$nextPageToken = isset($request->nextPageToken) && $request->nextPageToken ? $request->nextPageToken : null;
+		if($nextPageToken){
+			return new GroupSyncQueueItem($item->get_account_id(), $nextPageToken);
+		}
+
+		$this->update_in_latest_import($item->get_account_id());
+
+		return false;
+	}
+
+	/**
+	 * @throws Exception
+	 */
+	private function sync_locations(LocationSyncQueueItem $item){
+		global $wpdb;
+
+		$group_id = $wpdb->get_row($wpdb->prepare("SELECT id FROM {$wpdb->prefix}pgmb_group_cache WHERE google_id = %s", $item->get_parent()));
+		if(!$group_id){
+			throw new Exception('Could not find group');
+		}
+		$readMask = 'name,languageCode,storeCode,title,websiteUri,storefrontAddress,metadata,serviceArea,regularHours,specialHours';
+
+		$request = $this->api->list_locations($item->get_parent(), 100, $item->getPageToken(), null, null, $readMask);
+
+		$placeholders = [];
+		$values = [];
+
+		$locations = isset($request->locations) && is_array($request->locations) ? $request->locations : null;
+		if (!is_array( $locations ) || empty( $locations ) ) {
+			return false;
+		}
+
+		foreach($locations as $location){
+			$placeholders[] = "(%d, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %d)";
+			$values[] = $group_id->id;
+			$values[] = $location->name;
+			$values[] = !empty($location->storeCode) ? $location->storeCode : null;
+			$values[] = $location->title;
+			$values[] = !empty($location->languageCode) ? $location->languageCode : null;
+			$values[] = !empty($location->websiteUri) ? $location->websiteUri : null;
+			$values[] = !empty($location->regularHours) ? json_encode($location->regularHours) : null;
+			$values[] = !empty($location->specialHours) ? json_encode($location->specialHours) : null;
+			$values[] = !empty($location->labels) ? json_encode($location->labels) : null;
+			$values[] = $location->metadata ? json_encode($location->metadata) : null;
+			$values[] = !empty($location->storefrontAddress) ? json_encode($location->storefrontAddress) : null;
+			$values[] = !empty($location->serviceArea) ? json_encode($location->serviceArea) : null;
+			$values[] = current_time('mysql', true);
+			$values[] = 1;
+		}
+
+		$implode_placeholders = implode(',', $placeholders);
+
+		$result = $wpdb->query( $wpdb->prepare(
+			"INSERT INTO {$wpdb->prefix}pgmb_location_cache
+                (
+					group_id,
+					google_id,
+					store_code,
+					title,
+					language_code,
+					website_uri,
+					regular_hours,
+					special_hours,
+					labels,
+					metadata,
+                    storefront_address,
+					service_area,
+					imported_at,
+                 	in_latest_import
+                ) VALUES
+                    {$implode_placeholders}
+				ON DUPLICATE KEY UPDATE
+					group_id = VALUES(group_id),
+	                google_id = VALUES(google_id),
+                    store_code = VALUES(store_code),
+                    title = VALUES(title),
+                    language_code = VALUES(language_code),
+                    website_uri = VALUES(website_uri),
+                    regular_hours = VALUES(regular_hours),
+                    special_hours = VALUES(special_hours),
+                    labels = VALUES(labels),
+                    metadata = VALUES(metadata),
+                    storefront_address = VALUES(storefront_address),
+                    service_area = VALUES(service_area),
+                    imported_at = VALUES(imported_at),
+                  	in_latest_import = VALUES(in_latest_import)
+            ",
+			$values
+		)
+		);
+
+		if($result === false){
+			throw new Exception('Could not create location cache: '. $wpdb->last_error);
+		}
+
+		$nextPageToken = !empty($request->nextPageToken) ? $request->nextPageToken : null;
+		if($nextPageToken){
+			return new LocationSyncQueueItem($item->get_account_id(), $item->get_parent(), $nextPageToken);
+		}
+
+		//When all locations are imported from the account, update the in_latest_import flag
+//		$this->update_in_latest_import($item->get_account_id());
+
+		return false;
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/BackgroundProcessing/LocationSyncQueueItem.php
+++ b/post-to-google-my-business/src/BackgroundProcessing/LocationSyncQueueItem.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace PGMBBackgroundProcessing;
+
+
+class LocationSyncQueueItem extends GroupSyncQueueItem {
+	private $parent;
+
+	public function __construct( $account_id, $parent, $pageToken = '' ) {
+		parent::__construct( $account_id, $pageToken );
+		$this->parent = $parent;
+	}
+
+	/**
+	 * @return mixed
+	 */
+	public function get_parent() {
+		return $this->parent;
+	}
+}
 No newline at end of file
--- a/post-to-google-my-business/src/BackgroundProcessing/PostPublishProcess.php
+++ b/post-to-google-my-business/src/BackgroundProcessing/PostPublishProcess.php
@@ -3,16 +3,18 @@
 namespace PGMBBackgroundProcessing;

 use PGMBAPICachedGoogleMyBusiness;
+use PGMBApiCacheLocationCacheRepository;
 use PGMBGoogleLocalPostEditMask;
+use PGMBGoogleNormalizeLocationName;
 use PGMBParseFormFields;
 use PGMBPostTypesGooglePostEntity;
 use PGMBPostTypesGooglePostEntityRepository;
 use PGMBVendorTypistTechWPAdminNoticesAbstractNotice;
 use PGMBVendorTypistTechWPAdminNoticesStickyNotice;
-use PGMBVendorTypistTechWPAdminNoticesStore;
-use PGMB_Vendor_WP_Background_Process as Background_Process;
+use PGMBVendorTypistTechWPAdminNoticesStore as AdminNoticesStore;
+use PGMB_Vendor_WP_Background_Process as BackgroundProcess;
 use WP_Error;
-class PostPublishProcess extends Background_Process {
+class PostPublishProcess extends BackgroundProcess {
     protected $action = 'mbp_background_process';

     protected $api;
@@ -20,15 +22,26 @@
     protected $repository;

     /**
-     * @var Store
+     * @var AdminNoticesStore
      */
     protected $admin_notice_store;

-    public function __construct( CachedGoogleMyBusiness $api, GooglePostEntityRepository $repository, Store $admin_notice_store ) {
+    /**
+     * @var LocationCacheRepository
+     */
+    private $location_repository;
+
+    public function __construct(
+        CachedGoogleMyBusiness $api,
+        GooglePostEntityRepository $repository,
+        LocationCacheRepository $location_repository,
+        AdminNoticesStore $admin_notice_store
+    ) {
         parent::__construct();
         $this->api = $api;
         $this->repository = $repository;
         $this->admin_notice_store = $admin_notice_store;
+        $this->location_repository = $location_repository;
     }

     /**
@@ -60,14 +73,14 @@

     public function update_status( $entity_id ) {
         $entity = $this->repository->find_by_id( (int) $entity_id );
-        if ( !$entity instanceof GooglePostEntity ) {
+        if ( !$entity instanceof GooglePostEntity || empty( $entity->get_post_name() ) ) {
             return false;
         }
         try {
             $this->api->set_user_id( $entity->get_user_key() );
             $updated_post = $this->api->get_post( $entity->get_post_name() );
             $entity->set_post_success( $updated_post->name, $updated_post->state, $updated_post->searchUrl );
-        } catch ( Exception $e ) {
+        } catch ( Throwable $e ) {
             $entity->set_post_state( null )->set_post_failure( sprintf( __( 'Updating status failed: %s', 'post-to-google-my-business' ), $e->getMessage() ) );
         }
         $this->repository->persist( $entity );
@@ -82,13 +95,30 @@
         $this->delete_post( $user_key, $post_name );
     }

+    protected function lower_queue_count( $post_id ) {
+        $count = (int) get_post_meta( $post_id, "_pgmb_queued_items", true );
+        if ( $count > 0 ) {
+            $count--;
+            if ( $count === 0 ) {
+                delete_post_meta( $post_id, "_pgmb_queued_items" );
+            } else {
+                update_post_meta( $post_id, "_pgmb_queued_items", $count );
+            }
+        }
+    }
+
     public function create_google_post( $post_id, $location, $user_key = false ) {
         /*
          * user_key is not set pre 3.0.0, any posts that were scheduled before installing 3.0.0 will not set have user_key value,
          *
          * Can not rely on default location because if it is changed to a location on another account, the user_key will be incorrect
          */
+        $this->lower_queue_count( $post_id );
         $form_fields = get_post_meta( $post_id, 'mbp_form_fields', true );
+        if ( empty( $form_fields ) ) {
+            //The parent post is probably deleted
+            return;
+        }
         $parent_post_id = wp_get_post_parent_id( $post_id );
         $is_autopost = get_post_meta( $post_id, '_mbp_is_autopost', true );
         $created_post = $this->repository->find_by_parent( $post_id )->find_by_user_key( $user_key )->find_by_location( $location )->find_one();
@@ -107,12 +137,8 @@
             //			if(!isset($location_data->locationState->isVerified) || !$location_data->locationState->isVerified || !isset($location_data->locationState->isPublished) || !$location_data->locationState->isPublished){
             //				throw new InvalidArgumentException(__('This location is unverified, not public, or not eligible to publish posts.', 'post-to-google-my-business'));
             //			}
-            $localPost = $data->getLocalPost(
-                $this->api,
-                $parent_post_id,
-                $user_key,
-                $location
-            );
+            $location_ent = $this->location_repository->get_location_by_google_id( NormalizeLocationName::from_with_account( $location )->without_account_id() );
+            $localPost = $data->getLocalPost( $location_ent, $parent_post_id );
             if ( $post_name ) {
                 $oldPost = $this->api->get_post( $post_name );
                 $mask = new LocalPostEditMask($oldPost, $localPost);
@@ -161,14 +187,4 @@
         sleep( 1 );
     }

-    public function dispatch() {
-        update_option( 'pgmb_is_busy', true );
-        parent::dispatch();
-    }
-
-    protected function complete() {
-        delete_option( 'pgmb_is_busy' );
-        parent::complete();
-    }
-
 }
--- a/post-to-google-my-business/src/Components/BusinessSelector.php
+++ b/post-to-google-my-business/src/Components/BusinessSelector.php
@@ -5,6 +5,10 @@


 use PGMBAPICachedGoogleMyBusiness;
+use PGMBApiCacheGroupCacheRepository;
+use PGMBApiCacheLocationCacheRepository;
+use PGMBBackgroundProcessingAccountSyncQueueItem;
+use PGMBBackgroundProcessingLocationSyncProcess;

 class BusinessSelector {
 	protected $api;
@@ -13,9 +17,24 @@
 	protected $selected;
 	protected $flush_cache;
 	private $prefix;
+	/**
+	 * @var LocationSyncProcess
+	 */
+	private $location_sync_process;
+	/**
+	 * @var GroupCacheRepository
+	 */
+	private $group_cache;
+	/**
+	 * @var LocationCacheRepository
+	 */
+	private $location_cache;

-	public function __construct(CachedGoogleMyBusiness $api) {
+	public function __construct(CachedGoogleMyBusiness $api, LocationSyncProcess $location_sync_process, GroupCacheRepository $group_cache, LocationCacheRepository $location_cache) {
 		$this->api                 = $api;
+		$this->location_sync_process = $location_sync_process;
+		$this->group_cache = $group_cache;
+		$this->location_cache = $location_cache;
 	}

 	/**
@@ -326,6 +345,12 @@
 			wp_send_json_error(__('Invalid nonce', 'post-to-google-my-business'));
 		}
 		$accounts = $this->load_accounts();
+
+		if($this->location_sync_process->is_processing()){
+			wp_send_json_error([
+				'loading' => true,
+			]);
+		}
 //		$account_data = reset($accounts);
 //		$key = key($accounts);
 		wp_send_json_success($accounts);
@@ -339,22 +364,28 @@
 		$data = json_decode(stripslashes($_REQUEST['data']));

 		$account_key = sanitize_key($data->account_id);
-		$nextPageToken = isset($data->nextPageToken) && $data->nextPageToken ? $data->nextPageToken : null;
+
+		$offset = isset($data->offset) ? (int)$data->offset : 0;

 		$refresh = isset($data->refresh) && $data->refresh;
-		try{
-			$this->api->set_user_id($account_key);
-			$response = $this->api->list_accounts($refresh, 20, $nextPageToken);
-		}catch(Exception $exception){
-			wp_send_json_error(sprintf(__('Could not retrieve account or location groups from Google My Business: %s', 'post-to-google-my-business'), $exception->getMessage()));
+
+		if($refresh){
+			$this->location_sync_process->push_to_queue(new AccountSyncQueueItem($data->account_id))->save()->dispatch();
 		}

-		$accounts = isset($response->accounts) && is_array($response->accounts) ? $response->accounts : null;
-		if(!is_array($accounts) || count($accounts) < 1) {
+		$groups = $this->group_cache->get_groups_by_account_id($account_key, 100, $offset);
+
+		if(empty($groups)) {
 			wp_send_json_error(__('No user account or location groups found. Did you log in to the correct Google account?', 'post-to-google-my-business'));
 		}
-		$response->accounts = apply_filters('mbp_business_selector_groups', $accounts, $account_key);
-		wp_send_json_success($response);
+
+		$groups_api_formatted = array_map(function($group){
+			return $group->api_formatted();
+		}, $groups);
+
+		//Todo: pagination/offset
+		$groups_api_formatted = apply_filters('mbp_business_selector_groups', $groups_api_formatted, $account_key);
+		wp_send_json_success(['

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2024-13362
# Block reflected XSS via the 'url' parameter in Freemius SDK-based pages.
# Targets the JavaScript handling of 'sticky' admin notices where the url param is injected into DOM.
SecRule REQUEST_URI "@contains /wp-admin/" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'Freemius SDK Reflected XSS (CVE-2024-13362)',severity:'CRITICAL',tag:'CVE-2024-13362',t:none"
  SecRule ARGS:url "@rx (?:javascript|data|vbscript):" 
    "chain,t:none"
    SecRule REQUEST_METHOD "@streq GET" 
      "t:none"

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School