PluginProbe
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code / 5.5.4
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code v5.5.4
5.5.4 5.5.3 3.1.0 3.2.0 3.2.1 3.3.0 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.6.0 3.7.0 3.8.0 3.8.1 3.8.2 3.8.3 4.0.0 4.0.2 4.0.3 4.0.4 4.0.5 4.1.0 All 79 releases
copy-the-code / includes / telemetry / class-telemetry.php

class-telemetry.php in Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code 5.5.4, at includes/telemetry/class-telemetry.php

595 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Telemetry
4 *
5 * Sends anonymized usage data to Signals API when user has opted in.
6 * Privacy-first: no PII, hashed install IDs, opt-in only.
7 *
8 * @package CTC
9 * @since 5.2.0
10 */
11
12 namespace CTC;
13
14 use CTC\Global_Injector;
15 use CTC\Helper;
16 use CTC\Analytics\Database;
17
18 /**
19 * Telemetry Class
20 *
21 * Manages opt-in, payload building, and sending telemetry to signals.surror.workers.dev.
22 *
23 * @since 5.2.0
24 */
25 class Telemetry {
26
27 /**
28 * Option key for telemetry opt-in.
29 *
30 * @var string
31 */
32 const OPT_IN_OPTION = 'ctc_telemetry_opt_in';
33
34 /**
35 * Option key for install ID.
36 *
37 * @var string
38 */
39 const INSTALL_ID_OPTION = 'ctc_install_id';
40
41 /**
42 * Option key for first seen timestamp.
43 *
44 * @var string
45 */
46 const FIRST_SEEN_OPTION = 'ctc_telemetry_first_seen';
47
48 /**
49 * Option key for last sent timestamp.
50 *
51 * @var string
52 */
53 const LAST_SENT_OPTION = 'ctc_telemetry_last_sent';
54
55 /**
56 * Cron hook for weekly telemetry send.
57 *
58 * @var string
59 */
60 const CRON_HOOK = 'ctc_telemetry_weekly_send';
61
62 /**
63 * Minimum seconds between sends (throttle).
64 */
65 const SEND_INTERVAL = 7 * DAY_IN_SECONDS;
66
67 /**
68 * Signals API endpoint.
69 *
70 * @var string
71 */
72 const ENDPOINT = 'https://signals.surror.workers.dev/v1/telemetry';
73
74 /**
75 * Length of truncated install_id hash (sha256 produces 64 chars).
76 *
77 * @var int
78 */
79 const INSTALL_ID_HASH_LENGTH = 32;
80
81 /**
82 * Instance
83 *
84 * @var Telemetry|null
85 */
86 private static $instance = null;
87
88 /**
89 * Get instance.
90 *
91 * @return Telemetry
92 */
93 public static function get() {
94 if ( null === self::$instance ) {
95 self::$instance = new self();
96 }
97 return self::$instance;
98 }
99
100 /**
101 * Constructor.
102 */
103 private function __construct() {
104 self::maybe_set_first_seen();
105
106 add_action( 'wp_ajax_ctc_update_telemetry_opt_in', [ $this, 'ajax_update_opt_in' ] );
107 add_action( self::CRON_HOOK, [ __CLASS__, 'maybe_send' ] );
108 add_action( 'init', [ __CLASS__, 'maybe_schedule_cron' ], 20 );
109 }
110
111 /**
112 * Set first seen timestamp if not set.
113 */
114 private static function maybe_set_first_seen() {
115 if ( get_option( self::FIRST_SEEN_OPTION ) ) {
116 return;
117 }
118 update_option( self::FIRST_SEEN_OPTION, time() );
119 }
120
121 /**
122 * Generate a stable install ID hash from the current domain slug.
123 *
124 * Same domain (e.g. example.com, app.example.com) always yields the same id,
125 * so uninstall + reactivate on the same site is not counted as a new install.
126 * Uses the slug form so existing install_id hashes stay stable.
127 *
128 * @return string
129 */
130 private static function get_install_id_hash() {
131 $domain = self::get_domain_slug();
132 $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'ctc-telemetry-salt';
133 $full = hash( 'sha256', $domain . $salt );
134 return substr( $full, 0, self::INSTALL_ID_HASH_LENGTH );
135 }
136
137 /**
138 * Generate UUID v4 (kept for any future use; install identity is now domain-based).
139 *
140 * @return string
141 */
142 private static function generate_uuid() {
143 if ( function_exists( 'wp_generate_uuid' ) ) {
144 return wp_generate_uuid();
145 }
146
147 $data = random_bytes( 16 );
148 $data[6] = chr( ord( $data[6] ) & 0x0f | 0x40 );
149 $data[8] = chr( ord( $data[8] ) & 0x3f | 0x80 );
150
151 return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
152 }
153
154 /**
155 * Check if user has opted in.
156 *
157 * Hybrid: Freemius opt-in (connect + tracking) OR our own option (welcome/dashboard).
158 *
159 * @return bool
160 */
161 public static function get_opt_in() {
162 $from_freemius = function_exists( 'ctc_fs' )
163 && ctc_fs()->is_registered()
164 && ctc_fs()->is_tracking_allowed();
165 $from_our_option = (bool) get_option( self::OPT_IN_OPTION, false );
166
167 return $from_freemius || $from_our_option;
168 }
169
170 /**
171 * Set opt-in status.
172 *
173 * @param bool $opt_in Whether to opt in.
174 * @return bool
175 */
176 public static function set_opt_in( $opt_in ) {
177 return update_option( self::OPT_IN_OPTION, $opt_in ? '1' : '0' );
178 }
179
180 /**
181 * Build telemetry payload.
182 *
183 * @return array
184 */
185 private static function build_payload() {
186 $hashed_id = self::get_install_id_hash();
187
188 $rules = Global_Injector::get()->get_admin_rules();
189 $copy_as = [];
190 foreach ( $rules as $rule ) {
191 if ( ! empty( $rule['copy_as'] ) && ! in_array( $rule['copy_as'], $copy_as, true ) ) {
192 $copy_as[] = sanitize_key( $rule['copy_as'] );
193 }
194 }
195
196 // Real hostname for dashboards (e.g. example.com). install_id still uses get_domain_slug().
197 $domain = self::get_hostname();
198 $env = [
199 'wp_version' => get_bloginfo( 'version' ),
200 'php_version' => PHP_VERSION,
201 'theme_name' => wp_get_theme()->get( 'Name' ),
202 'theme_version' => wp_get_theme()->get( 'Version' ),
203 'active_plugins_count' => count( (array) get_option( 'active_plugins', [] ) ),
204 'multisite' => is_multisite(),
205 'domain' => $domain,
206 'active_themes' => self::get_active_themes(),
207 'inactive_themes' => self::get_inactive_themes(),
208 'wp' => self::get_wp_info(),
209 'server' => self::get_server_info(),
210 'active_plugins' => self::get_active_plugins_data(),
211 'inactive_plugins' => self::get_inactive_plugins_data(),
212 'users' => count_users(),
213 ];
214
215 $wc_data = self::get_woocommerce_data();
216 if ( ! empty( $wc_data ) ) {
217 $env['wc_products'] = $wc_data['products'] ?? [];
218 $env['wc_orders'] = $wc_data['orders'] ?? [];
219 }
220
221 $payload = [
222 'install_id' => $hashed_id,
223 'opt_in' => self::get_opt_in(),
224 'ts' => gmdate( 'c' ),
225 'plugin_version' => defined( 'CTC_VER' ) ? CTC_VER : '',
226 'environment' => $env,
227 'features' => [
228 'rules_count' => count( $rules ),
229 'is_pro' => Helper::is_pro(),
230 'copy_as_types' => $copy_as,
231 ],
232 'locale' => get_locale(),
233 ];
234
235 // Add copy-event aggregates from local analytics (shortcode + global-injector) when available.
236 $copies = self::get_copies_summary();
237 if ( ! empty( $copies ) ) {
238 $payload['copies'] = $copies;
239 }
240
241 return apply_filters( 'ctc/telemetry/payload', $payload );
242 }
243
244 /**
245 * Hostname from home URL (no www), for telemetry environment.domain.
246 *
247 * @return string
248 */
249 private static function get_hostname() {
250 $host = wp_parse_url( home_url(), PHP_URL_HOST );
251 if ( empty( $host ) ) {
252 return '';
253 }
254 $host = preg_replace( '/^www\./', '', strtolower( $host ) );
255 /**
256 * Filter the hostname sent in telemetry environment.domain.
257 *
258 * @param string $host Hostname without www.
259 */
260 return apply_filters( 'ctc/telemetry/hostname', $host );
261 }
262
263 /**
264 * Domain slug used only for stable install_id hashing (must not change format).
265 *
266 * @return string
267 */
268 private static function get_domain_slug() {
269 $host = wp_parse_url( home_url(), PHP_URL_HOST );
270 if ( empty( $host ) ) {
271 return '';
272 }
273 $host = preg_replace( '/^www\./', '', $host );
274 $slug = sanitize_key( str_replace( '.', '-', $host ) );
275 /**
276 * Filter the domain slug used for install_id hashing.
277 * Changing this remaps install identity — avoid unless intentional.
278 *
279 * @param string $slug Slug like example-com.
280 */
281 return apply_filters( 'ctc/telemetry/domain', $slug );
282 }
283
284 /**
285 * Get active theme(s) data.
286 *
287 * Includes current theme and parent theme when using a child theme.
288 *
289 * @return array<string, array{name: string, version: string, child_theme: bool, wc_support: bool, block_theme: bool}>
290 */
291 private static function get_active_themes() {
292 $theme = wp_get_theme();
293 $themes = [];
294 $slug = $theme->get_stylesheet();
295 $themes[ $slug ] = [
296 'name' => $theme->get( 'Name' ),
297 'version' => $theme->get( 'Version' ),
298 'child_theme' => (bool) $theme->parent(),
299 'wc_support' => current_theme_supports( 'woocommerce' ),
300 'block_theme' => function_exists( 'wp_is_block_theme' ) ? wp_is_block_theme() : false,
301 ];
302 $parent = $theme->parent();
303 if ( $parent ) {
304 $parent_slug = $parent->get_stylesheet();
305 $themes[ $parent_slug ] = [
306 'name' => $parent->get( 'Name' ),
307 'version' => $parent->get( 'Version' ),
308 'child_theme' => false,
309 'wc_support' => false,
310 'block_theme' => false,
311 ];
312 }
313 return $themes;
314 }
315
316 /**
317 * Get inactive themes data.
318 *
319 * @return array<string, array{name: string, version: string, child_theme: bool, wc_support: bool, block_theme: bool}>
320 */
321 private static function get_inactive_themes() {
322 if ( ! function_exists( 'wp_get_themes' ) ) {
323 return [];
324 }
325 $active_slug = wp_get_theme()->get_stylesheet();
326 $parent = wp_get_theme()->parent();
327 $active_parent = $parent ? $parent->get_stylesheet() : '';
328 $themes = [];
329 foreach ( wp_get_themes() as $slug => $theme ) {
330 if ( $slug === $active_slug || $slug === $active_parent ) {
331 continue;
332 }
333 $themes[ $slug ] = [
334 'name' => $theme->get( 'Name' ),
335 'version' => $theme->get( 'Version' ),
336 'child_theme' => (bool) $theme->parent(),
337 'wc_support' => false,
338 'block_theme' => false,
339 ];
340 }
341 return $themes;
342 }
343
344 /**
345 * Get WordPress environment info.
346 *
347 * @return array{memory_limit?: int, debug_mode?: bool, locale?: string, version?: string, multisite?: bool, env_type?: string}
348 */
349 private static function get_wp_info() {
350 $memory = ini_get( 'memory_limit' );
351 $bytes = $memory ? wp_convert_hr_to_bytes( $memory ) : 0;
352 return [
353 'memory_limit' => $bytes,
354 'debug_mode' => defined( 'WP_DEBUG' ) && WP_DEBUG,
355 'locale' => get_locale(),
356 'version' => get_bloginfo( 'version' ),
357 'multisite' => is_multisite(),
358 'env_type' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production',
359 ];
360 }
361
362 /**
363 * Get server info.
364 *
365 * @return array<string, mixed>
366 */
367 private static function get_server_info() {
368 global $wpdb;
369 return [
370 'software' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : null,
371 'php_version' => PHP_VERSION,
372 'php_post_max_size' => ini_get( 'post_max_size' ) ?: null,
373 'php_time_limit' => (int) ini_get( 'max_execution_time' ),
374 'php_max_input_vars' => (int) ini_get( 'max_input_vars' ),
375 'php_max_upload_size' => function_exists( 'wp_max_upload_size' ) ? wp_max_upload_size() : wp_convert_hr_to_bytes( ini_get( 'upload_max_filesize' ) ?: '0' ),
376 'php_default_timezone' => date_default_timezone_get(),
377 'mysql_version' => $wpdb && isset( $wpdb->db_version ) ? $wpdb->db_version() : null,
378 ];
379 }
380
381 /**
382 * Get active plugins with full data.
383 *
384 * @return list<array{slug: string, name: string, version: string, active: bool}>
385 */
386 private static function get_active_plugins_data() {
387 return self::get_plugins_data( true );
388 }
389
390 /**
391 * Get inactive plugins with full data.
392 *
393 * @return list<array{slug: string, name: string, version: string, active: bool}>
394 */
395 private static function get_inactive_plugins_data() {
396 return self::get_plugins_data( false );
397 }
398
399 /**
400 * Get plugins data (active or inactive).
401 *
402 * @param bool $active True for active, false for inactive.
403 * @return list<array{slug: string, name: string, version: string, active: bool, author?: string, author_uri?: string, plugin_uri?: string}>
404 */
405 private static function get_plugins_data( $active ) {
406 if ( ! function_exists( 'get_plugins' ) ) {
407 require_once ABSPATH . 'wp-admin/includes/plugin.php';
408 }
409 $all_plugins = get_plugins();
410 $active_plugins = get_option( 'active_plugins', [] );
411 $active_plugins = is_array( $active_plugins ) ? $active_plugins : [];
412 $result = [];
413 foreach ( $all_plugins as $file => $data ) {
414 $sitewide = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) : [];
415 $is_active = in_array( $file, $active_plugins, true ) || in_array( $file, $sitewide, true );
416 if ( $is_active !== $active ) {
417 continue;
418 }
419 $dir = dirname( $file );
420 $slug = '.' === $dir ? sanitize_key( pathinfo( $file, PATHINFO_FILENAME ) ) : sanitize_key( $dir );
421 $item = [
422 'slug' => $slug,
423 'name' => isset( $data['Name'] ) ? $data['Name'] : '',
424 'version' => isset( $data['Version'] ) ? $data['Version'] : '',
425 'active' => $active,
426 ];
427 if ( ! empty( $data['Author'] ) ) {
428 $item['author'] = $data['Author'];
429 }
430 if ( ! empty( $data['AuthorURI'] ) ) {
431 $item['author_uri'] = $data['AuthorURI'];
432 }
433 if ( ! empty( $data['PluginURI'] ) ) {
434 $item['plugin_uri'] = $data['PluginURI'];
435 }
436 $result[] = $item;
437 }
438 return $result;
439 }
440
441 /**
442 * Get copy-event summary from local analytics (ctc_analytics) for telemetry.
443 * Includes total_all, total_7d, total_30d from shortcode + global-injector events.
444 * Returns empty array if analytics table is missing or unavailable.
445 *
446 * @return array{total_all?: int, total_7d?: int, total_30d?: int}
447 */
448 private static function get_copies_summary() {
449 try {
450 $db = Database::get();
451 $now = Helper::mysql_now();
452 $ep = Helper::mysql_epoch();
453 $d7 = Helper::mysql_date_ago( '-7 days' );
454 $d30 = Helper::mysql_date_ago( '-30 days' );
455
456 $total_all = $db->get_total_copies( $ep, $now, null );
457 $total_7d = $db->get_total_copies( $d7, $now, null );
458 $total_30d = $db->get_total_copies( $d30, $now, null );
459 $by_source = $db->get_total_copies_by_source( $ep, $now );
460
461 $out = [
462 'total_all' => (int) $total_all,
463 'total_7d' => (int) $total_7d,
464 'total_30d' => (int) $total_30d,
465 ];
466 if ( ! empty( $by_source ) ) {
467 $out['by_source'] = $by_source;
468 }
469 return $out;
470 } catch ( \Throwable $e ) {
471 return [];
472 }
473 }
474
475 /**
476 * Get WooCommerce tracking data if available.
477 *
478 * @return array{products?: array, orders?: array}
479 */
480 private static function get_woocommerce_data() {
481 if ( ! apply_filters( 'ctc/telemetry/include_woocommerce', true ) ) {
482 return [];
483 }
484 if ( ! class_exists( 'WC_Tracker' ) ) {
485 return [];
486 }
487 $data = \WC_Tracker::get_tracking_data();
488 return [
489 'products' => $data['products'] ?? [],
490 'orders' => $data['orders'] ?? [],
491 ];
492 }
493
494 /**
495 * Send telemetry (only when opted in).
496 */
497 public static function maybe_send() {
498 if ( ! self::get_opt_in() ) {
499 return;
500 }
501
502 $last_sent = (int) get_option( self::LAST_SENT_OPTION, 0 );
503 if ( $last_sent && ( time() - $last_sent ) < self::SEND_INTERVAL ) {
504 return;
505 }
506
507 $payload = wp_json_encode( self::build_payload() );
508 if ( ! $payload ) {
509 return;
510 }
511
512 $response = wp_safe_remote_post(
513 self::ENDPOINT,
514 [
515 'body' => $payload,
516 'headers' => [
517 'Content-Type' => 'application/json',
518 ],
519 'timeout' => 30,
520 ]
521 );
522
523 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) >= 200 && wp_remote_retrieve_response_code( $response ) < 300 ) {
524 update_option( self::LAST_SENT_OPTION, time() );
525 }
526 }
527
528 /**
529 * AJAX handler for updating opt-in (dashboard and welcome notice).
530 */
531 public function ajax_update_opt_in() {
532 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'ctc_telemetry_opt_in' ) ) {
533 wp_send_json_error( 'Invalid nonce' );
534 }
535
536 if ( ! current_user_can( 'manage_options' ) ) {
537 wp_send_json_error( 'Insufficient permissions' );
538 }
539
540 $opt_in = isset( $_POST['opt_in'] ) && '1' === $_POST['opt_in'];
541
542 self::set_opt_in( $opt_in );
543
544 if ( $opt_in ) {
545 self::maybe_send();
546 self::schedule_cron();
547 } else {
548 self::unschedule_cron();
549 }
550
551 wp_send_json_success( [ 'opt_in' => $opt_in ] );
552 }
553
554 /**
555 * Schedule weekly telemetry cron (opt-in only).
556 */
557 public static function schedule_cron() {
558 if ( wp_next_scheduled( self::CRON_HOOK ) ) {
559 return;
560 }
561
562 // First run in 50 seconds (WooCommerce-style), then weekly.
563 wp_schedule_event( time() + 50, 'weekly', self::CRON_HOOK );
564 }
565
566 /**
567 * Unschedule telemetry cron.
568 */
569 public static function unschedule_cron() {
570 wp_clear_scheduled_hook( self::CRON_HOOK );
571 }
572
573 /**
574 * Ensure cron is scheduled when opted in (e.g. after plugin update).
575 */
576 public static function maybe_schedule_cron() {
577 if ( ! self::get_opt_in() ) {
578 return;
579 }
580 if ( wp_next_scheduled( self::CRON_HOOK ) ) {
581 return;
582 }
583 self::schedule_cron();
584 }
585
586 /**
587 * Delete options (for uninstall).
588 */
589 public static function cleanup() {
590 delete_option( self::OPT_IN_OPTION );
591 delete_option( self::FIRST_SEEN_OPTION );
592 delete_option( self::LAST_SENT_OPTION );
593 }
594 }
595