PluginProbe
SlimStat Analytics / 5.4.3
SlimStat Analytics v5.4.3
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.3, at wp-slimstat.php

1,728 lines 73.6 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.3
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.3');
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 // Allow third party tools to edit the options
238 self::$settings = apply_filters('slimstat_init_options', self::$settings);
239
240 $consent_integration = self::$settings['consent_integration'] ?? '';
241
242 // If WP Consent API is selected but the function doesn't exist, reset to default
243 if ('wp_consent_api' === $consent_integration && !function_exists('wp_has_consent')) {
244 $consent_integration = '';
245 self::$settings['consent_integration'] = '';
246 }
247
248 if ('' === $consent_integration && ('on' === (self::$settings['use_slimstat_banner'] ?? 'off'))) {
249 $consent_integration = 'slimstat_banner';
250 self::$settings['consent_integration'] = $consent_integration;
251 }
252
253 if ('slimstat_banner' === $consent_integration) {
254 self::$settings['use_slimstat_banner'] = 'on';
255 } else {
256 self::$settings['use_slimstat_banner'] = 'off';
257 }
258
259 // Allow third-party tools to use a custom database for Slimstat
260 self::$wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']);
261
262 // Define the folder where to store the geolocation database (shared among sites in a network, by default)
263 if (defined('UPLOADS')) {
264 self::$upload_dir = ABSPATH . UPLOADS . '/wp-slimstat';
265 } else {
266 $upload_dir_info = wp_upload_dir();
267 self::$upload_dir = $upload_dir_info['basedir'];
268
269 // Handle multisite environment
270 if (is_multisite() && !(is_main_network() && is_main_site() && defined('MULTISITE'))) {
271 self::$upload_dir = str_replace('/sites/' . get_current_blog_id(), '', self::$upload_dir);
272 }
273
274 self::$upload_dir .= '/wp-slimstat';
275 }
276
277 // Apply filter to allow customization of the upload directory
278 self::$upload_dir = apply_filters('slimstat_maxmind_path', self::$upload_dir);
279
280 // Allow add-ons to turn off the tracker based on other conditions
281 $is_tracking_filter = apply_filters('slimstat_filter_pre_tracking', false === strpos(self::get_request_uri(), 'wp-admin/admin-ajax.php'));
282 $is_tracking_filter_js = apply_filters('slimstat_filter_pre_tracking_js', true);
283
284 // Enable the tracker (both server- and client-side)
285 if ((!is_admin() || 'on' == self::$settings['track_admin_pages']) && 'on' == self::$settings['is_tracking'] && $is_tracking_filter) {
286
287 // Is server-side tracking active?
288 if ('on' != self::$settings['javascript_mode']) {
289 add_action(is_admin() ? 'admin_init' : 'wp', [\SlimStat\Tracker\Tracker::class, 'slimtrack'], 5);
290
291 if ('on' != self::$settings['ignore_wp_users']) {
292 add_action('login_init', [\SlimStat\Tracker\Tracker::class, 'slimtrack'], 10);
293 }
294 }
295
296 // Slimstat tracks screen resolutions, outbound links and other client-side information using a client-side tracker
297 add_action(is_admin() ? 'admin_enqueue_scripts' : 'wp_enqueue_scripts', [self::class, 'enqueue_tracker'], 15);
298 if ('on' != self::$settings['ignore_wp_users']) {
299 add_action('login_enqueue_scripts', [self::class, 'enqueue_tracker'], 10);
300 }
301
302 add_filter('script_loader_tag', [self::class, 'add_defer_to_script_tag'], 10, 2);
303 }
304
305 $banner_enabled = ('on' === (self::$settings['gdpr_enabled'] ?? 'on'))
306 && ('on' === (self::$settings['use_slimstat_banner'] ?? 'off'));
307 if ($banner_enabled) {
308 add_action('wp_enqueue_scripts', [self::class, 'enqueue_gdpr_assets'], 20);
309 add_action('login_enqueue_scripts', [self::class, 'enqueue_gdpr_assets'], 20);
310 add_action('wp_footer', [self::class, 'render_gdpr_banner'], 5);
311 add_action('login_footer', [self::class, 'render_gdpr_banner'], 5);
312 }
313
314 // Registers Slimstat with WP Consent API if enabled in plugin settings
315 if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') {
316 // Check if WP Consent API plugin is actually active
317 if (function_exists('wp_has_consent')) {
318 $plugin = plugin_basename(SLIMSTAT_FILE);
319 add_filter("wp_consent_api_registered_{$plugin}", '__return_true');
320
321 // Register cookie info with WP Consent API for CMP display
322 if (function_exists('wp_add_cookie_info')) {
323 wp_add_cookie_info(
324 'slimstat_tracking_code',
325 'SlimStat Analytics',
326 'statistics',
327 intval(self::$settings['session_duration'] ?? 1800) . ' ' . __('seconds', 'wp-slimstat'),
328 __('Session cookie that identifies returning visitors for analytics.', 'wp-slimstat'),
329 '',
330 false,
331 false
332 );
333 }
334 }
335 }
336
337 // Register WordPress Privacy API exporters and erasers (GDPR Article 15 & 17)
338 add_filter('wp_privacy_personal_data_exporters', [\SlimStat\Services\Privacy\DataExporter::class, 'registerExporters']);
339 add_filter('wp_privacy_personal_data_erasers', [\SlimStat\Services\Privacy\DataEraser::class, 'registerErasers']);
340
341 // Register privacy policy content
342 add_action('admin_init', [self::class, 'registerPrivacyPolicyContent']);
343
344 // Register AJAX handlers for consent upgrade/revocation (anonymous tracking mode)
345 \SlimStat\Services\Privacy\ConsentHandler::registerAjaxHandlers();
346
347 // Hook a DB clean-up routine to the daily cronjob
348 add_action('wp_slimstat_purge', [self::class, 'wp_slimstat_purge']);
349
350 // Hook IP hashing daily salt generation (for GDPR compliance)
351 add_action('wp_slimstat_generate_daily_salt', [\SlimStat\Providers\IPHashProvider::class, 'generateDailySalt']);
352
353 // Hook a GeoIP database update routine to the daily cronjob
354 add_action('wp_slimstat_update_geoip_database', [self::class, 'wp_slimstat_update_geoip_database']);
355
356 // Allow external domains on CORS requests
357 add_filter('allowed_http_origins', [self::class, 'open_cors_admin_ajax']);
358
359 // Internal GDPR banner/consent handling removed. Use external CMP plugins.
360
361 // If this request was a redirect, we should update the content type accordingly
362 add_filter('wp_redirect_status', [\SlimStat\Tracker\Tracker::class, 'update_content_type'], 10, 2);
363
364 // Shortcodes
365 add_shortcode('slimstat', [self::class, 'slimstat_shortcode'], 15);
366
367 // Init the plugin functionality
368 add_action('init', [self::class, 'init_plugin']);
369
370 // REST API Support
371 add_action('rest_api_init', [self::class, 'register_rest_route']);
372
373 // Load the admin library
374 if (is_user_logged_in()) {
375 include_once(plugin_dir_path(__FILE__) . 'admin/index.php');
376 add_action('init', ['wp_slimstat_admin', 'init'], 60);
377 }
378 }
379 // end init
380
381 /**
382 * Load plugin textdomain
383 *
384 * @return void
385 */
386 public static function load_textdomain()
387 {
388 load_plugin_textdomain('wp-slimstat', false, '/wp-slimstat/languages');
389 }
390
391 /**
392 * The main logging function
393 *
394 * @param string $message The message to be logged.
395 * @param string $level The log level (e.g., 'info', 'warning', 'error'). Default is 'info'.
396 *
397 * @uses error_log
398 */
399 public static function log($message, $level = 'info')
400 {
401 if (is_array($message)) {
402 $message = wp_json_encode($message);
403 }
404
405 $log_level = strtoupper($level);
406
407 // Log when debug is enabled
408 if (defined('WP_DEBUG') && WP_DEBUG) {
409 error_log(sprintf('[WP SLIMSTAT] [%s]: %s', $log_level, $message));
410 }
411 }
412
413 /**
414 * Resolve the active geolocation provider.
415 *
416 * New UI sets 'geolocation_provider' explicitly (incl. 'disable').
417 * Legacy installs only have 'enable_maxmind' (tri-state: 'on', 'no', 'disable').
418 *
419 * @return string|false 'maxmind', 'dbip', 'cloudflare', or false if disabled
420 */
421 public static function resolve_geolocation_provider()
422 {
423 static $cache = [];
424
425 // Sanitize both settings that drive resolution
426 $provider_san = sanitize_text_field(self::$settings['geolocation_provider'] ?? '');
427
428 // Normalize legacy tri-state ('on'|'no'|'disable') to deterministic token
429 $legacy_san = sanitize_text_field(self::$settings['enable_maxmind'] ?? '');
430 if ('on' === $legacy_san) {
431 $legacy_norm = 'on';
432 } elseif ('no' === $legacy_san) {
433 $legacy_norm = 'no';
434 } else {
435 $legacy_norm = 'disable';
436 }
437
438 // Cache key invalidates when settings change mid-request (e.g. settings save)
439 $cache_key = $provider_san . '|' . $legacy_norm;
440
441 if (array_key_exists($cache_key, $cache)) {
442 return $cache[$cache_key];
443 }
444
445 $result = false;
446
447 if ('' !== $provider_san) {
448 if ('disable' === $provider_san) {
449 $cache[$cache_key] = false;
450 return false;
451 }
452 if (in_array($provider_san, \SlimStat\Services\GeoService::ALL_PROVIDERS, true)) {
453 $cache[$cache_key] = $provider_san;
454 return $provider_san;
455 }
456 // Invalid value — fall through to legacy flag
457 }
458
459 if ('on' === $legacy_norm) {
460 $result = 'maxmind';
461 } elseif ('no' === $legacy_norm) {
462 $result = 'dbip';
463 }
464
465 $cache[$cache_key] = $result;
466 return $result;
467 }
468
469 /**
470 * Decodes the permalink
471 */
472 public static function get_request_uri()
473 {
474 $request_url = '';
475
476 if (isset($_SERVER['REQUEST_URI'])) {
477 return urldecode(sanitize_url(wp_unslash($_SERVER['REQUEST_URI'])));
478 } elseif (isset($_SERVER['SCRIPT_NAME'])) {
479 $request_url = sanitize_text_field(wp_unslash($_SERVER['SCRIPT_NAME']));
480 } elseif (isset($_SERVER['PHP_SELF'])) {
481 $request_url = sanitize_text_field(wp_unslash($_SERVER['PHP_SELF']));
482 }
483
484 if (isset($_SERVER['QUERY_STRING'])) {
485 $request_url .= '?' . sanitize_text_field(wp_unslash($_SERVER['QUERY_STRING']));
486 }
487
488 return $request_url;
489 }
490
491 // end get_request_uri
492
493 public static function is_local_ip_address($ip_address = '')
494 {
495 return !filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE);
496 }
497
498 /**
499 * Implements the Slimstat Shortcode API
500 */
501 public static function slimstat_shortcode($_attributes = '', $_content = '')
502 {
503 shortcode_atts([
504 'f' => '', // recent, popular, count, widget
505 'w' => '', // column to use (for recent, popular and count) or widget to use
506 's' => ' ', // separator
507 'o' => 0, // offset for counters
508 ], $_attributes);
509
510 $f = $_attributes['f'] ?? '';
511 $w = $_attributes['w'] ?? '';
512 $s = $_attributes['s'] ?? '';
513 $o = $_attributes['o'] ?? 0;
514 $output = '';
515 $where = '';
516 $as_column = '';
517 $s = sprintf("<span class='slimstat-item-separator'>%s</span>", $s);
518
519 // Look for required fields
520 if (empty($f) || empty($w)) {
521 return '<!-- Slimstat Shortcode Error: missing parameter -->';
522 }
523
524 // Validation the parameter w
525 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'])) {
526 return '<!-- Slimstat Shortcode Error: invalid parameter for w -->';
527 }
528
529 // Include the Reports Library, but don't initialize the database, since we will do that separately later
530 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php');
531 wp_slimstat_reports::init();
532
533 /**
534 * @SecurityProfile https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-0630
535 * Disabled because of the report from WP Scan
536 */
537 // Init the database library with the appropriate filters
538 /*if ( strpos ( $_content, 'WHERE:' ) !== false ) {
539 $where = html_entity_decode( str_replace( 'WHERE:', '', $_content ), ENT_QUOTES, 'UTF-8' );
540 }
541 else{*/
542 wp_slimstat_db::init(html_entity_decode($_content, ENT_QUOTES, 'UTF-8'));
543 //}
544
545 switch ($f) {
546 case 'count':
547 case 'count-all':
548 $output = wp_slimstat_db::count_records($w, $where, false === strpos($f, 'all')) + $o;
549 break;
550
551 case 'widget':
552 if (empty(wp_slimstat_reports::$reports[$w])) {
553 return __('Invalid Report ID', 'wp-slimstat');
554 }
555
556 wp_register_style('wp-slimstat-frontend', plugins_url('/admin/assets/css/slimstat.css', __FILE__), true, SLIMSTAT_ANALYTICS_VERSION);
557 wp_enqueue_style('wp-slimstat-frontend');
558
559 wp_slimstat_reports::$reports[$w]['callback_args']['is_widget'] = true;
560
561 ob_start();
562 echo wp_slimstat_reports::report_header($w);
563 call_user_func(wp_slimstat_reports::$reports[$w]['callback'], wp_slimstat_reports::$reports[$w]['callback_args']);
564 wp_slimstat_reports::report_footer();
565 $output = ob_get_contents();
566 ob_end_clean();
567 break;
568
569 case 'recent':
570 case 'recent-all':
571 case 'top':
572 case 'top-all':
573 $function = 'get_' . str_replace('-all', '', $f);
574
575 if ('*' == $w) {
576 $w = 'id';
577 }
578
579 $w = esc_html($w);
580 $w = self::string_to_array($w);
581
582 // Some columns are 'special' and need be removed from the list
583 $w_clean = array_diff($w, ['count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt']);
584
585 // The special value 'display_name' requires the username to be retrieved
586 if (in_array('display_name', $w)) {
587 $w_clean[] = 'username';
588 }
589
590 // The special value 'post_list' requires the resource to be retrieved
591 if (in_array('post_link', $w)) {
592 $w_clean[] = 'resource';
593 }
594
595 // The special value 'post_list_no_qs' requires a substring to be calculated
596 if (in_array('post_link_no_qs', $w)) {
597 $w_clean = ['SUBSTRING_INDEX( resource, "' . (get_option('permalink_structure') ? '?' : '&') . '", 1 )'];
598 $as_column = 'resource';
599 }
600
601 // Retrieve the data
602 $results = wp_slimstat_db::$function(implode(', ', $w_clean), $where, '', false === strpos($f, 'all'), $as_column);
603
604 // No data? No problem!
605 if (empty($results)) {
606 return '<!-- Slimstat Shortcode: No Data -->';
607 }
608
609 // Are nice permalinks enabled?
610 $permalinks_enabled = get_option('permalink_structure');
611
612 // Format results
613 $output = [];
614
615 foreach ($results as $result_idx => $a_result) {
616 foreach ($w as $a_column) {
617 $output[$result_idx][$a_column] = sprintf("<span class='col-%s'>", $a_column);
618
619 switch ($a_column) {
620 case 'count':
621 $output[$result_idx][$a_column] .= $a_result['counthits'];
622 break;
623
624 case 'country':
625 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('c-' . $a_result[$a_column]);
626 break;
627
628 case 'display_name':
629 $user_details = get_user_by('login', $a_result['username']);
630 if (!empty($user_details)) {
631 $output[$result_idx][$a_column] .= $user_details->display_name;
632 } else {
633 $output[$result_idx][$a_column] .= $a_result['username'];
634 }
635
636 break;
637
638 case 'dt':
639 $output[$result_idx][$a_column] .= date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $a_result['dt']);
640 break;
641
642 case 'hostname':
643 $output[$result_idx][$a_column] .= self::gethostbyaddr($a_result['ip']);
644 break;
645
646 case 'language':
647 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('l-' . $a_result[$a_column]);
648 break;
649
650 case 'platform':
651 $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string($a_result[$a_column]);
652 break;
653
654 case 'post_link':
655 case 'post_link_no_qs':
656 $post_id = url_to_postid($a_result['resource']);
657 if ($post_id > 0) {
658 $output[$result_idx][$a_column] .= sprintf("<a href='%s'>", esc_url( $a_result[ 'resource' ] )) . esc_html( get_the_title($post_id) ) . '</a>';
659 } else {
660 $output[$result_idx][$a_column] .= sprintf("<a href='%s'>%s</a>", esc_url( $a_result[ 'resource' ] ), esc_html( $a_result[ 'resource' ] ));
661 }
662 break;
663
664 default:
665 $output[$result_idx][$a_column] .= $a_result[$a_column] ?? '';
666 break;
667 }
668 $output[$result_idx][$a_column] .= '</span>';
669 }
670 $output[$result_idx] = '<li>' . implode($s, $output[$result_idx]) . '</li>';
671 }
672
673 $output = '<ul class="slimstat-shortcode ' . $f . implode('-', $w) . '">' . implode('', $output) . '</ul>';
674 break;
675
676 default:
677 break;
678 }
679
680 return $output;
681 }
682
683 // end slimstat_shortcode
684
685
686 public static function init_plugin()
687 {
688 // Include our browser detector library
689 \SlimStat\Services\Browscap::init();
690
691 // Make sure the upload directory is exist and is protected.
692 self::create_upload_directory();
693
694 // Ensure daily salt exists for IP hashing (GDPR compliance)
695 // This runs on every page load but only generates if missing
696 \SlimStat\Providers\IPHashProvider::generateDailySalt();
697
698 // Initialize adblock bypass functionality
699 \SlimStat\Tracker\Tracker::rewrite_rule_tracker();
700 add_action('template_redirect', [\SlimStat\Tracker\Tracker::class, 'adblocker_javascript']);
701 add_action('init', [\SlimStat\Tracker\Tracker::class, 'rewrite_rule_tracker']);
702 }
703
704 /**
705 * Opens given domains during CORS requests to admin-ajax.php
706 */
707 public static function open_cors_admin_ajax($_allowed_origins = [])
708 {
709 $exploded_domains = self::string_to_array(self::$settings['external_domains']);
710
711 if (!empty($exploded_domains) && !empty($exploded_domains[0])) {
712 $_allowed_origins = array_merge($_allowed_origins, $exploded_domains);
713 }
714
715 return $_allowed_origins;
716 }
717 // end open_cors_admin_ajax
718
719 /**
720 * Implements a REST API interface to retrieve Slimstat reports and metrics
721 */
722 public static function rest_api_response($_request = [])
723 {
724 $filters = '';
725 if (!empty($_request['filters'])) {
726 $filters = $_request['filters'];
727 }
728
729 if (empty($_request['dimension'])) {
730 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]);
731 }
732
733 if (empty($_request['function'])) {
734 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]);
735 }
736
737 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-db.php');
738 wp_slimstat_db::init($filters);
739
740 $response = [
741 'function' => htmlentities($_request['function'], ENT_QUOTES, 'UTF-8'),
742 'dimension' => htmlentities($_request['dimension'], ENT_QUOTES, 'UTF-8'),
743
744 'data' => 0,
745 ];
746
747 switch ($_request['function']) {
748 case 'count':
749 case 'count-all':
750 $response['data'] = wp_slimstat_db::count_records($_request['dimension'], '', false === strpos($_request['function'], '-all'));
751 break;
752
753 case 'recent':
754 case 'recent-all':
755 case 'top':
756 case 'top-all':
757 $function = 'get_' . str_replace('-all', '', $_request['function']);
758
759 // Retrieve the data
760 $response['data'] = array_values(wp_slimstat_db::$function($_request['dimension'], '', '', false === strpos($_request['function'], '-all')));
761 break;
762
763 default:
764 // This should never happen, because of the 'enum' condition for this parameter. But never say never...
765 $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]);
766 break;
767 }
768
769 return rest_ensure_response($response);
770 }
771 // end rest_api_response
772
773 /**
774 * Implements a REST API authentication mechanism via token
775 */
776 public static function rest_api_authorization($_request = [])
777 {
778 if (empty($_request['token'])) {
779 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]);
780 }
781 $valid_tokens = self::string_to_array(self::$settings['rest_api_tokens']);
782 foreach ($valid_tokens as $valid_token) {
783 if (is_string($valid_token) && is_string($_request['token']) && hash_equals($valid_token, $_request['token'])) {
784 return true;
785 }
786 }
787 return false;
788 }
789 // end rest_api_authorization
790
791 /**
792 * Registers a new REST API route for the Slimstat endpoint
793 */
794 public static function register_rest_route()
795 {
796 register_rest_route('slimstat/v1', '/get', [
797 'methods' => WP_REST_Server::READABLE,
798 'callback' => [self::class, 'rest_api_response'],
799 'permission_callback' => [self::class, 'rest_api_authorization'],
800 'args' => [
801 'token' => [
802 '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'),
803 'type' => 'string',
804 ],
805 'function' => [
806 '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'),
807 'type' => 'string',
808 'enum' => ['count', 'count-all', 'recent', 'recent-all', 'top', 'top-all'],
809 ],
810 'dimension' => [
811 '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'),
812 'type' => 'string',
813 'enum' => ['*', 'id', 'ip', 'username', 'email', 'country', 'referer', 'resource', 'searchterms', 'browser', 'platform', 'language', 'resolution', 'content_type', 'content_id', 'tz_offset', 'outbound_resource'],
814 ],
815 'filters' => [
816 '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'),
817 'type' => 'string',
818 ],
819 ],
820 ]);
821 }
822 // end register_rest_route
823
824 /**
825 * Converts a series of comma separated values into an array
826 */
827 public static function string_to_array($_option = '')
828 {
829 if (empty($_option) || !is_string($_option)) {
830 return [];
831 } else {
832 return array_filter(array_map('trim', explode(',', $_option)));
833 }
834 }
835 // end string_to_array
836
837 /**
838 * Returns Matomo search engine mapping JSON, cached.
839 */
840 public static function get_search_engines()
841 {
842 static $cached_search_engines = null;
843 if (null !== $cached_search_engines) {
844 return $cached_search_engines;
845 }
846
847 $data = get_transient('slimstat_matomo_searchengine');
848 if (false === $data) {
849 $json_path = plugin_dir_path(__FILE__) . 'admin/assets/data/matomo-searchengine.json';
850 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin file, WP_Filesystem not needed
851 $json = @file_get_contents($json_path);
852 $data = json_decode($json, true);
853 if (!is_array($data)) {
854 $data = [];
855 }
856 set_transient('slimstat_matomo_searchengine', $data, WEEK_IN_SECONDS);
857 }
858
859 $cached_search_engines = $data;
860 return $cached_search_engines;
861 }
862 // end get_search_engines
863
864 /**
865 * Toggles WordPress filters on date_i18n function
866 */
867 public static function toggle_date_i18n_filters($_turn_on = true)
868 {
869 if ($_turn_on && !empty(self::$date_i18n_filters) && is_array(self::$date_i18n_filters)) {
870 foreach (self::$date_i18n_filters as $i18n_priority => $i18n_func_list) {
871 foreach ($i18n_func_list as $func_args) {
872 if (!empty($func_args['function']) && is_string($func_args['function'])) {
873 add_filter('date_i8n', $func_args['function'], $i18n_priority, intval($func_args['accepted_args']));
874 }
875 }
876 }
877 } elseif (!empty($GLOBALS['wp_filter']['date_i18n']['callbacks']) && is_array($GLOBALS['wp_filter']['date_i18n']['callbacks'])) {
878 self::$date_i18n_filters = $GLOBALS['wp_filter']['date_i18n']['callbacks'];
879 remove_all_filters('date_i18n');
880 }
881 }
882 // end toggle_date_i18n_filters
883
884 /**
885 * Calls the date_i18n function without filters
886 */
887 public static function date_i18n($_format)
888 {
889 self::toggle_date_i18n_filters(false);
890 $date = date_i18n($_format);
891 self::toggle_date_i18n_filters(true);
892
893 return $date;
894 }
895 // end date_i18n
896
897 /**
898 * Returns default options with geolocation_provider set for fresh installs and resets.
899 *
900 * geolocation_provider is excluded from init_options() because init() merges
901 * those defaults into stored settings — which would override the legacy
902 * enable_maxmind flag on upgraded installs before lazy migration runs.
903 *
904 * Fresh installs default to DB-IP (free, no license key required).
905 */
906 public static function get_fresh_defaults()
907 {
908 $defaults = self::init_options();
909 $defaults['geolocation_provider'] = 'dbip';
910 return $defaults;
911 }
912
913 /**
914 * Returns the current geolocation precision ('country' or 'city').
915 */
916 public static function get_geolocation_precision()
917 {
918 return ('on' == self::$settings['geolocation_country']) ? 'country' : 'city';
919 }
920
921 /**
922 * Sets the default values for all the options
923 */
924 public static function init_options()
925 {
926 return [
927 'version' => SLIMSTAT_ANALYTICS_VERSION,
928 'secret' => wp_hash(wp_generate_password(64, true, true)),
929 'browscap_last_modified' => 0,
930
931 // General
932 // -----------------------------------------------------------------------
933
934 // General - Tracker
935 'is_tracking' => 'on',
936 'track_admin_pages' => 'no',
937 'javascript_mode' => 'off', // Changed: Enable server-side tracking by default
938
939 // General - WordPress Integration
940 'add_dashboard_widgets' => 'on',
941 'use_separate_menu' => 'on',
942 'add_posts_column' => 'no',
943 'posts_column_pageviews' => 'on',
944 'display_notifications' => 'on',
945
946 // General - Database
947 'auto_purge' => 420,
948 'auto_purge_delete' => 'on',
949
950 // Tracker
951 // -----------------------------------------------------------------------
952
953 // Tracker - Data Protection
954 // anonymize_ip: mask IP before storing; hash_ip: generate daily visitor_id based on masked IP + UA
955 'gdpr_enabled' => 'on', // Changed: Enable GDPR by default for safety
956 'anonymize_ip' => 'on', // Changed: Anonymize IPs by default
957 'hash_ip' => 'on', // Changed: Hash IPs by default
958 'set_tracker_cookie' => 'off', // Changed: Don't set cookies by default (GDPR-safe)
959 'use_slimstat_banner' => 'on', // Changed: Enable banner by default when GDPR is enabled
960 'consent_integration' => 'slimstat_banner', // Changed: Use SlimStat banner by default when GDPR is enabled
961 'consent_level_integration'=> 'statistics',
962 'opt_out_message' => '',
963 'gdpr_accept_button_text' => 'Accept',
964 'gdpr_decline_button_text' => 'Decline',
965 'gdpr_theme_mode' => 'auto', // 'light', 'dark', 'auto'
966 'anonymous_tracking' => 'off', // Changed: Enable anonymous tracking by default
967 'do_not_track' => 'off',
968 'display_opt_out' => 'no',
969 'opt_out_cookie_names' => '',
970 'opt_in_cookie_names' => '',
971
972 // Tracker - Link Tracking
973 'track_same_domain_referers' => 'no',
974 'do_not_track_outbound_classes_rel_href' => 'noslimstat,ab-item',
975 'extensions_to_track' => 'pdf,doc,xls,zip',
976
977 // Tracker - Advanced Options
978 // NOTE: geolocation_provider is intentionally NOT in init_options().
979 // init() merges these defaults into stored settings, which would override
980 // the legacy enable_maxmind flag on upgraded installs before lazy migration runs.
981 // Use get_fresh_defaults() for new installs and settings reset.
982 'geolocation_country' => 'on',
983 'session_duration' => 1800,
984 'extend_session' => 'no',
985 'enable_cdn' => 'no',
986 'ajax_relative_path' => 'no',
987
988 // Tracker - External Pages
989 'external_domains' => '',
990
991 // Reports
992 // -----------------------------------------------------------------------
993
994 // Reports - Functionality
995 'use_current_month_timespan' => 'no',
996 'posts_column_day_interval' => 28,
997 'rows_to_show' => '20',
998 'show_hits' => 'no',
999 'ip_lookup_service' => 'https://ip-api.com/#',
1000 'comparison_chart' => 'on',
1001 'show_display_name' => 'no',
1002 'convert_resource_urls_to_titles' => 'on',
1003 'convert_ip_addresses' => 'no',
1004
1005 // Reports - Access Log and World Map
1006 'refresh_interval' => '60',
1007 'number_results_raw_data' => '50',
1008 'max_dots_on_map' => '50',
1009
1010 // Reports - Miscellaneous
1011 'custom_css' => '',
1012 'chart_colors' => '',
1013 'mozcom_access_id' => '',
1014 'mozcom_secret_key' => '',
1015 'show_complete_user_agent_tooltip' => 'no',
1016 'async_load' => 'no',
1017 'limit_results' => '200',
1018 'enable_sov' => 'no',
1019
1020 // Exclusions
1021 // -----------------------------------------------------------------------
1022
1023 // Exclusions - User Properties
1024 'ignore_wp_users' => 'no',
1025 'ignore_spammers' => 'on',
1026 'ignore_bots' => 'no',
1027 'ignore_prefetch' => 'on',
1028 'ignore_users' => '',
1029 'ignore_ip' => '',
1030 'ignore_countries' => '',
1031 'ignore_languages' => '',
1032 'ignore_browsers' => '',
1033 'ignore_platforms' => '',
1034 'ignore_capabilities' => '',
1035
1036 // Exclusions - Page Properties
1037 'ignore_resources' => '',
1038 'ignore_referers' => '',
1039 'ignore_content_types' => '',
1040
1041 // Access Control
1042 // -----------------------------------------------------------------------
1043
1044 // Access Control - Reports
1045 'restrict_authors_view' => 'on',
1046 'capability_can_view' => 'manage_options',
1047 'can_view' => '',
1048
1049 // Access Control - Reports
1050 'tracking_request_method' => 'ajax',
1051
1052 // Access Control - Customizer
1053 'capability_can_customize' => 'manage_options',
1054 'can_customize' => '',
1055
1056 // Access Control - Settings
1057 'capability_can_admin' => 'manage_options',
1058 'can_admin' => '',
1059
1060 // Access Control - REST API
1061 'rest_api_tokens' => wp_hash(wp_generate_password(64, true, true)),
1062
1063 // Maintenance
1064 // -----------------------------------------------------------------------
1065 'last_tracker_error' => [0, '', 0],
1066 'show_sql_debug' => 'no',
1067 'db_indexes' => 'on',
1068 'enable_maxmind' => 'disable',
1069 'maxmind_license_key' => '',
1070 'enable_browscap' => 'no',
1071
1072 // Notices
1073 // -----------------------------------------------------------------------
1074 'notice_latest_news' => 'on',
1075 'notice_browscap' => 'on',
1076 'notice_geolite' => 'on',
1077 'notice_caching' => 'on',
1078
1079 // Network-wide Settings
1080 'locked_options' => '',
1081 ];
1082 }
1083 // end init_options
1084
1085 /**
1086 * Saves a given option in the database
1087 */
1088 public static function update_option($_key = '', $_value = '')
1089 {
1090 if (!is_network_admin()) {
1091 update_option($_key, $_value);
1092 } else {
1093 update_site_option($_key, $_value);
1094 }
1095 }
1096 // end update_option
1097
1098 /**
1099 * Attach a script to every page to track visitors' screen resolution and other browser-based information
1100 */
1101 public static function enqueue_tracker()
1102 {
1103 // Use the new unified tracking method setting
1104 $method = self::$settings['tracking_request_method'] ?? 'rest';
1105
1106 // Handle legacy 'adblock' value (renamed to 'adblock_bypass' in v5.3.0)
1107 if ( 'adblock' === $method ) {
1108 $method = 'adblock_bypass';
1109 }
1110
1111 // Prepare URLs for all methods
1112 $rest_url = rest_url('slimstat/v1/hit');
1113 $rest_base_url = rest_url();
1114 $ajax_url = admin_url('admin-ajax.php');
1115 $ajax_url_relative = admin_url('admin-ajax.php', 'relative');
1116 $adblock_hash = \SlimStat\Providers\RestApiManager::getSecureAdblockHash();
1117 $adblock_url = home_url(sprintf('request/%s/', $adblock_hash));
1118
1119 // Always provide all possible endpoints for fallback logic
1120 $params = [
1121 'transport' => $method,
1122 'ajaxurl_rest' => $rest_url,
1123 'resturl' => $rest_base_url,
1124 'ajaxurl_ajax' => ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url,
1125 'ajaxurl_adblock' => $adblock_url,
1126 ];
1127
1128 // Set the primary ajaxurl based on the selected method
1129 if ('rest' === $method) {
1130 $params['ajaxurl'] = $rest_url;
1131 } elseif ('ajax' === $method) {
1132 $params['ajaxurl'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url;
1133 } elseif ('adblock_bypass' === $method) {
1134 $params['ajaxurl'] = $adblock_url;
1135 // Also set transport to 'adblock_bypass' for JS clarity
1136 $params['transport'] = 'adblock_bypass';
1137 } else {
1138 $params['ajaxurl'] = $rest_url;
1139 }
1140
1141 $baseurl = parse_url(get_home_url());
1142 $params['baseurl'] = empty($baseurl['path']) ? '/' : $baseurl['path'];
1143
1144 if (!empty(self::$settings['do_not_track_outbound_classes_rel_href'])) {
1145 $params['dnt'] = str_replace(' ', '', self::$settings['do_not_track_outbound_classes_rel_href']);
1146 }
1147
1148 // Internal GDPR banner is optionally available alongside CMP integrations.
1149
1150 if ('on' != self::$settings['javascript_mode']) {
1151 if (empty(self::$stat['id']) || intval(self::$stat['id']) < 0) {
1152 return false;
1153 }
1154 $params['id'] = \SlimStat\Tracker\Utils::getValueWithChecksum(intval(self::$stat['id']));
1155 } else {
1156 $params['ci'] = \SlimStat\Tracker\Utils::getValueWithChecksum(\SlimStat\Tracker\Utils::base64UrlEncode(wp_json_encode(\SlimStat\Tracker\Utils::getContentInfo())));
1157 }
1158
1159 $params['wp_rest_nonce'] = wp_create_nonce('wp_rest');
1160 // Expose consent/DNT info to client
1161 $params['wp_consent_integration'] = (self::$settings['consent_integration'] ?? '') === 'wp_consent_api' ? 'enabled' : 'disabled';
1162 $params['consent_integration'] = self::$settings['consent_integration'] ?? '';
1163 $params['consent_level_integration'] = (self::$settings['consent_level_integration'] ?? 'statistics');
1164 $params['respect_dnt'] = self::$settings['do_not_track'] ?? 'off';
1165 $gdpr_enabled_setting = strtolower((string) (self::$settings['gdpr_enabled'] ?? 'on'));
1166 $params['gdpr_enabled'] = in_array($gdpr_enabled_setting, ['off', 'no', 'false', '0'], true) ? 'off' : 'on';
1167 $params['anonymous_tracking'] = self::$settings['anonymous_tracking'] ?? 'off';
1168 $params['anonymize_ip'] = self::$settings['anonymize_ip'] ?? 'no';
1169 $params['hash_ip'] = self::$settings['hash_ip'] ?? 'no';
1170 $params['set_tracker_cookie'] = self::$settings['set_tracker_cookie'] ?? 'on';
1171 $params['use_slimstat_banner'] = self::$settings['use_slimstat_banner'] ?? 'off';
1172
1173 if ('on' === $params['use_slimstat_banner']) {
1174 // Set GDPR consent endpoint based on tracking method
1175 if ('rest' === $method) {
1176 $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent');
1177 } elseif ('ajax' === $method) {
1178 $params['gdpr_consent_endpoint'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url;
1179 } elseif ('adblock_bypass' === $method) {
1180 $params['gdpr_consent_endpoint'] = $adblock_url;
1181 } else {
1182 $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent');
1183 }
1184 $params['gdpr_cookie_name'] = \SlimStat\Services\GDPRService::CONSENT_COOKIE_NAME;
1185 $params['gdpr_cookie_path'] = defined('COOKIEPATH') ? COOKIEPATH : '/';
1186 $params['gdpr_consent_method'] = $method;
1187 }
1188
1189 $params = apply_filters('slimstat_js_params', $params);
1190
1191 // Add dependencies for consent integrations (e.g., WP Consent API)
1192 $dependencies = [];
1193 if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') {
1194 // Only add dependency if the WP Consent API script is actually registered
1195 if (wp_script_is('wp-consent-api', 'registered') || wp_script_is('wp-consent-api', 'enqueued')) {
1196 $dependencies[] = 'wp-consent-api';
1197 }
1198 }
1199
1200 // Register the correct script for adblock bypass, CDN, or default
1201 $local_script_version = SLIMSTAT_ANALYTICS_VERSION;
1202 $local_script_path = plugin_dir_path(__FILE__) . 'wp-slimstat.min.js';
1203 if (file_exists($local_script_path)) {
1204 $local_script_version .= '.' . filemtime($local_script_path);
1205 }
1206
1207 if ('adblock_bypass' === $method) {
1208 $hash_js = md5(site_url() . 'slimstat');
1209 wp_register_script('wp_slimstat', home_url(sprintf('/%s.js/', $hash_js)), $dependencies, SLIMSTAT_ANALYTICS_VERSION, true);
1210 } elseif ('on' == self::$settings['enable_cdn']) {
1211 wp_register_script('wp_slimstat', 'https://cdn.jsdelivr.net/wp/wp-slimstat/tags/' . SLIMSTAT_ANALYTICS_VERSION . '/wp-slimstat.min.js', $dependencies, null, true);
1212 } else {
1213 wp_register_script('wp_slimstat', plugins_url('/wp-slimstat.min.js', __FILE__), $dependencies, $local_script_version, true);
1214 }
1215
1216 wp_enqueue_script('wp_slimstat');
1217
1218 /**
1219 * Registers the 'wp_slimstat' script as an interactivity module if the registration function exists.
1220 *
1221 * Ensures compatibility with WordPress Interactivity API by registering the script module and its dependencies.
1222 */
1223 if (function_exists('wp_interactivity_register_script_module')) {
1224 wp_interactivity_register_script_module('wp_slimstat', [
1225 'name' => 'wp_slimstat',
1226 'dependencies' => [],
1227 ]);
1228 }
1229
1230 wp_localize_script('wp_slimstat', 'SlimStatParams', $params);
1231
1232 return null;
1233 }
1234
1235 // end enqueue_tracker
1236
1237 /**
1238 * Enqueue assets for the internal SlimStat GDPR banner.
1239 *
1240 * @return void
1241 */
1242 public static function enqueue_gdpr_assets()
1243 {
1244 if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) {
1245 return;
1246 }
1247
1248 wp_enqueue_style(
1249 'wp_slimstat_gdpr_banner',
1250 plugins_url('/assets/css/gdpr-banner.css', __FILE__),
1251 [],
1252 SLIMSTAT_ANALYTICS_VERSION
1253 );
1254 }
1255
1256 /**
1257 * Render the SlimStat GDPR banner markup.
1258 *
1259 * @return void
1260 */
1261 public static function render_gdpr_banner()
1262 {
1263 if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) {
1264 return;
1265 }
1266
1267 if (is_admin() && !wp_doing_ajax()) {
1268 return;
1269 }
1270
1271 $gdpr_service = new \SlimStat\Services\GDPRService(self::$settings);
1272 $banner_html = $gdpr_service->getBannerHtml();
1273
1274 if ('' === $banner_html) {
1275 return;
1276 }
1277
1278 echo $banner_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized in GDPRService
1279 }
1280
1281 public static function add_defer_to_script_tag($_tag, $_handle)
1282 {
1283 if ('wp_slimstat' === $_handle && false === stripos($_tag, 'defer')) {
1284 $_tag = str_replace('<script ', '<script defer ', $_tag);
1285 }
1286
1287 return $_tag;
1288 }
1289
1290 /**
1291 * Removes old entries from the main table and performs other daily tasks
1292 */
1293 public static function wp_slimstat_purge()
1294 {
1295 $autopurge_interval = intval(self::$settings['auto_purge']);
1296
1297 if ($autopurge_interval <= 0) {
1298 return;
1299 }
1300
1301 $days_ago = strtotime(self::date_i18n('Y-m-d H:i:s') . sprintf(' -%d days', $autopurge_interval));
1302 $table_stats = $GLOBALS['wpdb']->prefix . 'slim_stats';
1303 $table_stats_archive = $GLOBALS['wpdb']->prefix . 'slim_stats_archive';
1304 $table_events = $GLOBALS['wpdb']->prefix . 'slim_events';
1305 $table_events_archive = $GLOBALS['wpdb']->prefix . 'slim_events_archive';
1306
1307 // Copy entries to the archive table, if needed
1308 if ('no' != self::$settings['auto_purge_delete']) {
1309 // Use Query builder for INSERT INTO ... SELECT ... with prepared statements
1310 $insert_sql = self::$wpdb->prepare(
1311 "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",
1312 $days_ago
1313 );
1314 $is_copy_done = self::$wpdb->query($insert_sql);
1315 if (false !== $is_copy_done) {
1316 \SlimStat\Utils\Query::delete($table_stats)->where('dt', '<', $days_ago)->execute();
1317 }
1318 $insert_sql_events = self::$wpdb->prepare(
1319 "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",
1320 $days_ago
1321 );
1322 $is_copy_done = self::$wpdb->query($insert_sql_events);
1323 if (false !== $is_copy_done) {
1324 \SlimStat\Utils\Query::delete($table_events)->where('dt', '<', $days_ago)->execute();
1325 }
1326 } else {
1327 // Delete old entries
1328 \SlimStat\Utils\Query::delete($table_stats)->where('dt', '<', $days_ago)->execute();
1329 \SlimStat\Utils\Query::delete($table_events)->where('dt', '<', $days_ago)->execute();
1330 }
1331
1332 // Optimize tables (keep as direct queries)
1333 self::$wpdb->query('OPTIMIZE TABLE ' . $table_stats);
1334 self::$wpdb->query('OPTIMIZE TABLE ' . $table_stats_archive);
1335 self::$wpdb->query('OPTIMIZE TABLE ' . $table_events);
1336 self::$wpdb->query('OPTIMIZE TABLE ' . $table_events_archive);
1337 }
1338
1339 public static function wp_slimstat_update_geoip_database()
1340 {
1341 // Calculate the most recent "first Tuesday + 2 days" that has already passed
1342 $this_month_update = strtotime('first Tuesday of this month') + (86400 * 2);
1343 $current_time = time();
1344
1345 // If this month's update window hasn't arrived yet, use last month's window
1346 if ($current_time < $this_month_update) {
1347 $this_update = strtotime('first Tuesday of last month') + (86400 * 2);
1348 } else {
1349 $this_update = $this_month_update;
1350 }
1351
1352 $last_update = get_option('slimstat_last_geoip_dl', 0);
1353 if ($last_update < $this_update) {
1354
1355 // Determine which geolocation provider to use
1356 $provider = self::resolve_geolocation_provider();
1357 if (false === $provider) {
1358 return;
1359 }
1360
1361 try {
1362 $geographicProvider = new \SlimStat\Services\Geolocation\GeolocationService($provider, []);
1363 $ok = $geographicProvider->updateDatabase();
1364
1365 if ($ok) {
1366 update_option('slimstat_last_geoip_dl', time());
1367 }
1368
1369 } catch (\Throwable $e) {
1370 wp_slimstat::log('Geolocation database update failed: ' . $e->getMessage(), 'error');
1371 }
1372 }
1373 }
1374
1375 /**
1376 * Register privacy policy content for WordPress Privacy Tools
1377 *
1378 * @since 5.4.0
1379 */
1380 public static function registerPrivacyPolicyContent()
1381 {
1382 if (!function_exists('wp_add_privacy_policy_content')) {
1383 return;
1384 }
1385
1386 $content = '<h2>' . __('SlimStat Analytics', 'wp-slimstat') . '</h2>';
1387 $content .= '<p><strong>' . __('What personal data we collect and why', 'wp-slimstat') . '</strong></p>';
1388 $content .= '<p>' . __('SlimStat Analytics collects the following data about website visitors:', 'wp-slimstat') . '</p>';
1389 $content .= '<ul>';
1390 $content .= '<li>' . __('IP Address: Collected for analytics and security purposes. May be anonymized or hashed based on your privacy settings.', 'wp-slimstat') . '</li>';
1391 $content .= '<li>' . __('Page URLs: Tracks which pages are visited to analyze website usage.', 'wp-slimstat') . '</li>';
1392 $content .= '<li>' . __('Referrer Information: Tracks where visitors came from (search engines, other websites, etc.).', 'wp-slimstat') . '</li>';
1393 $content .= '<li>' . __('Browser and Device Information: User agent, screen resolution, and device type for analytics.', 'wp-slimstat') . '</li>';
1394 $content .= '<li>' . __('Timestamp: Date and time of each page visit.', 'wp-slimstat') . '</li>';
1395
1396 if ('on' === (self::$settings['set_tracker_cookie'] ?? 'off')) {
1397 $content .= '<li>' . __('Cookies: A tracking cookie is used to identify returning visitors and maintain session continuity.', 'wp-slimstat') . '</li>';
1398 }
1399
1400 if ('on' !== (self::$settings['ignore_wp_users'] ?? 'off')) {
1401 $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>';
1402 }
1403
1404 $content .= '</ul>';
1405
1406 $content .= '<p><strong>' . __('How long we retain your data', 'wp-slimstat') . '</strong></p>';
1407 $retention_days = intval(self::$settings['auto_purge'] ?? 420);
1408 if ($retention_days > 0) {
1409 $content .= '<p>' . sprintf(__('Analytics data is automatically deleted after %d days, in compliance with GDPR data retention requirements.', 'wp-slimstat'), $retention_days) . '</p>';
1410 } else {
1411 $content .= '<p>' . __('Analytics data retention is currently disabled. Please contact the site administrator for information about data retention policies.', 'wp-slimstat') . '</p>';
1412 }
1413
1414 $content .= '<p><strong>' . __('Your rights', 'wp-slimstat') . '</strong></p>';
1415 $content .= '<p>' . __('Under GDPR, you have the right to:', 'wp-slimstat') . '</p>';
1416 $content .= '<ul>';
1417 $content .= '<li>' . __('Access your personal data collected by SlimStat', 'wp-slimstat') . '</li>';
1418 $content .= '<li>' . __('Request deletion of your personal data (Right to be Forgotten)', 'wp-slimstat') . '</li>';
1419 $content .= '<li>' . __('Opt-out of tracking by revoking consent (if GDPR mode is enabled)', 'wp-slimstat') . '</li>';
1420 $content .= '</ul>';
1421
1422 if ('on' === (self::$settings['gdpr_enabled'] ?? 'on')) {
1423 $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>';
1424 }
1425
1426 $content .= '<p><strong>' . __('Consent Management', 'wp-slimstat') . '</strong></p>';
1427 if ('on' === (self::$settings['anonymous_tracking'] ?? 'off')) {
1428 $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>';
1429 } else {
1430 $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>';
1431 }
1432
1433 wp_add_privacy_policy_content('SlimStat Analytics', $content);
1434 }
1435
1436 public static function add_plugin_manual_download_link($_links = [], $_plugin_file = '')
1437 {
1438 $a_clean_slug = str_replace(['wp-slimstat-', '/index.php'], ['', ''], $_plugin_file);
1439
1440 if (false !== ($download_url = get_transient('wp-slimstat-download-link-' . $a_clean_slug))) {
1441 $_links[] = '<a href="' . $download_url . '">Download ZIP</a>';
1442 } else {
1443 $url = 'https://www.wp-slimstat.com/update-checker/?slug=' . $a_clean_slug . '&key=' . urlencode(self::$settings['addon_licenses']['wp-slimstat-' . $a_clean_slug]);
1444 $response = wp_safe_remote_get($url, ['timeout' => 300, 'user-agent' => 'Slimstat Analytics/' . SLIMSTAT_ANALYTICS_VERSION . '; ' . home_url()]);
1445
1446 if (!is_wp_error($response) && 200 == wp_remote_retrieve_response_code($response)) {
1447 $data = @json_decode($response['body']);
1448
1449 if (is_object($data)) {
1450 $_links[] = '<a href="' . $data->download_url . '">Download ZIP</a>';
1451 set_transient('wp-slimstat-download-link-' . $a_clean_slug, $data->download_url, 172800); // 48 hours
1452 }
1453 }
1454 }
1455
1456 return $_links;
1457 }
1458
1459 /**
1460 * Resolves a given IP address, by keeping a local cache of hostnames to avoid multiple requests to the DNS server
1461 */
1462 public static function gethostbyaddr($_ip = '')
1463 {
1464 $hostname = get_transient('slimstat_' . $_ip);
1465
1466 if (empty($hostname)) {
1467 $hostname = gethostbyaddr($_ip);
1468 set_transient('slimstat_' . $_ip, $hostname, HOUR_IN_SECONDS);
1469 }
1470
1471 return $hostname;
1472 }
1473 // end gethostbyaddr
1474
1475 /**
1476 * Registers the Slimstat widget
1477 */
1478 public static function register_widget()
1479 {
1480 return register_widget('slimstat_widget');
1481 }
1482 // end register_widget
1483
1484 /**
1485 * Generates the key to see if a given host is listed as a search engine in the corresponding Json data file
1486 */
1487 public static function get_lossy_url($_url = '')
1488 {
1489 return preg_replace(
1490 [
1491 '/^(w+\d*|search)\./',
1492 '/(^|\.)m\./',
1493 '/(\.(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)(\/|$)/',
1494 '/(^|\.)(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)\./',
1495 ],
1496 [
1497 '',
1498 '$1',
1499 '.{}$4',
1500 '$1{}.',
1501 ],
1502 $_url
1503 );
1504 }
1505 // end get_lossy_url
1506
1507 /**
1508 * Check if slimstat pro plugin is installed
1509 */
1510 public static function pro_is_installed($pluginSlug = 'wp-slimstat-pro/wp-slimstat-pro.php')
1511 {
1512 include_once(ABSPATH . 'wp-admin/includes/plugin.php');
1513 return (bool) is_plugin_active($pluginSlug);
1514 }
1515
1516 /**
1517 * create upload directory
1518 */
1519 public static function create_upload_directory()
1520 {
1521 $upload_dir = self::$upload_dir;
1522 wp_mkdir_p($upload_dir);
1523
1524 /**
1525 * Create .htaccess to avoid public access.
1526 */
1527 if (is_dir($upload_dir) && is_writable($upload_dir)) {
1528 $htaccess_file = path_join($upload_dir, '.htaccess');
1529
1530 if (!file_exists($htaccess_file) && $handle = @fopen($htaccess_file, 'w')) {
1531 fwrite($handle, "Deny from all\n");
1532 fclose($handle);
1533 }
1534 }
1535 }
1536
1537 public static function get_schedule_interval($schedule)
1538 {
1539 $schedulesInterval = wp_get_schedules();
1540 $timeInterval = 86400;
1541 if (isset($schedulesInterval[$schedule]['interval'])) {
1542 $timeInterval = $schedulesInterval[$schedule]['interval'];
1543 }
1544 return $timeInterval;
1545 }
1546 }
1547
1548 // end of class declaration
1549
1550 class slimstat_widget extends WP_Widget
1551 {
1552 /**
1553 * Sets up the widgets name etc
1554 */
1555 public function __construct()
1556 {
1557 parent::__construct('slimstat_widget', 'Slimstat', [
1558 'classname' => 'slimstat_widget',
1559 'description' => 'Add a Slimstat report to your sidebar',
1560 ]);
1561 }
1562
1563 /**
1564 * Outputs the content of the widget
1565 *
1566 * @param array $args
1567 * @param array $instance
1568 */
1569 public function widget($_args = [], $_instance = [])
1570 {
1571 extract(shortcode_atts([
1572 'slimstat_widget_id' => '',
1573 'slimstat_widget_title' => '',
1574 'slimstat_widget_filters' => '',
1575 ], $_instance));
1576
1577 if (!empty($slimstat_widget_title)) {
1578 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']);
1579 }
1580 if (!empty($slimstat_widget_id)) {
1581 echo do_shortcode(sprintf("[slimstat f='widget' w='%s']%s[/slimstat]", $slimstat_widget_id, $slimstat_widget_filters));
1582 } else {
1583 echo '';
1584 }
1585 }
1586
1587 /**
1588 * Outputs the options form on admin
1589 *
1590 * @param array $instance The widget options
1591 */
1592 public function form($_instance)
1593 {
1594 extract(shortcode_atts([
1595 'slimstat_widget_id' => '',
1596 'slimstat_widget_title' => '',
1597 'slimstat_widget_filters' => '',
1598 ], $_instance));
1599
1600 // Let's build the dropdown
1601 include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php');
1602 wp_slimstat_reports::init();
1603 $select_options = '';
1604
1605 foreach (wp_slimstat_reports::$reports as $a_report_id => $a_report_info) {
1606 $select_options .= sprintf("<option value='%s' ", $a_report_id) . (($slimstat_widget_id == $a_report_id) ? 'selected="selected"' : '') . sprintf('>%s</option>', $a_report_info[ 'title' ]);
1607 }
1608 ?>
1609
1610 <p>
1611 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_id')); ?>"><?php _e('Report', 'wp-slimstat') ?></label>
1612 <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')); ?>">
1613 <option value="">Select a widget</option>
1614 <?php echo $select_options ?>
1615 </select>
1616 </p>
1617
1618 <p>
1619 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_title')); ?>"><?php _e('Title', 'wp-slimstat') ?></label>
1620 <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)) ?>">
1621 </p>
1622
1623 <p>
1624 <label for="<?php echo esc_attr($this->get_field_id('slimstat_widget_filters')); ?>"><?php _e('Optional filters', 'wp-slimstat'); ?></label>
1625 <a href="https://wp-slimstat.com/resources/what-is-the-syntax-of-a-slimstat-shortcode-#slimstat-operators" target="_blank">[?]</a>
1626 <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>
1627 </p>
1628 <?php
1629 }
1630
1631 /**
1632 * Processing widget options on save
1633 *
1634 * @param array $new_instance The new options
1635 * @param array $old_instance The previous options
1636 */
1637 public function update($_new_instance, $_old_instance)
1638 {
1639 $instance = $_old_instance;
1640
1641 $instance['slimstat_widget_id'] = sanitize_key($_new_instance['slimstat_widget_id'] ?? '');
1642 $instance['slimstat_widget_title'] = sanitize_text_field(wp_unslash($_new_instance['slimstat_widget_title'] ?? ''));
1643 $instance['slimstat_widget_filters'] = sanitize_textarea_field(wp_unslash($_new_instance['slimstat_widget_filters'] ?? ''));
1644 return $instance;
1645 }
1646 }
1647
1648 // Early initialize DB handle for add-ons that may access wp_slimstat::$wpdb before init() runs
1649 if (empty(wp_slimstat::$wpdb) && isset($GLOBALS['wpdb'])) {
1650 wp_slimstat::$wpdb = $GLOBALS['wpdb'];
1651 }
1652
1653 // Ok, let's go, Sparky!
1654 if (function_exists('add_action')) {
1655 // Since we use sendBeacon, this function sends raw POST data, which does not populate the $_POST variable automatically
1656 $http_content_type = isset($_SERVER['HTTP_CONTENT_TYPE']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_CONTENT_TYPE'])) : '';
1657 $content_type = isset($_SERVER['CONTENT_TYPE']) ? sanitize_text_field(wp_unslash($_SERVER['CONTENT_TYPE'])) : '';
1658 if ((!empty($http_content_type) || !empty($content_type)) && [] === $_POST) {
1659 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Required for reading php://input stream
1660 $raw_post_string = file_get_contents('php://input');
1661 parse_str($raw_post_string, wp_slimstat::$raw_post_array);
1662
1663 // Sanitize the action key from the raw body before using it
1664 if (!empty(wp_slimstat::$raw_post_array['action'])) {
1665 wp_slimstat::$raw_post_array['action'] = sanitize_key(
1666 wp_unslash(wp_slimstat::$raw_post_array['action'])
1667 );
1668 }
1669 } elseif ([] !== $_POST) {
1670 wp_slimstat::$raw_post_array = $_POST;
1671 }
1672
1673 // Init the Ajax listener
1674 if (!empty(wp_slimstat::$raw_post_array['action']) && 'slimtrack' == wp_slimstat::$raw_post_array['action']) {
1675
1676 // This is needed because admin-ajax.php is reading $_REQUEST to fire the corresponding action
1677 // Use a hardcoded literal instead of passing the user-supplied value
1678 if (empty($_POST['action'])) {
1679 $_POST['action'] = 'slimtrack';
1680 }
1681
1682 add_action('wp_ajax_nopriv_slimtrack', [\SlimStat\Tracker\Ajax::class, 'handle']);
1683 add_action('wp_ajax_slimtrack', [\SlimStat\Tracker\Ajax::class, 'handle']);
1684 }
1685
1686
1687 // 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.
1688 if (is_admin()) {
1689 include_once(plugin_dir_path(__FILE__) . 'admin/index.php');
1690 register_activation_hook(__FILE__, ['wp_slimstat_admin', 'init_environment']);
1691 register_deactivation_hook(__FILE__, ['wp_slimstat_admin', 'deactivate']);
1692 }
1693
1694 add_action('widgets_init', ['wp_slimstat', 'register_widget']);
1695
1696 // Load textdomain at init (required by WordPress 6.7.0+)
1697 add_action('init', ['wp_slimstat', 'load_textdomain'], 1);
1698
1699 // Add the appropriate actions
1700 add_action('plugins_loaded', ['wp_slimstat', 'init'], 20);
1701 // Add the action to fetch chart data
1702 add_action('wp_ajax_slimstat_fetch_chart_data', [\SlimStat\Modules\Chart::class, 'ajaxFetchChartData']);
1703 }
1704
1705 add_action('wp_ajax_slimstat_clear_cache', 'wp_slimstat_clear_cache_handler');
1706
1707 function wp_slimstat_clear_cache_handler()
1708 {
1709 if (!current_user_can('manage_options')) {
1710 wp_send_json_error(__('Permission denied', 'wp-slimstat'));
1711 }
1712 // Optional: check nonce if you add it to JS
1713 if (empty($_POST['security']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['security'])), 'slimstat_clear_cache')) {
1714 wp_send_json_error(__('Invalid nonce', 'wp-slimstat'));
1715 }
1716
1717 global $wpdb;
1718 $transients = $wpdb->get_col(
1719 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)
1720 );
1721 $count = 0;
1722 foreach ($transients as $transient) {
1723 delete_option($transient);
1724 $count++;
1725 }
1726 wp_send_json_success(sprintf(__('Slimstat cache cleared (%d items)', 'wp-slimstat'), $count));
1727 }
1728