| 1 |
<?php |
| 2 |
/** |
| 3 |
* POSIMYTH Analytics — shared client tracker base. |
| 4 |
* |
| 5 |
* Ship this file UNCHANGED inside every POSIMYTH plugin's `include/posimyth-sdk/` |
| 6 |
* folder. Each plugin provides a tiny subclass (Posimyth_Tracker_NE / _TPAE / _TPGB) |
| 7 |
* that only declares plugin-specific keys. All collection, scanning, consent-gating |
| 8 |
* and transport lives here so the three plugins never drift apart. |
| 9 |
* |
| 10 |
* Privacy: this payload carries no email, no IP, no user names and no post content. |
| 11 |
* |
| 12 |
* It DOES carry the site URL, and the hub stores that URL in plaintext so support can tell which |
| 13 |
* install a report came from. Earlier revisions of this comment said the hub only kept a one-way |
| 14 |
* SHA-256 hash of it; that was wrong — the hub's hash is a row-dedup key that sits alongside the |
| 15 |
* raw value, not a substitute for it. Do not describe this data as anonymous. |
| 16 |
* |
| 17 |
* The one exception to "no email" is the deactivation dialog's "I agree to be contacted" opt-in, |
| 18 |
* which attaches the admin email — and only when the user ticks that box. See |
| 19 |
* Posimyth_Deactivation_Survey::handle_ajax(). |
| 20 |
* |
| 21 |
* @package POSIMYTH\Analytics\SDK |
| 22 |
*/ |
| 23 |
|
| 24 |
if ( ! defined( 'ABSPATH' ) ) { |
| 25 |
exit; |
| 26 |
} |
| 27 |
|
| 28 |
if ( ! class_exists( 'Posimyth_Tracker_Base' ) ) { |
| 29 |
|
| 30 |
/** |
| 31 |
* Shared analytics tracker. Owns the hooks, the payload, the environment/content scanners and |
| 32 |
* the transport; each product subclasses it to supply its own identity and feature toggles. |
| 33 |
*/ |
| 34 |
abstract class Posimyth_Tracker_Base { |
| 35 |
|
| 36 |
/** |
| 37 |
* Guard so each concrete subclass boots its hooks only once. |
| 38 |
* |
| 39 |
* @var array<string,bool> |
| 40 |
*/ |
| 41 |
protected static $booted = array(); |
| 42 |
|
| 43 |
// Per-plugin configuration — implemented by each subclass. |
| 44 |
|
| 45 |
/** Short unique id, used for cron + option names. e.g. 'ne', 'tpae', 'tpgb'. */ |
| 46 |
abstract protected static function id(): string; |
| 47 |
|
| 48 |
/** WordPress plugin folder slug, e.g. 'nexter-extension'. */ |
| 49 |
abstract protected static function slug(): string; |
| 50 |
|
| 51 |
/** Option key that stores the user's opt-in (true = consent given). */ |
| 52 |
abstract protected static function opt_in_option(): string; |
| 53 |
|
| 54 |
/** Current plugin version string. */ |
| 55 |
abstract protected static function version(): string; |
| 56 |
|
| 57 |
/** Whether the Pro/premium build is active. */ |
| 58 |
abstract protected static function is_pro(): bool; |
| 59 |
|
| 60 |
/** |
| 61 |
* Map of feature/widget/block slug => bool, representing which features |
| 62 |
* are ENABLED in settings (toggled on). Counts of actual usage come from |
| 63 |
* used_features(). |
| 64 |
* |
| 65 |
* @return array<string,bool> |
| 66 |
*/ |
| 67 |
abstract protected static function enabled_features(): array; |
| 68 |
|
| 69 |
/* ----- Optional config: subclasses override only when relevant ----- */ |
| 70 |
|
| 71 |
/** |
| 72 |
* Map of feature slug => integer usage count, scanned from real content. |
| 73 |
* Default empty (e.g. Nexter Extension: extensions are toggle-only). |
| 74 |
* |
| 75 |
* @return array<string,int> |
| 76 |
*/ |
| 77 |
protected static function used_features(): array { |
| 78 |
return array(); |
| 79 |
} |
| 80 |
|
| 81 |
/** License status + plan. Default empty for free-only products. */ |
| 82 |
protected static function license(): array { |
| 83 |
return array( |
| 84 |
'status' => '', |
| 85 |
'plan' => '', |
| 86 |
); |
| 87 |
} |
| 88 |
|
| 89 |
/** Onboarding state string ('' when the plugin has no onboarding flow). */ |
| 90 |
protected static function onboarding_status(): string { |
| 91 |
return ''; |
| 92 |
} |
| 93 |
|
| 94 |
/** Human-readable product name, used in the privacy-policy suggestion. */ |
| 95 |
protected static function display_name(): string { |
| 96 |
return static::slug(); |
| 97 |
} |
| 98 |
|
| 99 |
// Bootstrap + WordPress hooks. |
| 100 |
|
| 101 |
/** |
| 102 |
* Registers this product's hooks. Safe to call more than once. |
| 103 |
*/ |
| 104 |
public static function init(): void { |
| 105 |
$class = static::class; |
| 106 |
if ( ! empty( self::$booted[ $class ] ) ) { |
| 107 |
return; |
| 108 |
} |
| 109 |
self::$booted[ $class ] = true; |
| 110 |
|
| 111 |
add_action( 'activated_plugin', array( static::class, 'on_activate' ), 10, 2 ); |
| 112 |
add_action( 'deactivated_plugin', array( static::class, 'on_deactivate' ), 10, 2 ); |
| 113 |
add_action( static::cron_hook(), array( static::class, 'heartbeat' ) ); |
| 114 |
|
| 115 |
// Listener for events queued by do_request() on hosts where the response cannot be flushed. |
| 116 |
// Registered unconditionally and before the multisite bail below: a queued event has to be |
| 117 |
// runnable on whichever blog the cron request lands on, and an unregistered hook would leave |
| 118 |
// the event firing against nothing forever. |
| 119 |
add_action( static::async_hook(), array( static::class, 'run_async_request' ), 10, 3 ); |
| 120 |
add_action( 'admin_init', array( static::class, 'register_privacy_policy_content' ) ); |
| 121 |
add_action( 'admin_init', array( static::class, 'maybe_catchup_heartbeat' ) ); |
| 122 |
|
| 123 |
// One heartbeat per INSTALL, not per blog (A6): on a network-activated multisite this |
| 124 |
// scheduled a weekly event on every single blog — N crons and N pings for one install. |
| 125 |
// The network reports once, from the main site. |
| 126 |
if ( is_multisite() && ! is_main_site() ) { |
| 127 |
return; |
| 128 |
} |
| 129 |
|
| 130 |
if ( ! wp_next_scheduled( static::cron_hook() ) ) { |
| 131 |
wp_schedule_event( time(), 'weekly', static::cron_hook() ); |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Runs an overdue heartbeat from an admin visit when WP-Cron is not firing (E7). |
| 137 |
* |
| 138 |
* On DISABLE_WP_CRON hosts, aggressive page caches or dormant sites, the weekly event can sit |
| 139 |
* past due forever and the install goes silent. A day past due means cron is not coming; an |
| 140 |
* admin page load then sends the ping (still consent-gated, still deferred to shutdown) and |
| 141 |
* reschedules. The transient stops this from probing more than once a day. |
| 142 |
*/ |
| 143 |
public static function maybe_catchup_heartbeat(): void { |
| 144 |
if ( is_multisite() && ! is_main_site() ) { |
| 145 |
return; |
| 146 |
} |
| 147 |
if ( ! static::has_consent() ) { |
| 148 |
return; |
| 149 |
} |
| 150 |
|
| 151 |
$next = wp_next_scheduled( static::cron_hook() ); |
| 152 |
if ( $next && $next > time() - DAY_IN_SECONDS ) { |
| 153 |
return; // Scheduled and not meaningfully overdue. |
| 154 |
} |
| 155 |
|
| 156 |
$lock = 'posimyth_' . static::id() . '_hb_catchup'; |
| 157 |
if ( get_transient( $lock ) ) { |
| 158 |
return; |
| 159 |
} |
| 160 |
set_transient( $lock, 1, DAY_IN_SECONDS ); |
| 161 |
|
| 162 |
wp_clear_scheduled_hook( static::cron_hook() ); |
| 163 |
wp_schedule_event( time() + WEEK_IN_SECONDS, 'weekly', static::cron_hook() ); |
| 164 |
|
| 165 |
static::heartbeat(); |
| 166 |
} |
| 167 |
|
| 168 |
/** |
| 169 |
* Feeds suggested text into WordPress's own Privacy Policy generator (Tools → Privacy), so a |
| 170 |
* site owner writing their policy is told about this plugin's data sharing instead of having to |
| 171 |
* discover it. Registered from init(), which is already behind the white-label gate — a |
| 172 |
* rebranded install must not surface POSIMYTH's name here either. |
| 173 |
*/ |
| 174 |
public static function register_privacy_policy_content(): void { |
| 175 |
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { |
| 176 |
return; |
| 177 |
} |
| 178 |
|
| 179 |
$name = static::display_name(); |
| 180 |
|
| 181 |
/* |
| 182 |
* This paragraph used to end with "nothing is sent unless an administrator turns on Share |
| 183 |
* Non-Sensitive Details", which is not true: submitting the deactivation feedback form sends |
| 184 |
* that one submission regardless of the setting, because pressing Submit there IS the |
| 185 |
* consent for it. The wording now states the exception instead of contradicting it. |
| 186 |
*/ |
| 187 |
$content = '<p>' . sprintf( |
| 188 |
/* translators: %s: product name */ |
| 189 |
esc_html__( '%s can send non-sensitive information about this website to POSIMYTH Innovation (api.posimyth.com) to help diagnose conflicts and prioritise fixes. This is off by default, and while it is off nothing is collected or sent automatically.', 'wdesignkit' ), |
| 190 |
esc_html( $name ) |
| 191 |
) . '</p>'; |
| 192 |
|
| 193 |
$content .= '<p>' . esc_html__( 'When enabled, the information sent is: the site URL, the plugin version, PHP / MySQL / WordPress versions, server software, timezone, locale, memory limit, maximum upload size, permalink structure, whether HTTPS / multisite / debug mode are on, the environment type, the number of registered users, the active theme and its version, the detected page builder, the list of active plugin folders and their count, which of the plugin\'s features are enabled and how often they are used, Pro/Free and licence status, and the install date. It is sent when the plugin is activated, when it is deactivated, and once a week in the background.', 'wdesignkit' ) . '</p>'; |
| 194 |
|
| 195 |
$content .= '<p>' . esc_html__( 'There is one case where information is sent even while the setting is off: the feedback form shown when the plugin is deactivated. If an administrator fills it in and presses Submit, that submission is sent - the reason chosen, any comment typed, and the same non-sensitive setup information listed above - because pressing Submit is the consent for it. Choosing Skip, pressing Esc or clicking outside the form sends nothing at all.', 'wdesignkit' ) . '</p>'; |
| 196 |
|
| 197 |
$content .= '<p>' . esc_html__( 'No post or page content, visitor data, user names or email addresses are included. The one exception is that same feedback form: if an administrator ticks "I agree to be contacted via email" there, the site administration email address is sent with the feedback so POSIMYTH can reply. That box is off by default.', 'wdesignkit' ) . '</p>'; |
| 198 |
|
| 199 |
wp_add_privacy_policy_content( $name, wp_kses_post( $content ) ); |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* Name of this product's heartbeat cron hook. |
| 204 |
* |
| 205 |
* @return string |
| 206 |
*/ |
| 207 |
protected static function cron_hook(): string { |
| 208 |
return 'posimyth_heartbeat_' . static::id(); |
| 209 |
} |
| 210 |
|
| 211 |
/** |
| 212 |
* Hook a deferred send runs on. |
| 213 |
* |
| 214 |
* Only used where fastcgi_finish_request() / litespeed_finish_request() are unavailable and the |
| 215 |
* current request is user-facing — see do_request() for why. Product-specific, like the |
| 216 |
* heartbeat hook, so two POSIMYTH plugins on one site never consume each other's events. |
| 217 |
* |
| 218 |
* @return string |
| 219 |
*/ |
| 220 |
protected static function async_hook(): string { |
| 221 |
return 'posimyth_track_' . static::id(); |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* Runs an event that do_request() queued rather than sending inline. |
| 226 |
* |
| 227 |
* Public because WordPress calls it through the cron hook. It re-enters do_request(), which is |
| 228 |
* safe: by then this is a cron or CLI request, so the "nobody is waiting" branch sends it |
| 229 |
* properly instead of queueing it a second time. |
| 230 |
* |
| 231 |
* @param string $event Event name. |
| 232 |
* @param array $extra Extra payload fields. |
| 233 |
* @param bool $bypass_consent Whether the original call bypassed the consent check. |
| 234 |
* @return void |
| 235 |
*/ |
| 236 |
public static function run_async_request( $event = 'heartbeat', $extra = array(), $bypass_consent = false ): void { |
| 237 |
static::do_request( (string) $event, is_array( $extra ) ? $extra : array(), (bool) $bypass_consent ); |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* Stops the weekly heartbeat. Call from the plugin's deactivation hook. |
| 242 |
* |
| 243 |
* Init() schedules this cron but nothing ever cleared it, so a deactivated plugin left a weekly |
| 244 |
* event behind in WordPress forever — firing against a hook with no listener, and reappearing in |
| 245 |
* every cron listing. |
| 246 |
* |
| 247 |
* @return void |
| 248 |
*/ |
| 249 |
public static function unschedule(): void { |
| 250 |
wp_clear_scheduled_hook( static::cron_hook() ); |
| 251 |
|
| 252 |
/* |
| 253 |
* Also drop any event do_request() queued but that has not run yet. |
| 254 |
* |
| 255 |
* These are one-off events carrying their own arguments; wp_clear_scheduled_hook() clears |
| 256 |
* every argument set for the hook, which is what is wanted. Without this, deactivating left |
| 257 |
* a pending `posimyth_track_*` event behind — firing against a hook with no listener once |
| 258 |
* the plugin is gone, and, worse, reporting a stale activation or heartbeat if the plugin |
| 259 |
* were reactivated before it fired. |
| 260 |
*/ |
| 261 |
wp_clear_scheduled_hook( static::async_hook() ); |
| 262 |
} |
| 263 |
|
| 264 |
/** |
| 265 |
* Removes everything this product stored. Call from uninstall, never from deactivation. |
| 266 |
* |
| 267 |
* Consent used to survive uninstalling: the opt-in option stayed in wp_options, so reinstalling |
| 268 |
* silently resumed sending without ever asking again. Someone who removes the plugin has |
| 269 |
* withdrawn from the arrangement, and reinstalling has to start from a clean slate. |
| 270 |
* |
| 271 |
* @param bool $include_suite_wide Also delete the options shared across the Nexter family. |
| 272 |
* Pass false while a sibling product is still installed, |
| 273 |
* otherwise removing one plugin silently resets the other's |
| 274 |
* consent and re-prompts a user who already decided. |
| 275 |
* @param string $suite_key Suite key those shared options are named after. |
| 276 |
* @return void |
| 277 |
*/ |
| 278 |
public static function purge_state( bool $include_suite_wide = false, string $suite_key = 'nexter_suite' ): void { |
| 279 |
static::unschedule(); |
| 280 |
|
| 281 |
$id = static::id(); |
| 282 |
delete_option( 'posimyth_' . $id . '_install_time' ); |
| 283 |
delete_option( 'posimyth_' . $id . '_usage' ); |
| 284 |
delete_option( 'posimyth_' . $id . '_first_use_at' ); |
| 285 |
delete_option( 'posimyth_' . $id . '_activate_reported' ); |
| 286 |
delete_transient( 'posimyth_' . $id . '_deact_reported' ); |
| 287 |
|
| 288 |
if ( $include_suite_wide ) { |
| 289 |
// These are stored as SITE options (A6: one consent per install, not per blog), so they |
| 290 |
// have to be removed as site options too — delete_option alone would leave the network |
| 291 |
// value behind and a reinstall would silently resume with the old answer. |
| 292 |
delete_site_option( static::opt_in_option() ); |
| 293 |
delete_site_option( 'posi_consent_dismissed_' . $suite_key ); |
| 294 |
delete_site_option( 'posi_consent_snoozed_until_' . $suite_key ); |
| 295 |
// Start of the notice's post-install quiet period. Left behind, a reinstall would inherit |
| 296 |
// an expired window and be asked on the first admin page load instead of getting the |
| 297 |
// couple of quiet days a fresh install is meant to get. |
| 298 |
delete_site_option( 'posi_consent_grace_start_' . $suite_key ); |
| 299 |
|
| 300 |
/* |
| 301 |
* These two belong to the Nexter suite specifically, not to whichever suite is being |
| 302 |
* purged, and their names are fixed rather than derived from $suite_key. |
| 303 |
* |
| 304 |
* That was harmless while `nexter_suite` was the only suite in existence. It stopped being |
| 305 |
* harmless when The Plus Addons for Elementor moved to its own `tpae_suite`: deleting TPAE |
| 306 |
* then reached across and wiped Nexter's onboarding-completed flag and its legacy-consent |
| 307 |
* migration marker, so Nexter's setup wizard could reopen and the migration could run a |
| 308 |
* second time — on a site where Nexter had not been touched at all. |
| 309 |
*/ |
| 310 |
if ( 'nexter_suite' === $suite_key ) { |
| 311 |
delete_site_option( 'posimyth_ne_legacy_consent_migrated' ); |
| 312 |
delete_option( 'nxt_onboarding_done' ); |
| 313 |
} |
| 314 |
|
| 315 |
// Older builds wrote these per-blog; clear that shape too so nothing lingers. |
| 316 |
delete_option( static::opt_in_option() ); |
| 317 |
delete_option( 'posi_consent_dismissed_' . $suite_key ); |
| 318 |
delete_option( 'posi_consent_snoozed_until_' . $suite_key ); |
| 319 |
delete_option( 'posi_consent_grace_start_' . $suite_key ); |
| 320 |
} |
| 321 |
} |
| 322 |
|
| 323 |
/** |
| 324 |
* Is this the plugin file belonging to THIS product? |
| 325 |
* |
| 326 |
* Using strpos( $plugin, slug() ) was wrong: `nexter-extension` is a substring of |
| 327 |
* `nexter-extension-pro/...` and of `my-nexter-extension/...`, so activating or deactivating a |
| 328 |
* differently-named plugin reported an event against this one. Compare the folder exactly. |
| 329 |
* |
| 330 |
* @param string $plugin Plugin file as WordPress passes it, e.g. "slug/slug.php". |
| 331 |
* @return bool |
| 332 |
*/ |
| 333 |
protected static function is_own_plugin_file( string $plugin ): bool { |
| 334 |
$folder = ( false !== strpos( $plugin, '/' ) ) ? dirname( $plugin ) : $plugin; |
| 335 |
return static::slug() === $folder; |
| 336 |
} |
| 337 |
|
| 338 |
/** |
| 339 |
* Reports an activation, but only for THIS product and only with consent. |
| 340 |
* |
| 341 |
* @param string $plugin Plugin file that was activated. |
| 342 |
* @param bool $network_wide Unused; part of the activated_plugin hook signature. |
| 343 |
*/ |
| 344 |
public static function on_activate( string $plugin, bool $network_wide ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- required by the hook signature. |
| 345 |
if ( ! static::is_own_plugin_file( $plugin ) ) { |
| 346 |
return; |
| 347 |
} |
| 348 |
static::record_install_time(); |
| 349 |
if ( ! static::has_consent() ) { |
| 350 |
return; |
| 351 |
} |
| 352 |
static::report_activation(); |
| 353 |
} |
| 354 |
|
| 355 |
/** |
| 356 |
* Reports a deactivation, but only for THIS product and only with consent. |
| 357 |
* |
| 358 |
* @param string $plugin Plugin file that was deactivated. |
| 359 |
* @param bool $network_wide Unused; part of the deactivated_plugin hook signature. |
| 360 |
*/ |
| 361 |
public static function on_deactivate( string $plugin, bool $network_wide ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- required by the hook signature. |
| 362 |
if ( ! static::is_own_plugin_file( $plugin ) ) { |
| 363 |
return; |
| 364 |
} |
| 365 |
delete_option( 'posimyth_' . static::id() . '_activate_reported' ); |
| 366 |
|
| 367 |
if ( ! static::has_consent() ) { |
| 368 |
return; |
| 369 |
} |
| 370 |
|
| 371 |
/* |
| 372 |
* The deactivation dialog may have just reported this same deactivation, with the reason |
| 373 |
* attached. WordPress then fires `deactivated_plugin` regardless, and this listener cannot |
| 374 |
* see what the user clicked — so every opted-in deactivation was counted twice on the hub: |
| 375 |
* once with a reason and once without, inflating churn for precisely the opted-in cohort. |
| 376 |
* The dialog leaves a short-lived marker; if it is there, the richer event has already gone. |
| 377 |
*/ |
| 378 |
if ( get_transient( 'posimyth_' . static::id() . '_deact_reported' ) ) { |
| 379 |
delete_transient( 'posimyth_' . static::id() . '_deact_reported' ); |
| 380 |
return; |
| 381 |
} |
| 382 |
|
| 383 |
static::do_request( 'deactivate' ); |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Marks this deactivation as already reported, so on_deactivate() does not send a second event. |
| 388 |
* |
| 389 |
* Called by the deactivation dialog right before it submits. Short-lived on purpose: it only |
| 390 |
* has to survive the hop from the dialog's AJAX call to the deactivation request that follows. |
| 391 |
* |
| 392 |
* @return void |
| 393 |
*/ |
| 394 |
public static function mark_deactivation_reported(): void { |
| 395 |
set_transient( 'posimyth_' . static::id() . '_deact_reported', 1, 5 * MINUTE_IN_SECONDS ); |
| 396 |
} |
| 397 |
|
| 398 |
/** |
| 399 |
* Sends `activate` at most once per active period. |
| 400 |
* |
| 401 |
* Four different places legitimately want to announce an activation — the notice's Allow, the |
| 402 |
* Dashboard toggle, onboarding, and the plugin's own activation hook — and each called |
| 403 |
* do_request('activate') directly, so one install could report several activations and the |
| 404 |
* hub's activation count drifted away from the install count. The flag is cleared on |
| 405 |
* deactivation, so a genuine reactivation is still counted. |
| 406 |
* |
| 407 |
* @return void |
| 408 |
*/ |
| 409 |
protected static function report_activation(): void { |
| 410 |
$key = 'posimyth_' . static::id() . '_activate_reported'; |
| 411 |
if ( get_option( $key ) ) { |
| 412 |
return; |
| 413 |
} |
| 414 |
update_option( $key, 1, false ); |
| 415 |
static::do_request( 'activate' ); |
| 416 |
} |
| 417 |
|
| 418 |
/** |
| 419 |
* Weekly background check-in, so long-lived installs keep reporting current state. |
| 420 |
*/ |
| 421 |
public static function heartbeat(): void { |
| 422 |
if ( ! static::has_consent() ) { |
| 423 |
return; |
| 424 |
} |
| 425 |
static::do_request( 'heartbeat' ); |
| 426 |
} |
| 427 |
|
| 428 |
/** |
| 429 |
* Activation ping for the plugin's OWN activation request. |
| 430 |
* |
| 431 |
* The `activated_plugin` hook in init() cannot help here: during a plugin's own activation |
| 432 |
* request WordPress has already fired `plugins_loaded` before it includes the plugin file, so |
| 433 |
* init() never runs and nothing is listening when `activated_plugin` fires. The result was |
| 434 |
* that activations were never recorded, while deactivations (where the plugin IS already |
| 435 |
* loaded) worked fine. Call this from register_activation_hook() instead. |
| 436 |
* |
| 437 |
* Consent-gated. |
| 438 |
*/ |
| 439 |
public static function on_self_activate(): void { |
| 440 |
static::record_install_time(); |
| 441 |
if ( ! static::has_consent() ) { |
| 442 |
return; |
| 443 |
} |
| 444 |
static::report_activation(); |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Called by the consent notice and the Dashboard toggle immediately after the user opts in. |
| 449 |
* |
| 450 |
* Both callers write the consent option before calling, so this guard is normally satisfied — |
| 451 |
* it is here so the function is safe on its own terms rather than relying on every present and |
| 452 |
* future caller remembering to set consent first. |
| 453 |
*/ |
| 454 |
public static function send_first_ping(): void { |
| 455 |
static::record_install_time(); |
| 456 |
if ( ! static::has_consent() ) { |
| 457 |
return; |
| 458 |
} |
| 459 |
static::report_activation(); |
| 460 |
} |
| 461 |
|
| 462 |
/** |
| 463 |
* Whether the user has opted in to sharing. Nothing is ever sent without this. |
| 464 |
* |
| 465 |
* Read as a SITE option (A6): on multisite the consent is one answer for the whole network — |
| 466 |
* per-blog options meant N notices and N independent consents for a single install, and no |
| 467 |
* way for the network admin to answer once. On a single site, get_site_option() falls back |
| 468 |
* to the ordinary option of the same name, so nothing changes there. |
| 469 |
* |
| 470 |
* @return bool |
| 471 |
*/ |
| 472 |
protected static function has_consent(): bool { |
| 473 |
return (bool) get_site_option( static::opt_in_option(), false ); |
| 474 |
} |
| 475 |
|
| 476 |
/** |
| 477 |
* Stores the first-seen timestamp once, so install age can be reported. |
| 478 |
*/ |
| 479 |
protected static function record_install_time(): void { |
| 480 |
$key = 'posimyth_' . static::id() . '_install_time'; |
| 481 |
if ( ! get_option( $key ) ) { |
| 482 |
add_option( $key, gmdate( 'Y-m-d H:i:s' ), '', false ); |
| 483 |
} |
| 484 |
} |
| 485 |
|
| 486 |
// Payload. |
| 487 |
|
| 488 |
/** |
| 489 |
* Assembles the non-sensitive payload for an event. Contains no personal data. |
| 490 |
* |
| 491 |
* @param string $event One of activate|deactivate|heartbeat. |
| 492 |
* @return array |
| 493 |
*/ |
| 494 |
public static function build_payload( string $event ): array { |
| 495 |
global $wpdb; |
| 496 |
|
| 497 |
$theme = wp_get_theme(); |
| 498 |
$parent = $theme->parent(); |
| 499 |
|
| 500 |
// Network-activated plugins live in the sitewide option, not in the per-blog list (B3): |
| 501 |
// on multisite, reading only active_plugins missed every network-activated plugin — this |
| 502 |
// SDK's own products included — so plugin_count, page_builder and posimyth_products were |
| 503 |
// all wrong there. Single site: get_site_option returns the default, merge is a no-op. |
| 504 |
$active_slugs = (array) get_option( 'active_plugins', array() ); |
| 505 |
if ( is_multisite() ) { |
| 506 |
$network = (array) get_site_option( 'active_sitewide_plugins', array() ); |
| 507 |
$active_slugs = array_values( array_unique( array_merge( $active_slugs, array_keys( $network ) ) ) ); |
| 508 |
} |
| 509 |
|
| 510 |
$license = static::license(); |
| 511 |
|
| 512 |
/* |
| 513 |
* Persist the first-seen date instead of substituting "now". |
| 514 |
* |
| 515 |
* When the option was missing — legacy installs that predate it, and subsites, where |
| 516 |
* record_install_time() never ran — this reported today's date and then threw it away, so |
| 517 |
* every weekly heartbeat claimed the site was installed today and install age was |
| 518 |
* permanently zero. Write it once, then keep reporting that same value. |
| 519 |
*/ |
| 520 |
$install_time = (string) get_option( 'posimyth_' . static::id() . '_install_time', '' ); |
| 521 |
if ( '' === $install_time ) { |
| 522 |
static::record_install_time(); |
| 523 |
$install_time = (string) get_option( 'posimyth_' . static::id() . '_install_time', gmdate( 'Y-m-d H:i:s' ) ); |
| 524 |
} |
| 525 |
|
| 526 |
return array( |
| 527 |
'plugin_slug' => static::slug(), |
| 528 |
'site_url' => home_url(), |
| 529 |
'event' => $event, |
| 530 |
|
| 531 |
// Server & environment. |
| 532 |
'php_version' => phpversion(), |
| 533 |
// Server version string; no WP API exposes it and caching a constant makes no sense. |
| 534 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 535 |
'mysql_version' => $wpdb->get_var( 'SELECT VERSION()' ), |
| 536 |
'server_software' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '', |
| 537 |
'timezone' => wp_timezone_string(), |
| 538 |
'debug_mode' => (int) ( defined( 'WP_DEBUG' ) && WP_DEBUG ), |
| 539 |
'max_upload_size' => size_format( wp_max_upload_size() ), |
| 540 |
'memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : '', |
| 541 |
'permalink' => static::permalink_label(), |
| 542 |
'is_ssl' => (int) is_ssl(), |
| 543 |
'is_local' => static::is_local_site(), |
| 544 |
'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production', |
| 545 |
|
| 546 |
// WordPress. |
| 547 |
'wp_version' => get_bloginfo( 'version' ), |
| 548 |
'locale' => get_locale(), |
| 549 |
'is_multisite' => (int) is_multisite(), |
| 550 |
'user_count' => static::user_count(), |
| 551 |
|
| 552 |
// Theme. |
| 553 |
'theme' => $theme->get_stylesheet(), |
| 554 |
'theme_version' => (string) $theme->get( 'Version' ), |
| 555 |
'parent_theme' => $parent ? $parent->get_stylesheet() : '', |
| 556 |
|
| 557 |
// Plugins / ecosystem. |
| 558 |
'page_builder' => static::detect_page_builder( $active_slugs ), |
| 559 |
'theme_builder' => static::detect_theme_builder( $active_slugs ), |
| 560 |
'active_plugins' => array_values( $active_slugs ), |
| 561 |
'plugin_count' => count( $active_slugs ), |
| 562 |
'posimyth_products' => static::detect_posimyth_products( $active_slugs ), |
| 563 |
|
| 564 |
// Identity (Astra-style: admin email for Free + Pro, sent only under |
| 565 |
// consent). Raw email is PII — the opt-in notice + readme must disclose it. |
| 566 |
// NOTE: no admin email / no PII is sent — the consent copy promises "no personal data, |
| 567 |
// ever", so the raw admin_email older builds included is intentionally omitted. Do not |
| 568 |
// re-add email or any personal identifier here without changing that copy + the docs. |
| 569 |
|
| 570 |
// This product. |
| 571 |
'plugin_version' => static::version(), |
| 572 |
'is_pro' => (int) static::is_pro(), |
| 573 |
'license_status' => (string) ( $license['status'] ?? '' ), |
| 574 |
'license_plan' => (string) ( $license['plan'] ?? '' ), |
| 575 |
'onboarding' => static::onboarding_status(), |
| 576 |
'install_time' => $install_time, |
| 577 |
|
| 578 |
/* |
| 579 |
* Feature adoption. Cast to objects so the JSON TYPE of each of these three keys is the |
| 580 |
* same on every site. |
| 581 |
* |
| 582 |
* These are string-keyed maps, and wp_json_encode() renders an EMPTY PHP array as `[]` |
| 583 |
* while a populated one becomes `{...}`. So a site with widgets installed but none |
| 584 |
* placed in content sent `"enabled_widgets":{…}` alongside `"widget_usage":[]` — two |
| 585 |
* different JSON types for one key, on one payload, for keys the hub joins against each |
| 586 |
* other. plugin_meta has the same problem: the base returns array() and a product that |
| 587 |
* does not override it sent `[]` where every other product sent an object. |
| 588 |
* |
| 589 |
* Casting an associative array to an object is a no-op for the populated case and turns |
| 590 |
* the empty case into `{}`, so the hub sees one shape per key regardless of the site. |
| 591 |
*/ |
| 592 |
'enabled_widgets' => (object) static::enabled_features(), |
| 593 |
'widget_usage' => (object) static::used_features_cached( $event ), |
| 594 |
'plugin_meta' => (object) static::plugin_meta(), |
| 595 |
); |
| 596 |
} |
| 597 |
|
| 598 |
/** |
| 599 |
* Product-specific settings that do not deserve a column of their own. |
| 600 |
* |
| 601 |
* The hub has accepted a `plugin_meta` object since 1.5 (nested up to 8 levels, capped at 64 KB) |
| 602 |
* and nothing has ever filled it. This is the place for the handful of settings that matter for |
| 603 |
* one product only — an asset-loading mode, a compatibility toggle — without adding a shared |
| 604 |
* column that stays NULL for every other product and without smuggling them into |
| 605 |
* enabled_widgets, which is joined against widget_usage and must stay a widget map. |
| 606 |
* |
| 607 |
* Keep it small and keep it non-personal: the same rules as the rest of the payload. Report the |
| 608 |
* stored value rather than the label shown in the UI, so a wording change in the dashboard does |
| 609 |
* not split one setting into two series on the hub. |
| 610 |
* |
| 611 |
* @return array<string,mixed> |
| 612 |
*/ |
| 613 |
protected static function plugin_meta(): array { |
| 614 |
return array(); |
| 615 |
} |
| 616 |
|
| 617 |
/** |
| 618 |
* Heavy content scans run only on the weekly background heartbeat and are |
| 619 |
* cached. Activate / deactivate (user-facing actions) read the cache so the |
| 620 |
* admin never waits on a scan. |
| 621 |
* |
| 622 |
* @param string $event Current event; only the heartbeat refreshes the cache. |
| 623 |
* @return array<string,int> |
| 624 |
*/ |
| 625 |
protected static function used_features_cached( string $event ): array { |
| 626 |
$key = 'posimyth_' . static::id() . '_usage'; |
| 627 |
|
| 628 |
if ( 'heartbeat' === $event ) { |
| 629 |
$usage = static::used_features(); |
| 630 |
update_option( $key, $usage, false ); |
| 631 |
return $usage; |
| 632 |
} |
| 633 |
|
| 634 |
return (array) get_option( $key, array() ); |
| 635 |
} |
| 636 |
|
| 637 |
// Environment helpers. |
| 638 |
|
| 639 |
/** |
| 640 |
* Permalink structure as a short label rather than the raw pattern. |
| 641 |
* |
| 642 |
* @return string |
| 643 |
*/ |
| 644 |
protected static function permalink_label(): string { |
| 645 |
$structure = get_option( 'permalink_structure', '' ); |
| 646 |
if ( '' === $structure ) { |
| 647 |
return 'plain'; |
| 648 |
} |
| 649 |
if ( '/%postname%/' === $structure ) { |
| 650 |
return 'post-name'; |
| 651 |
} |
| 652 |
if ( false !== strpos( $structure, '%postname%' ) ) { |
| 653 |
return 'custom-postname'; |
| 654 |
} |
| 655 |
if ( false !== strpos( $structure, '%year%' ) || false !== strpos( $structure, '%post_id%' ) ) { |
| 656 |
return 'date-or-numeric'; |
| 657 |
} |
| 658 |
return 'custom'; |
| 659 |
} |
| 660 |
|
| 661 |
/** |
| 662 |
* Whether this looks like a local/dev site, so hub stats can exclude it. |
| 663 |
* |
| 664 |
* @return int |
| 665 |
*/ |
| 666 |
protected static function is_local_site(): int { |
| 667 |
if ( function_exists( 'wp_get_environment_type' ) ) { |
| 668 |
$env = wp_get_environment_type(); |
| 669 |
if ( in_array( $env, array( 'local', 'development' ), true ) ) { |
| 670 |
return 1; |
| 671 |
} |
| 672 |
} |
| 673 |
|
| 674 |
$host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); |
| 675 |
if ( '' === $host ) { |
| 676 |
return 0; |
| 677 |
} |
| 678 |
$host = strtolower( $host ); |
| 679 |
|
| 680 |
$exact = array( 'localhost', '127.0.0.1', '::1' ); |
| 681 |
if ( in_array( $host, $exact, true ) ) { |
| 682 |
return 1; |
| 683 |
} |
| 684 |
|
| 685 |
// NOT '.dev': it has been a real, publicly registered gTLD since 2019 (with HSTS preload, |
| 686 |
// so these are HTTPS production sites) — treating it as local silently excluded them (E5). |
| 687 |
$suffixes = array( '.local', '.test', '.localhost', '.example', '.invalid' ); |
| 688 |
foreach ( $suffixes as $suffix ) { |
| 689 |
if ( substr( $host, -strlen( $suffix ) ) === $suffix ) { |
| 690 |
return 1; |
| 691 |
} |
| 692 |
} |
| 693 |
return 0; |
| 694 |
} |
| 695 |
|
| 696 |
/** |
| 697 |
* Total registered users — a size signal only, no user data. |
| 698 |
* |
| 699 |
* @return int |
| 700 |
*/ |
| 701 |
protected static function user_count(): int { |
| 702 |
/* |
| 703 |
* count_users() is not cached by WordPress and walks the whole user table, which is |
| 704 |
* genuinely slow on a membership site with hundreds of thousands of users. This value only |
| 705 |
* has to be roughly right — it is a size band, not an exact figure — so a day of cache |
| 706 |
* costs nothing and stops a weekly ping from turning into a table scan. |
| 707 |
*/ |
| 708 |
$cache_key = 'posimyth_' . static::id() . '_user_count'; |
| 709 |
$cached = get_transient( $cache_key ); |
| 710 |
if ( false !== $cached ) { |
| 711 |
return (int) $cached; |
| 712 |
} |
| 713 |
|
| 714 |
$counts = count_users(); |
| 715 |
$total = isset( $counts['total_users'] ) ? (int) $counts['total_users'] : 0; |
| 716 |
set_transient( $cache_key, $total, DAY_IN_SECONDS ); |
| 717 |
|
| 718 |
return $total; |
| 719 |
} |
| 720 |
|
| 721 |
/** |
| 722 |
* Which sibling POSIMYTH products are active alongside this one. |
| 723 |
* |
| 724 |
* @param array $plugins Active plugin files. |
| 725 |
* @return array<int,string> |
| 726 |
*/ |
| 727 |
protected static function detect_posimyth_products( array $plugins ): array { |
| 728 |
$known = array( |
| 729 |
'nexter-extension' => 'nexter-extension', |
| 730 |
'the-plus-addons-for-elementor-page-builder' => 'the-plus-addons-for-elementor', |
| 731 |
'the-plus-addons-for-block-editor' => 'the-plus-addons-for-block-editor', |
| 732 |
'nexter' => 'nexter-theme', |
| 733 |
'uichemy' => 'uichemy', |
| 734 |
'wdesignkit' => 'wdesignkit', |
| 735 |
// Sticky Header Effects for Elementor. Added to SHE's own copy in SDK 2.7.0 and merged |
| 736 |
// back here in 2.9.0 — while it was only in SHE's copy, any site where an older sibling |
| 737 |
// won the loader reported no SHE at all. |
| 738 |
'sticky-header-effects-for-elementor' => 'sticky-header-effects', |
| 739 |
); |
| 740 |
|
| 741 |
$found = array(); |
| 742 |
foreach ( $plugins as $file ) { |
| 743 |
$dir = explode( '/', $file )[0]; |
| 744 |
if ( isset( $known[ $dir ] ) ) { |
| 745 |
$found[ $known[ $dir ] ] = true; |
| 746 |
} |
| 747 |
} |
| 748 |
return array_keys( $found ); |
| 749 |
} |
| 750 |
|
| 751 |
/** |
| 752 |
* Page builder in use, so conflicts can be reproduced on the right stack. |
| 753 |
* |
| 754 |
* @param array $plugins Active plugin files. |
| 755 |
* @return string |
| 756 |
*/ |
| 757 |
protected static function detect_page_builder( array $plugins ): string { |
| 758 |
$known = array( |
| 759 |
'elementor' => 'elementor', |
| 760 |
'beaver-builder-plugin' => 'beaver-builder', |
| 761 |
'brizy' => 'brizy', |
| 762 |
'bricks' => 'bricks', |
| 763 |
'divi-builder' => 'divi', |
| 764 |
'js_composer' => 'wpbakery', |
| 765 |
'oxygen' => 'oxygen', |
| 766 |
'siteorigin-panels' => 'siteorigin', |
| 767 |
); |
| 768 |
|
| 769 |
foreach ( $plugins as $file ) { |
| 770 |
$dir = explode( '/', $file )[0]; |
| 771 |
if ( isset( $known[ $dir ] ) ) { |
| 772 |
return $known[ $dir ]; |
| 773 |
} |
| 774 |
} |
| 775 |
// Bricks ships as a theme, not a plugin. |
| 776 |
if ( 'bricks' === strtolower( (string) wp_get_theme()->get_template() ) ) { |
| 777 |
return 'bricks'; |
| 778 |
} |
| 779 |
return 'gutenberg'; |
| 780 |
} |
| 781 |
|
| 782 |
/** |
| 783 |
* Which theme builder the site actually BUILDS with. |
| 784 |
* |
| 785 |
* Deliberately separate from detect_page_builder() and from active_plugins. Those answer "what |
| 786 |
* is installed"; this answers "what did they build their header, footer and archive templates |
| 787 |
* with", which is the question you cannot get from a plugin list — Elementor Pro being active |
| 788 |
* says nothing about whether its Theme Builder was ever opened. |
| 789 |
* |
| 790 |
* Each candidate is proved by PUBLISHED template content, not by the plugin being present. The |
| 791 |
* first match wins and the rest are skipped, so a typical site costs one or two cached counts. |
| 792 |
* Order is intentional: our own builders first (a Nexter site that also has Elementor Pro is a |
| 793 |
* Nexter theme-builder site), then the common third parties. |
| 794 |
* |
| 795 |
* Returns a single slug so the hub can chart a distribution the same way it does page_builder. |
| 796 |
* A site using two builders reports the higher-priority one; co-presence is still visible in |
| 797 |
* active_plugins if it ever needs cross-checking. |
| 798 |
* |
| 799 |
* @param array $plugins Active plugin files, used to decide who owns Elementor theme templates. |
| 800 |
* @return string Slug, or 'none' when no template content exists anywhere. |
| 801 |
*/ |
| 802 |
protected static function detect_theme_builder( array $plugins = array() ): string { |
| 803 |
/** |
| 804 |
* Theme builders to test, in the order they should win. |
| 805 |
* |
| 806 |
* Each value is the post type whose published entries prove that builder is in use, except |
| 807 |
* `elementor-pro`, whose marker is the literal 'elementor-pro' — its library is shared with |
| 808 |
* page and section templates, so it needs the template-type check below rather than a count. |
| 809 |
* |
| 810 |
* @param array<string,string> $map slug => post type, or slug => 'elementor-pro'. |
| 811 |
*/ |
| 812 |
$map = apply_filters( |
| 813 |
'posimyth_theme_builder_post_types', |
| 814 |
array( |
| 815 |
// Nexter Extension's Theme Builder. Nexter Blocks has NO theme builder of its own — |
| 816 |
// it registers no post type at all and consumes this same `nxt_builder` CPT, which is |
| 817 |
// why there is no 'nexter-blocks' entry here. An earlier revision listed one against |
| 818 |
// `tpgb_template`; nothing ever registers that post type, so the entry could only |
| 819 |
// ever be skipped. A Blocks-only site therefore falls through to whatever it really |
| 820 |
// uses, or to 'none'. |
| 821 |
'nexter' => 'nxt_builder', |
| 822 |
|
| 823 |
/* |
| 824 |
* Third-party builders below. A wrong post-type name here does not break anything — |
| 825 |
* post_type_exists() simply never matches — but it does under-report that competitor |
| 826 |
* for as long as it is wrong, which is worse than leaving it out, because the data |
| 827 |
* looks complete. Confirm each against a real install before trusting its share; the |
| 828 |
* `posimyth_theme_builder_post_types` filter is the no-deploy way to correct one. |
| 829 |
*/ |
| 830 |
|
| 831 |
// Elementor ecosystem. |
| 832 |
'elementor-pro' => 'elementor-pro', |
| 833 |
'jet-theme-core' => 'jet-theme-core', |
| 834 |
|
| 835 |
// Other page builders with a theme-building layer. |
| 836 |
'beaver-themer' => 'fl-theme-layout', |
| 837 |
'divi' => 'et_theme_builder', |
| 838 |
'oxygen' => 'ct_template', |
| 839 |
'bricks' => 'bricks_template', |
| 840 |
'breakdance' => 'breakdance_template', |
| 841 |
'zion' => 'zion_template', |
| 842 |
'avada' => 'fusion_tb_section', |
| 843 |
'themify' => 'tbuilder_layout', |
| 844 |
'toolset' => 'dd_layouts', |
| 845 |
|
| 846 |
// Theme-supplied builders, mostly block based. |
| 847 |
'generatepress' => 'gp_elements', |
| 848 |
'astra' => 'astra-advanced-hook', |
| 849 |
'blocksy' => 'ct_content_block', |
| 850 |
'kadence' => 'kadence_element', |
| 851 |
'neve' => 'neve_custom_layouts', |
| 852 |
|
| 853 |
/* |
| 854 |
* WordPress's own Site Editor, last because it is the least specific answer: a site |
| 855 |
* running a dedicated theme builder usually has customised block templates as well, |
| 856 |
* and the dedicated tool is the more useful attribution. |
| 857 |
* |
| 858 |
* Note what this actually measures. A block theme's templates live as FILES until the |
| 859 |
* user edits one, at which point WordPress saves a `wp_template` post. So a published |
| 860 |
* row here means they opened the Site Editor and changed something — which is exactly |
| 861 |
* the "in use" signal wanted, not merely "runs a block theme". |
| 862 |
*/ |
| 863 |
'wp-site-editor' => 'wp_template', |
| 864 |
) |
| 865 |
); |
| 866 |
|
| 867 |
$dirs = array(); |
| 868 |
foreach ( $plugins as $plugin_file ) { |
| 869 |
$dirs[ explode( '/', (string) $plugin_file )[0] ] = true; |
| 870 |
} |
| 871 |
|
| 872 |
foreach ( $map as $slug => $post_type ) { |
| 873 |
if ( 'elementor-pro' === $post_type ) { |
| 874 |
if ( ! static::elementor_theme_builder_in_use() ) { |
| 875 |
continue; |
| 876 |
} |
| 877 |
|
| 878 |
/* |
| 879 |
* Theme templates exist in elementor_library — but that post type is shared, so |
| 880 |
* finding them there does not say who built them. The Plus Addons registers its own |
| 881 |
* theme locations through `elementor/theme/register_locations` and stores its |
| 882 |
* templates in exactly the same place, so attributing them to Elementor Pro was wrong |
| 883 |
* whenever Pro was not even installed. Credit whichever plugin present can actually |
| 884 |
* serve them, checking Pro first because it owns the feature when both are there. |
| 885 |
*/ |
| 886 |
if ( isset( $dirs['elementor-pro'] ) ) { |
| 887 |
return 'elementor-pro'; |
| 888 |
} |
| 889 |
if ( isset( $dirs['the-plus-addons-for-elementor-page-builder'] ) || isset( $dirs['theplus_elementor_addon'] ) ) { |
| 890 |
return 'tpae'; |
| 891 |
} |
| 892 |
|
| 893 |
// Templates are there and nothing active can render them — most likely built by a |
| 894 |
// plugin since removed. Say that, rather than crediting a plugin that is absent. |
| 895 |
return 'elementor-library-orphaned'; |
| 896 |
} |
| 897 |
|
| 898 |
if ( ! post_type_exists( $post_type ) ) { |
| 899 |
continue; |
| 900 |
} |
| 901 |
|
| 902 |
$counts = wp_count_posts( $post_type ); |
| 903 |
if ( isset( $counts->publish ) && (int) $counts->publish > 0 ) { |
| 904 |
return $slug; |
| 905 |
} |
| 906 |
} |
| 907 |
|
| 908 |
return 'none'; |
| 909 |
} |
| 910 |
|
| 911 |
/** |
| 912 |
* Whether Elementor Pro's Theme Builder has published templates. |
| 913 |
* |
| 914 |
* `elementor_library` is shared by page, section and container templates, none of which are |
| 915 |
* theme building, so the template type has to be checked. One prepared query rather than a |
| 916 |
* WP_Query with a meta clause, because this runs while a payload is being assembled. |
| 917 |
* |
| 918 |
* @return bool |
| 919 |
*/ |
| 920 |
protected static function elementor_theme_builder_in_use(): bool { |
| 921 |
if ( ! post_type_exists( 'elementor_library' ) ) { |
| 922 |
return false; |
| 923 |
} |
| 924 |
|
| 925 |
global $wpdb; |
| 926 |
|
| 927 |
$types = array( 'header', 'footer', 'single', 'single-post', 'single-page', 'archive', 'search-results', 'error-404' ); |
| 928 |
|
| 929 |
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one bounded existence check, no cache layer available for it. |
| 930 |
$found = $wpdb->get_var( |
| 931 |
$wpdb->prepare( |
| 932 |
"SELECT p.ID FROM {$wpdb->posts} p |
| 933 |
INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID |
| 934 |
WHERE p.post_type = 'elementor_library' |
| 935 |
AND p.post_status = 'publish' |
| 936 |
AND m.meta_key = '_elementor_template_type' |
| 937 |
AND m.meta_value IN (" . implode( ', ', array_fill( 0, count( $types ), '%s' ) ) . ') |
| 938 |
LIMIT 1', |
| 939 |
$types |
| 940 |
) |
| 941 |
); |
| 942 |
// phpcs:enable |
| 943 |
|
| 944 |
return ! empty( $found ); |
| 945 |
} |
| 946 |
|
| 947 |
// Content scanners (reused by TPAE / TPGB subclasses). |
| 948 |
// |
| 949 |
// Bounded + batched: capped at `posimyth_scan_post_cap` rows (default |
| 950 |
// 2000) and run only inside the weekly cron, so large sites stay safe. |
| 951 |
|
| 952 |
/** |
| 953 |
* Count Elementor widget usage by widgetType prefix (e.g. 'tp-'). |
| 954 |
* |
| 955 |
* @param string $prefix Widget name prefix to match, e.g. 'tp-'. |
| 956 |
* @return array<string,int> |
| 957 |
*/ |
| 958 |
protected static function scan_elementor_widgets( string $prefix ): array { |
| 959 |
global $wpdb; |
| 960 |
|
| 961 |
$counts = array(); |
| 962 |
$cap = (int) apply_filters( 'posimyth_scan_post_cap', 2000 ); |
| 963 |
$batch = 100; |
| 964 |
$last_id = 0; |
| 965 |
$scanned = 0; |
| 966 |
$like = '%"widgetType":"' . $wpdb->esc_like( $prefix ) . '%'; |
| 967 |
$regex = '/"widgetType":"(' . preg_quote( $prefix, '/' ) . '[a-z0-9_-]+)"/'; |
| 968 |
|
| 969 |
while ( $scanned < $cap ) { |
| 970 |
// Clamp the batch to what the cap still allows, so `posimyth_scan_post_cap` is an exact |
| 971 |
// bound rather than a between-batches check that could overshoot by a whole batch. |
| 972 |
$take = min( $batch, $cap - $scanned ); |
| 973 |
|
| 974 |
// Batched scan of Elementor's own meta blob; no core API can query inside it. Runs only |
| 975 |
// on the weekly cron and the result is cached in an option, so per-query caching would |
| 976 |
// add a second cache layer for a job that already runs at most once a week. |
| 977 |
// |
| 978 |
// Keyset pagination (meta_id > last, ORDER BY meta_id), not LIMIT/OFFSET (B5): with no |
| 979 |
// ORDER BY the batches had no defined order at all, so rows could repeat or be skipped |
| 980 |
// between them; and the batch is 100 rows because these are longtext blobs — 200 of |
| 981 |
// them at once was a real memory spike on page-heavy sites. |
| 982 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 983 |
$rows = $wpdb->get_results( |
| 984 |
$wpdb->prepare( |
| 985 |
"SELECT meta_id, meta_value FROM {$wpdb->postmeta} |
| 986 |
WHERE meta_key = '_elementor_data' AND meta_value LIKE %s AND meta_id > %d |
| 987 |
ORDER BY meta_id ASC LIMIT %d", |
| 988 |
$like, |
| 989 |
$last_id, |
| 990 |
$take |
| 991 |
), |
| 992 |
ARRAY_A |
| 993 |
); |
| 994 |
|
| 995 |
if ( empty( $rows ) ) { |
| 996 |
break; |
| 997 |
} |
| 998 |
|
| 999 |
foreach ( $rows as $row ) { |
| 1000 |
$last_id = (int) $row['meta_id']; |
| 1001 |
if ( preg_match_all( $regex, $row['meta_value'], $m ) ) { |
| 1002 |
foreach ( $m[1] as $widget ) { |
| 1003 |
$counts[ $widget ] = ( $counts[ $widget ] ?? 0 ) + 1; |
| 1004 |
} |
| 1005 |
} |
| 1006 |
} |
| 1007 |
|
| 1008 |
$scanned += count( $rows ); |
| 1009 |
if ( count( $rows ) < $take ) { |
| 1010 |
break; |
| 1011 |
} |
| 1012 |
} |
| 1013 |
|
| 1014 |
return $counts; |
| 1015 |
} |
| 1016 |
|
| 1017 |
/** |
| 1018 |
* Count Gutenberg block usage by namespace (e.g. 'tpgb/'). |
| 1019 |
* |
| 1020 |
* @param string $block_namespace Block namespace with trailing slash, e.g. 'tpgb/'. |
| 1021 |
* @return array<string,int> |
| 1022 |
*/ |
| 1023 |
protected static function scan_gutenberg_blocks( string $block_namespace ): array { |
| 1024 |
global $wpdb; |
| 1025 |
|
| 1026 |
$counts = array(); |
| 1027 |
$cap = (int) apply_filters( 'posimyth_scan_post_cap', 2000 ); |
| 1028 |
$batch = 100; |
| 1029 |
$last_id = 0; |
| 1030 |
$scanned = 0; |
| 1031 |
$like = '%wp:' . $wpdb->esc_like( $block_namespace ) . '%'; |
| 1032 |
// Negative lookbehind skips the closing "/wp:tpgb/..." comment so each |
| 1033 |
// block instance is counted once (opening tag only). |
| 1034 |
$regex = '#(?<!/)wp:' . preg_quote( $block_namespace, '#' ) . '([a-z0-9-]+)#'; |
| 1035 |
|
| 1036 |
while ( $scanned < $cap ) { |
| 1037 |
// Clamp to the remaining allowance — see the note in scan_elementor_widgets(). |
| 1038 |
$take = min( $batch, $cap - $scanned ); |
| 1039 |
|
| 1040 |
// Batched LIKE over post_content; WP_Query has no equivalent and would load full post |
| 1041 |
// objects for rows we only pattern-match. Weekly cron only, result cached in an option. |
| 1042 |
// Keyset pagination and a 100-row batch for the same reasons as the Elementor scan |
| 1043 |
// above (B5): OFFSET with no ORDER BY repeats/skips rows, and these are full posts. |
| 1044 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 1045 |
$rows = $wpdb->get_results( |
| 1046 |
$wpdb->prepare( |
| 1047 |
"SELECT ID, post_content FROM {$wpdb->posts} |
| 1048 |
WHERE post_status = 'publish' AND post_content LIKE %s AND ID > %d |
| 1049 |
ORDER BY ID ASC LIMIT %d", |
| 1050 |
$like, |
| 1051 |
$last_id, |
| 1052 |
$take |
| 1053 |
), |
| 1054 |
ARRAY_A |
| 1055 |
); |
| 1056 |
|
| 1057 |
if ( empty( $rows ) ) { |
| 1058 |
break; |
| 1059 |
} |
| 1060 |
|
| 1061 |
foreach ( $rows as $row ) { |
| 1062 |
$last_id = (int) $row['ID']; |
| 1063 |
if ( preg_match_all( $regex, $row['post_content'], $m ) ) { |
| 1064 |
// Store the bare block slug (e.g. 'tp-accordion') — no namespace, |
| 1065 |
// so it survives sanitize_key on the hub and matches the enabled key. |
| 1066 |
foreach ( $m[1] as $block ) { |
| 1067 |
$counts[ $block ] = ( $counts[ $block ] ?? 0 ) + 1; |
| 1068 |
} |
| 1069 |
} |
| 1070 |
} |
| 1071 |
|
| 1072 |
$scanned += count( $rows ); |
| 1073 |
if ( count( $rows ) < $take ) { |
| 1074 |
break; |
| 1075 |
} |
| 1076 |
} |
| 1077 |
|
| 1078 |
return $counts; |
| 1079 |
} |
| 1080 |
|
| 1081 |
// Transport — never blocks a request someone is waiting on. |
| 1082 |
|
| 1083 |
/** |
| 1084 |
* Sends one event to the hub, off the critical path of whatever request called it. |
| 1085 |
* |
| 1086 |
* Three routes, chosen in the shutdown handler below by what the host can actually do: |
| 1087 |
* flush the response and then send (FastCGI / LiteSpeed); send blocking because nothing is |
| 1088 |
* waiting (cron, WP-CLI); or queue a one-off cron event because the response cannot be flushed |
| 1089 |
* and a browser IS waiting. "Fire and forget" is deliberately not one of them — see the long |
| 1090 |
* comment in the closure for why 'blocking' => false does not mean what it looks like. |
| 1091 |
* |
| 1092 |
* @param string $event One of activate|deactivate|heartbeat. |
| 1093 |
* @param array $extra Extra fields merged over the payload (e.g. a deactivation reason). |
| 1094 |
* @param bool $bypass_consent Only the deactivation dialog's own Submit may pass true. |
| 1095 |
*/ |
| 1096 |
public static function do_request( string $event, array $extra = array(), bool $bypass_consent = false ): void { |
| 1097 |
// Refuse to send without consent unless the caller explicitly says this is the one |
| 1098 |
// legitimate exception. That exception is the deactivation dialog: pressing "Submit" there |
| 1099 |
// IS the consent for that single submission, so it must work even when the persistent |
| 1100 |
// sharing toggle is off. Everything else — activate, deactivate, heartbeat, first ping — |
| 1101 |
// leaves this false and is refused if consent is missing, so a future call site cannot |
| 1102 |
// bypass consent by simply forgetting to check it. |
| 1103 |
if ( ! $bypass_consent && ! static::has_consent() ) { |
| 1104 |
return; |
| 1105 |
} |
| 1106 |
|
| 1107 |
$endpoint = defined( 'POSIMYTH_ANALYTICS_ENDPOINT' ) |
| 1108 |
? POSIMYTH_ANALYTICS_ENDPOINT |
| 1109 |
: 'https://api.posimyth.com/wp-json/posimyth/v1/track'; |
| 1110 |
|
| 1111 |
// No ingest key and no request signature. |
| 1112 |
// |
| 1113 |
// The hub's endpoint is intentionally open. A shared key would have to be baked |
| 1114 |
// identically into every shipped plugin, and those plugins are publicly downloadable, so |
| 1115 |
// the key could be read straight out of a ZIP — it authenticated nothing while making |
| 1116 |
// rotation impossible. Abuse is bounded hub-side instead (per-IP and per-site rate |
| 1117 |
// limits, a plugin_slug allowlist, and strict payload sanitisation). |
| 1118 |
$headers = array( |
| 1119 |
'Content-Type' => 'application/json', |
| 1120 |
); |
| 1121 |
|
| 1122 |
// Send AFTER the response is flushed, with a timeout long enough to actually finish. |
| 1123 |
// |
| 1124 |
// This previously fired inline with 'timeout' => 0.01 and 'blocking' => false. 10ms is |
| 1125 |
// far less than a real round trip to the hub (measured ~4s), so cURL aborted during the |
| 1126 |
// DNS/TLS phase and the request never arrived — activate / deactivate / heartbeat all |
| 1127 |
// silently recorded nothing, with no error anywhere because the response was discarded. |
| 1128 |
// |
| 1129 |
// Deferring to 'shutdown' keeps the admin request just as fast (the user never waits), |
| 1130 |
// while giving the request the time it genuinely needs. |
| 1131 |
// |
| 1132 |
// An earlier version of this comment talked about an HMAC timestamp staying inside the hub's |
| 1133 |
// replay window. There is no HMAC and no replay window — see the note above the headers. |
| 1134 |
// Nothing here is signed or timestamped. |
| 1135 |
$timeout = (int) apply_filters( 'posimyth_analytics_request_timeout', 15 ); |
| 1136 |
|
| 1137 |
$class = static::class; |
| 1138 |
|
| 1139 |
/* |
| 1140 |
* Everything expensive happens INSIDE this closure. |
| 1141 |
* |
| 1142 |
* build_payload() used to run inline, before the response was flushed, which made the claim |
| 1143 |
* that this "never blocks the admin" untrue: it counts users, reads the plugin list, resolves |
| 1144 |
* the theme and (in other products) scans content, all while the admin waits. Only the HTTP |
| 1145 |
* call was deferred. Now the payload is assembled after the response has gone out too. |
| 1146 |
*/ |
| 1147 |
// Resolved out here rather than inside the closure: slug() is protected, and the debug log |
| 1148 |
// below is the only thing that needs it. |
| 1149 |
$slug = static::slug(); |
| 1150 |
|
| 1151 |
$async_hook = static::async_hook(); |
| 1152 |
|
| 1153 |
$dispatch = static function () use ( $class, $slug, $event, $extra, $bypass_consent, $endpoint, $headers, $timeout, $async_hook ) { |
| 1154 |
/* |
| 1155 |
* Flush the response first so nothing below adds latency to the page. |
| 1156 |
* |
| 1157 |
* LiteSpeed's PHP handler exposes litespeed_finish_request() and not |
| 1158 |
* fastcgi_finish_request(); it was previously missed, so every LiteSpeed site — a large |
| 1159 |
* share of shared hosting — took the slow path below for no reason. |
| 1160 |
*/ |
| 1161 |
$flushed = false; |
| 1162 |
if ( function_exists( 'fastcgi_finish_request' ) ) { |
| 1163 |
fastcgi_finish_request(); |
| 1164 |
$flushed = true; |
| 1165 |
} elseif ( function_exists( 'litespeed_finish_request' ) ) { |
| 1166 |
litespeed_finish_request(); |
| 1167 |
$flushed = true; |
| 1168 |
} |
| 1169 |
|
| 1170 |
/* |
| 1171 |
* Is anyone actually waiting for this request? |
| 1172 |
* |
| 1173 |
* On WP-Cron and WP-CLI there is no browser at the other end, so the send can block for |
| 1174 |
* as long as it needs and the response can be read (which is what makes the diagnostic |
| 1175 |
* at the bottom of this function work). REST and AJAX are NOT in this list: a user is |
| 1176 |
* waiting on both. |
| 1177 |
*/ |
| 1178 |
$unattended = ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) |
| 1179 |
|| ( defined( 'WP_CLI' ) && WP_CLI ) |
| 1180 |
|| ( defined( 'DOING_CRON' ) && DOING_CRON ); |
| 1181 |
|
| 1182 |
/* |
| 1183 |
* Where the response cannot be flushed, hand the event to cron instead of sending it on |
| 1184 |
* a user-facing request. |
| 1185 |
* |
| 1186 |
* The old code set 'blocking' => false here and its comment claimed "WordPress still |
| 1187 |
* opens the connection and writes the request, it simply does not wait for the reply". |
| 1188 |
* That is not what WordPress does. WP_Http_Curl::request() still calls curl_exec() |
| 1189 |
* synchronously when blocking is false — it only skips the header and body write |
| 1190 |
* callbacks (see the `if ( ! $parsed_args['blocking'] )` branch in |
| 1191 |
* wp-includes/class-wp-http-curl.php, which runs curl_exec() and then reads |
| 1192 |
* curl_getinfo()). So under mod_php, LiteSpeed's non-flushing handler or php-cgi the |
| 1193 |
* worker and the browser waited for the FULL hub round trip — measured at ~4s, capped |
| 1194 |
* only by the 5s timeout — on every activate, deactivate and catch-up heartbeat. |
| 1195 |
* Measured on the QA container: a dashboard load went from 0.13s to 5.96s and a |
| 1196 |
* deactivate from ~1s to 5.27s. |
| 1197 |
* |
| 1198 |
* Queueing moves both the HTTP call and the payload assembly (build_payload() runs |
| 1199 |
* inside this closure, and on the heartbeat that includes the full content scan — 1.64s |
| 1200 |
* on 4,313 posts) onto a cron request where nothing is waiting. |
| 1201 |
* |
| 1202 |
* `deactivate` is the one event that cannot be queued: by the time cron next runs the |
| 1203 |
* plugin is inactive, so nothing would be listening on the hook, the event would sit in |
| 1204 |
* the cron array indefinitely, and reactivating before it fired would report a |
| 1205 |
* deactivation that had already been superseded. Same for the deactivation dialog's own |
| 1206 |
* submission ($bypass_consent), which is the user's one-shot feedback and has to leave |
| 1207 |
* now or never. Those two send inline with a hard 2s ceiling: enough to resolve, connect |
| 1208 |
* and write the ~4 KB body — which is all the hub needs to receive it — without holding |
| 1209 |
* the Plugins screen for the hub's processing time. |
| 1210 |
*/ |
| 1211 |
$deferrable = ( 'deactivate' !== $event ) && ! $bypass_consent; |
| 1212 |
|
| 1213 |
if ( ! $flushed && ! $unattended && $deferrable ) { |
| 1214 |
// time() rather than time()+N: this should run on the very next cron pass. WordPress |
| 1215 |
// de-duplicates identical (hook, args) events scheduled within ten minutes, so a |
| 1216 |
// burst of admin loads cannot queue the same ping repeatedly. |
| 1217 |
if ( ! wp_next_scheduled( $async_hook, array( $event, $extra, $bypass_consent ) ) ) { |
| 1218 |
wp_schedule_single_event( time(), $async_hook, array( $event, $extra, $bypass_consent ) ); |
| 1219 |
} |
| 1220 |
return; |
| 1221 |
} |
| 1222 |
|
| 1223 |
$body = wp_json_encode( array_merge( call_user_func( array( $class, 'build_payload' ), $event ), $extra ) ); |
| 1224 |
|
| 1225 |
// Block — and therefore be able to read the response — whenever nothing is waiting on |
| 1226 |
// this request. The remaining case is the un-queueable deactivate on a non-flushing |
| 1227 |
// host, where the send is abandoned after 2s and the response is not read. |
| 1228 |
$blocking = $flushed || $unattended; |
| 1229 |
|
| 1230 |
$response = wp_remote_post( |
| 1231 |
$endpoint, |
| 1232 |
array( |
| 1233 |
'headers' => $headers, |
| 1234 |
'body' => $body, |
| 1235 |
'timeout' => $blocking ? $timeout : 2, |
| 1236 |
'blocking' => $blocking, |
| 1237 |
'data_format' => 'body', |
| 1238 |
'sslverify' => true, |
| 1239 |
) |
| 1240 |
); |
| 1241 |
|
| 1242 |
/* |
| 1243 |
* Say something when the hub refuses a ping. |
| 1244 |
* |
| 1245 |
* Until this existed the response was discarded whole, so a hub that accepted the |
| 1246 |
* request and then failed to store it was completely invisible: every product kept |
| 1247 |
* sending, every ping was dropped, and the only symptom was a dashboard that quietly |
| 1248 |
* stopped gaining rows. That is a failure mode which can run for months. A single line |
| 1249 |
* in the debug log turns it into something findable in minutes. |
| 1250 |
* |
| 1251 |
* Two deliberate limits: |
| 1252 |
* |
| 1253 |
* - WP_DEBUG only. This is a diagnostic, not a user-facing error, and a site that has |
| 1254 |
* debugging off must not accumulate log lines because a remote service is unwell. |
| 1255 |
* - Blocking requests only. On the one remaining non-blocking path the request is |
| 1256 |
* abandoned mid-flight, so $response carries no status at all — checking it there |
| 1257 |
* would report a failure on every single ping regardless of what the hub did. |
| 1258 |
* |
| 1259 |
* This used to be gated on $flushed, which silently disabled the diagnostic on exactly |
| 1260 |
* the hosts that needed it most: under mod_php — where a large share of installs run — |
| 1261 |
* a hub rejecting every ping logged nothing at all. It now follows $blocking, so cron |
| 1262 |
* runs on those hosts report just like a flushed request does. |
| 1263 |
* |
| 1264 |
* The body is truncated and the payload is never logged: the point is the status, not a |
| 1265 |
* copy of what was sent. |
| 1266 |
*/ |
| 1267 |
if ( $blocking && defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 1268 |
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); |
| 1269 |
|
| 1270 |
if ( $code < 200 || $code > 299 ) { |
| 1271 |
$detail = is_wp_error( $response ) |
| 1272 |
? $response->get_error_message() |
| 1273 |
: trim( (string) wp_remote_retrieve_body( $response ) ); |
| 1274 |
|
| 1275 |
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only. |
| 1276 |
sprintf( |
| 1277 |
'POSIMYTH analytics: %s "%s" ping was not accepted (HTTP %d) — %s', |
| 1278 |
$slug, |
| 1279 |
$event, |
| 1280 |
$code, |
| 1281 |
substr( $detail, 0, 300 ) |
| 1282 |
) |
| 1283 |
); |
| 1284 |
} |
| 1285 |
} |
| 1286 |
}; |
| 1287 |
|
| 1288 |
// If we are already inside shutdown (e.g. called from another shutdown handler), adding |
| 1289 |
// the action would never fire — send immediately instead. |
| 1290 |
if ( did_action( 'shutdown' ) ) { |
| 1291 |
$dispatch(); |
| 1292 |
return; |
| 1293 |
} |
| 1294 |
add_action( 'shutdown', $dispatch, 99 ); |
| 1295 |
} |
| 1296 |
} |
| 1297 |
} |
| 1298 |
|