PluginProbe
SlimStat Analytics / trunk
SlimStat Analytics vtrunk
5.5.0 5.4.12 4.7.4 4.7.4.1 4.7.5 4.7.5.1 4.7.5.2 4.7.5.3 4.7.6 4.7.6.1 4.7.7 4.7.8 4.7.8.1 4.7.8.2 4.7.8.3 4.7.9 4.7.9.1 4.8 4.8.1 4.8.2 4.8.3 4.8.4 4.8.4.1 4.8.5 4.8.5.1 All 212 releases
wp-slimstat / wp-slimstat.php

wp-slimstat.php in SlimStat Analytics trunk, at wp-slimstat.php

1,923 lines 85.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /*
3 * Plugin Name: SlimStat Analytics
4 * Plugin URI: https://wp-slimstat.com/
5 * Description: The leading web analytics plugin for WordPress
6 * Version: 5.5.0
7 * Author: Jason Crouse, VeronaLabs
8 * Text Domain: wp-slimstat
9 * Domain Path: /languages
10 * Author URI: https://wp-slimstat.com/
11 * License: GPL-2.0+
12 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
13 * Requires at least: 5.6
14 * Requires PHP: 7.4
15 */
16
17 // check if composer autoloader exists
18 if (!file_exists(__DIR__ . '/vendor/autoload.php')) {
19 return;
20 }
21
22 // Set the plugin version and directory
23 define('SLIMSTAT_ANALYTICS_VERSION', '5.5.0');
24 define('SLIMSTAT_FILE', __FILE__);
25 define('SLIMSTAT_DIR', __DIR__);
26 define('SLIMSTAT_URL', plugins_url('', __FILE__));
27
28 // include the autoloader if it exists
29 require_once __DIR__ . '/vendor/autoload.php';
30
31 // Load the Mozart-scoped Symfony/Polyfill/Php80 so own code can use PHP 8.0+
32 // stdlib functions (str_contains, str_starts_with, fdiv, get_debug_type, …)
33 // on PHP 7.4 hosts. The bootstrap short-circuits on PHP_VERSION_ID >= 80000.
34 // Skipping this load on PHP 7.4 is what produced the v5.4.14 wp-admin fatal.
35 require_once __DIR__ . '/src/Dependencies/Symfony/Polyfill/Php80/bootstrap.php';
36
37 // Include Constants.php to make SLIMSTAT_ANALYTICS_DIR available to traits
38 require_once __DIR__ . '/src/Constants.php';
39
40
41 /**
42 * Main Slimstat Analytics Class
43 *
44 * @package Wp_SlimStat
45 *
46 * @todo REFACTOR TRACKING STATE: The $data_js and $stat properties should be refactored into a
47 * proper state object pattern to maintain encapsulation. Currently these properties are
48 * public to support refactored tracker classes (SlimStat\Tracker\*), but this breaks
49 * encapsulation and creates security risks. Future implementation should:
50 * 1. Create a TrackingState class to encapsulate state management
51 * 2. Update all Tracker classes to use the state object
52 * 3. Make properties protected or private
53 * 4. Ensure all state modifications go through validated methods
54 * This is tracked as technical debt for version 6.0
55 */
56
57 // Include Constants.php to make SLIMSTAT_ANALYTICS_DIR available to traits
58 require_once __DIR__ . '/src/Constants.php';
59
60 class wp_slimstat
61 {
62 public static $settings = [];
63
64 public static $wpdb;
65 public static $upload_dir = '';
66
67 /**
68 * Flag indicating programmatic (server-side) tracking is active.
69 *
70 * When true, CMP consent checks are bypassed in Consent::canTrack() and
71 * Consent::piiAllowed(). This is used by slimtrack_server() for server-side
72 * contexts (cron, CLI, redirect handlers) where no browser session exists.
73 *
74 * DNT headers, IP anonymization/hashing, and other non-consent settings
75 * remain enforced.
76 *
77 * @var bool
78 * @since 5.4.3
79 */
80 public static $is_programmatic_tracking = false;
81
82 public static $update_checker = [];
83 public static $raw_post_array = [];
84
85 /**
86 * @var array Tracking data from JavaScript (for internal tracking use only)
87 * @internal Use get_data_js() / set_data_js() methods for controlled access.
88 *
89 * This property is now protected to maintain proper encapsulation and prevent external code
90 * from bypassing consent checks or corrupting tracking state. All tracker classes use the
91 * getter/setter methods which include validation and filter hooks for GDPR compliance.
92 */
93 protected static $data_js = ['id' => 0];
94
95 /**
96 * @var array Current pageview tracking data (for internal tracking use only)
97 * @internal Use get_stat() / set_stat() methods for controlled access.
98 *
99 * This property is now protected to maintain proper encapsulation and prevent external code
100 * from bypassing consent checks or corrupting tracking state. All tracker classes use the
101 * getter/setter methods which include validation and filter hooks for GDPR compliance.
102 */
103 protected static $stat = [];
104
105 protected static $date_i18n_filters = [];
106
107 /**
108 * Gets the current data_js array (for internal tracking use only)
109 *
110 * @return array
111 */
112 public static function get_data_js()
113 {
114 return self::$data_js;
115 }
116
117 /**
118 * Sets the data_js array (for internal tracking use only)
119 *
120 * This method provides controlled access to the data_js property and includes
121 * basic validation to prevent tampering.
122 *
123 * @param array $data_js The tracking data from JavaScript
124 * @return void
125 * @internal For use by SlimStat tracking classes only
126 */
127 public static function set_data_js($data_js)
128 {
129 // Validate that we're receiving an array
130 if (!is_array($data_js)) {
131 return;
132 }
133
134 // Apply filter to allow validation/modification by consent management systems
135 $data_js = apply_filters('slimstat_set_data_js', $data_js);
136
137 self::$data_js = $data_js;
138 }
139
140 /**
141 * Gets the current stat array (for internal tracking use only)
142 *
143 * @return array Current tracking state
144 * @internal For use by SlimStat tracking classes only
145 */
146 public static function get_stat()
147 {
148 return self::$stat;
149 }
150
151 /**
152 * Sets the stat array (for internal tracking use only)
153 *
154 * This method provides controlled access to the stat property and includes
155 * basic validation to prevent tampering and ensure consent compliance.
156 *
157 * @param array $stat The pageview tracking data
158 * @return void
159 * @internal For use by SlimStat tracking classes only
160 */
161 public static function set_stat($stat)
162 {
163 // Validate that we're receiving an array
164 if (!is_array($stat)) {
165 return;
166 }
167
168 // Apply filter to allow validation/modification by consent management systems
169 // This is critical for GDPR compliance - CMPs can inspect and modify data
170 $stat = apply_filters('slimstat_set_stat', $stat);
171
172 self::$stat = $stat;
173 }
174
175 /**
176 * Backward-compatible wrapper for the tracking API.
177 *
178 * This method delegates to the new namespaced Tracker class while maintaining
179 * the original method signature for third-party integrations.
180 *
181 * @since 5.4.3
182 * @return int|false The record ID on success, or a negative error code on failure.
183 */
184 public static function slimtrack()
185 {
186 return \SlimStat\Tracker\Tracker::slimtrack();
187 }
188
189 /**
190 * Server-side tracking API that bypasses CMP consent checks.
191 *
192 * Use this method for programmatic tracking in server-side contexts where no
193 * browser session exists (e.g., cron jobs, CLI scripts, redirect handlers).
194 *
195 * CMP consent is a browser-side concept. In server-side contexts, there is no
196 * browser session and CMP consent has no meaningful role.
197 *
198 * The following settings remain enforced:
199 * - DNT (Do Not Track) headers
200 * - IP anonymization and hashing settings
201 * - Tracker cookie configuration
202 * - All exclusion rules
203 *
204 * @since 5.4.3
205 * @return int|false The record ID on success, or a negative error code on failure.
206 */
207 public static function slimtrack_server()
208 {
209 $previous_programmatic_state = self::$is_programmatic_tracking;
210 self::$is_programmatic_tracking = true;
211
212 try {
213 $result = \SlimStat\Tracker\Tracker::slimtrack();
214 } finally {
215 self::$is_programmatic_tracking = $previous_programmatic_state;
216 }
217
218 return $result;
219 }
220
221 /**
222 * Initializes variables and actions
223 */
224 public static function init()
225 {
226 \SlimStat\Providers\RestApiManager::run();
227
228 // Load all the settings
229 if (is_network_admin() && (empty($_GET['page']) || false === strpos($_GET['page'], 'slimview'))) {
230 self::$settings = get_site_option('slimstat_options', []);
231 } else {
232 self::$settings = get_option('slimstat_options', []);
233 }
234
235 if (empty(self::$settings)) {
236 // Fresh install: set defaults including geolocation_provider=dbip
237 self::$settings = self::get_fresh_defaults();
238 self::update_option('slimstat_options', self::$settings);
239 }
240
241 self::$settings = array_merge(self::init_options(), self::$settings);
242
243 // One-shot migration: runs once on first boot after installing this build.
244 // '_migration_5460' is absent from all pre-5.4.6 installs; array_merge fills it
245 // with '0' from init_options(). After running, the flag stores the version that ran it.
246 // On downgrade→re-upgrade, the stored version will differ from SLIMSTAT_ANALYTICS_VERSION,
247 // allowing the migration to re-run if needed. '0' = never ran, version string = ran.
248 $_migration_ran = self::$settings['_migration_5460'] ?? '0';
249 if ('0' === $_migration_ran || (is_string($_migration_ran) && '0' !== $_migration_ran && version_compare($_migration_ran, SLIMSTAT_ANALYTICS_VERSION, '<'))) {
250 // --- Consent intent detection ---
251 // Read legacy v5.3.x consent settings to detect if user had configured privacy.
252 // These survive through v5.3.x → v5.4.x upgrades because array_merge preserves DB values.
253 $_had_opt_out_banner = ('on' === (self::$settings['display_opt_out'] ?? 'no'));
254 $_had_opt_out_cookies = !empty(trim(self::$settings['opt_out_cookie_names'] ?? ''));
255 $_had_opt_in_cookies = !empty(trim(self::$settings['opt_in_cookie_names'] ?? ''));
256
257 // Check if user deliberately chose a third-party CMP in v5.4.x
258 $_current_integration = self::$settings['consent_integration'] ?? '';
259 $_has_third_party_cmp = in_array($_current_integration, ['wp_consent_api', 'real_cookie_banner'], true);
260
261 if ($_has_third_party_cmp) {
262 // User deliberately configured a third-party CMP — preserve their setup
263 self::$settings['gdpr_enabled'] = 'on';
264 } elseif ($_had_opt_out_banner || $_had_opt_out_cookies || $_had_opt_in_cookies) {
265 // User had consent/privacy config in v5.3.x — map to GDPR system
266 self::$settings['gdpr_enabled'] = 'on';
267 self::$settings['use_slimstat_banner'] = 'on';
268 // Auto-detect best CMP: if opt-in cookies were set (third-party plugin)
269 // and WP Consent API is installed, use it. Otherwise use SlimStat Banner.
270 if ($_had_opt_in_cookies && function_exists('wp_has_consent')) {
271 self::$settings['consent_integration'] = 'wp_consent_api';
272 } else {
273 self::$settings['consent_integration'] = 'slimstat_banner';
274 }
275 } else {
276 // No consent config ever — pure v5.3.x behavior: all tracked, no banner
277 self::$settings['gdpr_enabled'] = 'off';
278 self::$settings['consent_integration'] = '';
279 self::$settings['use_slimstat_banner'] = 'off';
280 }
281
282 unset($_had_opt_out_banner, $_had_opt_out_cookies, $_had_opt_in_cookies,
283 $_current_integration, $_has_third_party_cmp);
284
285 // One-time resets for settings broken by v5.4.0-5.4.6 defaults.
286 // Gated on < 5.4.7 so future upgrades (5.4.8+) don't override admin choices.
287 // Skip for fresh installs ('0' = never ran, no broken settings to fix).
288 if ('0' !== $_migration_ran && version_compare($_migration_ran, '5.4.7', '<')) {
289 // Restore session cookie — Consent::piiAllowed() in Session.php gates
290 // the actual setcookie() call at runtime, not this setting.
291 if ('off' === (self::$settings['set_tracker_cookie'] ?? 'on')) {
292 self::$settings['set_tracker_cookie'] = 'on';
293 }
294
295 // javascript_mode='off' baked a stale per-visitor stat ID into cached HTML.
296 // Always reset — server-side mode was a v5.4.0 default, not a user choice.
297 if ('off' === (self::$settings['javascript_mode'] ?? 'on')) {
298 self::$settings['javascript_mode'] = 'on';
299 }
300
301 // anonymize_ip='on' and hash_ip='on' were v5.4.1 defaults that changed IP storage.
302 $_ss_ip_was_anonymized = ('on' === (self::$settings['anonymize_ip'] ?? 'off'));
303 $_ss_ip_was_hashed = ('on' === (self::$settings['hash_ip'] ?? 'off'));
304 if ($_ss_ip_was_anonymized) {
305 self::$settings['anonymize_ip'] = 'off';
306 }
307 if ($_ss_ip_was_hashed) {
308 self::$settings['hash_ip'] = 'off';
309 }
310 if ($_ss_ip_was_anonymized || $_ss_ip_was_hashed) {
311 set_transient('slimstat_migration_5460_ip_notice', '1', 7 * DAY_IN_SECONDS);
312 }
313 }
314
315 unset($_ss_ip_was_anonymized, $_ss_ip_was_hashed);
316 // Mark done — store the version so downgrade→re-upgrade can re-trigger if needed.
317 self::$settings['_migration_5460'] = SLIMSTAT_ANALYTICS_VERSION;
318 self::update_option('slimstat_options', self::$settings);
319
320 // Rewrite rules are flushed via two other paths:
321 // 1. Activation hook: admin/index.php init_environment() calls flush_rewrite_rules()
322 // 2. Settings change: RestApiManager sets 'slimstat_permalink_structure_updated' option,
323 // which triggers flush_rewrite_rules() on next init via rewriteRuleRequest()
324 // No flush needed here — doing so during migration (plugins_loaded) would fire before
325 // the rewrite rule is registered on 'init' and waste a DB write.
326 }
327
328 // Allow third party tools to edit the options
329 self::$settings = apply_filters('slimstat_init_options', self::$settings);
330
331 // Consent-sync: derive use_slimstat_banner from consent_integration.
332 // Only run when GDPR is on — when off, banner stays off and canTrack() returns true early.
333 if ('on' === (self::$settings['gdpr_enabled'] ?? 'off')) {
334 $consent_integration = self::$settings['consent_integration'] ?? '';
335
336 // If WP Consent API is selected but the plugin isn't installed, fall back to
337 // SlimStat's own banner so consent enforcement stays active. Resetting to ''
338 // would leave GDPR on but with no consent mechanism — getIntegrationKey()
339 // silently picks 'slimstat_banner' but without the banner UI enabled.
340 if ('wp_consent_api' === $consent_integration && !function_exists('wp_has_consent')) {
341 $consent_integration = 'slimstat_banner';
342 self::$settings['consent_integration'] = 'slimstat_banner';
343 }
344
345 if ('' === $consent_integration && ('on' === (self::$settings['use_slimstat_banner'] ?? 'off'))) {
346 $consent_integration = 'slimstat_banner';
347 self::$settings['consent_integration'] = $consent_integration;
348 }
349
350 if ('slimstat_banner' === $consent_integration) {
351 self::$settings['use_slimstat_banner'] = 'on';
352 } else {
353 self::$settings['use_slimstat_banner'] = 'off';
354 }
355 } // end GDPR consent-sync
356
357 // Allow third-party tools to use a custom database for Slimstat
358 self::$wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']);
359
360 // Define the folder where to store the geolocation database (shared among sites in a network, by default)
361 if (defined('UPLOADS')) {
362 self::$upload_dir = ABSPATH . UPLOADS . '/wp-slimstat';
363 } else {
364 $upload_dir_info = wp_upload_dir();
365 self::$upload_dir = $upload_dir_info['basedir'];
366
367 // Handle multisite environment
368 if (is_multisite() && !(is_main_network() && is_main_site() && defined('MULTISITE'))) {
369 self::$upload_dir = str_replace('/sites/' . get_current_blog_id(), '', self::$upload_dir);
370 }
371
372 self::$upload_dir .= '/wp-slimstat';
373 }
374
375 // Apply filter to allow customization of the upload directory
376 self::$upload_dir = apply_filters('slimstat_maxmind_path', self::$upload_dir);
377
378 // Allow add-ons to turn off the tracker based on other conditions.
379 // Exclude internal SlimStat endpoints from server-side tracking so they
380 // don't appear as page visits in the Access Log:
381 // - admin-ajax.php (AJAX tracking handler)
382 // - /request/{hash}/ (adblock bypass tracking endpoint)
383 // - /{hash}.js, /{hash}.css (adblock bypass JS/CSS file serving via Routing.php)
384 $_request_uri = self::get_request_uri();
385 $_is_internal_endpoint = false !== strpos($_request_uri, 'wp-admin/admin-ajax.php')
386 || (bool) preg_match('#/request/[a-f0-9]{32}/?$|/[a-f0-9]{32}\.(?:js|css)(?:\?|$)#', $_request_uri);
387 $is_tracking_filter = apply_filters('slimstat_filter_pre_tracking', !$_is_internal_endpoint);
388 $is_tracking_filter_js = apply_filters('slimstat_filter_pre_tracking_js', true);
389 unset($_request_uri, $_is_internal_endpoint);
390
391 // Enable the tracker (both server- and client-side)
392 if ((!is_admin() || 'on' == self::$settings['track_admin_pages']) && 'on' == self::$settings['is_tracking'] && $is_tracking_filter) {
393
394 // Is server-side tracking active?
395 if ('on' != self::$settings['javascript_mode']) {
396 add_action(is_admin() ? 'admin_init' : 'wp', [\SlimStat\Tracker\Tracker::class, 'slimtrack'], 5);
397
398 if ('on' != self::$settings['ignore_wp_users']) {
399 add_action('login_init', [\SlimStat\Tracker\Tracker::class, 'slimtrack'], 10);
400 }
401 }
402
403 // Slimstat tracks screen resolutions, outbound links and other client-side information using a client-side tracker
404 add_action(is_admin() ? 'admin_enqueue_scripts' : 'wp_enqueue_scripts', [self::class, 'enqueue_tracker'], 15);
405 if ('on' != self::$settings['ignore_wp_users']) {
406 add_action('login_enqueue_scripts', [self::class, 'enqueue_tracker'], 10);
407 }
408
409 add_filter('script_loader_tag', [self::class, 'add_defer_to_script_tag'], 10, 2);
410 }
411
412 $banner_enabled = ('on' === (self::$settings['gdpr_enabled'] ?? 'off'))
413 && ('on' === (self::$settings['use_slimstat_banner'] ?? 'off'));
414 if ($banner_enabled) {
415 add_action('wp_enqueue_scripts', [self::class, 'enqueue_gdpr_assets'], 20);
416 add_action('login_enqueue_scripts', [self::class, 'enqueue_gdpr_assets'], 20);
417 add_action('wp_footer', [self::class, 'render_gdpr_banner'], 5);
418 add_action('login_footer', [self::class, 'render_gdpr_banner'], 5);
419 }
420
421 // Registers Slimstat with WP Consent API if enabled in plugin settings
422 if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') {
423 // Check if WP Consent API plugin is actually active
424 if (function_exists('wp_has_consent')) {
425 $plugin = plugin_basename(SLIMSTAT_FILE);
426 add_filter("wp_consent_api_registered_{$plugin}", '__return_true');
427
428 // Register cookie info with WP Consent API for CMP display.
429 // Deferred to 'init' (priority 10) so the textdomain is loaded first
430 // (load_textdomain runs on 'init' priority 1). Calling __() here would
431 // trigger a _load_textdomain_just_in_time notice in WordPress 6.7+.
432 if (function_exists('wp_add_cookie_info')) {
433 $session_duration = intval(self::$settings['session_duration'] ?? 1800);
434 add_action('init', static function () use ($session_duration) {
435 wp_add_cookie_info(
436 'slimstat_tracking_code',
437 __('SlimStat Analytics', 'wp-slimstat'),
438 'statistics',
439 sprintf(
440 /* translators: %d: number of seconds for session duration */
441 _n('%d second', '%d seconds', $session_duration, 'wp-slimstat'),
442 $session_duration
443 ),
444 __('Session cookie that identifies returning visitors for analytics.', 'wp-slimstat'),
445 '',
446 false,
447 false
448 );
449 }, 10);
450 }
451 }
452 }
453
454 // Register WordPress Privacy API exporters and erasers (GDPR Article 15 & 17)
455 add_filter('wp_privacy_personal_data_exporters', [\SlimStat\Services\Privacy\DataExporter::class, 'registerExporters']);
456 add_filter('wp_privacy_personal_data_erasers', [\SlimStat\Services\Privacy\DataEraser::class, 'registerErasers']);
457
458 // Register privacy policy content
459 add_action('admin_init', [self::class, 'registerPrivacyPolicyContent']);
460
461 // One-time notice when the v5.4.6 migration reset IP anonymization settings
462 add_action('admin_notices', [self::class, 'show_migration_5460_ip_notice']);
463
464 // Register AJAX handlers for consent upgrade/revocation (anonymous tracking mode)
465 \SlimStat\Services\Privacy\ConsentHandler::registerAjaxHandlers();
466
467 // Hook a DB clean-up routine to the daily cronjob
468 add_action('wp_slimstat_purge', [self::class, 'wp_slimstat_purge']);
469
470 // Hook IP hashing daily salt generation (for GDPR compliance)
471 add_action('wp_slimstat_generate_daily_salt', [\SlimStat\Providers\IPHashProvider::class, 'generateDailySalt']);
472
473 // Hook a GeoIP database update routine to the daily cronjob
474 add_action('wp_slimstat_update_geoip_database', [self::class, 'wp_slimstat_update_geoip_database']);
475
476 // Allow external domains on CORS requests
477 add_filter('allowed_http_origins', [self::class, 'open_cors_admin_ajax']);
478
479 // Internal GDPR banner/consent handling removed. Use external CMP plugins.
480
481 // If this request was a redirect, we should update the content type accordingly
482 add_filter('wp_redirect_status', [\SlimStat\Tracker\Tracker::class, 'update_content_type'], 10, 2);
483
484 // Shortcodes
485 add_shortcode('slimstat', [self::class, 'slimstat_shortcode'], 15);
486
487 // Init the plugin functionality
488 add_action('init', [self::class, 'init_plugin']);
489
490 // REST API Support
491 add_action('rest_api_init', [self::class, 'register_rest_route']);
492
493 // Load the admin library
494 if (is_user_logged_in()) {
495 include_once(plugin_dir_path(__FILE__) . 'admin/index.php');
496 add_action('init', ['wp_slimstat_admin', 'init'], 60);
497 }
498 }
499 // end init
500
501 /**
502 * Load plugin textdomain
503 *
504 * @return void
505 */
506 public static function load_textdomain()
507 {
508 load_plugin_textdomain('wp-slimstat', false, '/wp-slimstat/languages');
509 }
510
511 /**
512 * Show a one-time admin notice when the v5.4.6 migration reset anonymize_ip
513 * or hash_ip from 'on' to 'off'. EU-facing sites may need to re-enable these.
514 * The transient is deleted after display so the notice appears exactly once.
515 */
516 public static function show_migration_5460_ip_notice(): void
517 {
518 if (!current_user_can('manage_options')) {
519 return;
520 }
521
522 if (!get_transient('slimstat_migration_5460_ip_notice')) {
523 return;
524 }
525
526 delete_transient('slimstat_migration_5460_ip_notice');
527
528 $settings_url = admin_url('admin.php?page=slimconfig&tab=2');
529 ?>
530 <div class="notice notice-warning">
531 <p>
532 <strong><?php esc_html_e('SlimStat Analytics — IP Privacy Settings Reset', 'wp-slimstat'); ?></strong><br>
533 <?php esc_html_e('This update restored full-IP storage (the 5.3.x default) by turning off IP anonymization and daily visitor hashing. If your site serves EU visitors, please review your Data Protection settings.', 'wp-slimstat'); ?>
534 &nbsp;<a href="<?php echo esc_url($settings_url); ?>"><?php esc_html_e('Review Settings → Data Protection', 'wp-slimstat'); ?></a>
535 </p>
536 </div>
537 <?php
538 }
539
540 /**
541 * The main logging function
542 *
543 * @param string $message The message to be logged.
544 * @param string $level The log level (e.g., 'info', 'warning', 'error'). Default is 'info'.
545 *
546 * @uses error_log
547 */
548 public static function log($message, $level = 'info')
549 {
550 if (is_array($message)) {
551 $message = wp_json_encode($message);
552 }
553
554 $log_level = strtoupper($level);
555
556 // Log when debug is enabled
557 if (defined('WP_DEBUG') && WP_DEBUG) {
558 error_log(sprintf('[WP SLIMSTAT] [%s]: %s', $log_level, $message));
559 }
560 }
561
562 /**
563 * Resolve the active geolocation provider.
564 *
565 * New UI sets 'geolocation_provider' explicitly (incl. 'disable').
566 * Legacy installs only have 'enable_maxmind' (tri-state: 'on', 'no', 'disable').
567 *
568 * @return string|false 'maxmind', 'dbip', 'cloudflare', or false if disabled
569 */
570 public static function resolve_geolocation_provider()
571 {
572 static $cache = [];
573
574 // Sanitize both settings that drive resolution
575 $provider_san = sanitize_text_field(self::$settings['geolocation_provider'] ?? '');
576
577 // Normalize legacy tri-state ('on'|'no'|'disable') to deterministic token
578 $legacy_san = sanitize_text_field(self::$settings['enable_maxmind'] ?? '');
579 if ('on' === $legacy_san) {
580 $legacy_norm = 'on';
581 } elseif ('no' === $legacy_san) {
582 $legacy_norm = 'no';
583 } else {
584 $legacy_norm = 'disable';
585 }
586
587 // Cache key invalidates when settings change mid-request (e.g. settings save)
588 $cache_key = $provider_san . '|' . $legacy_norm;
589
590 if (array_key_exists($cache_key, $cache)) {
591 return $cache[$cache_key];
592 }
593
594 $result = false;
595
596 if ('' !== $provider_san) {
597 if ('disable' === $provider_san) {
598 $cache[$cache_key] = false;
599 return false;
600 }
601 if (in_array($provider_san, \SlimStat\Services\GeoService::ALL_PROVIDERS, true)) {
602 $cache[$cache_key] = $provider_san;
603 return $provider_san;
604 }
605 // Invalid value — fall through to legacy flag
606 }
607
608 if ('on' === $legacy_norm) {
609 $result = 'maxmind';
610 } elseif ('no' === $legacy_norm) {
611 $result = 'dbip';
612 }
613
614 $cache[$cache_key] = $result;
615 return $result;
616 }
617
618 /**
619 * Decodes the permalink
620 */
621 public static function get_request_uri()
622 {
623 $request_url = '';
624
625 if (isset($_SERVER['REQUEST_URI'])) {
626 return urldecode(sanitize_url(wp_unslash($_SERVER['REQUEST_URI'])));
627 } elseif (isset($_SERVER['SCRIPT_NAME'])) {
628 $request_url = sanitize_text_field(wp_unslash($_SERVER['SCRIPT_NAME']));
629 } elseif (isset($_SERVER['PHP_SELF'])) {
630 $request_url = sanitize_text_field(wp_unslash($_SERVER['PHP_SELF']));
631 }
632
633 if (isset($_SERVER['QUERY_STRING'])) {
634 $request_url .= '?' . sanitize_text_field(wp_unslash($_SERVER['QUERY_STRING']));
635 }
636
637 return $request_url;
638 }
639
640 // end get_request_uri
641
642 public static function is_local_ip_address($ip_address = '')
643 {
644 return !filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE);
645 }
646
647 /**
648 * Implements the Slimstat Shortcode API
649 */
650 public static function slimstat_shortcode($_attributes = '', $_content = '')
651 {
652 shortcode_atts([
653 'f' => '', // recent, popular, count, widget
654 'w' => '', // column to use (for recent, popular and count) or widget to use
655 's' => ' ', // separator
656 'o' => 0, // offset for counters
657 ], $_attributes);
658
659 $f = $_attributes['f'] ?? '';
660 $w = $_attributes['w'] ?? '';
661 $s = $_attributes['s'] ?? '';
662 $o = $_attributes['o'] ?? 0;
663 $output = '';
664 $where = '';
665 $as_column = '';
666 $s = sprintf("<span class='slimstat-item-separator'>%s</span>", $s);
667
668 // Look for required fields
669 if (empty($f) || empty($w)) {
670 return '<!-- Slimstat Shortcode Error: missing parameter -->';
671 }
672
673 // Validation the parameter w
674 $w = (string) $w;
675 if (false === in_array($w, ['*', 'count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt', 'username', 'post_link', 'ip', 'id', 'searchterms', 'username', 'resource', 'country', 'browser', 'platform', 'language', 'slim_p1_01', 'slim_p1_03', 'slim_p1_04', 'slim_p1_06', 'slim_p1_08', 'slim_p1_10', 'slim_p1_11', 'slim_p1_12', 'slim_p1_13', 'slim_p1_15', 'slim_p1_17', 'slim_p1_18', 'slim_p1_19_01', 'slim_p2_01', 'slim_p2_02', 'slim_p2_03', 'slim_p2_04', 'slim_p2_05', 'slim_p2_06', 'slim_p2_07', 'slim_p2_08', 'slim_p2_12', 'slim_p2_13', 'slim_p2_14', 'slim_p2_15', 'slim_p2_16', 'slim_p2_17', 'slim_p2_18', 'slim_p2_19', 'slim_p2_20', 'slim_p2_21', 'slim_p2_22_01', 'slim_p2_24', 'slim_p2_25', 'slim_p3_01', 'slim_p3_02', 'slim_p4_01', 'slim_p4_02', 'slim_p4_04', 'slim_p4_05', 'slim_p4_06', 'slim_p4_07', 'slim_p4_09', 'slim_p4_10', 'slim_p4_11', 'slim_p4_12', 'slim_p4_13', 'slim_p4_15', 'slim_p4_16', 'slim_p4_18', 'slim_p4_19', 'slim_p4_20', 'slim_p4_21', 'slim_p4_22', 'slim_p4_23', 'slim_p4_24', 'slim_p4_25', 'slim_p4_26_01', 'slim_p4_27', 'slim_p6_01', 'slim_p9_01', 'slim_p9_02', 'slim_p2_23'], true)) {
676 return '<!-- Slimstat Shortcode Error: invalid parameter for w -->';
677 }
678
679 // Include the Reports Library, but don't initialize the database, since we will do that separately later
680 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php');
681 wp_slimstat_reports::init();
682
683 /**
684 * @SecurityProfile https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-0630
685 * Disabled because of the report from WP Scan
686 */
687 // Init the database library with the appropriate filters
688 /*if ( strpos ( $_content, 'WHERE:' ) !== false ) {
689 $where = html_entity_decode( str_replace( 'WHERE:', '', $_content ), ENT_QUOTES, 'UTF-8' );
690 }
691 else{*/
692 wp_slimstat_db::init(html_entity_decode($_content, ENT_QUOTES, 'UTF-8'));
693 //}
694
695 switch ($f) {
696 case 'count':
697 case 'count-all':
698 $output = wp_slimstat_db::count_records($w, $where, false === strpos($f, 'all')) + $o;
699 break;
700
701 case 'widget':
702 if (empty(wp_slimstat_reports::$reports[$w])) {
703 return __('Invalid Report ID', 'wp-slimstat');
704 }
705
706 wp_register_style('wp-slimstat-frontend', plugins_url('/admin/assets/css/slimstat.css', __FILE__), true, SLIMSTAT_ANALYTICS_VERSION);
707 wp_enqueue_style('wp-slimstat-frontend');
708
709 wp_slimstat_reports::$reports[$w]['callback_args']['is_widget'] = true;
710
711 ob_start();
712 echo wp_slimstat_reports::report_header($w);
713 call_user_func(wp_slimstat_reports::$reports[$w]['callback'], wp_slimstat_reports::$reports[$w]['callback_args']);
714 wp_slimstat_reports::report_footer();
715 $output = ob_get_contents();
716 ob_end_clean();
717 break;
718
719 case 'recent':
720 case 'recent-all':
721 case 'top':
722 case 'top-all':
723 $function = 'get_' . str_replace('-all', '', $f);
724
725 if ('*' === $w) {
726 $w = 'id';
727 }
728
729 $w = esc_html($w);
730 $w = self::string_to_array($w);
731
732 // Some columns are 'special' and need be removed from the list
733 $w_clean = array_diff($w, ['count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt']);
734
735 // The special value 'display_name' requires the username to be retrieved
736 if (in_array('display_name', $w)) {
737 $w_clean[] = 'username';
738 }
739
740 // The special value 'post_list' requires the resource to be retrieved
741 if (in_array('post_link', $w)) {
742 $w_clean[] = 'resource';
743 }
744
745 // The special value 'post_list_no_qs' requires a substring to be calculated
746 if (in_array('post_link_no_qs', $w)) {
747 $w_clean = ['SUBSTRING_INDEX( resource, "' . (get_option('permalink_structure') ? '?' : '&') . '", 1 )'];
748 $as_column = 'resource';
749 }
750
751 // Retrieve the data
752 $results = wp_slimstat_db::$function(implode(', ', $w_clean), $where, '', false === strpos($f, 'all'), $as_column);
753
754 // No data? No problem!
755 if (empty($results)) {
756 return '<!-- Slimstat Shortcode: No Data -->';
757 }
758
759 // Are nice permalinks enabled?
760 $permalinks_enabled = get_option('permalink_structure');
761
762 // Format results
763 $output = [];
764
765 foreach ($results as $result_idx => $a_result) {
766 foreach ($w as $a_column) {
767 $output[$result_idx][$a_column] = sprintf("<span class='col-%s'>", $a_column);
768
769 switch ($a_column) {
770 case 'count':
771 $output[$result_idx][$a_column] .= $a_result['counthits'];
772 break;
773
774 case 'country':
775 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('c-' . $a_result[$a_column]);
776 break;
777
778 case 'display_name':
779 $user_details = get_user_by('login', $a_result['username']);
780 if (!empty($user_details)) {
781 $output[$result_idx][$a_column] .= $user_details->display_name;
782 } else {
783 $output[$result_idx][$a_column] .= $a_result['username'];
784 }
785
786 break;
787
788 case 'dt':
789 $output[$result_idx][$a_column] .= date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $a_result['dt']);
790 break;
791
792 case 'hostname':
793 $output[$result_idx][$a_column] .= self::gethostbyaddr($a_result['ip']);
794 break;
795
796 case 'language':
797 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('l-' . $a_result[$a_column]);
798 break;
799
800 case 'platform':
801 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string($a_result[$a_column]);
802 break;
803
804 case 'post_link':
805 case 'post_link_no_qs':
806 $post_id = url_to_postid($a_result['resource']);
807 if ($post_id > 0) {
808 $output[$result_idx][$a_column] .= sprintf("<a href='%s'>", esc_url( $a_result[ 'resource' ] )) . esc_html( get_the_title($post_id) ) . '</a>';
809 } else {
810 $output[$result_idx][$a_column] .= sprintf("<a href='%s'>%s</a>", esc_url( $a_result[ 'resource' ] ), esc_html( $a_result[ 'resource' ] ));
811 }
812 break;
813
814 default:
815 $output[$result_idx][$a_column] .= $a_result[$a_column] ?? '';
816 break;
817 }
818 $output[$result_idx][$a_column] .= '</span>';
819 }
820 $output[$result_idx] = '<li>' . implode($s, $output[$result_idx]) . '</li>';
821 }
822
823 $output = '<ul class="slimstat-shortcode ' . $f . implode('-', $w) . '">' . implode('', $output) . '</ul>';
824 break;
825
826 default:
827 break;
828 }
829
830 return $output;
831 }
832
833 // end slimstat_shortcode
834
835
836 public static function init_plugin()
837 {
838 // Include our browser detector library
839 \SlimStat\Services\Browscap::init();
840
841 // Make sure the upload directory is exist and is protected.
842 self::create_upload_directory();
843
844 // Ensure daily salt exists for IP hashing (GDPR compliance)
845 // This runs on every page load but only generates if missing
846 \SlimStat\Providers\IPHashProvider::generateDailySalt();
847
848 // Initialize adblock bypass functionality
849 \SlimStat\Tracker\Tracker::rewrite_rule_tracker();
850 add_action('template_redirect', [\SlimStat\Tracker\Tracker::class, 'adblocker_javascript']);
851 add_action('init', [\SlimStat\Tracker\Tracker::class, 'rewrite_rule_tracker']);
852 }
853
854 /**
855 * Opens given domains during CORS requests to admin-ajax.php
856 */
857 public static function open_cors_admin_ajax($_allowed_origins = [])
858 {
859 $exploded_domains = self::string_to_array(self::$settings['external_domains']);
860
861 if (!empty($exploded_domains) && !empty($exploded_domains[0])) {
862 $_allowed_origins = array_merge($_allowed_origins, $exploded_domains);
863 }
864
865 return $_allowed_origins;
866 }
867 // end open_cors_admin_ajax
868
869 /**
870 * Implements a REST API interface to retrieve Slimstat reports and metrics
871 */
872 public static function rest_api_response($_request = [])
873 {
874 $filters = '';
875 if (!empty($_request['filters'])) {
876 $filters = $_request['filters'];
877 }
878
879 if (empty($_request['dimension'])) {
880 return new WP_Error('rest_invalid', esc_html__('[REST API] The <code>dimension</code> parameter is required. Please review your request and try again.', 'wp-slimstat'), ['status' => 400]);
881 }
882
883 if (empty($_request['function'])) {
884 return new WP_Error('rest_invalid', esc_html__('[REST API] The <code>function</code> parameter is required. Please review your request and try again.', 'wp-slimstat'), ['status' => 400]);
885 }
886
887 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-db.php');
888 wp_slimstat_db::init($filters);
889
890 $response = [
891 'function' => htmlentities($_request['function'], ENT_QUOTES, 'UTF-8'),
892 'dimension' => htmlentities($_request['dimension'], ENT_QUOTES, 'UTF-8'),
893
894 'data' => 0,
895 ];
896
897 switch ($_request['function']) {
898 case 'count':
899 case 'count-all':
900 $response['data'] = wp_slimstat_db::count_records($_request['dimension'], '', false === strpos($_request['function'], '-all'));
901 break;
902
903 case 'recent':
904 case 'recent-all':
905 case 'top':
906 case 'top-all':
907 $function = 'get_' . str_replace('-all', '', $_request['function']);
908
909 // Retrieve the data
910 $response['data'] = array_values(wp_slimstat_db::$function($_request['dimension'], '', '', false === strpos($_request['function'], '-all')));
911 break;
912
913 default:
914 // This should never happen, because of the 'enum' condition for this parameter. But never say never...
915 $response['data'] = new WP_Error('rest_invalid', esc_html__('[REST API] You sent an invalid request. Accepted function values include: <code>count, count-all, recent, recent-all, top and top-all</code>. Please review your request and try again.', 'wp-slimstat'), ['status' => 400]);
916 break;
917 }
918
919 return rest_ensure_response($response);
920 }
921 // end rest_api_response
922
923 /**
924 * Implements a REST API authentication mechanism via token
925 */
926 public static function rest_api_authorization($_request = [])
927 {
928 if (empty($_request['token'])) {
929 return new WP_Error('rest_invalid', esc_html__('[REST API] Please use a valid token in order to access the REST API endpoint at this URL.', 'wp-slimstat'), ['status' => 400]);
930 }
931 $valid_tokens = self::string_to_array(self::$settings['rest_api_tokens']);
932 foreach ($valid_tokens as $valid_token) {
933 if (is_string($valid_token) && is_string($_request['token']) && hash_equals($valid_token, $_request['token'])) {
934 return true;
935 }
936 }
937 return false;
938 }
939 // end rest_api_authorization
940
941 /**
942 * Registers a new REST API route for the Slimstat endpoint
943 */
944 public static function register_rest_route()
945 {
946 register_rest_route('slimstat/v1', '/get', [
947 'methods' => WP_REST_Server::READABLE,
948 'callback' => [self::class, 'rest_api_response'],
949 'permission_callback' => [self::class, 'rest_api_authorization'],
950 'args' => [
951 'token' => [
952 'description' => __('You will need to specify a valid token to be able to query the data. Tokens are defined in Slimstat > Settings > Access Control.', 'wp-slimstat'),
953 'type' => 'string',
954 ],
955 'function' => [
956 'description' => __('This parameter specifies the type of QUERY you would like to perform. Accepted funciton values include: count, count-all, recent, recent-all, top and top-all.', 'wp-slimstat'),
957 'type' => 'string',
958 'enum' => ['count', 'count-all', 'recent', 'recent-all', 'top', 'top-all'],
959 ],
960 'dimension' => [
961 'description' => __('This parameter indicates what dimension to return: * (all data), ip, resource, browser, operating system, etc. You can only specify one dimension at a time.', 'wp-slimstat'),
962 'type' => 'string',
963 'enum' => ['*', 'id', 'ip', 'username', 'email', 'country', 'referer', 'resource', 'searchterms', 'browser', 'platform', 'language', 'resolution', 'content_type', 'content_id', 'tz_offset', 'outbound_resource'],
964 ],
965 'filters' => [
966 'description' => __('This parameter is used to filter a given dimension (resources, browsers, operating systems, etc) so that it satisfies certain conditions (i.e.: browser contains Chrome). Please make sure to urlencode this value, and to use the usual filter format: browser contains Chrome&&&referer contains slim (encoded: browser%20contains%20Chrome%26%26%26referer%20contains%20slim)', 'wp-slimstat'),
967 'type' => 'string',
968 ],
969 ],
970 ]);
971 }
972 // end register_rest_route
973
974 /**
975 * Converts a series of comma separated values into an array
976 */
977 public static function string_to_array($_option = '')
978 {
979 if (empty($_option) || !is_string($_option)) {
980 return [];
981 } else {
982 return array_filter(array_map('trim', explode(',', $_option)));
983 }
984 }
985 // end string_to_array
986
987 /**
988 * Returns Matomo search engine mapping JSON, cached.
989 */
990 public static function get_search_engines()
991 {
992 static $cached_search_engines = null;
993 if (null !== $cached_search_engines) {
994 return $cached_search_engines;
995 }
996
997 $data = get_transient('slimstat_matomo_searchengine');
998 if (false === $data) {
999 $json_path = plugin_dir_path(__FILE__) . 'admin/assets/data/matomo-searchengine.json';
1000 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin file, WP_Filesystem not needed
1001 $json = @file_get_contents($json_path);
1002 $data = json_decode($json, true);
1003 if (!is_array($data)) {
1004 $data = [];
1005 }
1006 set_transient('slimstat_matomo_searchengine', $data, WEEK_IN_SECONDS);
1007 }
1008
1009 $cached_search_engines = $data;
1010 return $cached_search_engines;
1011 }
1012 // end get_search_engines
1013
1014 /**
1015 * Toggles WordPress filters on date_i18n function
1016 */
1017 public static function toggle_date_i18n_filters($_turn_on = true)
1018 {
1019 if ($_turn_on && !empty(self::$date_i18n_filters) && is_array(self::$date_i18n_filters)) {
1020 foreach (self::$date_i18n_filters as $i18n_priority => $i18n_func_list) {
1021 foreach ($i18n_func_list as $func_args) {
1022 if (!empty($func_args['function']) && is_string($func_args['function'])) {
1023 add_filter('date_i8n', $func_args['function'], $i18n_priority, intval($func_args['accepted_args']));
1024 }
1025 }
1026 }
1027 } elseif (!empty($GLOBALS['wp_filter']['date_i18n']['callbacks']) && is_array($GLOBALS['wp_filter']['date_i18n']['callbacks'])) {
1028 self::$date_i18n_filters = $GLOBALS['wp_filter']['date_i18n']['callbacks'];
1029 remove_all_filters('date_i18n');
1030 }
1031 }
1032 // end toggle_date_i18n_filters
1033
1034 /**
1035 * Calls the date_i18n function without filters
1036 */
1037 public static function date_i18n($_format)
1038 {
1039 self::toggle_date_i18n_filters(false);
1040 $date = date_i18n($_format);
1041 self::toggle_date_i18n_filters(true);
1042
1043 return $date;
1044 }
1045 // end date_i18n
1046
1047 /**
1048 * Returns the current timestamp in the same format stored in the dt column.
1049 * MUST be used by all queries that compare against dt values.
1050 *
1051 * WordPress date_i18n('U') returns current_time('timestamp') — a legacy
1052 * quirk where 'U' format includes the site's GMT offset. This matches
1053 * how Processor::process() stores $stat['dt'] via self::date_i18n('U').
1054 *
1055 * @since 5.4.7
1056 * @return int Current timestamp matching dt column format
1057 */
1058 public static function now(): int {
1059 return (int) self::date_i18n('U');
1060 }
1061
1062 /**
1063 * Returns default options with geolocation_provider set for fresh installs and resets.
1064 *
1065 * geolocation_provider is excluded from init_options() because init() merges
1066 * those defaults into stored settings — which would override the legacy
1067 * enable_maxmind flag on upgraded installs before lazy migration runs.
1068 *
1069 * Fresh installs default to DB-IP (free, no license key required).
1070 */
1071 public static function get_fresh_defaults()
1072 {
1073 $defaults = self::init_options();
1074 $defaults['geolocation_provider'] = 'dbip';
1075 return $defaults;
1076 }
1077
1078 /**
1079 * Returns the current geolocation precision ('country' or 'city').
1080 */
1081 public static function get_geolocation_precision()
1082 {
1083 return ('on' == self::$settings['geolocation_country']) ? 'country' : 'city';
1084 }
1085
1086 /**
1087 * Sets the default values for all the options
1088 */
1089 public static function init_options()
1090 {
1091 return [
1092 'version' => SLIMSTAT_ANALYTICS_VERSION,
1093 '_migration_5460' => '0', // one-shot: reset broken v5.4.1 defaults on first boot after this build
1094 'secret' => wp_hash(wp_generate_password(64, true, true)),
1095 'browscap_last_modified' => 0,
1096
1097 // General
1098 // -----------------------------------------------------------------------
1099
1100 // General - Tracker
1101 'is_tracking' => 'on',
1102 'track_admin_pages' => 'no',
1103 'javascript_mode' => 'on', // Client mode: works with all caching plugins (WP Rocket, W3TC, etc.)
1104
1105 // General - WordPress Integration
1106 'add_dashboard_widgets' => 'on',
1107 'use_separate_menu' => 'on',
1108 'add_posts_column' => 'no',
1109 'posts_column_pageviews' => 'on',
1110 'display_notifications' => 'on',
1111
1112 // General - Database
1113 'auto_purge' => 420,
1114 'auto_purge_delete' => 'on',
1115
1116 // Tracker
1117 // -----------------------------------------------------------------------
1118
1119 // Tracker - Data Protection
1120 // anonymize_ip: mask IP before storing; hash_ip: generate daily visitor_id based on masked IP + UA
1121 'gdpr_enabled' => 'off', // v5.3.x had no GDPR — off by default; admin enables when ready
1122 'anonymize_ip' => 'off', // Restored: full IPs stored by default (5.3.x behavior)
1123 'hash_ip' => 'off', // Restored: no daily visitor hash by default (5.3.x behavior)
1124 'set_tracker_cookie' => 'on', // v5.3.x default: session cookie identifies returning visitors
1125 'use_slimstat_banner' => 'off', // Admin must explicitly enable via consent integration
1126 'consent_integration' => '', // No CMP by default — admin selects when enabling GDPR
1127 'consent_level_integration'=> 'statistics',
1128 'opt_out_message' => '',
1129 'gdpr_accept_button_text' => 'Accept',
1130 'gdpr_decline_button_text' => 'Decline',
1131 'gdpr_theme_mode' => 'auto', // 'light', 'dark', 'auto'
1132 'anonymous_tracking' => 'off', // Changed: Enable anonymous tracking by default
1133 'do_not_track' => 'off',
1134 'display_opt_out' => 'no',
1135 'opt_out_cookie_names' => '',
1136 'opt_in_cookie_names' => '',
1137
1138 // Tracker - Link Tracking
1139 'track_same_domain_referers' => 'no',
1140 'do_not_track_outbound_classes_rel_href' => 'noslimstat,ab-item',
1141 'extensions_to_track' => 'pdf,doc,xls,zip',
1142
1143 // Tracker - Advanced Options
1144 // NOTE: geolocation_provider is intentionally NOT in init_options().
1145 // init() merges these defaults into stored settings, which would override
1146 // the legacy enable_maxmind flag on upgraded installs before lazy migration runs.
1147 // Use get_fresh_defaults() for new installs and settings reset.
1148 'geolocation_country' => 'on',
1149 'session_duration' => 1800,
1150 'extend_session' => 'no',
1151 'enable_cdn' => 'no',
1152 'ajax_relative_path' => 'no',
1153
1154 // Tracker - External Pages
1155 'external_domains' => '',
1156
1157 // Reports
1158 // -----------------------------------------------------------------------
1159
1160 // Reports - Functionality
1161 'use_current_month_timespan' => 'no',
1162 'posts_column_day_interval' => 28,
1163 'rows_to_show' => '20',
1164 'ip_lookup_service' => 'https://ip-api.com/#',
1165 'comparison_chart' => 'on',
1166 'show_display_name' => 'no',
1167 'convert_resource_urls_to_titles' => 'on',
1168 'convert_ip_addresses' => 'no',
1169
1170 // Reports - Access Log and World Map
1171 'refresh_interval' => '60',
1172 'number_results_raw_data' => '50',
1173 'max_dots_on_map' => '50',
1174
1175 // Reports - Miscellaneous
1176 'custom_css' => '',
1177 'chart_colors' => '',
1178 'mozcom_access_id' => '',
1179 'mozcom_secret_key' => '',
1180 'show_complete_user_agent_tooltip' => 'no',
1181 'async_load' => 'no',
1182 'limit_results' => '200',
1183 'enable_sov' => 'no',
1184
1185 // Exclusions
1186 // -----------------------------------------------------------------------
1187
1188 // Exclusions - User Properties
1189 'ignore_wp_users' => 'no',
1190 'ignore_spammers' => 'on',
1191 'ignore_bots' => 'no',
1192 'ignore_prefetch' => 'on',
1193 'ignore_users' => '',
1194 'ignore_ip' => '',
1195 'ignore_countries' => '',
1196 'ignore_languages' => '',
1197 'ignore_browsers' => '',
1198 'ignore_platforms' => '',
1199 'ignore_capabilities' => '',
1200
1201 // Exclusions - Page Properties
1202 'ignore_resources' => '',
1203 'ignore_referers' => '',
1204 'ignore_content_types' => '',
1205
1206 // Access Control
1207 // -----------------------------------------------------------------------
1208
1209 // Access Control - Reports
1210 'restrict_authors_view' => 'on',
1211 'capability_can_view' => 'manage_options',
1212 'can_view' => '',
1213
1214 // Access Control - Reports
1215 'tracking_request_method' => 'ajax',
1216
1217 // Access Control - Customizer
1218 'capability_can_customize' => 'manage_options',
1219 'can_customize' => '',
1220
1221 // Access Control - Settings
1222 'capability_can_admin' => 'manage_options',
1223 'can_admin' => '',
1224
1225 // Access Control - REST API
1226 'rest_api_tokens' => wp_hash(wp_generate_password(64, true, true)),
1227
1228 // Maintenance
1229 // -----------------------------------------------------------------------
1230 'last_tracker_error' => [0, '', 0],
1231 'show_sql_debug' => 'no',
1232 'slimstat_debug' => 'off',
1233 'db_indexes' => 'on',
1234 'enable_maxmind' => 'disable',
1235 'maxmind_license_key' => '',
1236 'enable_browscap' => 'no',
1237
1238 // Notices
1239 // -----------------------------------------------------------------------
1240 'notice_latest_news' => 'on',
1241 'notice_browscap' => 'on',
1242 'notice_browscap_fileinfo' => 'on',
1243 'notice_geolite' => 'on',
1244 'notice_caching' => 'on',
1245
1246 // Network-wide Settings
1247 'locked_options' => '',
1248 ];
1249 }
1250 // end init_options
1251
1252 /**
1253 * Saves a given option in the database
1254 */
1255 public static function update_option($_key = '', $_value = '')
1256 {
1257 if (!is_network_admin()) {
1258 update_option($_key, $_value);
1259 } else {
1260 update_site_option($_key, $_value);
1261 }
1262 }
1263 // end update_option
1264
1265 /**
1266 * Attach a script to every page to track visitors' screen resolution and other browser-based information
1267 */
1268 public static function enqueue_tracker()
1269 {
1270 // Use the new unified tracking method setting
1271 $method = self::$settings['tracking_request_method'] ?? 'rest';
1272
1273 // Handle legacy 'adblock' value (renamed to 'adblock_bypass' in v5.3.0)
1274 if ( 'adblock' === $method ) {
1275 $method = 'adblock_bypass';
1276 }
1277
1278 // Prepare URLs for all methods
1279 $rest_url = rest_url('slimstat/v1/hit');
1280 $rest_base_url = rest_url();
1281 // Mirror WordPress core's non-pretty REST routing so query fallback still works
1282 // on index-permalink and subdirectory installs.
1283 $rest_query_base = trailingslashit(get_home_url(null, '', 'rest'));
1284 if ('index.php' !== substr(untrailingslashit($rest_query_base), -9)) {
1285 $rest_query_base .= 'index.php';
1286 }
1287 $rest_query_url = add_query_arg('rest_route', '/slimstat/v1/hit', $rest_query_base);
1288 $ajax_url = admin_url('admin-ajax.php');
1289 $ajax_url_relative = admin_url('admin-ajax.php', 'relative');
1290
1291 $params = [
1292 'transport' => $method,
1293 'ajaxurl_rest' => $rest_url,
1294 'ajaxurl_rest_query' => $rest_query_url,
1295 'resturl' => $rest_base_url,
1296 'ajaxurl_ajax' => ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url,
1297 ];
1298
1299 // Only provide adblock bypass URL when the rewrite rule is active.
1300 // The rewrite rule is only registered for 'adblock_bypass' transport,
1301 // so this URL would 404 for other transports — a dead fallback.
1302 if ('adblock_bypass' === $method) {
1303 $adblock_hash = \SlimStat\Providers\RestApiManager::getSecureAdblockHash();
1304 $params['ajaxurl_adblock'] = home_url(sprintf('request/%s/', $adblock_hash));
1305 }
1306
1307 // Set the primary ajaxurl based on the selected method
1308 if ('rest' === $method) {
1309 $params['ajaxurl'] = $rest_url;
1310 } elseif ('ajax' === $method) {
1311 $params['ajaxurl'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url;
1312 } elseif ('adblock_bypass' === $method) {
1313 $params['ajaxurl'] = $params['ajaxurl_adblock'];
1314 // Also set transport to 'adblock_bypass' for JS clarity
1315 $params['transport'] = 'adblock_bypass';
1316 } else {
1317 $params['ajaxurl'] = $rest_url;
1318 }
1319
1320 $baseurl = parse_url(get_home_url());
1321 $params['baseurl'] = empty($baseurl['path']) ? '/' : $baseurl['path'];
1322
1323 if (!empty(self::$settings['do_not_track_outbound_classes_rel_href'])) {
1324 $params['dnt'] = str_replace(' ', '', self::$settings['do_not_track_outbound_classes_rel_href']);
1325 }
1326
1327 // Internal GDPR banner is optionally available alongside CMP integrations.
1328
1329 if ('on' != self::$settings['javascript_mode']) {
1330 if (empty(self::$stat['id']) || intval(self::$stat['id']) < 0) {
1331 return false;
1332 }
1333 $params['id'] = \SlimStat\Tracker\Utils::getValueWithChecksum(intval(self::$stat['id']));
1334 } else {
1335 $params['ci'] = \SlimStat\Tracker\Utils::getValueWithChecksum(\SlimStat\Tracker\Utils::base64UrlEncode(wp_json_encode(\SlimStat\Tracker\Utils::getContentInfo())));
1336 }
1337
1338 // Always generate wp_rest_nonce (needed for consent banner CSRF protection).
1339 // The JS uses is_logged_in to decide whether to send it as X-WP-Nonce header.
1340 // Anonymous pages: is_logged_in='0' → no header → no 403 on cached pages.
1341 // Admin-cached pages: is_logged_in='1' (stale) → sends nonce → may 403 → retry
1342 // without nonce (handled by JS retry logic). This is acceptable since most caches
1343 // exclude logged-in users, and the retry adds only one extra request.
1344 $params['wp_rest_nonce'] = wp_create_nonce('wp_rest');
1345 $params['is_logged_in'] = is_user_logged_in() ? '1' : '0';
1346 // Expose consent/DNT info to client
1347 $params['wp_consent_integration'] = (self::$settings['consent_integration'] ?? '') === 'wp_consent_api' ? 'enabled' : 'disabled';
1348 $params['consent_integration'] = self::$settings['consent_integration'] ?? '';
1349 $params['consent_level_integration'] = (self::$settings['consent_level_integration'] ?? 'statistics');
1350 $params['respect_dnt'] = self::$settings['do_not_track'] ?? 'off';
1351 $gdpr_enabled_setting = strtolower((string) (self::$settings['gdpr_enabled'] ?? 'off'));
1352 $params['gdpr_enabled'] = in_array($gdpr_enabled_setting, ['off', 'no', 'false', '0'], true) ? 'off' : 'on';
1353 $params['anonymous_tracking'] = self::$settings['anonymous_tracking'] ?? 'off';
1354 $params['anonymize_ip'] = self::$settings['anonymize_ip'] ?? 'no';
1355 $params['hash_ip'] = self::$settings['hash_ip'] ?? 'no';
1356 $params['set_tracker_cookie'] = self::$settings['set_tracker_cookie'] ?? 'on';
1357 // Mirror the same dual-condition guard used by the PHP banner output (lines 305-306):
1358 // banner HTML is only rendered when BOTH gdpr_enabled=on AND use_slimstat_banner=on.
1359 // If gdpr_enabled is off, the banner DOM never exists — JS must not enter banner-init mode
1360 // or it will set a "ran" lock and silently skip _send_pageview for all visitors.
1361 $params['use_slimstat_banner'] = ('on' === $params['gdpr_enabled'] && 'on' === (self::$settings['use_slimstat_banner'] ?? 'off')) ? 'on' : 'off';
1362
1363 if ('on' === $params['use_slimstat_banner']) {
1364 // Set GDPR consent endpoint based on tracking method
1365 if ('rest' === $method) {
1366 $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent');
1367 } elseif ('ajax' === $method) {
1368 $params['gdpr_consent_endpoint'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url;
1369 } elseif ('adblock_bypass' === $method) {
1370 $params['gdpr_consent_endpoint'] = $params['ajaxurl_adblock'];
1371 } else {
1372 $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent');
1373 }
1374 $params['gdpr_cookie_name'] = \SlimStat\Services\GDPRService::CONSENT_COOKIE_NAME;
1375 $params['gdpr_cookie_path'] = defined('COOKIEPATH') ? COOKIEPATH : '/';
1376 $params['gdpr_cookie_domain'] = defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '';
1377 $params['gdpr_consent_method'] = $method;
1378 }
1379
1380 if ('on' === self::$settings['slimstat_debug'] || (defined('WP_DEBUG') && WP_DEBUG)) {
1381 $params['slimstat_debug'] = 'on';
1382 }
1383
1384 $params = apply_filters('slimstat_js_params', $params);
1385
1386 // Add dependencies for consent integrations (e.g., WP Consent API)
1387 $dependencies = [];
1388 if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') {
1389 // Only add dependency if the WP Consent API script is actually registered
1390 if (wp_script_is('wp-consent-api', 'registered') || wp_script_is('wp-consent-api', 'enqueued')) {
1391 $dependencies[] = 'wp-consent-api';
1392 }
1393 }
1394
1395 // Register the correct script for adblock bypass, CDN, or default
1396 $local_script_version = SLIMSTAT_ANALYTICS_VERSION;
1397 $local_script_path = plugin_dir_path(__FILE__) . 'wp-slimstat.min.js';
1398 if (file_exists($local_script_path)) {
1399 $local_script_version .= '.' . filemtime($local_script_path);
1400 }
1401
1402 if ('adblock_bypass' === $method) {
1403 $hash_js = md5(site_url() . 'slimstat');
1404 wp_register_script('wp_slimstat', home_url(sprintf('/%s.js/', $hash_js)), $dependencies, SLIMSTAT_ANALYTICS_VERSION, true);
1405 } elseif ('on' == self::$settings['enable_cdn']) {
1406 wp_register_script('wp_slimstat', 'https://cdn.jsdelivr.net/wp/wp-slimstat/tags/' . SLIMSTAT_ANALYTICS_VERSION . '/wp-slimstat.min.js', $dependencies, null, true);
1407 } else {
1408 wp_register_script('wp_slimstat', plugins_url('/wp-slimstat.min.js', __FILE__), $dependencies, $local_script_version, true);
1409 }
1410
1411 wp_enqueue_script('wp_slimstat');
1412
1413 /**
1414 * Registers the 'wp_slimstat' script as an interactivity module if the registration function exists.
1415 *
1416 * Ensures compatibility with WordPress Interactivity API by registering the script module and its dependencies.
1417 */
1418 if (function_exists('wp_interactivity_register_script_module')) {
1419 wp_interactivity_register_script_module('wp_slimstat', [
1420 'name' => 'wp_slimstat',
1421 'dependencies' => [],
1422 ]);
1423 }
1424
1425 wp_localize_script('wp_slimstat', 'SlimStatParams', $params);
1426
1427 return null;
1428 }
1429
1430 // end enqueue_tracker
1431
1432 /**
1433 * Enqueue assets for the internal SlimStat GDPR banner.
1434 *
1435 * @return void
1436 */
1437 public static function enqueue_gdpr_assets()
1438 {
1439 if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) {
1440 return;
1441 }
1442
1443 wp_enqueue_style(
1444 'wp_slimstat_gdpr_banner',
1445 plugins_url('/assets/css/gdpr-banner.css', __FILE__),
1446 [],
1447 SLIMSTAT_ANALYTICS_VERSION
1448 );
1449 }
1450
1451 /**
1452 * Render the SlimStat GDPR banner markup.
1453 *
1454 * @return void
1455 */
1456 public static function render_gdpr_banner()
1457 {
1458 if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) {
1459 return;
1460 }
1461
1462 if (is_admin() && !wp_doing_ajax()) {
1463 return;
1464 }
1465
1466 $gdpr_service = new \SlimStat\Services\GDPRService(self::$settings);
1467 $banner_html = $gdpr_service->getBannerHtml();
1468
1469 if ('' === $banner_html) {
1470 return;
1471 }
1472
1473 echo $banner_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized in GDPRService
1474 }
1475
1476 public static function add_defer_to_script_tag($_tag, $_handle)
1477 {
1478 if ('wp_slimstat' === $_handle && false === stripos($_tag, 'defer')) {
1479 $_tag = str_replace('<script ', '<script defer ', $_tag);
1480 }
1481
1482 return $_tag;
1483 }
1484
1485 /**
1486 * Removes old entries from the main table and performs other daily tasks
1487 */
1488 public static function wp_slimstat_purge()
1489 {
1490 $autopurge_interval = intval(self::$settings['auto_purge']);
1491
1492 if ($autopurge_interval <= 0) {
1493 return;
1494 }
1495
1496 $days_ago = self::now() - ( $autopurge_interval * DAY_IN_SECONDS );
1497 $table_stats = $GLOBALS['wpdb']->prefix . 'slim_stats';
1498 $table_stats_archive = $GLOBALS['wpdb']->prefix . 'slim_stats_archive';
1499 $table_events = $GLOBALS['wpdb']->prefix . 'slim_events';
1500 $table_events_archive = $GLOBALS['wpdb']->prefix . 'slim_events_archive';
1501
1502 // Copy entries to the archive table, if needed
1503 if ('no' != self::$settings['auto_purge_delete']) {
1504 // Use Query builder for INSERT INTO ... SELECT ... with prepared statements
1505 $insert_sql = self::$wpdb->prepare(
1506 "INSERT INTO {$table_stats_archive} (id, ip, other_ip, username, email, country, location, city, referer, resource, searchterms, notes, visit_id, server_latency, page_performance, browser, browser_version, browser_type, platform, language, fingerprint, user_agent, resolution, screen_width, screen_height, content_type, category, author, content_id, tz_offset, outbound_resource, dt_out, dt) SELECT id, ip, other_ip, username, email, country, location, city, referer, resource, searchterms, notes, visit_id, server_latency, page_performance, browser, browser_version, browser_type, platform, language, fingerprint, user_agent, resolution, screen_width, screen_height, content_type, category, author, content_id, tz_offset, outbound_resource, dt_out, dt FROM {$table_stats} WHERE dt < %d",
1507 $days_ago
1508 );
1509 $is_copy_done = self::$wpdb->query($insert_sql);
1510 if (false !== $is_copy_done) {
1511 \SlimStat\Utils\Query::delete($table_stats)->where('dt', '<', $days_ago)->execute();
1512 }
1513 $insert_sql_events = self::$wpdb->prepare(
1514 "INSERT INTO {$table_events_archive} (type, event_description, notes, position, id, dt) SELECT type, event_description, notes, position, id, dt FROM {$table_events} WHERE dt < %d",
1515 $days_ago
1516 );
1517 $is_copy_done = self::$wpdb->query($insert_sql_events);
1518 if (false !== $is_copy_done) {
1519 \SlimStat\Utils\Query::delete($table_events)->where('dt', '<', $days_ago)->execute();
1520 }
1521 } else {
1522 // Delete old entries
1523 \SlimStat\Utils\Query::delete($table_stats)->where('dt', '<', $days_ago)->execute();
1524 \SlimStat\Utils\Query::delete($table_events)->where('dt', '<', $days_ago)->execute();
1525 }
1526
1527 // Optimize tables (keep as direct queries)
1528 self::$wpdb->query('OPTIMIZE TABLE ' . $table_stats);
1529 self::$wpdb->query('OPTIMIZE TABLE ' . $table_stats_archive);
1530 self::$wpdb->query('OPTIMIZE TABLE ' . $table_events);
1531 self::$wpdb->query('OPTIMIZE TABLE ' . $table_events_archive);
1532 }
1533
1534 public static function wp_slimstat_update_geoip_database()
1535 {
1536 // Calculate the most recent "first Tuesday + 2 days" that has already passed
1537 $this_month_update = strtotime('first Tuesday of this month') + (86400 * 2);
1538 $current_time = time();
1539
1540 // If this month's update window hasn't arrived yet, use last month's window
1541 if ($current_time < $this_month_update) {
1542 $this_update = strtotime('first Tuesday of last month') + (86400 * 2);
1543 } else {
1544 $this_update = $this_month_update;
1545 }
1546
1547 $last_update = get_option('slimstat_last_geoip_dl', 0);
1548 if ($last_update < $this_update) {
1549
1550 // Determine which geolocation provider to use
1551 $provider = self::resolve_geolocation_provider();
1552 if (false === $provider) {
1553 return;
1554 }
1555
1556 try {
1557 $geographicProvider = new \SlimStat\Services\Geolocation\GeolocationService($provider, []);
1558 $ok = $geographicProvider->updateDatabase();
1559
1560 if ($ok) {
1561 update_option('slimstat_last_geoip_dl', time());
1562 }
1563
1564 } catch (\Throwable $e) {
1565 wp_slimstat::log('Geolocation database update failed: ' . $e->getMessage(), 'error');
1566 }
1567 }
1568 }
1569
1570 /**
1571 * Register privacy policy content for WordPress Privacy Tools
1572 *
1573 * @since 5.4.0
1574 */
1575 public static function registerPrivacyPolicyContent()
1576 {
1577 if (!function_exists('wp_add_privacy_policy_content')) {
1578 return;
1579 }
1580
1581 $content = '<h2>' . __('SlimStat Analytics', 'wp-slimstat') . '</h2>';
1582 $content .= '<p><strong>' . __('What personal data we collect and why', 'wp-slimstat') . '</strong></p>';
1583 $content .= '<p>' . __('SlimStat Analytics collects the following data about website visitors:', 'wp-slimstat') . '</p>';
1584 $content .= '<ul>';
1585 $content .= '<li>' . __('IP Address: Collected for analytics and security purposes. May be anonymized or hashed based on your privacy settings.', 'wp-slimstat') . '</li>';
1586 $content .= '<li>' . __('Page URLs: Tracks which pages are visited to analyze website usage.', 'wp-slimstat') . '</li>';
1587 $content .= '<li>' . __('Referrer Information: Tracks where visitors came from (search engines, other websites, etc.).', 'wp-slimstat') . '</li>';
1588 $content .= '<li>' . __('Browser and Device Information: User agent, screen resolution, and device type for analytics.', 'wp-slimstat') . '</li>';
1589 $content .= '<li>' . __('Timestamp: Date and time of each page visit.', 'wp-slimstat') . '</li>';
1590
1591 if ('on' === (self::$settings['set_tracker_cookie'] ?? 'off')) {
1592 $content .= '<li>' . __('Cookies: A tracking cookie is used to identify returning visitors and maintain session continuity.', 'wp-slimstat') . '</li>';
1593 }
1594
1595 if ('on' !== (self::$settings['ignore_wp_users'] ?? 'off')) {
1596 $content .= '<li>' . __('User Information: If you are logged in, your username and email may be associated with your visits (only with consent when GDPR mode is enabled).', 'wp-slimstat') . '</li>';
1597 }
1598
1599 $content .= '</ul>';
1600
1601 $content .= '<p><strong>' . __('How long we retain your data', 'wp-slimstat') . '</strong></p>';
1602 $retention_days = intval(self::$settings['auto_purge'] ?? 420);
1603 if ($retention_days > 0) {
1604 $content .= '<p>' . sprintf(__('Analytics data is automatically deleted after %d days, in compliance with GDPR data retention requirements.', 'wp-slimstat'), $retention_days) . '</p>';
1605 } else {
1606 $content .= '<p>' . __('Analytics data retention is currently disabled. Please contact the site administrator for information about data retention policies.', 'wp-slimstat') . '</p>';
1607 }
1608
1609 $content .= '<p><strong>' . __('Your rights', 'wp-slimstat') . '</strong></p>';
1610 $content .= '<p>' . __('Under GDPR, you have the right to:', 'wp-slimstat') . '</p>';
1611 $content .= '<ul>';
1612 $content .= '<li>' . __('Access your personal data collected by SlimStat', 'wp-slimstat') . '</li>';
1613 $content .= '<li>' . __('Request deletion of your personal data (Right to be Forgotten)', 'wp-slimstat') . '</li>';
1614 $content .= '<li>' . __('Opt-out of tracking by revoking consent (if GDPR mode is enabled)', 'wp-slimstat') . '</li>';
1615 $content .= '</ul>';
1616
1617 if ('on' === (self::$settings['gdpr_enabled'] ?? 'off')) {
1618 $content .= '<p>' . __('You can exercise these rights by using the WordPress Privacy Tools (Tools → Export Personal Data / Erase Personal Data) or by contacting the site administrator.', 'wp-slimstat') . '</p>';
1619 }
1620
1621 $content .= '<p><strong>' . __('Consent Management', 'wp-slimstat') . '</strong></p>';
1622 if ('on' === (self::$settings['anonymous_tracking'] ?? 'off')) {
1623 $content .= '<p>' . __('This website uses Anonymous Tracking Mode. Initial tracking occurs without collecting personally identifiable information (PII). Full tracking with PII collection only occurs after you grant explicit consent.', 'wp-slimstat') . '</p>';
1624 } else {
1625 $content .= '<p>' . __('Tracking requires your consent when GDPR mode is enabled. You can grant or revoke consent at any time through the consent management interface.', 'wp-slimstat') . '</p>';
1626 }
1627
1628 wp_add_privacy_policy_content('SlimStat Analytics', $content);
1629 }
1630
1631 public static function add_plugin_manual_download_link($_links = [], $_plugin_file = '')
1632 {
1633 $a_clean_slug = str_replace(['wp-slimstat-', '/index.php'], ['', ''], $_plugin_file);
1634
1635 if (false !== ($download_url = get_transient('wp-slimstat-download-link-' . $a_clean_slug))) {
1636 $_links[] = '<a href="' . $download_url . '">Download ZIP</a>';
1637 } else {
1638 $url = 'https://www.wp-slimstat.com/update-checker/?slug=' . $a_clean_slug . '&key=' . urlencode(self::$settings['addon_licenses']['wp-slimstat-' . $a_clean_slug]);
1639 $response = wp_safe_remote_get($url, ['timeout' => 300, 'user-agent' => 'Slimstat Analytics/' . SLIMSTAT_ANALYTICS_VERSION . '; ' . home_url()]);
1640
1641 if (!is_wp_error($response) && 200 == wp_remote_retrieve_response_code($response)) {
1642 $data = @json_decode($response['body']);
1643
1644 if (is_object($data)) {
1645 $_links[] = '<a href="' . $data->download_url . '">Download ZIP</a>';
1646 set_transient('wp-slimstat-download-link-' . $a_clean_slug, $data->download_url, 172800); // 48 hours
1647 }
1648 }
1649 }
1650
1651 return $_links;
1652 }
1653
1654 /**
1655 * Resolves a given IP address, by keeping a local cache of hostnames to avoid multiple requests to the DNS server
1656 */
1657 public static function gethostbyaddr($_ip = '')
1658 {
1659 $hostname = get_transient('slimstat_' . $_ip);
1660
1661 if (empty($hostname)) {
1662 $hostname = gethostbyaddr($_ip);
1663 set_transient('slimstat_' . $_ip, $hostname, HOUR_IN_SECONDS);
1664 }
1665
1666 return $hostname;
1667 }
1668 // end gethostbyaddr
1669
1670 /**
1671 * Registers the Slimstat widget
1672 */
1673 public static function register_widget()
1674 {
1675 return register_widget('slimstat_widget');
1676 }
1677 // end register_widget
1678
1679 /**
1680 * Generates the key to see if a given host is listed as a search engine in the corresponding Json data file
1681 */
1682 public static function get_lossy_url($_url = '')
1683 {
1684 return preg_replace(
1685 [
1686 '/^(w+\d*|search)\./',
1687 '/(^|\.)m\./',
1688 '/(\.(com|org|net|co|it|edu))?\.(ad|ae|af|ag|ai|al|am|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bl|bm|bn|bo|bq|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cw|cx|cy|cz|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mf|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tr|tt|tv|tw|tz|ua|ug|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)(\/|$)/',
1689 '/(^|\.)(ad|ae|af|ag|ai|al|am|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bl|bm|bn|bo|bq|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cw|cx|cy|cz|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mf|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tr|tt|tv|tw|tz|ua|ug|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)\./',
1690 ],
1691 [
1692 '',
1693 '$1',
1694 '.{}$4',
1695 '$1{}.',
1696 ],
1697 $_url
1698 );
1699 }
1700 // end get_lossy_url
1701
1702 /**
1703 * Check if slimstat pro plugin is installed
1704 */
1705 public static function pro_is_installed($pluginSlug = 'wp-slimstat-pro/wp-slimstat-pro.php')
1706 {
1707 include_once(ABSPATH . 'wp-admin/includes/plugin.php');
1708 return (bool) is_plugin_active($pluginSlug);
1709 }
1710
1711 /**
1712 * create upload directory
1713 */
1714 public static function create_upload_directory()
1715 {
1716 $upload_dir = self::$upload_dir;
1717 wp_mkdir_p($upload_dir);
1718
1719 /**
1720 * Create .htaccess to avoid public access.
1721 */
1722 if (is_dir($upload_dir) && is_writable($upload_dir)) {
1723 $htaccess_file = path_join($upload_dir, '.htaccess');
1724
1725 if (!file_exists($htaccess_file) && $handle = @fopen($htaccess_file, 'w')) {
1726 fwrite($handle, "Deny from all\n");
1727 fclose($handle);
1728 }
1729 }
1730 }
1731
1732 public static function get_schedule_interval($schedule)
1733 {
1734 $schedulesInterval = wp_get_schedules();
1735 $timeInterval = 86400;
1736 if (isset($schedulesInterval[$schedule]['interval'])) {
1737 $timeInterval = $schedulesInterval[$schedule]['interval'];
1738 }
1739 return $timeInterval;
1740 }
1741 }
1742
1743 // end of class declaration
1744
1745 class slimstat_widget extends WP_Widget
1746 {
1747 /**
1748 * Sets up the widgets name etc
1749 */
1750 public function __construct()
1751 {
1752 parent::__construct('slimstat_widget', 'Slimstat', [
1753 'classname' => 'slimstat_widget',
1754 'description' => 'Add a Slimstat report to your sidebar',
1755 ]);
1756 }
1757
1758 /**
1759 * Outputs the content of the widget
1760 *
1761 * @param array $args
1762 * @param array $instance
1763 */
1764 public function widget($_args = [], $_instance = [])
1765 {
1766 extract(shortcode_atts([
1767 'slimstat_widget_id' => '',
1768 'slimstat_widget_title' => '',
1769 'slimstat_widget_filters' => '',
1770 ], $_instance));
1771
1772 if (!empty($slimstat_widget_title)) {
1773 echo (empty($_args['before_title']) ? '<h2 class="widget-title">' : $_args['before_title']) . esc_html($slimstat_widget_title) . (empty($_args['after_title']) ? '</h2>' : $_args['after_title']);
1774 }
1775 if (!empty($slimstat_widget_id)) {
1776 echo do_shortcode(sprintf("[slimstat f='widget' w='%s']%s[/slimstat]", $slimstat_widget_id, $slimstat_widget_filters));
1777 } else {
1778 echo '';
1779 }
1780 }
1781
1782 /**
1783 * Outputs the options form on admin
1784 *
1785 * @param array $instance The widget options
1786 */
1787 public function form($_instance)
1788 {
1789 extract(shortcode_atts([
1790 'slimstat_widget_id' => '',
1791 'slimstat_widget_title' => '',
1792 'slimstat_widget_filters' => '',
1793 ], $_instance));
1794
1795 // Let's build the dropdown
1796 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php');
1797 wp_slimstat_reports::init();
1798 $select_options = '';
1799
1800 foreach (wp_slimstat_reports::$reports as $a_report_id => $a_report_info) {
1801 $select_options .= sprintf("<option value='%s' ", $a_report_id) . (($slimstat_widget_id == $a_report_id) ? 'selected="selected"' : '') . sprintf('>%s</option>', $a_report_info[ 'title' ]);
1802 }
1803 ?>
1804
1805 <p>
1806 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_id')); ?>"><?php _e('Report', 'wp-slimstat') ?></label>
1807 <select class="widefat" id="<?php echo esc_attr($this->get_field_id('slimstat_widget_id')); ?>" name="<?php echo esc_attr($this->get_field_name('slimstat_widget_id')); ?>">
1808 <option value="">Select a widget</option>
1809 <?php echo $select_options ?>
1810 </select>
1811 </p>
1812
1813 <p>
1814 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_title')); ?>"><?php _e('Title', 'wp-slimstat') ?></label>
1815 <input type="text" class="widefat" id="<?php echo esc_attr($this->get_field_id('slimstat_widget_title')); ?>" name="<?php echo esc_attr($this->get_field_name('slimstat_widget_title')); ?>" value="<?php echo trim(strip_tags($slimstat_widget_title)) ?>">
1816 </p>
1817
1818 <p>
1819 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_filters')); ?>"><?php _e('Optional filters', 'wp-slimstat'); ?></label>
1820 <a href="https://wp-slimstat.com/resources/what-is-the-syntax-of-a-slimstat-shortcode-#slimstat-operators" target="_blank">[?]</a>
1821 <textarea class="widefat" id="<?php echo esc_attr($this->get_field_id('slimstat_widget_filters')); ?>" name="<?php echo esc_attr($this->get_field_name('slimstat_widget_filters')); ?>"><?php echo trim(strip_tags($slimstat_widget_filters)) ?></textarea>
1822 </p>
1823 <?php
1824 }
1825
1826 /**
1827 * Processing widget options on save
1828 *
1829 * @param array $new_instance The new options
1830 * @param array $old_instance The previous options
1831 */
1832 public function update($_new_instance, $_old_instance)
1833 {
1834 $instance = $_old_instance;
1835
1836 $instance['slimstat_widget_id'] = sanitize_key($_new_instance['slimstat_widget_id'] ?? '');
1837 $instance['slimstat_widget_title'] = sanitize_text_field(wp_unslash($_new_instance['slimstat_widget_title'] ?? ''));
1838 $instance['slimstat_widget_filters'] = sanitize_textarea_field(wp_unslash($_new_instance['slimstat_widget_filters'] ?? ''));
1839 return $instance;
1840 }
1841 }
1842
1843 // Early initialize DB handle for add-ons that may access wp_slimstat::$wpdb before init() runs
1844 if (empty(wp_slimstat::$wpdb) && isset($GLOBALS['wpdb'])) {
1845 wp_slimstat::$wpdb = $GLOBALS['wpdb'];
1846 }
1847
1848 // Ok, let's go, Sparky!
1849 if (function_exists('add_action')) {
1850 // Since we use sendBeacon, this function sends raw POST data, which does not populate the $_POST variable automatically
1851 $http_content_type = isset($_SERVER['HTTP_CONTENT_TYPE']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_CONTENT_TYPE'])) : '';
1852 $content_type = isset($_SERVER['CONTENT_TYPE']) ? sanitize_text_field(wp_unslash($_SERVER['CONTENT_TYPE'])) : '';
1853 if ((!empty($http_content_type) || !empty($content_type)) && [] === $_POST) {
1854 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Required for reading php://input stream
1855 $raw_post_string = file_get_contents('php://input');
1856 parse_str($raw_post_string, wp_slimstat::$raw_post_array);
1857
1858 // Sanitize the action key from the raw body before using it
1859 if (!empty(wp_slimstat::$raw_post_array['action'])) {
1860 wp_slimstat::$raw_post_array['action'] = sanitize_key(
1861 wp_unslash(wp_slimstat::$raw_post_array['action'])
1862 );
1863 }
1864 } elseif ([] !== $_POST) {
1865 wp_slimstat::$raw_post_array = $_POST;
1866 }
1867
1868 // Init the Ajax listener
1869 if (!empty(wp_slimstat::$raw_post_array['action']) && 'slimtrack' == wp_slimstat::$raw_post_array['action']) {
1870
1871 // This is needed because admin-ajax.php is reading $_REQUEST to fire the corresponding action
1872 // Use a hardcoded literal instead of passing the user-supplied value
1873 if (empty($_POST['action'])) {
1874 $_POST['action'] = 'slimtrack';
1875 }
1876
1877 add_action('wp_ajax_nopriv_slimtrack', [\SlimStat\Tracker\Ajax::class, 'handle']);
1878 add_action('wp_ajax_slimtrack', [\SlimStat\Tracker\Ajax::class, 'handle']);
1879 }
1880
1881
1882 // From the codex: You can't call register_activation_hook() inside a function hooked to the 'plugins_loaded' or 'init' hooks (or any other hook). These hooks are called before the plugin is loaded or activated.
1883 if (is_admin()) {
1884 include_once(plugin_dir_path(__FILE__) . 'admin/index.php');
1885 register_activation_hook(__FILE__, ['wp_slimstat_admin', 'init_environment']);
1886 register_deactivation_hook(__FILE__, ['wp_slimstat_admin', 'deactivate']);
1887 }
1888
1889 add_action('widgets_init', ['wp_slimstat', 'register_widget']);
1890
1891 // Load textdomain at init (required by WordPress 6.7.0+)
1892 add_action('init', ['wp_slimstat', 'load_textdomain'], 1);
1893
1894 // Add the appropriate actions
1895 add_action('plugins_loaded', ['wp_slimstat', 'init'], 20);
1896 // Add the action to fetch chart data
1897 add_action('wp_ajax_slimstat_fetch_chart_data', [\SlimStat\Modules\Chart::class, 'ajaxFetchChartData']);
1898 }
1899
1900 add_action('wp_ajax_slimstat_clear_cache', 'wp_slimstat_clear_cache_handler');
1901
1902 function wp_slimstat_clear_cache_handler()
1903 {
1904 if (!current_user_can('manage_options')) {
1905 wp_send_json_error(__('Permission denied', 'wp-slimstat'));
1906 }
1907 // Optional: check nonce if you add it to JS
1908 if (empty($_POST['security']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['security'])), 'slimstat_clear_cache')) {
1909 wp_send_json_error(__('Invalid nonce', 'wp-slimstat'));
1910 }
1911
1912 global $wpdb;
1913 $transients = $wpdb->get_col(
1914 sprintf("SELECT option_name FROM %s WHERE option_name LIKE '_transient_wp_slimstat_query_%%' OR option_name LIKE '_transient_timeout_wp_slimstat_query_%%'", $wpdb->options)
1915 );
1916 $count = 0;
1917 foreach ($transients as $transient) {
1918 delete_option($transient);
1919 $count++;
1920 }
1921 wp_send_json_success(sprintf(__('Slimstat cache cleared (%d items)', 'wp-slimstat'), $count));
1922 }
1923