PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / UsageTracking / Reviews / ReviewsReporter.php
reviews-feed / class / Common / UsageTracking / Reviews Last commit date
ReviewsReporter.php 1 week ago
ReviewsReporter.php
856 lines
1 <?php
2
3 /**
4 * Reviews reporter for Smash Usage Tracking.
5 *
6 * Single reporter handling both free and pro variants.
7 *
8 * @package SmashBalloon\Reviews\Common\UsageTracking\Reviews
9 * @since 1.0
10 */
11
12 namespace SmashBalloon\Reviews\Common\UsageTracking\Reviews;
13
14 use SmashBalloon\Reviews\Common\UsageTracking\ReporterInterface;
15 use SmashBalloon\Reviews\Common\UsageTracking\Config;
16 use SmashBalloon\Reviews\Common\UsageTracking\EventRecorder;
17
18 if (! defined('ABSPATH')) {
19 exit;
20 }
21
22 class ReviewsReporter implements ReporterInterface {
23 /**
24 * Payload schema version. 1.1 added feeds{} and features_enabled{},
25 * 1.2 added environment{} — this reporter sends all of them, so it declares
26 * 1.2 rather than the 1.1 it originally shipped with.
27 */
28 const SCHEMA_VERSION = '1.2';
29
30 /**
31 * License tier integer → string map.
32 *
33 * @var array
34 */
35 private static $tier_map = array(
36 0 => 'free',
37 1 => 'basic',
38 2 => 'plus',
39 3 => 'elite',
40 );
41
42 /**
43 * Whitelisted feed setting keys to include in latest_10_feeds.
44 *
45 * @var string[]
46 */
47 private static $feed_settings_whitelist = array(
48 'layout',
49 'gridDesktopColumns',
50 'carouselDesktopColumns',
51 'showHeader',
52 'postElements',
53 'feedTemplate',
54 );
55
56 /**
57 * Plugin slug for payload root.
58 *
59 * @return string
60 */
61 public function get_plugin_slug()
62 {
63 return 'reviews';
64 }
65
66 /**
67 * Schema version for the report payload.
68 *
69 * @return string
70 */
71 public function get_schema_version()
72 {
73 return self::SCHEMA_VERSION;
74 }
75
76 /**
77 * Configuration snapshot.
78 *
79 * @return array
80 */
81 public function get_configuration_snapshot()
82 {
83 $global_settings = $this->get_global_settings();
84 $all_feed_data = $this->get_all_feed_data();
85
86 return array(
87 'environment' => $this->get_environment(),
88 'global_settings' => $global_settings,
89 'sources' => $this->get_sources_summary(),
90 'providers' => $this->get_providers_summary(),
91 'latest_10_feeds' => $this->get_latest_feeds($all_feed_data),
92 'feeds' => $this->get_feeds_summary($all_feed_data),
93 'features_enabled' => $this->get_features_enabled($all_feed_data),
94 'version' => defined('SBRVER') ? SBRVER : '',
95 'license_tier' => $this->get_license_tier(),
96 'license_status' => $this->get_license_status(),
97 'license_expires' => $this->get_license_expires(),
98 'license_item_id' => $this->get_license_item_id(),
99 );
100 }
101
102 /**
103 * Dynamic metrics for the given period.
104 *
105 * @param string|int $period_start Start of period (ISO 8601 or timestamp).
106 * @param string|int $period_end End of period (ISO 8601 or timestamp).
107 * @return array
108 */
109 public function get_dynamic_metrics($period_start, $period_end)
110 {
111 $ts_start = is_numeric($period_start) ? (int) $period_start : (int) strtotime($period_start);
112 $ts_end = is_numeric($period_end) ? (int) $period_end : (int) strtotime($period_end);
113
114 return array(
115 'period_start' => $period_start,
116 'period_end' => $period_end,
117 'performance' => $this->get_performance_metrics(),
118 'errors' => $this->get_error_metrics(),
119 'events' => $this->get_events_for_period($ts_start, $ts_end),
120 'days_active' => $this->get_days_active((string) $period_start, (string) $period_end),
121 'session_duration' => $this->get_session_duration(),
122 );
123 }
124
125 // ──────────────────────────────────────────────────────────────────────────
126 // Configuration snapshot helpers
127 // ──────────────────────────────────────────────────────────────────────────
128
129 /**
130 * Environment data (WP, PHP, theme, locale, multisite, install age).
131 *
132 * @return array
133 */
134 private function get_environment()
135 {
136 $install_ts = null;
137 $statuses = $this->get_option_array('sbr_statuses');
138 if (! empty($statuses['first_install']) && is_numeric($statuses['first_install'])) {
139 $install_ts = (int) $statuses['first_install'];
140 }
141 $install_age_days = $install_ts ? max(0, (int) ((time() - $install_ts) / DAY_IN_SECONDS)) : 0;
142
143 $theme = wp_get_theme();
144 $theme_name = $theme->exists() ? $theme->get('Name') : '';
145
146 return array(
147 'wp_version' => get_bloginfo('version'),
148 'php_version' => PHP_VERSION,
149 'active_theme' => $theme_name,
150 'locale' => get_locale(),
151 'multisite' => is_multisite(),
152 'site_count' => is_multisite() ? (int) get_blog_count() : 1,
153 'active_plugins_count' => count(
154 array_unique(
155 array_merge(
156 (array) get_option('active_plugins', array()),
157 array_keys((array) get_site_option('active_sitewide_plugins', array()))
158 )
159 )
160 ),
161 'install_age_days' => $install_age_days,
162 );
163 }
164
165 /**
166 * Global SBR settings from the sbr_settings option.
167 *
168 * @return array
169 */
170 private function get_global_settings()
171 {
172 $settings = $this->get_option_array('sbr_settings');
173
174 return array(
175 'feedTemplate' => isset($settings['feedTemplate']) ? $settings['feedTemplate'] : 'default',
176 'layout' => isset($settings['layout']) ? $settings['layout'] : 'list',
177 'preserve_settings' => ! empty($settings['preserve_settings']),
178 // Via the helper, not the raw key: with the key absent the default
179 // is per-edition, and reading the key directly would make the
180 // payload assert usagetracking:false while transmitting.
181 'usagetracking' => Config::is_enabled(),
182 );
183 }
184
185 /**
186 * Sources summary (connected count, by provider) from sbr_sources table.
187 *
188 * @return array
189 */
190 private function get_sources_summary()
191 {
192 global $wpdb;
193 $sources_table = $wpdb->prefix . SBR_SOURCES_TABLE;
194 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $sources_table)) === $sources_table;
195
196 $connected_count = 0;
197 $by_provider = array();
198
199 if ($table_exists) {
200 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
201 $connected_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$sources_table}");
202
203 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
204 $rows = $wpdb->get_results("SELECT provider, COUNT(*) AS cnt FROM {$sources_table} GROUP BY provider", ARRAY_A);
205 if (is_array($rows)) {
206 foreach ($rows as $row) {
207 $provider = sanitize_text_field((string) $row['provider']);
208 $by_provider[ $provider ] = (int) $row['cnt'];
209 }
210 }
211 }
212
213 return array(
214 'connected_count' => $connected_count,
215 'by_provider' => $by_provider,
216 );
217 }
218
219 /**
220 * Providers summary (Reviews-unique): connected_sources and error_count per provider.
221 * Queries sbr_sources cross-referenced with sbr_errors for error counts.
222 *
223 * @return array
224 */
225 private function get_providers_summary()
226 {
227 global $wpdb;
228 $sources_table = $wpdb->prefix . SBR_SOURCES_TABLE;
229 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $sources_table)) === $sources_table;
230
231 $providers = array();
232
233 if ($table_exists) {
234 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
235 $rows = $wpdb->get_results("SELECT provider, COUNT(*) AS cnt FROM {$sources_table} GROUP BY provider", ARRAY_A);
236 if (is_array($rows)) {
237 foreach ($rows as $row) {
238 $provider = sanitize_text_field((string) $row['provider']);
239 $providers[ $provider ] = array(
240 'connected_sources' => (int) $row['cnt'],
241 'error_count' => 0,
242 );
243 }
244 }
245 }
246
247 // Cross-reference sbr_errors option to get error counts per provider.
248 $raw_errors = get_option('sbr_errors', array());
249 if (is_array($raw_errors)) {
250 foreach ($raw_errors as $err) {
251 $provider = isset($err['provider']) ? sanitize_text_field((string) $err['provider']) : 'unknown';
252 if (isset($providers[ $provider ])) {
253 ++$providers[ $provider ]['error_count'];
254 } else {
255 // Provider has errors but no sources — still report it.
256 $providers[ $provider ] = array(
257 'connected_sources' => 0,
258 'error_count' => 1,
259 );
260 }
261 }
262 }
263
264 return $providers;
265 }
266
267 /**
268 * Load every feed's decoded settings plus feed_name, sorted newest-first.
269 * One DB query shared across get_latest_feeds(), get_feeds_summary(), and
270 * get_features_enabled() to avoid multiple table scans per report.
271 *
272 * feed_style (custom CSS) is a COLUMN of the feeds table, not a key
273 * inside the settings JSON — SBR_Feed_Saver writes it as its own field —
274 * so it must be selected explicitly. Only its length is carried: the
275 * payload never needs the CSS itself.
276 *
277 * @return array<int, array{feed_name: string, settings: array, custom_css_length: int}>
278 */
279 private function get_all_feed_data(): array
280 {
281 global $wpdb;
282 $table = $wpdb->prefix . SBR_FEEDS_TABLE;
283 $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table;
284
285 if (! $table_exists) {
286 return array();
287 }
288
289 $rows = $wpdb->get_results(
290 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
291 "SELECT feed_name, settings, feed_style FROM {$table} ORDER BY last_modified DESC LIMIT 500",
292 ARRAY_A
293 );
294
295 if (! is_array($rows)) {
296 return array();
297 }
298
299 $out = array();
300 foreach ($rows as $row) {
301 $decoded = ! empty($row['settings']) ? json_decode($row['settings'], true) : array();
302 $out[] = array(
303 'feed_name' => isset($row['feed_name']) ? sanitize_text_field((string) $row['feed_name']) : '',
304 'settings' => is_array($decoded) ? $decoded : array(),
305 'custom_css_length' => isset($row['feed_style']) && is_string($row['feed_style']) ? strlen(trim($row['feed_style'])) : 0,
306 );
307 }
308
309 return $out;
310 }
311
312 /**
313 * Latest 15 feeds with whitelisted settings.
314 * Payload key kept as 'latest_10_feeds' for backwards compatibility.
315 *
316 * @param array<int, array{feed_name: string, settings: array, custom_css_length: int}> $all_feed_data From get_all_feed_data().
317 * @return array
318 */
319 private function get_latest_feeds(array $all_feed_data): array
320 {
321 $feeds = array();
322 foreach (array_slice($all_feed_data, 0, 15) as $row) {
323 $feed_name = $row['feed_name'];
324 if (strlen($feed_name) > 255) {
325 $feed_name = substr($feed_name, 0, 255);
326 }
327 $feeds[] = array(
328 'feed_name' => $feed_name,
329 'settings' => $this->pick_whitelisted_settings($row['settings']),
330 'custom_css_length' => (int) $row['custom_css_length'],
331 );
332 }
333 return $feeds;
334 }
335
336 /**
337 * Aggregate feed layout distribution across all feeds.
338 *
339 * @param array<int, array{feed_name: string, settings: array}> $all_feed_data From get_all_feed_data().
340 * @return array { total_count, by_type, by_layout }
341 */
342 private function get_feeds_summary(array $all_feed_data): array
343 {
344 $by_type = array();
345 $by_layout = array();
346
347 foreach ($all_feed_data as $row) {
348 $s = $row['settings'];
349 // Reviews feeds use 'feedTemplate' as type
350 $type = isset($s['feedTemplate']) ? (string) $s['feedTemplate'] : 'default';
351 $layout = isset($s['layout']) ? (string) $s['layout'] : 'list';
352
353 $by_type[ $type ] = ($by_type[ $type ] ?? 0) + 1;
354 $by_layout[ $layout ] = ($by_layout[ $layout ] ?? 0) + 1;
355 }
356
357 return array(
358 'total_count' => count($all_feed_data),
359 'by_type' => $by_type,
360 'by_layout' => $by_layout,
361 );
362 }
363
364 /**
365 * Flat boolean feature map for the Laravel dashboard.
366 * Feed-level flags are true when ANY feed on this site uses the feature.
367 * review_form is site-level: the review-submission feature is the Forms
368 * integration (WPForms/Formidable/... collections), whose usage marker is
369 * the sb_connected_forms option — feed settings carry no flag for it.
370 *
371 * @param array<int, array{feed_name: string, settings: array, custom_css_length: int}> $all_feed_data From get_all_feed_data().
372 * @return array<string,bool>
373 */
374 private function get_features_enabled(array $all_feed_data): array
375 {
376 $feed_flags = array(
377 'load_more' => false,
378 'show_header' => false,
379 'lightbox' => false,
380 'masonry_layout' => false,
381 'carousel' => false,
382 'moderation_mode' => false,
383 'custom_css' => false,
384 'star_filter' => false,
385 );
386
387 foreach ($all_feed_data as $row) {
388 $s = $row['settings'];
389
390 if (! $feed_flags['load_more'] && ! empty($s['showLoadButton'])) {
391 $feed_flags['load_more'] = true;
392 }
393 if (! $feed_flags['show_header'] && ! empty($s['showHeader'])) {
394 $feed_flags['show_header'] = true;
395 }
396 if (! $feed_flags['lightbox'] && in_array('media', isset($s['postElements']) ? (array) $s['postElements'] : array(), true)) {
397 $feed_flags['lightbox'] = true;
398 }
399 if (! $feed_flags['masonry_layout'] && isset($s['layout']) && 'masonry' === $s['layout']) {
400 $feed_flags['masonry_layout'] = true;
401 }
402 if (! $feed_flags['carousel'] && isset($s['layout']) && 'carousel' === $s['layout']) {
403 $feed_flags['carousel'] = true;
404 }
405 if (! $feed_flags['moderation_mode'] && ! empty($s['moderationEnabled'])) {
406 $feed_flags['moderation_mode'] = true;
407 }
408 if (! $feed_flags['custom_css'] && $row['custom_css_length'] > 0) {
409 $feed_flags['custom_css'] = true;
410 }
411 if (! $feed_flags['star_filter'] && ! empty($s['includedStarFilters'])) {
412 $feed_flags['star_filter'] = true;
413 }
414
415 // Early exit once all feed-level flags are confirmed true.
416 if (! in_array(false, $feed_flags, true)) {
417 break;
418 }
419 }
420
421 $connected_forms = get_option('sb_connected_forms', array());
422 $feed_flags['review_form'] = is_array($connected_forms) && ! empty($connected_forms);
423
424 return $feed_flags;
425 }
426
427 /**
428 * Return only whitelisted feed settings.
429 *
430 * @param array $settings Raw feed settings.
431 * @return array
432 */
433 private function pick_whitelisted_settings(array $settings): array
434 {
435 $out = array();
436 foreach (self::$feed_settings_whitelist as $key) {
437 if (! array_key_exists($key, $settings)) {
438 continue;
439 }
440 $value = $settings[ $key ];
441 if (is_array($value)) {
442 $out[ $key ] = $value;
443 } elseif (is_scalar($value)) {
444 $out[ $key ] = $value;
445 }
446 }
447 return $out;
448 }
449
450 // ──────────────────────────────────────────────────────────────────────────
451 // License helpers (single reporter handles both free & pro)
452 // ──────────────────────────────────────────────────────────────────────────
453
454 /**
455 * License tier string.
456 *
457 * @return string free|basic|plus|elite
458 */
459 private function get_license_tier()
460 {
461 if (! \SmashBalloon\Reviews\Common\Util::sbr_is_pro()) {
462 return 'free';
463 }
464 $statuses = $this->get_option_array('sbr_statuses');
465 $int_tier = isset($statuses['license_tier']) && is_numeric($statuses['license_tier']) ? (int) $statuses['license_tier'] : 0;
466 return self::$tier_map[ $int_tier ] ?? 'free';
467 }
468
469 /**
470 * License status string or null.
471 *
472 * @return string|null
473 */
474 private function get_license_status()
475 {
476 if (! \SmashBalloon\Reviews\Common\Util::sbr_is_pro()) {
477 return null;
478 }
479 $settings = $this->get_option_array('sbr_settings');
480 $status = $settings['license_status'] ?? null;
481 return is_string($status) ? $status : null;
482 }
483
484 /**
485 * License expiry date string or null.
486 *
487 * @return string|null
488 */
489 private function get_license_expires()
490 {
491 if (! \SmashBalloon\Reviews\Common\Util::sbr_is_pro()) {
492 return null;
493 }
494 $settings = $this->get_option_array('sbr_settings');
495 $info = $settings['license_info'] ?? array();
496 $expires = is_array($info) ? ($info['expires'] ?? null) : null;
497 return is_string($expires) ? $expires : null;
498 }
499
500 /**
501 * License item / price ID as integer or null.
502 *
503 * @return int|null
504 */
505 private function get_license_item_id()
506 {
507 if (! \SmashBalloon\Reviews\Common\Util::sbr_is_pro()) {
508 return null;
509 }
510 $settings = $this->get_option_array('sbr_settings');
511 $info = $settings['license_info'] ?? array();
512 return is_array($info) && isset($info['price_id']) && is_numeric($info['price_id']) ? (int) $info['price_id'] : null;
513 }
514
515 // ──────────────────────────────────────────────────────────────────────────
516 // Dynamic metrics helpers
517 // ──────────────────────────────────────────────────────────────────────────
518
519 /**
520 * Performance metrics: feed caches count, reviews post count.
521 *
522 * @return array
523 */
524 private function get_performance_metrics()
525 {
526 global $wpdb;
527
528 $cache_table = $wpdb->prefix . SBR_FEED_CACHES_TABLE;
529 $reviews_posts_table = $wpdb->prefix . SBR_POSTS_TABLE;
530
531 $cache_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $cache_table)) === $cache_table;
532 $reviews_table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $reviews_posts_table)) === $reviews_posts_table;
533
534 $feed_caches_count = 0;
535 $reviews_count = 0;
536
537 if ($cache_table_exists) {
538 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
539 $feed_caches_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$cache_table}");
540 }
541 if ($reviews_table_exists) {
542 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name derived from $wpdb->prefix, not user input.
543 $reviews_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$reviews_posts_table}");
544 }
545
546 return array(
547 'feed_caches_count' => $feed_caches_count,
548 'reviews_count' => $reviews_count,
549 );
550 }
551
552 /**
553 * Error metrics from sbr_errors and sbr_error_reporter options.
554 *
555 * @return array
556 */
557 private function get_error_metrics(): array
558 {
559 $raw_errors = $this->get_option_array('sbr_errors'); // relay API errors
560 $reporter = $this->get_option_array('sbr_error_reporter'); // aggregated errors
561 $settings = $this->get_option_array('sbr_settings');
562 $statuses = $this->get_option_array('sbr_statuses');
563
564 $relay = $this->collect_relay_errors($raw_errors);
565
566 $accounts = isset($reporter['accounts']) && is_array($reporter['accounts']) ? $reporter['accounts'] : array();
567 $revoked = isset($reporter['revoked']) && is_array($reporter['revoked']) ? $reporter['revoked'] : array();
568 $connection = isset($reporter['connection']) && is_array($reporter['connection']) ? $reporter['connection'] : array();
569
570 $account = $this->collect_account_errors($accounts);
571
572 return array(
573 'api_failures' => $relay['api_failures'],
574 'token_errors' => $relay['token_errors'] + $account['token_errors'],
575 'by_provider' => $relay['by_provider'],
576 'by_id' => $relay['by_id'],
577 'account_errors' => $account['account_errors'],
578 'token_revoked' => count($revoked),
579 'license_error' => $this->get_license_error($settings, $raw_errors),
580 'connection_critical' => ! empty($connection['critical']),
581 'last_license_check' => $statuses['last_cron_update'] ?? null,
582 'latest' => array_slice(array_merge($relay['latest'], $account['latest']), 0, 10),
583 );
584 }
585
586 /**
587 * Aggregate relay API errors from the sbr_errors option.
588 *
589 * @param array $raw_errors Entries from the sbr_errors option.
590 * @return array{api_failures:int,token_errors:int,by_provider:array,by_id:array,latest:array}
591 */
592 private function collect_relay_errors(array $raw_errors): array
593 {
594 $out = array(
595 'api_failures' => 0,
596 'token_errors' => 0,
597 'by_provider' => array(),
598 'by_id' => array(),
599 'latest' => array(),
600 );
601
602 foreach ($raw_errors as $err) {
603 ++$out['api_failures'];
604 $provider = isset($err['provider']) ? sanitize_text_field((string) $err['provider']) : 'unknown';
605 $err_id = isset($err['id']) ? sanitize_text_field((string) $err['id']) : 'unknown';
606 $endpoint = isset($err['endpoint']) ? $this->sanitize_endpoint((string) $err['endpoint']) : null;
607
608 $out['by_provider'][ $provider ] = ($out['by_provider'][ $provider ] ?? 0) + 1;
609 $out['by_id'][ $err_id ] = ($out['by_id'][ $err_id ] ?? 0) + 1;
610
611 if ('invalidToken' === $err_id) {
612 ++$out['token_errors'];
613 }
614
615 $out['latest'][] = array(
616 'source' => 'relay',
617 'id' => $err_id,
618 'provider' => $provider,
619 'endpoint' => $endpoint,
620 );
621 }
622
623 return $out;
624 }
625
626 /**
627 * Aggregate token & permission errors from sbr_error_reporter accounts.
628 *
629 * @param array $accounts Accounts map from the sbr_error_reporter option.
630 * @return array{account_errors:int,token_errors:int,latest:array}
631 */
632 private function collect_account_errors(array $accounts): array
633 {
634 $out = array(
635 'account_errors' => 0,
636 'token_errors' => 0,
637 'latest' => array(),
638 );
639
640 foreach ($accounts as $account_id => $error_types) {
641 if (! is_array($error_types)) {
642 continue;
643 }
644 if (! empty($error_types['accesstoken'])) {
645 ++$out['token_errors'];
646 ++$out['account_errors'];
647 $out['latest'][] = array(
648 'source' => 'account',
649 'id' => 'accesstoken',
650 // The account key is a provider business/place ID —
651 // identifying data the rest of the payload avoids, so it
652 // is not transmitted.
653 'provider' => 'account',
654 'critical' => ! empty($error_types['accesstoken']['critical']),
655 );
656 }
657 if (! empty($error_types['api'])) {
658 ++$out['account_errors'];
659 // The stored option is populated from provider API responses —
660 // force the code to an int before it enters the payload.
661 $code = isset($error_types['api']['error']['code']) && is_scalar($error_types['api']['error']['code'])
662 ? (int) $error_types['api']['error']['code']
663 : null;
664 if (in_array($code, array( 190, 104, 999 ), true)) {
665 ++$out['token_errors'];
666 }
667 $out['latest'][] = array(
668 'source' => 'account',
669 'id' => 'api_' . (null === $code ? 'unknown' : $code),
670 'provider' => 'account',
671 'critical' => ! empty($error_types['api']['critical']),
672 );
673 }
674 }
675
676 return $out;
677 }
678
679 /**
680 * Resolve the license error state (pro only).
681 *
682 * @param array $settings The sbr_settings option.
683 * @param array $raw_errors Entries from the sbr_errors option.
684 * @return string|null
685 */
686 private function get_license_error(array $settings, array $raw_errors): ?string
687 {
688 if (! \SmashBalloon\Reviews\Common\Util::sbr_is_pro()) {
689 return null;
690 }
691
692 $license_status = $settings['license_status'] ?? null;
693 $license_error = null;
694 if ('deactivated' === $license_status) {
695 $license_error = 'deactivated';
696 } elseif ('invalid' === $license_status) {
697 $license_error = 'invalid';
698 } elseif (empty($settings['license_key'])) {
699 $license_error = 'missing_key';
700 }
701
702 foreach ($raw_errors as $err) {
703 // is_string, not just isset: the option is free-form and an array
704 // haystack is an uncaught TypeError on PHP 8.
705 if (isset($err['endpoint']) && is_string($err['endpoint']) && strpos($err['endpoint'], 'auth/license') !== false) {
706 $license_error = $license_error ?? ('relay_auth_' . sanitize_text_field((string) ($err['id'] ?? 'unknown')));
707 }
708 }
709
710 return $license_error;
711 }
712
713 /**
714 * Number of days in the period when the plugin was actively used.
715 *
716 * @param string $period_start Y-m-d.
717 * @param string $period_end Y-m-d.
718 * @return int
719 */
720 private function get_days_active(string $period_start, string $period_end): int
721 {
722 $dates = get_option(Config::OPTION_ACTIVE_DATES, array());
723 if (! is_array($dates) || empty($dates)) {
724 return 0;
725 }
726 $count = 0;
727 $start = strtotime($period_start);
728 $end = strtotime($period_end);
729 foreach ($dates as $d) {
730 if (! is_string($d)) {
731 continue;
732 }
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, 0 if not tracked.
743 *
744 * @return int
745 */
746 private function get_session_duration(): int
747 {
748 $durations = get_option(Config::OPTION_SESSION_DURATIONS, array());
749 if (! is_array($durations) || empty($durations)) {
750 return 0;
751 }
752 return (int) round(array_sum($durations) / count($durations));
753 }
754
755 /**
756 * Event counts and last_date for each event from sbr_smash_usage_events.
757 *
758 * The store has held the name-keyed {count,last_date} map since the
759 * feature first shipped — no version ever wrote timestamped list entries,
760 * so there is no legacy-list branch here. If one ever fired it would
761 * double-report: reset_events_after_send() subtracts by event-name key,
762 * which can never match a numerically-keyed list.
763 *
764 * @param int $ts_start Period start timestamp (payload metadata only).
765 * @param int $ts_end Period end timestamp (payload metadata only).
766 * @return array Event name => [ 'count' => int, 'last_date' => string|null ].
767 */
768 private function get_events_for_period($ts_start, $ts_end)
769 {
770 unset($ts_start, $ts_end);
771
772 $events = get_option(EventRecorder::OPTION_NAME, array());
773 if (! is_array($events)) {
774 return array();
775 }
776
777 // Accumulate-then-clear format: report all stored events regardless
778 // of last_date. The period parameters are payload metadata only — filtering
779 // by last_date would silently exclude events recorded today (period_end is
780 // yesterday) and, on a site used every day, exclude them again every week.
781 $out = array();
782 foreach ($events as $name => $value) {
783 if (! is_string($name) || '' === $name) {
784 continue;
785 }
786 if (is_array($value) && isset($value['count'])) {
787 $last_date = isset($value['last_date']) && is_string($value['last_date']) ? $value['last_date'] : null;
788 $out[ $name ] = array(
789 'count' => (int) $value['count'],
790 'last_date' => $last_date,
791 );
792 continue;
793 }
794 if (is_numeric($value)) {
795 $out[ $name ] = array(
796 'count' => (int) $value,
797 'last_date' => null,
798 );
799 }
800 }
801
802 return $out;
803 }
804
805 /**
806 * Reduce a logged endpoint URL to its path only.
807 *
808 * Relay error entries store the full request URL, whose query string can
809 * carry provider credentials (e.g. Google's bare `key` parameter). Strip
810 * the query entirely and redact anything that still looks sensitive.
811 *
812 * @param string $url Full endpoint URL from the error log.
813 * @return string|null
814 */
815 private function sanitize_endpoint($url)
816 {
817 $path = wp_parse_url($url, PHP_URL_PATH);
818 if (! is_string($path) || '' === $path) {
819 return null;
820 }
821 $path = $this->sanitize_error_message(sanitize_text_field($path));
822
823 return '' === $path ? null : $path;
824 }
825
826 private function sanitize_error_message(string $message, int $max_len = 300): string
827 {
828 // Redact known credential key=value patterns
829 $message = (string) preg_replace(
830 '/\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|key|place_id)\s*[=:]\s*["\']?[^\s&"\'\\\\,\]}\)]{4,}["\']?/i',
831 '$1=[REDACTED]',
832 $message
833 );
834 // Redact Bearer tokens
835 $message = (string) preg_replace('/\bBearer\s+[A-Za-z0-9\-._~+\/]+=*/i', 'Bearer [REDACTED]', $message);
836 if (strlen($message) > $max_len) {
837 $message = substr($message, 0, $max_len) . '...';
838 }
839 return $message;
840 }
841
842 /**
843 * get_option() wrapper guaranteeing an array, since options can be
844 * corrupted into scalars.
845 *
846 * @param string $name Option name.
847 * @return array
848 */
849 private function get_option_array(string $name): array
850 {
851 $value = get_option($name, array());
852
853 return is_array($value) ? $value : array();
854 }
855 }
856