PluginProbe
SlimStat Analytics / 5.4.6
SlimStat Analytics v5.4.6
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 5.4.6, at wp-slimstat.php

1,907 lines 85.0 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.4.6
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.4.6');
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 // Include Constants.php to make SLIMSTAT_ANALYTICS_DIR available to traits
32 require_once __DIR__ . '/src/Constants.php';
33
34
35 /**
36 * Main Slimstat Analytics Class
37 *
38 * @package Wp_SlimStat
39 *
40 * @todo REFACTOR TRACKING STATE: The $data_js and $stat properties should be refactored into a
41 * proper state object pattern to maintain encapsulation. Currently these properties are
42 * public to support refactored tracker classes (SlimStat\Tracker\*), but this breaks
43 * encapsulation and creates security risks. Future implementation should:
44 * 1. Create a TrackingState class to encapsulate state management
45 * 2. Update all Tracker classes to use the state object
46 * 3. Make properties protected or private
47 * 4. Ensure all state modifications go through validated methods
48 * This is tracked as technical debt for version 6.0
49 */
50
51 // Include Constants.php to make SLIMSTAT_ANALYTICS_DIR available to traits
52 require_once __DIR__ . '/src/Constants.php';
53
54 class wp_slimstat
55 {
56 public static $settings = [];
57
58 public static $wpdb;
59 public static $upload_dir = '';
60
61 /**
62 * Flag indicating programmatic (server-side) tracking is active.
63 *
64 * When true, CMP consent checks are bypassed in Consent::canTrack() and
65 * Consent::piiAllowed(). This is used by slimtrack_server() for server-side
66 * contexts (cron, CLI, redirect handlers) where no browser session exists.
67 *
68 * DNT headers, IP anonymization/hashing, and other non-consent settings
69 * remain enforced.
70 *
71 * @var bool
72 * @since 5.4.3
73 */
74 public static $is_programmatic_tracking = false;
75
76 public static $update_checker = [];
77 public static $raw_post_array = [];
78
79 /**
80 * @var array Tracking data from JavaScript (for internal tracking use only)
81 * @internal Use get_data_js() / set_data_js() methods for controlled access.
82 *
83 * This property is now protected to maintain proper encapsulation and prevent external code
84 * from bypassing consent checks or corrupting tracking state. All tracker classes use the
85 * getter/setter methods which include validation and filter hooks for GDPR compliance.
86 */
87 protected static $data_js = ['id' => 0];
88
89 /**
90 * @var array Current pageview tracking data (for internal tracking use only)
91 * @internal Use get_stat() / set_stat() methods for controlled access.
92 *
93 * This property is now protected to maintain proper encapsulation and prevent external code
94 * from bypassing consent checks or corrupting tracking state. All tracker classes use the
95 * getter/setter methods which include validation and filter hooks for GDPR compliance.
96 */
97 protected static $stat = [];
98
99 protected static $date_i18n_filters = [];
100
101 /**
102 * Gets the current data_js array (for internal tracking use only)
103 *
104 * @return array
105 */
106 public static function get_data_js()
107 {
108 return self::$data_js;
109 }
110
111 /**
112 * Sets the data_js array (for internal tracking use only)
113 *
114 * This method provides controlled access to the data_js property and includes
115 * basic validation to prevent tampering.
116 *
117 * @param array $data_js The tracking data from JavaScript
118 * @return void
119 * @internal For use by SlimStat tracking classes only
120 */
121 public static function set_data_js($data_js)
122 {
123 // Validate that we're receiving an array
124 if (!is_array($data_js)) {
125 return;
126 }
127
128 // Apply filter to allow validation/modification by consent management systems
129 $data_js = apply_filters('slimstat_set_data_js', $data_js);
130
131 self::$data_js = $data_js;
132 }
133
134 /**
135 * Gets the current stat array (for internal tracking use only)
136 *
137 * @return array Current tracking state
138 * @internal For use by SlimStat tracking classes only
139 */
140 public static function get_stat()
141 {
142 return self::$stat;
143 }
144
145 /**
146 * Sets the stat array (for internal tracking use only)
147 *
148 * This method provides controlled access to the stat property and includes
149 * basic validation to prevent tampering and ensure consent compliance.
150 *
151 * @param array $stat The pageview tracking data
152 * @return void
153 * @internal For use by SlimStat tracking classes only
154 */
155 public static function set_stat($stat)
156 {
157 // Validate that we're receiving an array
158 if (!is_array($stat)) {
159 return;
160 }
161
162 // Apply filter to allow validation/modification by consent management systems
163 // This is critical for GDPR compliance - CMPs can inspect and modify data
164 $stat = apply_filters('slimstat_set_stat', $stat);
165
166 self::$stat = $stat;
167 }
168
169 /**
170 * Backward-compatible wrapper for the tracking API.
171 *
172 * This method delegates to the new namespaced Tracker class while maintaining
173 * the original method signature for third-party integrations.
174 *
175 * @since 5.4.3
176 * @return int|false The record ID on success, or a negative error code on failure.
177 */
178 public static function slimtrack()
179 {
180 return \SlimStat\Tracker\Tracker::slimtrack();
181 }
182
183 /**
184 * Server-side tracking API that bypasses CMP consent checks.
185 *
186 * Use this method for programmatic tracking in server-side contexts where no
187 * browser session exists (e.g., cron jobs, CLI scripts, redirect handlers).
188 *
189 * CMP consent is a browser-side concept. In server-side contexts, there is no
190 * browser session and CMP consent has no meaningful role.
191 *
192 * The following settings remain enforced:
193 * - DNT (Do Not Track) headers
194 * - IP anonymization and hashing settings
195 * - Tracker cookie configuration
196 * - All exclusion rules
197 *
198 * @since 5.4.3
199 * @return int|false The record ID on success, or a negative error code on failure.
200 */
201 public static function slimtrack_server()
202 {
203 $previous_programmatic_state = self::$is_programmatic_tracking;
204 self::$is_programmatic_tracking = true;
205
206 try {
207 $result = \SlimStat\Tracker\Tracker::slimtrack();
208 } finally {
209 self::$is_programmatic_tracking = $previous_programmatic_state;
210 }
211
212 return $result;
213 }
214
215 /**
216 * Initializes variables and actions
217 */
218 public static function init()
219 {
220 \SlimStat\Providers\RestApiManager::run();
221
222 // Load all the settings
223 if (is_network_admin() && (empty($_GET['page']) || false === strpos($_GET['page'], 'slimview'))) {
224 self::$settings = get_site_option('slimstat_options', []);
225 } else {
226 self::$settings = get_option('slimstat_options', []);
227 }
228
229 if (empty(self::$settings)) {
230 // Fresh install: set defaults including geolocation_provider=dbip
231 self::$settings = self::get_fresh_defaults();
232 self::update_option('slimstat_options', self::$settings);
233 }
234
235 self::$settings = array_merge(self::init_options(), self::$settings);
236
237 // One-shot migration: runs once on first boot after installing this build.
238 // '_migration_5460' is absent from all pre-5.4.6 installs; array_merge fills it
239 // with '0' from init_options(). After running, the flag stores the version that ran it.
240 // On downgrade→re-upgrade, the stored version will differ from SLIMSTAT_ANALYTICS_VERSION,
241 // allowing the migration to re-run if needed. '0' = never ran, version string = ran.
242 $_migration_ran = self::$settings['_migration_5460'] ?? '0';
243 if ('0' === $_migration_ran || (is_string($_migration_ran) && '0' !== $_migration_ran && version_compare($_migration_ran, SLIMSTAT_ANALYTICS_VERSION, '<'))) {
244 // Save ORIGINAL use_slimstat_banner before consent-intent detection modifies it.
245 // This is the reliable v5.4.1 default fingerprint used for javascript_mode reset below.
246 $_ss_banner_was_on_original = ('on' === (self::$settings['use_slimstat_banner'] ?? 'off'));
247
248 // --- Consent intent detection ---
249 // Read legacy v5.3.x consent settings to detect if user had configured privacy.
250 // These survive through v5.3.x → v5.4.x upgrades because array_merge preserves DB values.
251 $_had_opt_out_banner = ('on' === (self::$settings['display_opt_out'] ?? 'no'));
252 $_had_opt_out_cookies = !empty(trim(self::$settings['opt_out_cookie_names'] ?? ''));
253 $_had_opt_in_cookies = !empty(trim(self::$settings['opt_in_cookie_names'] ?? ''));
254
255 // Check if user deliberately chose a third-party CMP in v5.4.x
256 $_current_integration = self::$settings['consent_integration'] ?? '';
257 $_has_third_party_cmp = in_array($_current_integration, ['wp_consent_api', 'real_cookie_banner'], true);
258
259 if ($_has_third_party_cmp) {
260 // User deliberately configured a third-party CMP — preserve their setup
261 self::$settings['gdpr_enabled'] = 'on';
262 } elseif ($_had_opt_out_banner || $_had_opt_out_cookies || $_had_opt_in_cookies) {
263 // User had consent/privacy config in v5.3.x — map to GDPR system
264 self::$settings['gdpr_enabled'] = 'on';
265 self::$settings['use_slimstat_banner'] = 'on';
266 // Auto-detect best CMP: if opt-in cookies were set (third-party plugin)
267 // and WP Consent API is installed, use it. Otherwise use SlimStat Banner.
268 if ($_had_opt_in_cookies && function_exists('wp_has_consent')) {
269 self::$settings['consent_integration'] = 'wp_consent_api';
270 } else {
271 self::$settings['consent_integration'] = 'slimstat_banner';
272 }
273 } else {
274 // No consent config ever — pure v5.3.x behavior: all tracked, no banner
275 self::$settings['gdpr_enabled'] = 'off';
276 self::$settings['consent_integration'] = '';
277 self::$settings['use_slimstat_banner'] = 'off';
278 }
279
280 // Restore session cookie when GDPR is off (v5.3.x default was 'on')
281 if ('off' === self::$settings['gdpr_enabled']
282 && 'off' === (self::$settings['set_tracker_cookie'] ?? 'on')) {
283 self::$settings['set_tracker_cookie'] = 'on';
284 }
285
286 unset($_had_opt_out_banner, $_had_opt_out_cookies, $_had_opt_in_cookies,
287 $_current_integration, $_has_third_party_cmp);
288
289 // use_slimstat_banner='on' in the ORIGINAL DB was the v5.4.1 fingerprint.
290 // Use the saved original value (before consent-intent detection modified it).
291 $_ss_banner_was_on = $_ss_banner_was_on_original;
292 unset($_ss_banner_was_on_original);
293 // javascript_mode='off' baked a stale per-visitor stat ID into cached HTML, causing
294 // every cached-page visitor to silently update the first visitor's DB record.
295 // ONLY reset when banner was also 'on' (v5.4.1 paired-default fingerprint) so that
296 // 5.3.x users who deliberately chose Server mode are not touched.
297 if ($_ss_banner_was_on && 'off' === (self::$settings['javascript_mode'] ?? 'on')) {
298 self::$settings['javascript_mode'] = 'on';
299 }
300 // anonymize_ip='on' and hash_ip='on' were v5.4.1 defaults that changed IP storage.
301 // Restore 5.3.x behavior: full IPs stored, no daily visitor hash.
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 // Queue a one-time admin notice when IP storage behavior changed so admins
311 // know to review Settings → Data Protection (EU sites may need to re-enable).
312 if ($_ss_ip_was_anonymized || $_ss_ip_was_hashed) {
313 set_transient('slimstat_migration_5460_ip_notice', '1', 7 * DAY_IN_SECONDS);
314 }
315 unset($_ss_banner_was_on, $_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 if (false == in_array($w, ['count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt', 'username', 'post_link', 'ip', 'id', 'searchterms', 'username', 'resource', '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_p2_23'])) {
675 return '<!-- Slimstat Shortcode Error: invalid parameter for w -->';
676 }
677
678 // Include the Reports Library, but don't initialize the database, since we will do that separately later
679 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php');
680 wp_slimstat_reports::init();
681
682 /**
683 * @SecurityProfile https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-0630
684 * Disabled because of the report from WP Scan
685 */
686 // Init the database library with the appropriate filters
687 /*if ( strpos ( $_content, 'WHERE:' ) !== false ) {
688 $where = html_entity_decode( str_replace( 'WHERE:', '', $_content ), ENT_QUOTES, 'UTF-8' );
689 }
690 else{*/
691 wp_slimstat_db::init(html_entity_decode($_content, ENT_QUOTES, 'UTF-8'));
692 //}
693
694 switch ($f) {
695 case 'count':
696 case 'count-all':
697 $output = wp_slimstat_db::count_records($w, $where, false === strpos($f, 'all')) + $o;
698 break;
699
700 case 'widget':
701 if (empty(wp_slimstat_reports::$reports[$w])) {
702 return __('Invalid Report ID', 'wp-slimstat');
703 }
704
705 wp_register_style('wp-slimstat-frontend', plugins_url('/admin/assets/css/slimstat.css', __FILE__), true, SLIMSTAT_ANALYTICS_VERSION);
706 wp_enqueue_style('wp-slimstat-frontend');
707
708 wp_slimstat_reports::$reports[$w]['callback_args']['is_widget'] = true;
709
710 ob_start();
711 echo wp_slimstat_reports::report_header($w);
712 call_user_func(wp_slimstat_reports::$reports[$w]['callback'], wp_slimstat_reports::$reports[$w]['callback_args']);
713 wp_slimstat_reports::report_footer();
714 $output = ob_get_contents();
715 ob_end_clean();
716 break;
717
718 case 'recent':
719 case 'recent-all':
720 case 'top':
721 case 'top-all':
722 $function = 'get_' . str_replace('-all', '', $f);
723
724 if ('*' == $w) {
725 $w = 'id';
726 }
727
728 $w = esc_html($w);
729 $w = self::string_to_array($w);
730
731 // Some columns are 'special' and need be removed from the list
732 $w_clean = array_diff($w, ['count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt']);
733
734 // The special value 'display_name' requires the username to be retrieved
735 if (in_array('display_name', $w)) {
736 $w_clean[] = 'username';
737 }
738
739 // The special value 'post_list' requires the resource to be retrieved
740 if (in_array('post_link', $w)) {
741 $w_clean[] = 'resource';
742 }
743
744 // The special value 'post_list_no_qs' requires a substring to be calculated
745 if (in_array('post_link_no_qs', $w)) {
746 $w_clean = ['SUBSTRING_INDEX( resource, "' . (get_option('permalink_structure') ? '?' : '&') . '", 1 )'];
747 $as_column = 'resource';
748 }
749
750 // Retrieve the data
751 $results = wp_slimstat_db::$function(implode(', ', $w_clean), $where, '', false === strpos($f, 'all'), $as_column);
752
753 // No data? No problem!
754 if (empty($results)) {
755 return '<!-- Slimstat Shortcode: No Data -->';
756 }
757
758 // Are nice permalinks enabled?
759 $permalinks_enabled = get_option('permalink_structure');
760
761 // Format results
762 $output = [];
763
764 foreach ($results as $result_idx => $a_result) {
765 foreach ($w as $a_column) {
766 $output[$result_idx][$a_column] = sprintf("<span class='col-%s'>", $a_column);
767
768 switch ($a_column) {
769 case 'count':
770 $output[$result_idx][$a_column] .= $a_result['counthits'];
771 break;
772
773 case 'country':
774 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('c-' . $a_result[$a_column]);
775 break;
776
777 case 'display_name':
778 $user_details = get_user_by('login', $a_result['username']);
779 if (!empty($user_details)) {
780 $output[$result_idx][$a_column] .= $user_details->display_name;
781 } else {
782 $output[$result_idx][$a_column] .= $a_result['username'];
783 }
784
785 break;
786
787 case 'dt':
788 $output[$result_idx][$a_column] .= date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $a_result['dt']);
789 break;
790
791 case 'hostname':
792 $output[$result_idx][$a_column] .= self::gethostbyaddr($a_result['ip']);
793 break;
794
795 case 'language':
796 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('l-' . $a_result[$a_column]);
797 break;
798
799 case 'platform':
800 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string($a_result[$a_column]);
801 break;
802
803 case 'post_link':
804 case 'post_link_no_qs':
805 $post_id = url_to_postid($a_result['resource']);
806 if ($post_id > 0) {
807 $output[$result_idx][$a_column] .= sprintf("<a href='%s'>", esc_url( $a_result[ 'resource' ] )) . esc_html( get_the_title($post_id) ) . '</a>';
808 } else {
809 $output[$result_idx][$a_column] .= sprintf("<a href='%s'>%s</a>", esc_url( $a_result[ 'resource' ] ), esc_html( $a_result[ 'resource' ] ));
810 }
811 break;
812
813 default:
814 $output[$result_idx][$a_column] .= $a_result[$a_column] ?? '';
815 break;
816 }
817 $output[$result_idx][$a_column] .= '</span>';
818 }
819 $output[$result_idx] = '<li>' . implode($s, $output[$result_idx]) . '</li>';
820 }
821
822 $output = '<ul class="slimstat-shortcode ' . $f . implode('-', $w) . '">' . implode('', $output) . '</ul>';
823 break;
824
825 default:
826 break;
827 }
828
829 return $output;
830 }
831
832 // end slimstat_shortcode
833
834
835 public static function init_plugin()
836 {
837 // Include our browser detector library
838 \SlimStat\Services\Browscap::init();
839
840 // Make sure the upload directory is exist and is protected.
841 self::create_upload_directory();
842
843 // Ensure daily salt exists for IP hashing (GDPR compliance)
844 // This runs on every page load but only generates if missing
845 \SlimStat\Providers\IPHashProvider::generateDailySalt();
846
847 // Initialize adblock bypass functionality
848 \SlimStat\Tracker\Tracker::rewrite_rule_tracker();
849 add_action('template_redirect', [\SlimStat\Tracker\Tracker::class, 'adblocker_javascript']);
850 add_action('init', [\SlimStat\Tracker\Tracker::class, 'rewrite_rule_tracker']);
851 }
852
853 /**
854 * Opens given domains during CORS requests to admin-ajax.php
855 */
856 public static function open_cors_admin_ajax($_allowed_origins = [])
857 {
858 $exploded_domains = self::string_to_array(self::$settings['external_domains']);
859
860 if (!empty($exploded_domains) && !empty($exploded_domains[0])) {
861 $_allowed_origins = array_merge($_allowed_origins, $exploded_domains);
862 }
863
864 return $_allowed_origins;
865 }
866 // end open_cors_admin_ajax
867
868 /**
869 * Implements a REST API interface to retrieve Slimstat reports and metrics
870 */
871 public static function rest_api_response($_request = [])
872 {
873 $filters = '';
874 if (!empty($_request['filters'])) {
875 $filters = $_request['filters'];
876 }
877
878 if (empty($_request['dimension'])) {
879 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]);
880 }
881
882 if (empty($_request['function'])) {
883 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]);
884 }
885
886 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-db.php');
887 wp_slimstat_db::init($filters);
888
889 $response = [
890 'function' => htmlentities($_request['function'], ENT_QUOTES, 'UTF-8'),
891 'dimension' => htmlentities($_request['dimension'], ENT_QUOTES, 'UTF-8'),
892
893 'data' => 0,
894 ];
895
896 switch ($_request['function']) {
897 case 'count':
898 case 'count-all':
899 $response['data'] = wp_slimstat_db::count_records($_request['dimension'], '', false === strpos($_request['function'], '-all'));
900 break;
901
902 case 'recent':
903 case 'recent-all':
904 case 'top':
905 case 'top-all':
906 $function = 'get_' . str_replace('-all', '', $_request['function']);
907
908 // Retrieve the data
909 $response['data'] = array_values(wp_slimstat_db::$function($_request['dimension'], '', '', false === strpos($_request['function'], '-all')));
910 break;
911
912 default:
913 // This should never happen, because of the 'enum' condition for this parameter. But never say never...
914 $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]);
915 break;
916 }
917
918 return rest_ensure_response($response);
919 }
920 // end rest_api_response
921
922 /**
923 * Implements a REST API authentication mechanism via token
924 */
925 public static function rest_api_authorization($_request = [])
926 {
927 if (empty($_request['token'])) {
928 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]);
929 }
930 $valid_tokens = self::string_to_array(self::$settings['rest_api_tokens']);
931 foreach ($valid_tokens as $valid_token) {
932 if (is_string($valid_token) && is_string($_request['token']) && hash_equals($valid_token, $_request['token'])) {
933 return true;
934 }
935 }
936 return false;
937 }
938 // end rest_api_authorization
939
940 /**
941 * Registers a new REST API route for the Slimstat endpoint
942 */
943 public static function register_rest_route()
944 {
945 register_rest_route('slimstat/v1', '/get', [
946 'methods' => WP_REST_Server::READABLE,
947 'callback' => [self::class, 'rest_api_response'],
948 'permission_callback' => [self::class, 'rest_api_authorization'],
949 'args' => [
950 'token' => [
951 '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'),
952 'type' => 'string',
953 ],
954 'function' => [
955 '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'),
956 'type' => 'string',
957 'enum' => ['count', 'count-all', 'recent', 'recent-all', 'top', 'top-all'],
958 ],
959 'dimension' => [
960 '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'),
961 'type' => 'string',
962 'enum' => ['*', 'id', 'ip', 'username', 'email', 'country', 'referer', 'resource', 'searchterms', 'browser', 'platform', 'language', 'resolution', 'content_type', 'content_id', 'tz_offset', 'outbound_resource'],
963 ],
964 'filters' => [
965 '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'),
966 'type' => 'string',
967 ],
968 ],
969 ]);
970 }
971 // end register_rest_route
972
973 /**
974 * Converts a series of comma separated values into an array
975 */
976 public static function string_to_array($_option = '')
977 {
978 if (empty($_option) || !is_string($_option)) {
979 return [];
980 } else {
981 return array_filter(array_map('trim', explode(',', $_option)));
982 }
983 }
984 // end string_to_array
985
986 /**
987 * Returns Matomo search engine mapping JSON, cached.
988 */
989 public static function get_search_engines()
990 {
991 static $cached_search_engines = null;
992 if (null !== $cached_search_engines) {
993 return $cached_search_engines;
994 }
995
996 $data = get_transient('slimstat_matomo_searchengine');
997 if (false === $data) {
998 $json_path = plugin_dir_path(__FILE__) . 'admin/assets/data/matomo-searchengine.json';
999 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin file, WP_Filesystem not needed
1000 $json = @file_get_contents($json_path);
1001 $data = json_decode($json, true);
1002 if (!is_array($data)) {
1003 $data = [];
1004 }
1005 set_transient('slimstat_matomo_searchengine', $data, WEEK_IN_SECONDS);
1006 }
1007
1008 $cached_search_engines = $data;
1009 return $cached_search_engines;
1010 }
1011 // end get_search_engines
1012
1013 /**
1014 * Toggles WordPress filters on date_i18n function
1015 */
1016 public static function toggle_date_i18n_filters($_turn_on = true)
1017 {
1018 if ($_turn_on && !empty(self::$date_i18n_filters) && is_array(self::$date_i18n_filters)) {
1019 foreach (self::$date_i18n_filters as $i18n_priority => $i18n_func_list) {
1020 foreach ($i18n_func_list as $func_args) {
1021 if (!empty($func_args['function']) && is_string($func_args['function'])) {
1022 add_filter('date_i8n', $func_args['function'], $i18n_priority, intval($func_args['accepted_args']));
1023 }
1024 }
1025 }
1026 } elseif (!empty($GLOBALS['wp_filter']['date_i18n']['callbacks']) && is_array($GLOBALS['wp_filter']['date_i18n']['callbacks'])) {
1027 self::$date_i18n_filters = $GLOBALS['wp_filter']['date_i18n']['callbacks'];
1028 remove_all_filters('date_i18n');
1029 }
1030 }
1031 // end toggle_date_i18n_filters
1032
1033 /**
1034 * Calls the date_i18n function without filters
1035 */
1036 public static function date_i18n($_format)
1037 {
1038 self::toggle_date_i18n_filters(false);
1039 $date = date_i18n($_format);
1040 self::toggle_date_i18n_filters(true);
1041
1042 return $date;
1043 }
1044 // end date_i18n
1045
1046 /**
1047 * Returns default options with geolocation_provider set for fresh installs and resets.
1048 *
1049 * geolocation_provider is excluded from init_options() because init() merges
1050 * those defaults into stored settings — which would override the legacy
1051 * enable_maxmind flag on upgraded installs before lazy migration runs.
1052 *
1053 * Fresh installs default to DB-IP (free, no license key required).
1054 */
1055 public static function get_fresh_defaults()
1056 {
1057 $defaults = self::init_options();
1058 $defaults['geolocation_provider'] = 'dbip';
1059 return $defaults;
1060 }
1061
1062 /**
1063 * Returns the current geolocation precision ('country' or 'city').
1064 */
1065 public static function get_geolocation_precision()
1066 {
1067 return ('on' == self::$settings['geolocation_country']) ? 'country' : 'city';
1068 }
1069
1070 /**
1071 * Sets the default values for all the options
1072 */
1073 public static function init_options()
1074 {
1075 return [
1076 'version' => SLIMSTAT_ANALYTICS_VERSION,
1077 '_migration_5460' => '0', // one-shot: reset broken v5.4.1 defaults on first boot after this build
1078 'secret' => wp_hash(wp_generate_password(64, true, true)),
1079 'browscap_last_modified' => 0,
1080
1081 // General
1082 // -----------------------------------------------------------------------
1083
1084 // General - Tracker
1085 'is_tracking' => 'on',
1086 'track_admin_pages' => 'no',
1087 'javascript_mode' => 'on', // Client mode: works with all caching plugins (WP Rocket, W3TC, etc.)
1088
1089 // General - WordPress Integration
1090 'add_dashboard_widgets' => 'on',
1091 'use_separate_menu' => 'on',
1092 'add_posts_column' => 'no',
1093 'posts_column_pageviews' => 'on',
1094 'display_notifications' => 'on',
1095
1096 // General - Database
1097 'auto_purge' => 420,
1098 'auto_purge_delete' => 'on',
1099
1100 // Tracker
1101 // -----------------------------------------------------------------------
1102
1103 // Tracker - Data Protection
1104 // anonymize_ip: mask IP before storing; hash_ip: generate daily visitor_id based on masked IP + UA
1105 'gdpr_enabled' => 'off', // v5.3.x had no GDPR — off by default; admin enables when ready
1106 'anonymize_ip' => 'off', // Restored: full IPs stored by default (5.3.x behavior)
1107 'hash_ip' => 'off', // Restored: no daily visitor hash by default (5.3.x behavior)
1108 'set_tracker_cookie' => 'on', // v5.3.x default: session cookie identifies returning visitors
1109 'use_slimstat_banner' => 'off', // Admin must explicitly enable via consent integration
1110 'consent_integration' => '', // No CMP by default — admin selects when enabling GDPR
1111 'consent_level_integration'=> 'statistics',
1112 'opt_out_message' => '',
1113 'gdpr_accept_button_text' => 'Accept',
1114 'gdpr_decline_button_text' => 'Decline',
1115 'gdpr_theme_mode' => 'auto', // 'light', 'dark', 'auto'
1116 'anonymous_tracking' => 'off', // Changed: Enable anonymous tracking by default
1117 'do_not_track' => 'off',
1118 'display_opt_out' => 'no',
1119 'opt_out_cookie_names' => '',
1120 'opt_in_cookie_names' => '',
1121
1122 // Tracker - Link Tracking
1123 'track_same_domain_referers' => 'no',
1124 'do_not_track_outbound_classes_rel_href' => 'noslimstat,ab-item',
1125 'extensions_to_track' => 'pdf,doc,xls,zip',
1126
1127 // Tracker - Advanced Options
1128 // NOTE: geolocation_provider is intentionally NOT in init_options().
1129 // init() merges these defaults into stored settings, which would override
1130 // the legacy enable_maxmind flag on upgraded installs before lazy migration runs.
1131 // Use get_fresh_defaults() for new installs and settings reset.
1132 'geolocation_country' => 'on',
1133 'session_duration' => 1800,
1134 'extend_session' => 'no',
1135 'enable_cdn' => 'no',
1136 'ajax_relative_path' => 'no',
1137
1138 // Tracker - External Pages
1139 'external_domains' => '',
1140
1141 // Reports
1142 // -----------------------------------------------------------------------
1143
1144 // Reports - Functionality
1145 'use_current_month_timespan' => 'no',
1146 'posts_column_day_interval' => 28,
1147 'rows_to_show' => '20',
1148 'show_hits' => 'no',
1149 'ip_lookup_service' => 'https://ip-api.com/#',
1150 'comparison_chart' => 'on',
1151 'show_display_name' => 'no',
1152 'convert_resource_urls_to_titles' => 'on',
1153 'convert_ip_addresses' => 'no',
1154
1155 // Reports - Access Log and World Map
1156 'refresh_interval' => '60',
1157 'number_results_raw_data' => '50',
1158 'max_dots_on_map' => '50',
1159
1160 // Reports - Miscellaneous
1161 'custom_css' => '',
1162 'chart_colors' => '',
1163 'mozcom_access_id' => '',
1164 'mozcom_secret_key' => '',
1165 'show_complete_user_agent_tooltip' => 'no',
1166 'async_load' => 'no',
1167 'limit_results' => '200',
1168 'enable_sov' => 'no',
1169
1170 // Exclusions
1171 // -----------------------------------------------------------------------
1172
1173 // Exclusions - User Properties
1174 'ignore_wp_users' => 'no',
1175 'ignore_spammers' => 'on',
1176 'ignore_bots' => 'no',
1177 'ignore_prefetch' => 'on',
1178 'ignore_users' => '',
1179 'ignore_ip' => '',
1180 'ignore_countries' => '',
1181 'ignore_languages' => '',
1182 'ignore_browsers' => '',
1183 'ignore_platforms' => '',
1184 'ignore_capabilities' => '',
1185
1186 // Exclusions - Page Properties
1187 'ignore_resources' => '',
1188 'ignore_referers' => '',
1189 'ignore_content_types' => '',
1190
1191 // Access Control
1192 // -----------------------------------------------------------------------
1193
1194 // Access Control - Reports
1195 'restrict_authors_view' => 'on',
1196 'capability_can_view' => 'manage_options',
1197 'can_view' => '',
1198
1199 // Access Control - Reports
1200 'tracking_request_method' => 'ajax',
1201
1202 // Access Control - Customizer
1203 'capability_can_customize' => 'manage_options',
1204 'can_customize' => '',
1205
1206 // Access Control - Settings
1207 'capability_can_admin' => 'manage_options',
1208 'can_admin' => '',
1209
1210 // Access Control - REST API
1211 'rest_api_tokens' => wp_hash(wp_generate_password(64, true, true)),
1212
1213 // Maintenance
1214 // -----------------------------------------------------------------------
1215 'last_tracker_error' => [0, '', 0],
1216 'show_sql_debug' => 'no',
1217 'slimstat_debug' => 'off',
1218 'db_indexes' => 'on',
1219 'enable_maxmind' => 'disable',
1220 'maxmind_license_key' => '',
1221 'enable_browscap' => 'no',
1222
1223 // Notices
1224 // -----------------------------------------------------------------------
1225 'notice_latest_news' => 'on',
1226 'notice_browscap' => 'on',
1227 'notice_geolite' => 'on',
1228 'notice_caching' => 'on',
1229
1230 // Network-wide Settings
1231 'locked_options' => '',
1232 ];
1233 }
1234 // end init_options
1235
1236 /**
1237 * Saves a given option in the database
1238 */
1239 public static function update_option($_key = '', $_value = '')
1240 {
1241 if (!is_network_admin()) {
1242 update_option($_key, $_value);
1243 } else {
1244 update_site_option($_key, $_value);
1245 }
1246 }
1247 // end update_option
1248
1249 /**
1250 * Attach a script to every page to track visitors' screen resolution and other browser-based information
1251 */
1252 public static function enqueue_tracker()
1253 {
1254 // Use the new unified tracking method setting
1255 $method = self::$settings['tracking_request_method'] ?? 'rest';
1256
1257 // Handle legacy 'adblock' value (renamed to 'adblock_bypass' in v5.3.0)
1258 if ( 'adblock' === $method ) {
1259 $method = 'adblock_bypass';
1260 }
1261
1262 // Prepare URLs for all methods
1263 $rest_url = rest_url('slimstat/v1/hit');
1264 $rest_base_url = rest_url();
1265 // Mirror WordPress core's non-pretty REST routing so query fallback still works
1266 // on index-permalink and subdirectory installs.
1267 $rest_query_base = trailingslashit(get_home_url(null, '', 'rest'));
1268 if ('index.php' !== substr(untrailingslashit($rest_query_base), -9)) {
1269 $rest_query_base .= 'index.php';
1270 }
1271 $rest_query_url = add_query_arg('rest_route', '/slimstat/v1/hit', $rest_query_base);
1272 $ajax_url = admin_url('admin-ajax.php');
1273 $ajax_url_relative = admin_url('admin-ajax.php', 'relative');
1274
1275 $params = [
1276 'transport' => $method,
1277 'ajaxurl_rest' => $rest_url,
1278 'ajaxurl_rest_query' => $rest_query_url,
1279 'resturl' => $rest_base_url,
1280 'ajaxurl_ajax' => ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url,
1281 ];
1282
1283 // Only provide adblock bypass URL when the rewrite rule is active.
1284 // The rewrite rule is only registered for 'adblock_bypass' transport,
1285 // so this URL would 404 for other transports — a dead fallback.
1286 if ('adblock_bypass' === $method) {
1287 $adblock_hash = \SlimStat\Providers\RestApiManager::getSecureAdblockHash();
1288 $params['ajaxurl_adblock'] = home_url(sprintf('request/%s/', $adblock_hash));
1289 }
1290
1291 // Set the primary ajaxurl based on the selected method
1292 if ('rest' === $method) {
1293 $params['ajaxurl'] = $rest_url;
1294 } elseif ('ajax' === $method) {
1295 $params['ajaxurl'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url;
1296 } elseif ('adblock_bypass' === $method) {
1297 $params['ajaxurl'] = $params['ajaxurl_adblock'];
1298 // Also set transport to 'adblock_bypass' for JS clarity
1299 $params['transport'] = 'adblock_bypass';
1300 } else {
1301 $params['ajaxurl'] = $rest_url;
1302 }
1303
1304 $baseurl = parse_url(get_home_url());
1305 $params['baseurl'] = empty($baseurl['path']) ? '/' : $baseurl['path'];
1306
1307 if (!empty(self::$settings['do_not_track_outbound_classes_rel_href'])) {
1308 $params['dnt'] = str_replace(' ', '', self::$settings['do_not_track_outbound_classes_rel_href']);
1309 }
1310
1311 // Internal GDPR banner is optionally available alongside CMP integrations.
1312
1313 if ('on' != self::$settings['javascript_mode']) {
1314 if (empty(self::$stat['id']) || intval(self::$stat['id']) < 0) {
1315 return false;
1316 }
1317 $params['id'] = \SlimStat\Tracker\Utils::getValueWithChecksum(intval(self::$stat['id']));
1318 } else {
1319 $params['ci'] = \SlimStat\Tracker\Utils::getValueWithChecksum(\SlimStat\Tracker\Utils::base64UrlEncode(wp_json_encode(\SlimStat\Tracker\Utils::getContentInfo())));
1320 }
1321
1322 // Always generate wp_rest_nonce (needed for consent banner CSRF protection).
1323 // The JS uses is_logged_in to decide whether to send it as X-WP-Nonce header.
1324 // Anonymous pages: is_logged_in='0' → no header → no 403 on cached pages.
1325 // Admin-cached pages: is_logged_in='1' (stale) → sends nonce → may 403 → retry
1326 // without nonce (handled by JS retry logic). This is acceptable since most caches
1327 // exclude logged-in users, and the retry adds only one extra request.
1328 $params['wp_rest_nonce'] = wp_create_nonce('wp_rest');
1329 $params['is_logged_in'] = is_user_logged_in() ? '1' : '0';
1330 // Expose consent/DNT info to client
1331 $params['wp_consent_integration'] = (self::$settings['consent_integration'] ?? '') === 'wp_consent_api' ? 'enabled' : 'disabled';
1332 $params['consent_integration'] = self::$settings['consent_integration'] ?? '';
1333 $params['consent_level_integration'] = (self::$settings['consent_level_integration'] ?? 'statistics');
1334 $params['respect_dnt'] = self::$settings['do_not_track'] ?? 'off';
1335 $gdpr_enabled_setting = strtolower((string) (self::$settings['gdpr_enabled'] ?? 'off'));
1336 $params['gdpr_enabled'] = in_array($gdpr_enabled_setting, ['off', 'no', 'false', '0'], true) ? 'off' : 'on';
1337 $params['anonymous_tracking'] = self::$settings['anonymous_tracking'] ?? 'off';
1338 $params['anonymize_ip'] = self::$settings['anonymize_ip'] ?? 'no';
1339 $params['hash_ip'] = self::$settings['hash_ip'] ?? 'no';
1340 $params['set_tracker_cookie'] = self::$settings['set_tracker_cookie'] ?? 'on';
1341 // Mirror the same dual-condition guard used by the PHP banner output (lines 305-306):
1342 // banner HTML is only rendered when BOTH gdpr_enabled=on AND use_slimstat_banner=on.
1343 // If gdpr_enabled is off, the banner DOM never exists — JS must not enter banner-init mode
1344 // or it will set a "ran" lock and silently skip _send_pageview for all visitors.
1345 $params['use_slimstat_banner'] = ('on' === $params['gdpr_enabled'] && 'on' === (self::$settings['use_slimstat_banner'] ?? 'off')) ? 'on' : 'off';
1346
1347 if ('on' === $params['use_slimstat_banner']) {
1348 // Set GDPR consent endpoint based on tracking method
1349 if ('rest' === $method) {
1350 $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent');
1351 } elseif ('ajax' === $method) {
1352 $params['gdpr_consent_endpoint'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url;
1353 } elseif ('adblock_bypass' === $method) {
1354 $params['gdpr_consent_endpoint'] = $params['ajaxurl_adblock'];
1355 } else {
1356 $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent');
1357 }
1358 $params['gdpr_cookie_name'] = \SlimStat\Services\GDPRService::CONSENT_COOKIE_NAME;
1359 $params['gdpr_cookie_path'] = defined('COOKIEPATH') ? COOKIEPATH : '/';
1360 $params['gdpr_cookie_domain'] = defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '';
1361 $params['gdpr_consent_method'] = $method;
1362 }
1363
1364 if ('on' === self::$settings['slimstat_debug'] || (defined('WP_DEBUG') && WP_DEBUG)) {
1365 $params['slimstat_debug'] = 'on';
1366 }
1367
1368 $params = apply_filters('slimstat_js_params', $params);
1369
1370 // Add dependencies for consent integrations (e.g., WP Consent API)
1371 $dependencies = [];
1372 if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') {
1373 // Only add dependency if the WP Consent API script is actually registered
1374 if (wp_script_is('wp-consent-api', 'registered') || wp_script_is('wp-consent-api', 'enqueued')) {
1375 $dependencies[] = 'wp-consent-api';
1376 }
1377 }
1378
1379 // Register the correct script for adblock bypass, CDN, or default
1380 $local_script_version = SLIMSTAT_ANALYTICS_VERSION;
1381 $local_script_path = plugin_dir_path(__FILE__) . 'wp-slimstat.min.js';
1382 if (file_exists($local_script_path)) {
1383 $local_script_version .= '.' . filemtime($local_script_path);
1384 }
1385
1386 if ('adblock_bypass' === $method) {
1387 $hash_js = md5(site_url() . 'slimstat');
1388 wp_register_script('wp_slimstat', home_url(sprintf('/%s.js/', $hash_js)), $dependencies, SLIMSTAT_ANALYTICS_VERSION, true);
1389 } elseif ('on' == self::$settings['enable_cdn']) {
1390 wp_register_script('wp_slimstat', 'https://cdn.jsdelivr.net/wp/wp-slimstat/tags/' . SLIMSTAT_ANALYTICS_VERSION . '/wp-slimstat.min.js', $dependencies, null, true);
1391 } else {
1392 wp_register_script('wp_slimstat', plugins_url('/wp-slimstat.min.js', __FILE__), $dependencies, $local_script_version, true);
1393 }
1394
1395 wp_enqueue_script('wp_slimstat');
1396
1397 /**
1398 * Registers the 'wp_slimstat' script as an interactivity module if the registration function exists.
1399 *
1400 * Ensures compatibility with WordPress Interactivity API by registering the script module and its dependencies.
1401 */
1402 if (function_exists('wp_interactivity_register_script_module')) {
1403 wp_interactivity_register_script_module('wp_slimstat', [
1404 'name' => 'wp_slimstat',
1405 'dependencies' => [],
1406 ]);
1407 }
1408
1409 wp_localize_script('wp_slimstat', 'SlimStatParams', $params);
1410
1411 return null;
1412 }
1413
1414 // end enqueue_tracker
1415
1416 /**
1417 * Enqueue assets for the internal SlimStat GDPR banner.
1418 *
1419 * @return void
1420 */
1421 public static function enqueue_gdpr_assets()
1422 {
1423 if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) {
1424 return;
1425 }
1426
1427 wp_enqueue_style(
1428 'wp_slimstat_gdpr_banner',
1429 plugins_url('/assets/css/gdpr-banner.css', __FILE__),
1430 [],
1431 SLIMSTAT_ANALYTICS_VERSION
1432 );
1433 }
1434
1435 /**
1436 * Render the SlimStat GDPR banner markup.
1437 *
1438 * @return void
1439 */
1440 public static function render_gdpr_banner()
1441 {
1442 if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) {
1443 return;
1444 }
1445
1446 if (is_admin() && !wp_doing_ajax()) {
1447 return;
1448 }
1449
1450 $gdpr_service = new \SlimStat\Services\GDPRService(self::$settings);
1451 $banner_html = $gdpr_service->getBannerHtml();
1452
1453 if ('' === $banner_html) {
1454 return;
1455 }
1456
1457 echo $banner_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized in GDPRService
1458 }
1459
1460 public static function add_defer_to_script_tag($_tag, $_handle)
1461 {
1462 if ('wp_slimstat' === $_handle && false === stripos($_tag, 'defer')) {
1463 $_tag = str_replace('<script ', '<script defer ', $_tag);
1464 }
1465
1466 return $_tag;
1467 }
1468
1469 /**
1470 * Removes old entries from the main table and performs other daily tasks
1471 */
1472 public static function wp_slimstat_purge()
1473 {
1474 $autopurge_interval = intval(self::$settings['auto_purge']);
1475
1476 if ($autopurge_interval <= 0) {
1477 return;
1478 }
1479
1480 $days_ago = strtotime(self::date_i18n('Y-m-d H:i:s') . sprintf(' -%d days', $autopurge_interval));
1481 $table_stats = $GLOBALS['wpdb']->prefix . 'slim_stats';
1482 $table_stats_archive = $GLOBALS['wpdb']->prefix . 'slim_stats_archive';
1483 $table_events = $GLOBALS['wpdb']->prefix . 'slim_events';
1484 $table_events_archive = $GLOBALS['wpdb']->prefix . 'slim_events_archive';
1485
1486 // Copy entries to the archive table, if needed
1487 if ('no' != self::$settings['auto_purge_delete']) {
1488 // Use Query builder for INSERT INTO ... SELECT ... with prepared statements
1489 $insert_sql = self::$wpdb->prepare(
1490 "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",
1491 $days_ago
1492 );
1493 $is_copy_done = self::$wpdb->query($insert_sql);
1494 if (false !== $is_copy_done) {
1495 \SlimStat\Utils\Query::delete($table_stats)->where('dt', '<', $days_ago)->execute();
1496 }
1497 $insert_sql_events = self::$wpdb->prepare(
1498 "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",
1499 $days_ago
1500 );
1501 $is_copy_done = self::$wpdb->query($insert_sql_events);
1502 if (false !== $is_copy_done) {
1503 \SlimStat\Utils\Query::delete($table_events)->where('dt', '<', $days_ago)->execute();
1504 }
1505 } else {
1506 // Delete old entries
1507 \SlimStat\Utils\Query::delete($table_stats)->where('dt', '<', $days_ago)->execute();
1508 \SlimStat\Utils\Query::delete($table_events)->where('dt', '<', $days_ago)->execute();
1509 }
1510
1511 // Optimize tables (keep as direct queries)
1512 self::$wpdb->query('OPTIMIZE TABLE ' . $table_stats);
1513 self::$wpdb->query('OPTIMIZE TABLE ' . $table_stats_archive);
1514 self::$wpdb->query('OPTIMIZE TABLE ' . $table_events);
1515 self::$wpdb->query('OPTIMIZE TABLE ' . $table_events_archive);
1516 }
1517
1518 public static function wp_slimstat_update_geoip_database()
1519 {
1520 // Calculate the most recent "first Tuesday + 2 days" that has already passed
1521 $this_month_update = strtotime('first Tuesday of this month') + (86400 * 2);
1522 $current_time = time();
1523
1524 // If this month's update window hasn't arrived yet, use last month's window
1525 if ($current_time < $this_month_update) {
1526 $this_update = strtotime('first Tuesday of last month') + (86400 * 2);
1527 } else {
1528 $this_update = $this_month_update;
1529 }
1530
1531 $last_update = get_option('slimstat_last_geoip_dl', 0);
1532 if ($last_update < $this_update) {
1533
1534 // Determine which geolocation provider to use
1535 $provider = self::resolve_geolocation_provider();
1536 if (false === $provider) {
1537 return;
1538 }
1539
1540 try {
1541 $geographicProvider = new \SlimStat\Services\Geolocation\GeolocationService($provider, []);
1542 $ok = $geographicProvider->updateDatabase();
1543
1544 if ($ok) {
1545 update_option('slimstat_last_geoip_dl', time());
1546 }
1547
1548 } catch (\Throwable $e) {
1549 wp_slimstat::log('Geolocation database update failed: ' . $e->getMessage(), 'error');
1550 }
1551 }
1552 }
1553
1554 /**
1555 * Register privacy policy content for WordPress Privacy Tools
1556 *
1557 * @since 5.4.0
1558 */
1559 public static function registerPrivacyPolicyContent()
1560 {
1561 if (!function_exists('wp_add_privacy_policy_content')) {
1562 return;
1563 }
1564
1565 $content = '<h2>' . __('SlimStat Analytics', 'wp-slimstat') . '</h2>';
1566 $content .= '<p><strong>' . __('What personal data we collect and why', 'wp-slimstat') . '</strong></p>';
1567 $content .= '<p>' . __('SlimStat Analytics collects the following data about website visitors:', 'wp-slimstat') . '</p>';
1568 $content .= '<ul>';
1569 $content .= '<li>' . __('IP Address: Collected for analytics and security purposes. May be anonymized or hashed based on your privacy settings.', 'wp-slimstat') . '</li>';
1570 $content .= '<li>' . __('Page URLs: Tracks which pages are visited to analyze website usage.', 'wp-slimstat') . '</li>';
1571 $content .= '<li>' . __('Referrer Information: Tracks where visitors came from (search engines, other websites, etc.).', 'wp-slimstat') . '</li>';
1572 $content .= '<li>' . __('Browser and Device Information: User agent, screen resolution, and device type for analytics.', 'wp-slimstat') . '</li>';
1573 $content .= '<li>' . __('Timestamp: Date and time of each page visit.', 'wp-slimstat') . '</li>';
1574
1575 if ('on' === (self::$settings['set_tracker_cookie'] ?? 'off')) {
1576 $content .= '<li>' . __('Cookies: A tracking cookie is used to identify returning visitors and maintain session continuity.', 'wp-slimstat') . '</li>';
1577 }
1578
1579 if ('on' !== (self::$settings['ignore_wp_users'] ?? 'off')) {
1580 $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>';
1581 }
1582
1583 $content .= '</ul>';
1584
1585 $content .= '<p><strong>' . __('How long we retain your data', 'wp-slimstat') . '</strong></p>';
1586 $retention_days = intval(self::$settings['auto_purge'] ?? 420);
1587 if ($retention_days > 0) {
1588 $content .= '<p>' . sprintf(__('Analytics data is automatically deleted after %d days, in compliance with GDPR data retention requirements.', 'wp-slimstat'), $retention_days) . '</p>';
1589 } else {
1590 $content .= '<p>' . __('Analytics data retention is currently disabled. Please contact the site administrator for information about data retention policies.', 'wp-slimstat') . '</p>';
1591 }
1592
1593 $content .= '<p><strong>' . __('Your rights', 'wp-slimstat') . '</strong></p>';
1594 $content .= '<p>' . __('Under GDPR, you have the right to:', 'wp-slimstat') . '</p>';
1595 $content .= '<ul>';
1596 $content .= '<li>' . __('Access your personal data collected by SlimStat', 'wp-slimstat') . '</li>';
1597 $content .= '<li>' . __('Request deletion of your personal data (Right to be Forgotten)', 'wp-slimstat') . '</li>';
1598 $content .= '<li>' . __('Opt-out of tracking by revoking consent (if GDPR mode is enabled)', 'wp-slimstat') . '</li>';
1599 $content .= '</ul>';
1600
1601 if ('on' === (self::$settings['gdpr_enabled'] ?? 'off')) {
1602 $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>';
1603 }
1604
1605 $content .= '<p><strong>' . __('Consent Management', 'wp-slimstat') . '</strong></p>';
1606 if ('on' === (self::$settings['anonymous_tracking'] ?? 'off')) {
1607 $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>';
1608 } else {
1609 $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>';
1610 }
1611
1612 wp_add_privacy_policy_content('SlimStat Analytics', $content);
1613 }
1614
1615 public static function add_plugin_manual_download_link($_links = [], $_plugin_file = '')
1616 {
1617 $a_clean_slug = str_replace(['wp-slimstat-', '/index.php'], ['', ''], $_plugin_file);
1618
1619 if (false !== ($download_url = get_transient('wp-slimstat-download-link-' . $a_clean_slug))) {
1620 $_links[] = '<a href="' . $download_url . '">Download ZIP</a>';
1621 } else {
1622 $url = 'https://www.wp-slimstat.com/update-checker/?slug=' . $a_clean_slug . '&key=' . urlencode(self::$settings['addon_licenses']['wp-slimstat-' . $a_clean_slug]);
1623 $response = wp_safe_remote_get($url, ['timeout' => 300, 'user-agent' => 'Slimstat Analytics/' . SLIMSTAT_ANALYTICS_VERSION . '; ' . home_url()]);
1624
1625 if (!is_wp_error($response) && 200 == wp_remote_retrieve_response_code($response)) {
1626 $data = @json_decode($response['body']);
1627
1628 if (is_object($data)) {
1629 $_links[] = '<a href="' . $data->download_url . '">Download ZIP</a>';
1630 set_transient('wp-slimstat-download-link-' . $a_clean_slug, $data->download_url, 172800); // 48 hours
1631 }
1632 }
1633 }
1634
1635 return $_links;
1636 }
1637
1638 /**
1639 * Resolves a given IP address, by keeping a local cache of hostnames to avoid multiple requests to the DNS server
1640 */
1641 public static function gethostbyaddr($_ip = '')
1642 {
1643 $hostname = get_transient('slimstat_' . $_ip);
1644
1645 if (empty($hostname)) {
1646 $hostname = gethostbyaddr($_ip);
1647 set_transient('slimstat_' . $_ip, $hostname, HOUR_IN_SECONDS);
1648 }
1649
1650 return $hostname;
1651 }
1652 // end gethostbyaddr
1653
1654 /**
1655 * Registers the Slimstat widget
1656 */
1657 public static function register_widget()
1658 {
1659 return register_widget('slimstat_widget');
1660 }
1661 // end register_widget
1662
1663 /**
1664 * Generates the key to see if a given host is listed as a search engine in the corresponding Json data file
1665 */
1666 public static function get_lossy_url($_url = '')
1667 {
1668 return preg_replace(
1669 [
1670 '/^(w+\d*|search)\./',
1671 '/(^|\.)m\./',
1672 '/(\.(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)(\/|$)/',
1673 '/(^|\.)(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)\./',
1674 ],
1675 [
1676 '',
1677 '$1',
1678 '.{}$4',
1679 '$1{}.',
1680 ],
1681 $_url
1682 );
1683 }
1684 // end get_lossy_url
1685
1686 /**
1687 * Check if slimstat pro plugin is installed
1688 */
1689 public static function pro_is_installed($pluginSlug = 'wp-slimstat-pro/wp-slimstat-pro.php')
1690 {
1691 include_once(ABSPATH . 'wp-admin/includes/plugin.php');
1692 return (bool) is_plugin_active($pluginSlug);
1693 }
1694
1695 /**
1696 * create upload directory
1697 */
1698 public static function create_upload_directory()
1699 {
1700 $upload_dir = self::$upload_dir;
1701 wp_mkdir_p($upload_dir);
1702
1703 /**
1704 * Create .htaccess to avoid public access.
1705 */
1706 if (is_dir($upload_dir) && is_writable($upload_dir)) {
1707 $htaccess_file = path_join($upload_dir, '.htaccess');
1708
1709 if (!file_exists($htaccess_file) && $handle = @fopen($htaccess_file, 'w')) {
1710 fwrite($handle, "Deny from all\n");
1711 fclose($handle);
1712 }
1713 }
1714 }
1715
1716 public static function get_schedule_interval($schedule)
1717 {
1718 $schedulesInterval = wp_get_schedules();
1719 $timeInterval = 86400;
1720 if (isset($schedulesInterval[$schedule]['interval'])) {
1721 $timeInterval = $schedulesInterval[$schedule]['interval'];
1722 }
1723 return $timeInterval;
1724 }
1725 }
1726
1727 // end of class declaration
1728
1729 class slimstat_widget extends WP_Widget
1730 {
1731 /**
1732 * Sets up the widgets name etc
1733 */
1734 public function __construct()
1735 {
1736 parent::__construct('slimstat_widget', 'Slimstat', [
1737 'classname' => 'slimstat_widget',
1738 'description' => 'Add a Slimstat report to your sidebar',
1739 ]);
1740 }
1741
1742 /**
1743 * Outputs the content of the widget
1744 *
1745 * @param array $args
1746 * @param array $instance
1747 */
1748 public function widget($_args = [], $_instance = [])
1749 {
1750 extract(shortcode_atts([
1751 'slimstat_widget_id' => '',
1752 'slimstat_widget_title' => '',
1753 'slimstat_widget_filters' => '',
1754 ], $_instance));
1755
1756 if (!empty($slimstat_widget_title)) {
1757 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']);
1758 }
1759 if (!empty($slimstat_widget_id)) {
1760 echo do_shortcode(sprintf("[slimstat f='widget' w='%s']%s[/slimstat]", $slimstat_widget_id, $slimstat_widget_filters));
1761 } else {
1762 echo '';
1763 }
1764 }
1765
1766 /**
1767 * Outputs the options form on admin
1768 *
1769 * @param array $instance The widget options
1770 */
1771 public function form($_instance)
1772 {
1773 extract(shortcode_atts([
1774 'slimstat_widget_id' => '',
1775 'slimstat_widget_title' => '',
1776 'slimstat_widget_filters' => '',
1777 ], $_instance));
1778
1779 // Let's build the dropdown
1780 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php');
1781 wp_slimstat_reports::init();
1782 $select_options = '';
1783
1784 foreach (wp_slimstat_reports::$reports as $a_report_id => $a_report_info) {
1785 $select_options .= sprintf("<option value='%s' ", $a_report_id) . (($slimstat_widget_id == $a_report_id) ? 'selected="selected"' : '') . sprintf('>%s</option>', $a_report_info[ 'title' ]);
1786 }
1787 ?>
1788
1789 <p>
1790 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_id')); ?>"><?php _e('Report', 'wp-slimstat') ?></label>
1791 <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')); ?>">
1792 <option value="">Select a widget</option>
1793 <?php echo $select_options ?>
1794 </select>
1795 </p>
1796
1797 <p>
1798 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_title')); ?>"><?php _e('Title', 'wp-slimstat') ?></label>
1799 <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)) ?>">
1800 </p>
1801
1802 <p>
1803 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_filters')); ?>"><?php _e('Optional filters', 'wp-slimstat'); ?></label>
1804 <a href="https://wp-slimstat.com/resources/what-is-the-syntax-of-a-slimstat-shortcode-#slimstat-operators" target="_blank">[?]</a>
1805 <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>
1806 </p>
1807 <?php
1808 }
1809
1810 /**
1811 * Processing widget options on save
1812 *
1813 * @param array $new_instance The new options
1814 * @param array $old_instance The previous options
1815 */
1816 public function update($_new_instance, $_old_instance)
1817 {
1818 $instance = $_old_instance;
1819
1820 $instance['slimstat_widget_id'] = sanitize_key($_new_instance['slimstat_widget_id'] ?? '');
1821 $instance['slimstat_widget_title'] = sanitize_text_field(wp_unslash($_new_instance['slimstat_widget_title'] ?? ''));
1822 $instance['slimstat_widget_filters'] = sanitize_textarea_field(wp_unslash($_new_instance['slimstat_widget_filters'] ?? ''));
1823 return $instance;
1824 }
1825 }
1826
1827 // Early initialize DB handle for add-ons that may access wp_slimstat::$wpdb before init() runs
1828 if (empty(wp_slimstat::$wpdb) && isset($GLOBALS['wpdb'])) {
1829 wp_slimstat::$wpdb = $GLOBALS['wpdb'];
1830 }
1831
1832 // Ok, let's go, Sparky!
1833 if (function_exists('add_action')) {
1834 // Since we use sendBeacon, this function sends raw POST data, which does not populate the $_POST variable automatically
1835 $http_content_type = isset($_SERVER['HTTP_CONTENT_TYPE']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_CONTENT_TYPE'])) : '';
1836 $content_type = isset($_SERVER['CONTENT_TYPE']) ? sanitize_text_field(wp_unslash($_SERVER['CONTENT_TYPE'])) : '';
1837 if ((!empty($http_content_type) || !empty($content_type)) && [] === $_POST) {
1838 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Required for reading php://input stream
1839 $raw_post_string = file_get_contents('php://input');
1840 parse_str($raw_post_string, wp_slimstat::$raw_post_array);
1841
1842 // Sanitize the action key from the raw body before using it
1843 if (!empty(wp_slimstat::$raw_post_array['action'])) {
1844 wp_slimstat::$raw_post_array['action'] = sanitize_key(
1845 wp_unslash(wp_slimstat::$raw_post_array['action'])
1846 );
1847 }
1848 } elseif ([] !== $_POST) {
1849 wp_slimstat::$raw_post_array = $_POST;
1850 }
1851
1852 // Init the Ajax listener
1853 if (!empty(wp_slimstat::$raw_post_array['action']) && 'slimtrack' == wp_slimstat::$raw_post_array['action']) {
1854
1855 // This is needed because admin-ajax.php is reading $_REQUEST to fire the corresponding action
1856 // Use a hardcoded literal instead of passing the user-supplied value
1857 if (empty($_POST['action'])) {
1858 $_POST['action'] = 'slimtrack';
1859 }
1860
1861 add_action('wp_ajax_nopriv_slimtrack', [\SlimStat\Tracker\Ajax::class, 'handle']);
1862 add_action('wp_ajax_slimtrack', [\SlimStat\Tracker\Ajax::class, 'handle']);
1863 }
1864
1865
1866 // 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.
1867 if (is_admin()) {
1868 include_once(plugin_dir_path(__FILE__) . 'admin/index.php');
1869 register_activation_hook(__FILE__, ['wp_slimstat_admin', 'init_environment']);
1870 register_deactivation_hook(__FILE__, ['wp_slimstat_admin', 'deactivate']);
1871 }
1872
1873 add_action('widgets_init', ['wp_slimstat', 'register_widget']);
1874
1875 // Load textdomain at init (required by WordPress 6.7.0+)
1876 add_action('init', ['wp_slimstat', 'load_textdomain'], 1);
1877
1878 // Add the appropriate actions
1879 add_action('plugins_loaded', ['wp_slimstat', 'init'], 20);
1880 // Add the action to fetch chart data
1881 add_action('wp_ajax_slimstat_fetch_chart_data', [\SlimStat\Modules\Chart::class, 'ajaxFetchChartData']);
1882 }
1883
1884 add_action('wp_ajax_slimstat_clear_cache', 'wp_slimstat_clear_cache_handler');
1885
1886 function wp_slimstat_clear_cache_handler()
1887 {
1888 if (!current_user_can('manage_options')) {
1889 wp_send_json_error(__('Permission denied', 'wp-slimstat'));
1890 }
1891 // Optional: check nonce if you add it to JS
1892 if (empty($_POST['security']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['security'])), 'slimstat_clear_cache')) {
1893 wp_send_json_error(__('Invalid nonce', 'wp-slimstat'));
1894 }
1895
1896 global $wpdb;
1897 $transients = $wpdb->get_col(
1898 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)
1899 );
1900 $count = 0;
1901 foreach ($transients as $transient) {
1902 delete_option($transient);
1903 $count++;
1904 }
1905 wp_send_json_success(sprintf(__('Slimstat cache cleared (%d items)', 'wp-slimstat'), $count));
1906 }
1907