PluginProbe ʕ •ᴥ•ʔ
Custom Twitter Feeds – A Tweets Widget or X Feed Widget / 2.7.0
Custom Twitter Feeds – A Tweets Widget or X Feed Widget v2.7.0
2.8.0 2.7.0 2.6.1 2.6.0 1.0 1.0.1 1.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2 1.2.1 1.2.10 1.2.11 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.4 1.4.1 1.5 1.5.1 1.6 1.6.1 1.7 1.8 1.8.1 1.8.2 1.8.3 1.8.4 2.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.1 2.1.1 2.1.2 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3.0 2.3.1 2.5.4 2.5.5 trunk
custom-twitter-feeds / inc / UsageTracking / Twitter / TwitterFreeReporter.php
custom-twitter-feeds / inc / UsageTracking / Twitter Last commit date
TwitterFreeReporter.php 1 month ago
TwitterFreeReporter.php
821 lines
1 <?php
2 /**
3 * Twitter reporter for Smash Usage Tracking — Free plugin variant.
4 *
5 * Collects environment, global settings, connected accounts, feeds summary,
6 * features enabled, performance metrics, and error metrics specific to
7 * Custom Twitter Feeds (Free).
8 *
9 * @package TwitterFeed\UsageTracking\Twitter
10 * @since 2.6
11 */
12
13 namespace TwitterFeed\UsageTracking\Twitter;
14
15 use TwitterFeed\UsageTracking\ReporterInterface;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 class TwitterFreeReporter implements ReporterInterface {
22
23 const SCHEMA_VERSION = '1.0';
24
25 /**
26 * Plugin slug for payload root.
27 *
28 * @return string
29 */
30 public function get_plugin_slug() {
31 return 'twitter';
32 }
33
34 /**
35 * Schema version for the report payload.
36 *
37 * @return string
38 */
39 public function get_schema_version() {
40 return self::SCHEMA_VERSION;
41 }
42
43 /**
44 * Configuration snapshot (environment, settings, sources, feeds, features).
45 *
46 * @return array
47 */
48 public function get_configuration_snapshot() {
49 $global_settings = $this->get_global_settings();
50
51 // Single DB scan — reused for latest sample, summary, and features map.
52 $all_feed_data = $this->get_all_feed_data();
53
54 return array(
55 'environment' => $this->get_environment(),
56 'global_settings' => $global_settings,
57 'sources' => $this->get_sources_summary(),
58 'latest_15_feeds' => $this->get_latest_feeds( $all_feed_data ),
59 'feeds' => $this->get_feeds_summary( $all_feed_data ),
60 'features_enabled' => $this->get_features_enabled( $all_feed_data, $global_settings ),
61 'version' => defined( 'CTF_VERSION' ) ? CTF_VERSION : '',
62 'license_tier' => $this->get_license_tier(),
63 'license_status' => $this->get_license_status(),
64 'license_expires' => $this->get_license_expires(),
65 'license_item_id' => $this->get_license_item_id(),
66 );
67 }
68
69 /**
70 * Dynamic metrics for the given period.
71 *
72 * @param string|int $period_start Start of period (ISO 8601 or timestamp).
73 * @param string|int $period_end End of period (ISO 8601 or timestamp).
74 * @return array
75 */
76 public function get_dynamic_metrics( $period_start, $period_end ) {
77 $ts_start = is_numeric( $period_start ) ? (int) $period_start : strtotime( $period_start );
78 $ts_end = is_numeric( $period_end ) ? (int) $period_end : strtotime( $period_end );
79
80 return array(
81 'period_start' => $period_start,
82 'period_end' => $period_end,
83 'performance' => $this->get_performance_metrics(),
84 'errors' => $this->get_error_metrics(),
85 'events' => $this->get_events_for_period( $ts_start, $ts_end ),
86 'days_active' => $this->get_days_active( $period_start, $period_end ),
87 'session_duration' => $this->get_session_duration(),
88 );
89 }
90
91 /**
92 * Environment data (WP, PHP, theme, locale, multisite, install age).
93 *
94 * @return array
95 */
96 private function get_environment() {
97 $install_ts = null;
98 $statuses = get_option( 'ctf_statuses', array() );
99 if ( ! empty( $statuses['first_install'] ) && is_numeric( $statuses['first_install'] ) ) {
100 $install_ts = (int) $statuses['first_install'];
101 }
102 if ( null === $install_ts ) {
103 $install_ts = (int) get_option( 'ctf_installed_timestamp', 0 );
104 }
105 $install_age_days = $install_ts ? max( 0, (int) ((time() - $install_ts) / DAY_IN_SECONDS) ) : 0;
106
107 $theme = wp_get_theme();
108 $theme_name = $theme->exists() ? $theme->get( 'Name' ) : '';
109
110 return array(
111 'wp_version' => get_bloginfo( 'version' ),
112 'php_version' => PHP_VERSION,
113 'active_theme' => $theme_name,
114 'locale' => get_locale(),
115 'multisite' => is_multisite(),
116 'site_count' => is_multisite() ? (int) get_blog_count() : 1,
117 'active_plugins_count' => count(
118 array_unique(
119 array_merge(
120 (array) get_option( 'active_plugins', array() ),
121 array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) )
122 )
123 )
124 ),
125 'install_age_days' => $install_age_days,
126 );
127 }
128
129 /**
130 * Global CTF settings (caching, gdpr, preserve, advanced tab).
131 *
132 * @return array
133 */
134 private function get_global_settings() {
135 $opts = get_option( 'ctf_options', array() );
136 if ( ! is_array( $opts ) ) {
137 $opts = array();
138 }
139
140 return array(
141 'preserve_settings' => ! empty( $opts['preserve_settings'] ),
142 'cache_time' => isset( $opts['cache_time'] ) ? $opts['cache_time'] : '',
143 'cache_time_unit' => isset( $opts['cache_time_unit'] ) ? $opts['cache_time_unit'] : '',
144 'caching_type' => isset( $opts['ctf_caching_type'] ) ? $opts['ctf_caching_type'] : 'page',
145 'cron_interval' => isset( $opts['ctf_cache_cron_interval'] ) ? $opts['ctf_cache_cron_interval'] : '',
146 'ajax_theme' => ! empty( $opts['ajax_theme'] ),
147 'autores' => ! empty( $opts['autores'] ),
148 'creditctf' => ! empty( $opts['creditctf'] ),
149 'gdpr' => isset( $opts['gdpr'] ) ? $opts['gdpr'] : '',
150 'persistent_cache' => ! empty( $opts['persistentcache'] ),
151 'rebranding' => ! empty( $opts['rebranding'] ),
152 'sslonly' => ! empty( $opts['sslonly'] ),
153 'disable_intents' => ! empty( $opts['disableintents'] ),
154 'head_enqueue' => ! empty( $opts['headenqueue'] ),
155 'custom_templates' => ! empty( $opts['customtemplates'] ),
156 'request_method' => isset( $opts['request_method'] ) ? $opts['request_method'] : 'auto',
157 );
158 }
159
160 /**
161 * Sources summary — connected accounts from ctf_options.
162 * CTF has no separate sources table; OAuth-connected accounts live in ctf_options.
163 *
164 * @return array
165 */
166 private function get_sources_summary() {
167 $opts = get_option( 'ctf_options', array() );
168 $accounts = isset( $opts['connected_accounts'] ) && is_array( $opts['connected_accounts'] )
169 ? $opts['connected_accounts']
170 : array();
171
172 $connected_count = count( $accounts );
173
174 // Tally account types where available.
175 $account_type = array(
176 'personal' => 0,
177 'business' => 0,
178 'other' => 0,
179 );
180 foreach ( $accounts as $account ) {
181 if ( ! is_array( $account ) ) {
182 continue;
183 }
184 $type = isset( $account['type'] ) ? strtolower( (string) $account['type'] ) : 'other';
185 if ( isset( $account_type[ $type ] ) ) {
186 ++$account_type[ $type ];
187 } else {
188 ++$account_type['other'];
189 }
190 }
191
192 return array(
193 'connected_accounts_count' => $connected_count,
194 'account_type' => $account_type,
195 );
196 }
197
198 /**
199 * Whitelist of feed setting keys to track.
200 *
201 * @var string[]
202 */
203 private static $feed_settings_whitelist = array(
204 'type',
205 'layout',
206 'masonry',
207 'carousel',
208 'num',
209 'loadmore',
210 'showheader',
211 'showbio',
212 'disablelightbox',
213 'include_media',
214 'include_twittercards',
215 'include_retweeter',
216 'include_avatar',
217 'include_author',
218 'include_text',
219 'include_date',
220 'include_actions',
221 'include_twitterlink',
222 'include_linkbox',
223 'include_replied_to',
224 'autoscroll',
225 'persistentcache',
226 'carouselpag',
227 'includeretweets',
228 'includereplies',
229 'includewords',
230 'excludewords',
231 'masonrycols',
232 'carouselcols',
233 'cachetime',
234 'cache_time',
235 'cache_time_unit',
236 'usertimeline_text',
237 'hashtag_text',
238 'search_text',
239 'moderation',
240 );
241
242 /**
243 * Load every feed's decoded settings plus feed_name, sorted newest-first.
244 *
245 * @return array[]
246 */
247 private function get_all_feed_data(): array {
248 global $wpdb;
249 $table = $wpdb->prefix . 'ctf_feeds';
250 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
251
252 if ( ! $table_exists ) {
253 return array();
254 }
255
256 $rows = $wpdb->get_results(
257 "SELECT feed_name, settings FROM {$table} ORDER BY last_modified DESC LIMIT 500", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
258 ARRAY_A
259 );
260
261 $out = array();
262 foreach ( $rows as $row ) {
263 $decoded = ! empty( $row['settings'] ) ? json_decode( $row['settings'], true ) : array();
264 $out[] = array(
265 'feed_name' => isset( $row['feed_name'] ) ? sanitize_text_field( (string) $row['feed_name'] ) : '',
266 'settings' => is_array( $decoded ) ? $decoded : array(),
267 );
268 }
269
270 return $out;
271 }
272
273 /**
274 * Latest 15 feeds with whitelisted settings.
275 *
276 * @param array[] $all_feed_data From get_all_feed_data().
277 * @return array
278 */
279 private function get_latest_feeds( array $all_feed_data ): array {
280 $feeds = array();
281 foreach ( array_slice( $all_feed_data, 0, 15 ) as $row ) {
282 $feed_name = $row['feed_name'];
283 if ( strlen( $feed_name ) > 255 ) {
284 $feed_name = substr( $feed_name, 0, 255 );
285 }
286 $feeds[] = array(
287 'feed_name' => $feed_name,
288 'settings' => $this->pick_whitelisted_settings( $row['settings'] ),
289 );
290 }
291 return $feeds;
292 }
293
294 /**
295 * Aggregate feed type and layout distribution across ALL feeds.
296 *
297 * Twitter feed types: usertimeline, hashtag, search, lists, mentions
298 * Twitter layouts: list, grid, masonry, carousel
299 *
300 * @param array[] $all_feed_data From get_all_feed_data().
301 * @return array
302 */
303 private function get_feeds_summary( array $all_feed_data ): array {
304 $by_type = array();
305 $by_layout = array();
306
307 foreach ( $all_feed_data as $row ) {
308 $s = $row['settings'];
309
310 // Determine feed type
311 $type = isset( $s['type'] ) ? (string) $s['type'] : 'usertimeline';
312
313 // Determine layout — CTF uses 'layout', 'masonry', 'carousel' flags
314 if ( isset( $s['layout'] ) ) {
315 $layout = (string) $s['layout'];
316 } elseif ( ! empty( $s['masonry'] ) ) {
317 $layout = 'masonry';
318 } elseif ( ! empty( $s['carousel'] ) ) {
319 $layout = 'carousel';
320 } else {
321 $layout = 'list';
322 }
323
324 $by_type[ $type ] = ($by_type[ $type ] ?? 0) + 1;
325 $by_layout[ $layout ] = ($by_layout[ $layout ] ?? 0) + 1;
326 }
327
328 return array(
329 'total_count' => count( $all_feed_data ),
330 'by_type' => $by_type,
331 'by_layout' => $by_layout,
332 );
333 }
334
335 /**
336 * Flat boolean feature map for the Laravel dashboard's feature adoption page.
337 *
338 * @param array[] $all_feed_data From get_all_feed_data().
339 * @param array $global_settings From get_global_settings().
340 * @return array<string,bool>
341 */
342 private function get_features_enabled( array $all_feed_data, array $global_settings ): array {
343 $feed_flags = array(
344 'load_more' => false,
345 'show_header' => false,
346 'masonry_layout' => false,
347 'carousel' => false,
348 'lightbox' => false,
349 'hashtag_feeds' => false,
350 'search_feeds' => false,
351 'lists_feeds' => false,
352 'user_timeline' => false,
353 'mentions_feeds' => false,
354 'autoscroll' => false,
355 'moderation' => false,
356 'link_cards' => false,
357 'include_media' => false,
358 'include_retweets' => false,
359 'show_bio' => false,
360 );
361
362 foreach ( $all_feed_data as $row ) {
363 $s = $row['settings'];
364
365 if ( ! $feed_flags['load_more'] && ! empty( $s['loadmore'] ) ) {
366 $feed_flags['load_more'] = true;
367 }
368 if ( ! $feed_flags['show_header'] && ! empty( $s['showheader'] ) ) {
369 $feed_flags['show_header'] = true;
370 }
371 if ( ! $feed_flags['masonry_layout'] && ( ! empty( $s['masonry'] ) || (isset( $s['layout'] ) && 'masonry' === $s['layout'])) ) {
372 $feed_flags['masonry_layout'] = true;
373 }
374 if ( ! $feed_flags['carousel'] && ( ! empty( $s['carousel'] ) || (isset( $s['layout'] ) && 'carousel' === $s['layout'])) ) {
375 $feed_flags['carousel'] = true;
376 }
377 if ( ! $feed_flags['lightbox'] && empty( $s['disablelightbox'] ) ) {
378 $feed_flags['lightbox'] = true;
379 }
380 if ( ! $feed_flags['hashtag_feeds'] && isset( $s['type'] ) && 'hashtag' === $s['type'] ) {
381 $feed_flags['hashtag_feeds'] = true;
382 }
383 if ( ! $feed_flags['search_feeds'] && isset( $s['type'] ) && 'search' === $s['type'] ) {
384 $feed_flags['search_feeds'] = true;
385 }
386 if ( ! $feed_flags['lists_feeds'] && isset( $s['type'] ) && 'lists' === $s['type'] ) {
387 $feed_flags['lists_feeds'] = true;
388 }
389 if ( ! $feed_flags['user_timeline'] && isset( $s['type'] ) && 'usertimeline' === $s['type'] ) {
390 $feed_flags['user_timeline'] = true;
391 }
392 if ( ! $feed_flags['mentions_feeds'] && isset( $s['type'] ) && 'mentions' === $s['type'] ) {
393 $feed_flags['mentions_feeds'] = true;
394 }
395 if ( ! $feed_flags['autoscroll'] && ! empty( $s['autoscroll'] ) ) {
396 $feed_flags['autoscroll'] = true;
397 }
398 if ( ! $feed_flags['moderation'] && ! empty( $s['moderation'] ) ) {
399 $feed_flags['moderation'] = true;
400 }
401 if ( ! $feed_flags['link_cards'] && ! empty( $s['include_twittercards'] ) ) {
402 $feed_flags['link_cards'] = true;
403 }
404 if ( ! $feed_flags['include_media'] && ! empty( $s['include_media'] ) ) {
405 $feed_flags['include_media'] = true;
406 }
407 if ( ! $feed_flags['include_retweets'] && ! empty( $s['includeretweets'] ) ) {
408 $feed_flags['include_retweets'] = true;
409 }
410 if ( ! $feed_flags['show_bio'] && ! empty( $s['showbio'] ) ) {
411 $feed_flags['show_bio'] = true;
412 }
413
414 if ( ! in_array( false, $feed_flags, true ) ) {
415 break;
416 }
417 }
418
419 return array_merge(
420 $feed_flags,
421 array(
422 'ajax_theme_fix' => (bool) ($global_settings['ajax_theme'] ?? false),
423 'persistent_cache' => (bool) ($global_settings['persistent_cache'] ?? false),
424 'gdpr_enabled' => isset( $global_settings['gdpr'] ) && '' !== $global_settings['gdpr'],
425 'rebranding' => (bool) ($global_settings['rebranding'] ?? false),
426 'credit_link' => (bool) ($global_settings['creditctf'] ?? false),
427 'preserve_settings' => (bool) ($global_settings['preserve_settings'] ?? false),
428 'auto_resize' => (bool) ($global_settings['autores'] ?? false),
429 )
430 );
431 }
432
433 /**
434 * Return only whitelisted feed settings.
435 *
436 * @param array $settings Raw feed settings.
437 * @return array
438 */
439 private function pick_whitelisted_settings( array $settings ) {
440 $out = array();
441 foreach ( self::$feed_settings_whitelist as $key ) {
442 if ( ! array_key_exists( $key, $settings ) ) {
443 continue;
444 }
445 $value = $settings[ $key ];
446 if ( is_array( $value ) ) {
447 $out[ $key ] = $value;
448 } elseif ( is_scalar( $value ) ) {
449 $out[ $key ] = $value;
450 }
451 }
452 return $out;
453 }
454
455 // ── License methods ───────────────────────────────────────────────────────
456 // The Free plugin has no EDD license infrastructure. These return static
457 // values so the payload is always consistent and the dashboard can correctly
458 // segment free vs paid sites.
459
460 /** @return string */
461 private function get_license_tier() {
462 return 'free'; }
463
464 /** @return null */
465 private function get_license_status() {
466 return null; }
467
468 /** @return null */
469 private function get_license_expires() {
470 return null; }
471
472 /** @return null */
473 private function get_license_item_id() {
474 return null; }
475
476 // ── Metrics methods ───────────────────────────────────────────────────────
477
478 /**
479 * Performance metrics (feed caches count, cached posts count).
480 *
481 * @return array
482 */
483 private function get_performance_metrics() {
484 global $wpdb;
485
486 $feed_caches_count = 0;
487 $cache_table = $wpdb->prefix . 'ctf_feed_caches';
488 $cache_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $cache_table ) ) === $cache_table;
489 if ( $cache_exists ) {
490 $feed_caches_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$cache_table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
491 }
492
493 $cached_posts_count = 0;
494 $posts_table = $wpdb->prefix . 'ctf_posts';
495 $posts_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $posts_table ) ) === $posts_table;
496 if ( $posts_exists ) {
497 $cached_posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$posts_table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
498 }
499
500 $cache_requests_count = (int) get_option( 'ctf_smash_cache_requests_count', 0 );
501
502 return array(
503 'feed_caches_count' => $feed_caches_count,
504 'cached_posts_count' => $cached_posts_count,
505 'cache_requests_count' => $cache_requests_count,
506 );
507 }
508
509 /**
510 * Map a Twitter/X API HTTP status code or legacy Twitter API error code to a category.
511 *
512 * HTTP codes: 401 (auth), 403 (permission), 404 (not_found), 429 (rate_limit), 5xx (server)
513 * Legacy Twitter v1.1 codes:
514 * auth: 32, 64, 135, 215, 326
515 * rate_limit: 88, 185
516 * permission: 93, 130, 131
517 * not_found: 34, 144
518 * server: 503
519 *
520 * @param int|string $code
521 * @return string
522 */
523 private function categorise_error_code( $code ): string {
524 $code = (int) $code;
525
526 // HTTP auth errors
527 if ( 401 === $code ) {
528 return 'auth';
529 }
530 // HTTP permission errors
531 if ( 403 === $code ) {
532 return 'permission';
533 }
534 // HTTP not found
535 if ( 404 === $code ) {
536 return 'not_found';
537 }
538 // HTTP rate limiting
539 if ( 429 === $code ) {
540 return 'rate_limit';
541 }
542 // HTTP server errors
543 if ( $code >= 500 && $code < 600 ) {
544 return 'server';
545 }
546
547 // Legacy Twitter v1.1 API error codes
548 if ( in_array( $code, array( 32, 64, 135, 215, 326 ), true ) ) {
549 return 'auth';
550 }
551 if ( in_array( $code, array( 88, 185 ), true ) ) {
552 return 'rate_limit';
553 }
554 if ( in_array( $code, array( 93, 130, 131 ), true ) ) {
555 return 'permission';
556 }
557 if ( in_array( $code, array( 34, 144 ), true ) ) {
558 return 'not_found';
559 }
560 if ( 503 === $code ) {
561 return 'server';
562 }
563
564 return 'other';
565 }
566
567 /**
568 * Error metrics: categorised counts and latest 10 errors.
569 *
570 * CTF stores errors in two places:
571 * - Legacy: ctf_errors option (array of plain strings)
572 * - SmashTwitter: ctf_statuses['smash_twitter']['error_log'] (array of strings with timestamps)
573 *
574 * @return array
575 */
576 private function get_error_metrics() {
577 $latest = $this->build_latest_errors_array();
578
579 $by_type = array(
580 'auth' => 0,
581 'rate_limit' => 0,
582 'permission' => 0,
583 'not_found' => 0,
584 'server' => 0,
585 'network' => 0,
586 'other' => 0,
587 );
588 $critical_count = 0;
589 foreach ( $latest as $err ) {
590 $cat = isset( $err['category'] ) ? $err['category'] : 'other';
591 if ( array_key_exists( $cat, $by_type ) ) {
592 ++$by_type[ $cat ];
593 } else {
594 ++$by_type['other'];
595 }
596 if ( ! empty( $err['critical'] ) ) {
597 ++$critical_count;
598 }
599 }
600
601 $api_failures = count( $latest );
602
603 return array(
604 'api_failures' => $api_failures,
605 'by_type' => $by_type,
606 'critical_count' => $critical_count,
607 'latest' => array_slice( $latest, 0, 10 ),
608 );
609 }
610
611 /**
612 * Build annotated error list from both legacy ctf_errors and SmashTwitter error_log.
613 *
614 * @return array
615 */
616 private function build_latest_errors_array() {
617 $latest = array();
618
619 // SmashTwitter error_log (newer, timestamped strings)
620 $statuses = get_option( 'ctf_statuses', array() );
621 $smash_log = isset( $statuses['smash_twitter']['error_log'] ) && is_array( $statuses['smash_twitter']['error_log'] )
622 ? $statuses['smash_twitter']['error_log']
623 : array();
624
625 foreach ( array_slice( $smash_log, -10 ) as $log_entry ) {
626 $str = is_string( $log_entry ) ? $log_entry : '';
627 $prefix = '';
628 $logged_at = '';
629
630 if ( preg_match( '/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+-\s+/', $str, $m ) ) {
631 $logged_at = $m[1];
632 $prefix = $m[0];
633 }
634
635 $message = trim( substr( $str, strlen( $prefix ) ) );
636 $message = $this->sanitize_error_message( $message, 300 );
637 $code = null;
638 $category = 'other';
639
640 // Extract HTTP codes (3 digits) or message-based codes
641 if ( preg_match( '/(?:error|code)[:\s]+(\d{3,})/i', $message, $cm ) ) {
642 $code = (int) $cm[1];
643 $category = $this->categorise_error_code( $code );
644 } elseif ( stripos( $message, 'too_many_requests' ) !== false || stripos( $message, 'rate limit' ) !== false ) {
645 $category = 'rate_limit';
646 } elseif ( stripos( $message, 'could_not_authenticate' ) !== false || stripos( $message, 'unauthorized' ) !== false ) {
647 $category = 'auth';
648 } elseif ( stripos( $message, 'HTTP request error' ) !== false ) {
649 $category = 'network';
650 }
651
652 $entry = array(
653 'type' => 'smash_log',
654 'category' => $category,
655 'logged_at' => $logged_at,
656 'message' => $message,
657 'critical' => in_array( $category, array( 'auth', 'permission' ), true ),
658 );
659 if ( null !== $code ) {
660 $entry['error_code'] = $code;
661 }
662 $latest[] = $entry;
663 }
664
665 // Legacy ctf_errors option (array of plain error strings)
666 $legacy_errors = get_option( 'ctf_errors', array() );
667 if ( is_array( $legacy_errors ) ) {
668 foreach ( array_slice( $legacy_errors, -5 ) as $error_str ) {
669 $message = is_string( $error_str ) ? $error_str : '';
670 $message = $this->sanitize_error_message( $message, 300 );
671 $code = null;
672 $category = 'other';
673
674 if ( preg_match( '/(?:error|code)[:\s]+(\d{3,})/i', $message, $cm ) ) {
675 $code = (int) $cm[1];
676 $category = $this->categorise_error_code( $code );
677 }
678
679 $entry = array(
680 'type' => 'legacy_log',
681 'category' => $category,
682 'message' => $message,
683 'critical' => in_array( $category, array( 'auth', 'permission' ), true ),
684 );
685 if ( null !== $code ) {
686 $entry['error_code'] = $code;
687 }
688 $latest[] = $entry;
689 }
690 }
691
692 return $latest;
693 }
694
695 /**
696 * Strip tokens and truncate error message.
697 *
698 * @param string $message
699 * @param int $max_len
700 * @return string
701 */
702 private function sanitize_error_message( $message, $max_len = 300 ) {
703 // Redact known credential key=value patterns
704 $message = preg_replace(
705 '/\b(access_token|accesstoken|api_key|api_secret|client_id|client_secret|consumer_key|consumer_secret|secret_key|auth_token|refresh_token|private_key|token)\s*[=:]\s*["\']?[^\s&"\'\\\\,\]}\)]{4,}["\']?/i',
706 '$1=[REDACTED]',
707 $message
708 );
709 // Redact Bearer tokens
710 $message = preg_replace( '/\bBearer\s+[A-Za-z0-9\-._~+\/]+=*/i', 'Bearer [REDACTED]', $message );
711 if ( strlen( $message ) > $max_len ) {
712 $message = substr( $message, 0, $max_len ) . '...';
713 }
714 return $message;
715 }
716
717 /**
718 * Days active in the given period.
719 *
720 * @param string $period_start
721 * @param string $period_end
722 * @return int|false
723 */
724 private function get_days_active( $period_start, $period_end ) {
725 $dates = get_option( \TwitterFeed\UsageTracking\Config::OPTION_ACTIVE_DATES, array() );
726 if ( ! is_array( $dates ) || empty( $dates ) ) {
727 return 0;
728 }
729 $count = 0;
730 $start = strtotime( $period_start );
731 $end = strtotime( $period_end );
732 foreach ( $dates as $d ) {
733 $ts = strtotime( $d );
734 if ( false !== $ts && $ts >= $start && $ts <= $end ) {
735 ++$count;
736 }
737 }
738 return $count;
739 }
740
741 /**
742 * Average of last recorded session durations in seconds.
743 *
744 * @return int|false
745 */
746 private function get_session_duration() {
747 $durations = get_option( \TwitterFeed\UsageTracking\Config::OPTION_SESSION_DURATIONS, array() );
748 if ( ! is_array( $durations ) || empty( $durations ) ) {
749 return 0;
750 }
751 return (int) round( array_sum( $durations ) / count( $durations ) );
752 }
753
754 /**
755 * Event counts and last_date for each event.
756 *
757 * @param int $ts_start
758 * @param int $ts_end
759 * @return array
760 */
761 private function get_events_for_period( $ts_start, $ts_end ) {
762 $events = get_option( 'ctf_smash_usage_events', array() );
763 if ( ! is_array( $events ) ) {
764 return array();
765 }
766
767 $first = reset( $events );
768 $is_legacy_list = is_array( $first ) && ! isset( $first['count'] ) && (isset( $first['event'] ) || isset( $first['timestamp'] ) || isset( $first['time'] ));
769
770 if ( $is_legacy_list ) {
771 $out = array();
772 foreach ( $events as $entry ) {
773 if ( ! is_array( $entry ) ) {
774 continue;
775 }
776 $ts = isset( $entry['timestamp'] ) ? (int) $entry['timestamp'] : (isset( $entry['time'] ) ? (int) $entry['time'] : 0);
777 if ( $ts < $ts_start || $ts > $ts_end ) {
778 continue;
779 }
780 $name = isset( $entry['event'] ) ? $entry['event'] : (isset( $entry['name'] ) ? $entry['name'] : '');
781 if ( '' !== $name ) {
782 if ( ! isset( $out[ $name ] ) ) {
783 $out[ $name ] = array(
784 'count' => 0,
785 'last_date' => null,
786 );
787 }
788 ++$out[ $name ]['count'];
789 $date = $ts ? gmdate( 'Y-m-d', $ts ) : null;
790 if ( $date && (null === $out[ $name ]['last_date'] || $date > $out[ $name ]['last_date']) ) {
791 $out[ $name ]['last_date'] = $date;
792 }
793 }
794 }
795 return $out;
796 }
797
798 $out = array();
799 foreach ( $events as $name => $value ) {
800 if ( ! is_string( $name ) || '' === $name ) {
801 continue;
802 }
803 if ( is_array( $value ) && isset( $value['count'] ) ) {
804 $out[ $name ] = array(
805 'count' => (int) $value['count'],
806 'last_date' => isset( $value['last_date'] ) && is_string( $value['last_date'] ) ? $value['last_date'] : null,
807 );
808 continue;
809 }
810 if ( is_numeric( $value ) ) {
811 $out[ $name ] = array(
812 'count' => (int) $value,
813 'last_date' => null,
814 );
815 }
816 }
817
818 return $out;
819 }
820 }
821