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: ‘
‘. More importantly, the message text and button are now separated into distinct
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.
--- 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.
# 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
What is CVE-2024-13362?
Understanding the vulnerabilityCVE-2024-13362 is a reflected DOM-based cross-site scripting (XSS) vulnerability affecting the Freemius SDK used in various WordPress plugins. It allows unauthenticated attackers to inject arbitrary JavaScript into web pages through insufficient input sanitization of the ‘url’ parameter.
How does this vulnerability work?
Mechanism of exploitationThe vulnerability occurs when user-controlled URLs are concatenated into HTML without proper escaping. An attacker can craft a malicious URL containing JavaScript, which executes when a user clicks the link, allowing the attacker to run scripts in the context of the victim’s session.
Who is affected by CVE-2024-13362?
Identifying vulnerable usersWordPress administrators using the Freemius SDK version 2.10.1 or earlier are affected. This includes users of any plugins or themes that incorporate the Freemius SDK, such as the ‘post-to-google-my-business’ plugin.
How can I check if my site is vulnerable?
Verifying your WordPress installationCheck the version of the Freemius SDK used in your plugins. If it is version 2.10.1 or earlier, your site is vulnerable. You can typically find this information in the plugin’s details within the WordPress admin dashboard.
How can I fix this vulnerability?
Steps to mitigate the issueUpdate the affected plugins to the patched version that includes the Freemius SDK version 2.11.0 or later. This version contains fixes for the input sanitization issues that led to the vulnerability.
What does the severity rating of Medium mean?
Interpreting the CVSS scoreA CVSS score of 6.1 indicates a medium severity vulnerability. This means that while the vulnerability requires user interaction to exploit, it can still lead to significant risks such as session hijacking or unauthorized actions within the WordPress admin interface.
What is a proof of concept for this vulnerability?
Demonstrating the exploitA proof of concept involves creating a malicious URL that includes a JavaScript payload in the ‘url’ parameter. For example, a URL like ‘https://victim.com/wp-admin/?url=javascript:alert(document.cookie)’ can be used to demonstrate the execution of arbitrary scripts in the victim’s browser.
What are the potential impacts of this vulnerability?
Consequences of exploitationSuccessful exploitation can allow attackers to execute arbitrary JavaScript, leading to session hijacking, unauthorized actions, and data exfiltration. This can compromise sensitive information such as cookies, nonce values, and user data.
What changes were made in the patched version?
Understanding the patchThe patch modifies how user input is handled in the ‘class-freemius.php’ file, separating message text from the button HTML to prevent direct concatenation of user-controlled URLs. It also adds input validation and escaping for the URL.
Is user interaction required to exploit this vulnerability?
Understanding exploitation requirementsYes, exploitation requires user interaction. The attacker must trick a user into clicking a crafted link that contains the malicious URL. This social engineering aspect can limit the effectiveness of the attack.
How widespread is this vulnerability?
Scope of the issueThe Freemius SDK is widely used across thousands of WordPress plugins and themes, making this vulnerability potentially high-impact despite the requirement for user interaction. Many sites could be at risk if they use affected versions.
What should I do if I cannot update my plugins immediately?
Mitigation strategiesIf immediate updates are not possible, consider disabling the affected plugins until a patch can be applied. Additionally, educate users about the risks of clicking unknown links to reduce the likelihood of exploitation.
Where can I find more information about CVE-2024-13362?
Additional resourcesMore information can be found on the official CVE database, security advisories from plugin developers, and security blogs that analyze vulnerabilities. Keeping abreast of security updates and best practices is essential for WordPress administrators.
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.
Trusted by Developers & Organizations







