= 80000. // Skipping this load on PHP 7.4 is what produced the v5.4.14 wp-admin fatal. require_once __DIR__ . '/src/Dependencies/Symfony/Polyfill/Php80/bootstrap.php'; // Include Constants.php to make SLIMSTAT_ANALYTICS_DIR available to traits require_once __DIR__ . '/src/Constants.php'; /** * Main Slimstat Analytics Class * * @package Wp_SlimStat * * @todo REFACTOR TRACKING STATE: The $data_js and $stat properties should be refactored into a * proper state object pattern to maintain encapsulation. Currently these properties are * public to support refactored tracker classes (SlimStat\Tracker\*), but this breaks * encapsulation and creates security risks. Future implementation should: * 1. Create a TrackingState class to encapsulate state management * 2. Update all Tracker classes to use the state object * 3. Make properties protected or private * 4. Ensure all state modifications go through validated methods * This is tracked as technical debt for version 6.0 */ // Include Constants.php to make SLIMSTAT_ANALYTICS_DIR available to traits require_once __DIR__ . '/src/Constants.php'; class wp_slimstat { public static $settings = []; public static $wpdb; public static $upload_dir = ''; /** * Flag indicating programmatic (server-side) tracking is active. * * When true, CMP consent checks are bypassed in Consent::canTrack() and * Consent::piiAllowed(). This is used by slimtrack_server() for server-side * contexts (cron, CLI, redirect handlers) where no browser session exists. * * DNT headers, IP anonymization/hashing, and other non-consent settings * remain enforced. * * @var bool * @since 5.4.3 */ public static $is_programmatic_tracking = false; public static $update_checker = []; public static $raw_post_array = []; /** * @var array Tracking data from JavaScript (for internal tracking use only) * @internal Use get_data_js() / set_data_js() methods for controlled access. * * This property is now protected to maintain proper encapsulation and prevent external code * from bypassing consent checks or corrupting tracking state. All tracker classes use the * getter/setter methods which include validation and filter hooks for GDPR compliance. */ protected static $data_js = ['id' => 0]; /** * @var array Current pageview tracking data (for internal tracking use only) * @internal Use get_stat() / set_stat() methods for controlled access. * * This property is now protected to maintain proper encapsulation and prevent external code * from bypassing consent checks or corrupting tracking state. All tracker classes use the * getter/setter methods which include validation and filter hooks for GDPR compliance. */ protected static $stat = []; protected static $date_i18n_filters = []; /** * Gets the current data_js array (for internal tracking use only) * * @return array */ public static function get_data_js() { return self::$data_js; } /** * Sets the data_js array (for internal tracking use only) * * This method provides controlled access to the data_js property and includes * basic validation to prevent tampering. * * @param array $data_js The tracking data from JavaScript * @return void * @internal For use by SlimStat tracking classes only */ public static function set_data_js($data_js) { // Validate that we're receiving an array if (!is_array($data_js)) { return; } // Apply filter to allow validation/modification by consent management systems $data_js = apply_filters('slimstat_set_data_js', $data_js); self::$data_js = $data_js; } /** * Gets the current stat array (for internal tracking use only) * * @return array Current tracking state * @internal For use by SlimStat tracking classes only */ public static function get_stat() { return self::$stat; } /** * Sets the stat array (for internal tracking use only) * * This method provides controlled access to the stat property and includes * basic validation to prevent tampering and ensure consent compliance. * * @param array $stat The pageview tracking data * @return void * @internal For use by SlimStat tracking classes only */ public static function set_stat($stat) { // Validate that we're receiving an array if (!is_array($stat)) { return; } // Apply filter to allow validation/modification by consent management systems // This is critical for GDPR compliance - CMPs can inspect and modify data $stat = apply_filters('slimstat_set_stat', $stat); self::$stat = $stat; } /** * Backward-compatible wrapper for the tracking API. * * This method delegates to the new namespaced Tracker class while maintaining * the original method signature for third-party integrations. * * @since 5.4.3 * @return int|false The record ID on success, or a negative error code on failure. */ public static function slimtrack() { return \SlimStat\Tracker\Tracker::slimtrack(); } /** * Server-side tracking API that bypasses CMP consent checks. * * Use this method for programmatic tracking in server-side contexts where no * browser session exists (e.g., cron jobs, CLI scripts, redirect handlers). * * CMP consent is a browser-side concept. In server-side contexts, there is no * browser session and CMP consent has no meaningful role. * * The following settings remain enforced: * - DNT (Do Not Track) headers * - IP anonymization and hashing settings * - Tracker cookie configuration * - All exclusion rules * * @since 5.4.3 * @return int|false The record ID on success, or a negative error code on failure. */ public static function slimtrack_server() { $previous_programmatic_state = self::$is_programmatic_tracking; self::$is_programmatic_tracking = true; try { $result = \SlimStat\Tracker\Tracker::slimtrack(); } finally { self::$is_programmatic_tracking = $previous_programmatic_state; } return $result; } /** * Initializes variables and actions */ public static function init() { \SlimStat\Providers\RestApiManager::run(); // Load all the settings if (is_network_admin() && (empty($_GET['page']) || false === strpos($_GET['page'], 'slimview'))) { self::$settings = get_site_option('slimstat_options', []); } else { self::$settings = get_option('slimstat_options', []); } if (empty(self::$settings)) { // Fresh install: set defaults including geolocation_provider=dbip self::$settings = self::get_fresh_defaults(); self::update_option('slimstat_options', self::$settings); } self::$settings = array_merge(self::init_options(), self::$settings); // One-shot migration: runs once on first boot after installing this build. // '_migration_5460' is absent from all pre-5.4.6 installs; array_merge fills it // with '0' from init_options(). After running, the flag stores the version that ran it. // On downgrade→re-upgrade, the stored version will differ from SLIMSTAT_ANALYTICS_VERSION, // allowing the migration to re-run if needed. '0' = never ran, version string = ran. $_migration_ran = self::$settings['_migration_5460'] ?? '0'; if ('0' === $_migration_ran || (is_string($_migration_ran) && '0' !== $_migration_ran && version_compare($_migration_ran, SLIMSTAT_ANALYTICS_VERSION, '<'))) { // --- Consent intent detection --- // Read legacy v5.3.x consent settings to detect if user had configured privacy. // These survive through v5.3.x → v5.4.x upgrades because array_merge preserves DB values. $_had_opt_out_banner = ('on' === (self::$settings['display_opt_out'] ?? 'no')); $_had_opt_out_cookies = !empty(trim(self::$settings['opt_out_cookie_names'] ?? '')); $_had_opt_in_cookies = !empty(trim(self::$settings['opt_in_cookie_names'] ?? '')); // Check if user deliberately chose a third-party CMP in v5.4.x $_current_integration = self::$settings['consent_integration'] ?? ''; $_has_third_party_cmp = in_array($_current_integration, ['wp_consent_api', 'real_cookie_banner'], true); if ($_has_third_party_cmp) { // User deliberately configured a third-party CMP — preserve their setup self::$settings['gdpr_enabled'] = 'on'; } elseif ($_had_opt_out_banner || $_had_opt_out_cookies || $_had_opt_in_cookies) { // User had consent/privacy config in v5.3.x — map to GDPR system self::$settings['gdpr_enabled'] = 'on'; self::$settings['use_slimstat_banner'] = 'on'; // Auto-detect best CMP: if opt-in cookies were set (third-party plugin) // and WP Consent API is installed, use it. Otherwise use SlimStat Banner. if ($_had_opt_in_cookies && function_exists('wp_has_consent')) { self::$settings['consent_integration'] = 'wp_consent_api'; } else { self::$settings['consent_integration'] = 'slimstat_banner'; } } else { // No consent config ever — pure v5.3.x behavior: all tracked, no banner self::$settings['gdpr_enabled'] = 'off'; self::$settings['consent_integration'] = ''; self::$settings['use_slimstat_banner'] = 'off'; } unset($_had_opt_out_banner, $_had_opt_out_cookies, $_had_opt_in_cookies, $_current_integration, $_has_third_party_cmp); // One-time resets for settings broken by v5.4.0-5.4.6 defaults. // Gated on < 5.4.7 so future upgrades (5.4.8+) don't override admin choices. // Skip for fresh installs ('0' = never ran, no broken settings to fix). if ('0' !== $_migration_ran && version_compare($_migration_ran, '5.4.7', '<')) { // Restore session cookie — Consent::piiAllowed() in Session.php gates // the actual setcookie() call at runtime, not this setting. if ('off' === (self::$settings['set_tracker_cookie'] ?? 'on')) { self::$settings['set_tracker_cookie'] = 'on'; } // javascript_mode='off' baked a stale per-visitor stat ID into cached HTML. // Always reset — server-side mode was a v5.4.0 default, not a user choice. if ('off' === (self::$settings['javascript_mode'] ?? 'on')) { self::$settings['javascript_mode'] = 'on'; } // anonymize_ip='on' and hash_ip='on' were v5.4.1 defaults that changed IP storage. $_ss_ip_was_anonymized = ('on' === (self::$settings['anonymize_ip'] ?? 'off')); $_ss_ip_was_hashed = ('on' === (self::$settings['hash_ip'] ?? 'off')); if ($_ss_ip_was_anonymized) { self::$settings['anonymize_ip'] = 'off'; } if ($_ss_ip_was_hashed) { self::$settings['hash_ip'] = 'off'; } if ($_ss_ip_was_anonymized || $_ss_ip_was_hashed) { set_transient('slimstat_migration_5460_ip_notice', '1', 7 * DAY_IN_SECONDS); } } unset($_ss_ip_was_anonymized, $_ss_ip_was_hashed); // Mark done — store the version so downgrade→re-upgrade can re-trigger if needed. self::$settings['_migration_5460'] = SLIMSTAT_ANALYTICS_VERSION; self::update_option('slimstat_options', self::$settings); // Rewrite rules are flushed via two other paths: // 1. Activation hook: admin/index.php init_environment() calls flush_rewrite_rules() // 2. Settings change: RestApiManager sets 'slimstat_permalink_structure_updated' option, // which triggers flush_rewrite_rules() on next init via rewriteRuleRequest() // No flush needed here — doing so during migration (plugins_loaded) would fire before // the rewrite rule is registered on 'init' and waste a DB write. } // Allow third party tools to edit the options self::$settings = apply_filters('slimstat_init_options', self::$settings); // Consent-sync: derive use_slimstat_banner from consent_integration. // Only run when GDPR is on — when off, banner stays off and canTrack() returns true early. if ('on' === (self::$settings['gdpr_enabled'] ?? 'off')) { $consent_integration = self::$settings['consent_integration'] ?? ''; // If WP Consent API is selected but the plugin isn't installed, fall back to // SlimStat's own banner so consent enforcement stays active. Resetting to '' // would leave GDPR on but with no consent mechanism — getIntegrationKey() // silently picks 'slimstat_banner' but without the banner UI enabled. if ('wp_consent_api' === $consent_integration && !function_exists('wp_has_consent')) { $consent_integration = 'slimstat_banner'; self::$settings['consent_integration'] = 'slimstat_banner'; } if ('' === $consent_integration && ('on' === (self::$settings['use_slimstat_banner'] ?? 'off'))) { $consent_integration = 'slimstat_banner'; self::$settings['consent_integration'] = $consent_integration; } if ('slimstat_banner' === $consent_integration) { self::$settings['use_slimstat_banner'] = 'on'; } else { self::$settings['use_slimstat_banner'] = 'off'; } } // end GDPR consent-sync // Allow third-party tools to use a custom database for Slimstat self::$wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']); // Define the folder where to store the geolocation database (shared among sites in a network, by default) if (defined('UPLOADS')) { self::$upload_dir = ABSPATH . UPLOADS . '/wp-slimstat'; } else { $upload_dir_info = wp_upload_dir(); self::$upload_dir = $upload_dir_info['basedir']; // Handle multisite environment if (is_multisite() && !(is_main_network() && is_main_site() && defined('MULTISITE'))) { self::$upload_dir = str_replace('/sites/' . get_current_blog_id(), '', self::$upload_dir); } self::$upload_dir .= '/wp-slimstat'; } // Apply filter to allow customization of the upload directory self::$upload_dir = apply_filters('slimstat_maxmind_path', self::$upload_dir); // Allow add-ons to turn off the tracker based on other conditions. // Exclude internal SlimStat endpoints from server-side tracking so they // don't appear as page visits in the Access Log: // - admin-ajax.php (AJAX tracking handler) // - /request/{hash}/ (adblock bypass tracking endpoint) // - /{hash}.js, /{hash}.css (adblock bypass JS/CSS file serving via Routing.php) $_request_uri = self::get_request_uri(); $_is_internal_endpoint = false !== strpos($_request_uri, 'wp-admin/admin-ajax.php') || (bool) preg_match('#/request/[a-f0-9]{32}/?$|/[a-f0-9]{32}\.(?:js|css)(?:\?|$)#', $_request_uri); $is_tracking_filter = apply_filters('slimstat_filter_pre_tracking', !$_is_internal_endpoint); $is_tracking_filter_js = apply_filters('slimstat_filter_pre_tracking_js', true); unset($_request_uri, $_is_internal_endpoint); // Enable the tracker (both server- and client-side) if ((!is_admin() || 'on' == self::$settings['track_admin_pages']) && 'on' == self::$settings['is_tracking'] && $is_tracking_filter) { // Is server-side tracking active? if ('on' != self::$settings['javascript_mode']) { add_action(is_admin() ? 'admin_init' : 'wp', [\SlimStat\Tracker\Tracker::class, 'slimtrack'], 5); if ('on' != self::$settings['ignore_wp_users']) { add_action('login_init', [\SlimStat\Tracker\Tracker::class, 'slimtrack'], 10); } } // Slimstat tracks screen resolutions, outbound links and other client-side information using a client-side tracker add_action(is_admin() ? 'admin_enqueue_scripts' : 'wp_enqueue_scripts', [self::class, 'enqueue_tracker'], 15); if ('on' != self::$settings['ignore_wp_users']) { add_action('login_enqueue_scripts', [self::class, 'enqueue_tracker'], 10); } add_filter('script_loader_tag', [self::class, 'add_defer_to_script_tag'], 10, 2); } $banner_enabled = ('on' === (self::$settings['gdpr_enabled'] ?? 'off')) && ('on' === (self::$settings['use_slimstat_banner'] ?? 'off')); if ($banner_enabled) { add_action('wp_enqueue_scripts', [self::class, 'enqueue_gdpr_assets'], 20); add_action('login_enqueue_scripts', [self::class, 'enqueue_gdpr_assets'], 20); add_action('wp_footer', [self::class, 'render_gdpr_banner'], 5); add_action('login_footer', [self::class, 'render_gdpr_banner'], 5); } // Registers Slimstat with WP Consent API if enabled in plugin settings if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') { // Check if WP Consent API plugin is actually active if (function_exists('wp_has_consent')) { $plugin = plugin_basename(SLIMSTAT_FILE); add_filter("wp_consent_api_registered_{$plugin}", '__return_true'); // Register cookie info with WP Consent API for CMP display. // Deferred to 'init' (priority 10) so the textdomain is loaded first // (load_textdomain runs on 'init' priority 1). Calling __() here would // trigger a _load_textdomain_just_in_time notice in WordPress 6.7+. if (function_exists('wp_add_cookie_info')) { $session_duration = intval(self::$settings['session_duration'] ?? 1800); add_action('init', static function () use ($session_duration) { wp_add_cookie_info( 'slimstat_tracking_code', __('SlimStat Analytics', 'wp-slimstat'), 'statistics', sprintf( /* translators: %d: number of seconds for session duration */ _n('%d second', '%d seconds', $session_duration, 'wp-slimstat'), $session_duration ), __('Session cookie that identifies returning visitors for analytics.', 'wp-slimstat'), '', false, false ); }, 10); } } } // Register WordPress Privacy API exporters and erasers (GDPR Article 15 & 17) add_filter('wp_privacy_personal_data_exporters', [\SlimStat\Services\Privacy\DataExporter::class, 'registerExporters']); add_filter('wp_privacy_personal_data_erasers', [\SlimStat\Services\Privacy\DataEraser::class, 'registerErasers']); // Register privacy policy content add_action('admin_init', [self::class, 'registerPrivacyPolicyContent']); // One-time notice when the v5.4.6 migration reset IP anonymization settings add_action('admin_notices', [self::class, 'show_migration_5460_ip_notice']); // Register AJAX handlers for consent upgrade/revocation (anonymous tracking mode) \SlimStat\Services\Privacy\ConsentHandler::registerAjaxHandlers(); // Hook a DB clean-up routine to the daily cronjob add_action('wp_slimstat_purge', [self::class, 'wp_slimstat_purge']); // Hook IP hashing daily salt generation (for GDPR compliance) add_action('wp_slimstat_generate_daily_salt', [\SlimStat\Providers\IPHashProvider::class, 'generateDailySalt']); // Hook a GeoIP database update routine to the daily cronjob add_action('wp_slimstat_update_geoip_database', [self::class, 'wp_slimstat_update_geoip_database']); // Allow external domains on CORS requests add_filter('allowed_http_origins', [self::class, 'open_cors_admin_ajax']); // Internal GDPR banner/consent handling removed. Use external CMP plugins. // If this request was a redirect, we should update the content type accordingly add_filter('wp_redirect_status', [\SlimStat\Tracker\Tracker::class, 'update_content_type'], 10, 2); // Shortcodes add_shortcode('slimstat', [self::class, 'slimstat_shortcode'], 15); // Init the plugin functionality add_action('init', [self::class, 'init_plugin']); // REST API Support add_action('rest_api_init', [self::class, 'register_rest_route']); // Load the admin library if (is_user_logged_in()) { include_once(plugin_dir_path(__FILE__) . 'admin/index.php'); add_action('init', ['wp_slimstat_admin', 'init'], 60); } } // end init /** * Load plugin textdomain * * @return void */ public static function load_textdomain() { load_plugin_textdomain('wp-slimstat', false, '/wp-slimstat/languages'); } /** * Show a one-time admin notice when the v5.4.6 migration reset anonymize_ip * or hash_ip from 'on' to 'off'. EU-facing sites may need to re-enable these. * The transient is deleted after display so the notice appears exactly once. */ public static function show_migration_5460_ip_notice(): void { if (!current_user_can('manage_options')) { return; } if (!get_transient('slimstat_migration_5460_ip_notice')) { return; } delete_transient('slimstat_migration_5460_ip_notice'); $settings_url = admin_url('admin.php?page=slimconfig&tab=2'); ?>


 

'', // recent, popular, count, widget 'w' => '', // column to use (for recent, popular and count) or widget to use 's' => ' ', // separator 'o' => 0, // offset for counters ], $_attributes); $f = $_attributes['f'] ?? ''; $w = $_attributes['w'] ?? ''; $s = $_attributes['s'] ?? ''; $o = $_attributes['o'] ?? 0; $output = ''; $where = ''; $as_column = ''; $s = sprintf("%s", $s); // Look for required fields if (empty($f) || empty($w)) { return ''; } // Validation the parameter w $w = (string) $w; if (false === in_array($w, ['*', 'count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt', 'username', 'post_link', 'ip', 'id', 'searchterms', 'username', 'resource', 'country', 'browser', 'platform', 'language', 'slim_p1_01', 'slim_p1_03', 'slim_p1_04', 'slim_p1_06', 'slim_p1_08', 'slim_p1_10', 'slim_p1_11', 'slim_p1_12', 'slim_p1_13', 'slim_p1_15', 'slim_p1_17', 'slim_p1_18', 'slim_p1_19_01', 'slim_p2_01', 'slim_p2_02', 'slim_p2_03', 'slim_p2_04', 'slim_p2_05', 'slim_p2_06', 'slim_p2_07', 'slim_p2_08', 'slim_p2_12', 'slim_p2_13', 'slim_p2_14', 'slim_p2_15', 'slim_p2_16', 'slim_p2_17', 'slim_p2_18', 'slim_p2_19', 'slim_p2_20', 'slim_p2_21', 'slim_p2_22_01', 'slim_p2_24', 'slim_p2_25', 'slim_p3_01', 'slim_p3_02', 'slim_p4_01', 'slim_p4_02', 'slim_p4_04', 'slim_p4_05', 'slim_p4_06', 'slim_p4_07', 'slim_p4_09', 'slim_p4_10', 'slim_p4_11', 'slim_p4_12', 'slim_p4_13', 'slim_p4_15', 'slim_p4_16', 'slim_p4_18', 'slim_p4_19', 'slim_p4_20', 'slim_p4_21', 'slim_p4_22', 'slim_p4_23', 'slim_p4_24', 'slim_p4_25', 'slim_p4_26_01', 'slim_p4_27', 'slim_p6_01', 'slim_p9_01', 'slim_p9_02', 'slim_p2_23'], true)) { return ''; } // Include the Reports Library, but don't initialize the database, since we will do that separately later include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-reports.php'); wp_slimstat_reports::init(); /** * @SecurityProfile https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-0630 * Disabled because of the report from WP Scan */ // Init the database library with the appropriate filters /*if ( strpos ( $_content, 'WHERE:' ) !== false ) { $where = html_entity_decode( str_replace( 'WHERE:', '', $_content ), ENT_QUOTES, 'UTF-8' ); } else{*/ wp_slimstat_db::init(html_entity_decode($_content, ENT_QUOTES, 'UTF-8')); //} switch ($f) { case 'count': case 'count-all': $output = wp_slimstat_db::count_records($w, $where, false === strpos($f, 'all')) + $o; break; case 'widget': if (empty(wp_slimstat_reports::$reports[$w])) { return __('Invalid Report ID', 'wp-slimstat'); } wp_register_style('wp-slimstat-frontend', plugins_url('/admin/assets/css/slimstat.css', __FILE__), true, SLIMSTAT_ANALYTICS_VERSION); wp_enqueue_style('wp-slimstat-frontend'); wp_slimstat_reports::$reports[$w]['callback_args']['is_widget'] = true; ob_start(); echo wp_slimstat_reports::report_header($w); call_user_func(wp_slimstat_reports::$reports[$w]['callback'], wp_slimstat_reports::$reports[$w]['callback_args']); wp_slimstat_reports::report_footer(); $output = ob_get_contents(); ob_end_clean(); break; case 'recent': case 'recent-all': case 'top': case 'top-all': $function = 'get_' . str_replace('-all', '', $f); if ('*' === $w) { $w = 'id'; } $w = esc_html($w); $w = self::string_to_array($w); // Some columns are 'special' and need be removed from the list $w_clean = array_diff($w, ['count', 'display_name', 'hostname', 'post_link', 'post_link_no_qs', 'dt']); // The special value 'display_name' requires the username to be retrieved if (in_array('display_name', $w)) { $w_clean[] = 'username'; } // The special value 'post_list' requires the resource to be retrieved if (in_array('post_link', $w)) { $w_clean[] = 'resource'; } // The special value 'post_list_no_qs' requires a substring to be calculated if (in_array('post_link_no_qs', $w)) { $w_clean = ['SUBSTRING_INDEX( resource, "' . (get_option('permalink_structure') ? '?' : '&') . '", 1 )']; $as_column = 'resource'; } // Retrieve the data $results = wp_slimstat_db::$function(implode(', ', $w_clean), $where, '', false === strpos($f, 'all'), $as_column); // No data? No problem! if (empty($results)) { return ''; } // Are nice permalinks enabled? $permalinks_enabled = get_option('permalink_structure'); // Format results $output = []; foreach ($results as $result_idx => $a_result) { foreach ($w as $a_column) { $output[$result_idx][$a_column] = sprintf("", $a_column); switch ($a_column) { case 'count': $output[$result_idx][$a_column] .= $a_result['counthits']; break; case 'country': $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('c-' . $a_result[$a_column]); break; case 'display_name': $user_details = get_user_by('login', $a_result['username']); if (!empty($user_details)) { $output[$result_idx][$a_column] .= $user_details->display_name; } else { $output[$result_idx][$a_column] .= $a_result['username']; } break; case 'dt': $output[$result_idx][$a_column] .= date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $a_result['dt']); break; case 'hostname': $output[$result_idx][$a_column] .= self::gethostbyaddr($a_result['ip']); break; case 'language': $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string('l-' . $a_result[$a_column]); break; case 'platform': $output[$result_idx][$a_column] .= wp_slimstat_i18n::get_string($a_result[$a_column]); break; case 'post_link': case 'post_link_no_qs': $post_id = url_to_postid($a_result['resource']); if ($post_id > 0) { $output[$result_idx][$a_column] .= sprintf("", esc_url( $a_result[ 'resource' ] )) . esc_html( get_the_title($post_id) ) . ''; } else { $output[$result_idx][$a_column] .= sprintf("%s", esc_url( $a_result[ 'resource' ] ), esc_html( $a_result[ 'resource' ] )); } break; default: $output[$result_idx][$a_column] .= $a_result[$a_column] ?? ''; break; } $output[$result_idx][$a_column] .= ''; } $output[$result_idx] = '
  • ' . implode($s, $output[$result_idx]) . '
  • '; } $output = ''; break; default: break; } return $output; } // end slimstat_shortcode public static function init_plugin() { // Include our browser detector library \SlimStat\Services\Browscap::init(); // Make sure the upload directory is exist and is protected. self::create_upload_directory(); // Ensure daily salt exists for IP hashing (GDPR compliance) // This runs on every page load but only generates if missing \SlimStat\Providers\IPHashProvider::generateDailySalt(); // Initialize adblock bypass functionality \SlimStat\Tracker\Tracker::rewrite_rule_tracker(); add_action('template_redirect', [\SlimStat\Tracker\Tracker::class, 'adblocker_javascript']); add_action('init', [\SlimStat\Tracker\Tracker::class, 'rewrite_rule_tracker']); } /** * Opens given domains during CORS requests to admin-ajax.php */ public static function open_cors_admin_ajax($_allowed_origins = []) { $exploded_domains = self::string_to_array(self::$settings['external_domains']); if (!empty($exploded_domains) && !empty($exploded_domains[0])) { $_allowed_origins = array_merge($_allowed_origins, $exploded_domains); } return $_allowed_origins; } // end open_cors_admin_ajax /** * Implements a REST API interface to retrieve Slimstat reports and metrics */ public static function rest_api_response($_request = []) { $filters = ''; if (!empty($_request['filters'])) { $filters = $_request['filters']; } if (empty($_request['dimension'])) { return new WP_Error('rest_invalid', esc_html__('[REST API] The dimension parameter is required. Please review your request and try again.', 'wp-slimstat'), ['status' => 400]); } if (empty($_request['function'])) { return new WP_Error('rest_invalid', esc_html__('[REST API] The function parameter is required. Please review your request and try again.', 'wp-slimstat'), ['status' => 400]); } include_once(plugin_dir_path(__FILE__) . 'admin/view/wp-slimstat-db.php'); wp_slimstat_db::init($filters); $response = [ 'function' => htmlentities($_request['function'], ENT_QUOTES, 'UTF-8'), 'dimension' => htmlentities($_request['dimension'], ENT_QUOTES, 'UTF-8'), 'data' => 0, ]; switch ($_request['function']) { case 'count': case 'count-all': $response['data'] = wp_slimstat_db::count_records($_request['dimension'], '', false === strpos($_request['function'], '-all')); break; case 'recent': case 'recent-all': case 'top': case 'top-all': $function = 'get_' . str_replace('-all', '', $_request['function']); // Retrieve the data $response['data'] = array_values(wp_slimstat_db::$function($_request['dimension'], '', '', false === strpos($_request['function'], '-all'))); break; default: // This should never happen, because of the 'enum' condition for this parameter. But never say never... $response['data'] = new WP_Error('rest_invalid', esc_html__('[REST API] You sent an invalid request. Accepted function values include: count, count-all, recent, recent-all, top and top-all. Please review your request and try again.', 'wp-slimstat'), ['status' => 400]); break; } return rest_ensure_response($response); } // end rest_api_response /** * Implements a REST API authentication mechanism via token */ public static function rest_api_authorization($_request = []) { if (empty($_request['token'])) { 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]); } $valid_tokens = self::string_to_array(self::$settings['rest_api_tokens']); foreach ($valid_tokens as $valid_token) { if (is_string($valid_token) && is_string($_request['token']) && hash_equals($valid_token, $_request['token'])) { return true; } } return false; } // end rest_api_authorization /** * Registers a new REST API route for the Slimstat endpoint */ public static function register_rest_route() { register_rest_route('slimstat/v1', '/get', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [self::class, 'rest_api_response'], 'permission_callback' => [self::class, 'rest_api_authorization'], 'args' => [ 'token' => [ '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'), 'type' => 'string', ], 'function' => [ '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'), 'type' => 'string', 'enum' => ['count', 'count-all', 'recent', 'recent-all', 'top', 'top-all'], ], 'dimension' => [ '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'), 'type' => 'string', 'enum' => ['*', 'id', 'ip', 'username', 'email', 'country', 'referer', 'resource', 'searchterms', 'browser', 'platform', 'language', 'resolution', 'content_type', 'content_id', 'tz_offset', 'outbound_resource'], ], 'filters' => [ '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'), 'type' => 'string', ], ], ]); } // end register_rest_route /** * Converts a series of comma separated values into an array */ public static function string_to_array($_option = '') { if (empty($_option) || !is_string($_option)) { return []; } else { return array_filter(array_map('trim', explode(',', $_option))); } } // end string_to_array /** * Returns Matomo search engine mapping JSON, cached. */ public static function get_search_engines() { static $cached_search_engines = null; if (null !== $cached_search_engines) { return $cached_search_engines; } $data = get_transient('slimstat_matomo_searchengine'); if (false === $data) { $json_path = plugin_dir_path(__FILE__) . 'admin/assets/data/matomo-searchengine.json'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin file, WP_Filesystem not needed $json = @file_get_contents($json_path); $data = json_decode($json, true); if (!is_array($data)) { $data = []; } set_transient('slimstat_matomo_searchengine', $data, WEEK_IN_SECONDS); } $cached_search_engines = $data; return $cached_search_engines; } // end get_search_engines /** * Toggles WordPress filters on date_i18n function */ public static function toggle_date_i18n_filters($_turn_on = true) { if ($_turn_on && !empty(self::$date_i18n_filters) && is_array(self::$date_i18n_filters)) { foreach (self::$date_i18n_filters as $i18n_priority => $i18n_func_list) { foreach ($i18n_func_list as $func_args) { if (!empty($func_args['function']) && is_string($func_args['function'])) { add_filter('date_i8n', $func_args['function'], $i18n_priority, intval($func_args['accepted_args'])); } } } } elseif (!empty($GLOBALS['wp_filter']['date_i18n']['callbacks']) && is_array($GLOBALS['wp_filter']['date_i18n']['callbacks'])) { self::$date_i18n_filters = $GLOBALS['wp_filter']['date_i18n']['callbacks']; remove_all_filters('date_i18n'); } } // end toggle_date_i18n_filters /** * Calls the date_i18n function without filters */ public static function date_i18n($_format) { self::toggle_date_i18n_filters(false); $date = date_i18n($_format); self::toggle_date_i18n_filters(true); return $date; } // end date_i18n /** * Returns the current timestamp in the same format stored in the dt column. * MUST be used by all queries that compare against dt values. * * WordPress date_i18n('U') returns current_time('timestamp') — a legacy * quirk where 'U' format includes the site's GMT offset. This matches * how Processor::process() stores $stat['dt'] via self::date_i18n('U'). * * @since 5.4.7 * @return int Current timestamp matching dt column format */ public static function now(): int { return (int) self::date_i18n('U'); } /** * Returns default options with geolocation_provider set for fresh installs and resets. * * geolocation_provider is excluded from init_options() because init() merges * those defaults into stored settings — which would override the legacy * enable_maxmind flag on upgraded installs before lazy migration runs. * * Fresh installs default to DB-IP (free, no license key required). */ public static function get_fresh_defaults() { $defaults = self::init_options(); $defaults['geolocation_provider'] = 'dbip'; return $defaults; } /** * Returns the current geolocation precision ('country' or 'city'). */ public static function get_geolocation_precision() { return ('on' == self::$settings['geolocation_country']) ? 'country' : 'city'; } /** * Sets the default values for all the options */ public static function init_options() { return [ 'version' => SLIMSTAT_ANALYTICS_VERSION, '_migration_5460' => '0', // one-shot: reset broken v5.4.1 defaults on first boot after this build 'secret' => wp_hash(wp_generate_password(64, true, true)), 'browscap_last_modified' => 0, // General // ----------------------------------------------------------------------- // General - Tracker 'is_tracking' => 'on', 'track_admin_pages' => 'no', 'javascript_mode' => 'on', // Client mode: works with all caching plugins (WP Rocket, W3TC, etc.) // General - WordPress Integration 'add_dashboard_widgets' => 'on', 'use_separate_menu' => 'on', 'add_posts_column' => 'no', 'posts_column_pageviews' => 'on', 'display_notifications' => 'on', // General - Database 'auto_purge' => 420, 'auto_purge_delete' => 'on', // Tracker // ----------------------------------------------------------------------- // Tracker - Data Protection // anonymize_ip: mask IP before storing; hash_ip: generate daily visitor_id based on masked IP + UA 'gdpr_enabled' => 'off', // v5.3.x had no GDPR — off by default; admin enables when ready 'anonymize_ip' => 'off', // Restored: full IPs stored by default (5.3.x behavior) 'hash_ip' => 'off', // Restored: no daily visitor hash by default (5.3.x behavior) 'set_tracker_cookie' => 'on', // v5.3.x default: session cookie identifies returning visitors 'use_slimstat_banner' => 'off', // Admin must explicitly enable via consent integration 'consent_integration' => '', // No CMP by default — admin selects when enabling GDPR 'consent_level_integration'=> 'statistics', 'opt_out_message' => '', 'gdpr_accept_button_text' => 'Accept', 'gdpr_decline_button_text' => 'Decline', 'gdpr_theme_mode' => 'auto', // 'light', 'dark', 'auto' 'anonymous_tracking' => 'off', // Changed: Enable anonymous tracking by default 'do_not_track' => 'off', 'display_opt_out' => 'no', 'opt_out_cookie_names' => '', 'opt_in_cookie_names' => '', // Tracker - Link Tracking 'track_same_domain_referers' => 'no', 'do_not_track_outbound_classes_rel_href' => 'noslimstat,ab-item', 'extensions_to_track' => 'pdf,doc,xls,zip', // Tracker - Advanced Options // NOTE: geolocation_provider is intentionally NOT in init_options(). // init() merges these defaults into stored settings, which would override // the legacy enable_maxmind flag on upgraded installs before lazy migration runs. // Use get_fresh_defaults() for new installs and settings reset. 'geolocation_country' => 'on', 'session_duration' => 1800, 'extend_session' => 'no', 'enable_cdn' => 'no', 'ajax_relative_path' => 'no', // Tracker - External Pages 'external_domains' => '', // Reports // ----------------------------------------------------------------------- // Reports - Functionality 'use_current_month_timespan' => 'no', 'posts_column_day_interval' => 28, 'rows_to_show' => '20', 'ip_lookup_service' => 'https://ip-api.com/#', 'comparison_chart' => 'on', 'show_display_name' => 'no', 'convert_resource_urls_to_titles' => 'on', 'convert_ip_addresses' => 'no', // Reports - Access Log and World Map 'refresh_interval' => '60', 'number_results_raw_data' => '50', 'max_dots_on_map' => '50', // Reports - Miscellaneous 'custom_css' => '', 'chart_colors' => '', 'mozcom_access_id' => '', 'mozcom_secret_key' => '', 'show_complete_user_agent_tooltip' => 'no', 'async_load' => 'no', 'limit_results' => '200', 'enable_sov' => 'no', // Exclusions // ----------------------------------------------------------------------- // Exclusions - User Properties 'ignore_wp_users' => 'no', 'ignore_spammers' => 'on', 'ignore_bots' => 'no', 'ignore_prefetch' => 'on', 'ignore_users' => '', 'ignore_ip' => '', 'ignore_countries' => '', 'ignore_languages' => '', 'ignore_browsers' => '', 'ignore_platforms' => '', 'ignore_capabilities' => '', // Exclusions - Page Properties 'ignore_resources' => '', 'ignore_referers' => '', 'ignore_content_types' => '', // Access Control // ----------------------------------------------------------------------- // Access Control - Reports 'restrict_authors_view' => 'on', 'capability_can_view' => 'manage_options', 'can_view' => '', // Access Control - Reports 'tracking_request_method' => 'ajax', // Access Control - Customizer 'capability_can_customize' => 'manage_options', 'can_customize' => '', // Access Control - Settings 'capability_can_admin' => 'manage_options', 'can_admin' => '', // Access Control - REST API 'rest_api_tokens' => wp_hash(wp_generate_password(64, true, true)), // Maintenance // ----------------------------------------------------------------------- 'last_tracker_error' => [0, '', 0], 'show_sql_debug' => 'no', 'slimstat_debug' => 'off', 'db_indexes' => 'on', 'enable_maxmind' => 'disable', 'maxmind_license_key' => '', 'enable_browscap' => 'no', // Notices // ----------------------------------------------------------------------- 'notice_latest_news' => 'on', 'notice_browscap' => 'on', 'notice_browscap_fileinfo' => 'on', 'notice_geolite' => 'on', 'notice_caching' => 'on', // Network-wide Settings 'locked_options' => '', ]; } // end init_options /** * Saves a given option in the database */ public static function update_option($_key = '', $_value = '') { if (!is_network_admin()) { update_option($_key, $_value); } else { update_site_option($_key, $_value); } } // end update_option /** * Attach a script to every page to track visitors' screen resolution and other browser-based information */ public static function enqueue_tracker() { // Use the new unified tracking method setting $method = self::$settings['tracking_request_method'] ?? 'rest'; // Handle legacy 'adblock' value (renamed to 'adblock_bypass' in v5.3.0) if ( 'adblock' === $method ) { $method = 'adblock_bypass'; } // Prepare URLs for all methods $rest_url = rest_url('slimstat/v1/hit'); $rest_base_url = rest_url(); // Mirror WordPress core's non-pretty REST routing so query fallback still works // on index-permalink and subdirectory installs. $rest_query_base = trailingslashit(get_home_url(null, '', 'rest')); if ('index.php' !== substr(untrailingslashit($rest_query_base), -9)) { $rest_query_base .= 'index.php'; } $rest_query_url = add_query_arg('rest_route', '/slimstat/v1/hit', $rest_query_base); $ajax_url = admin_url('admin-ajax.php'); $ajax_url_relative = admin_url('admin-ajax.php', 'relative'); $params = [ 'transport' => $method, 'ajaxurl_rest' => $rest_url, 'ajaxurl_rest_query' => $rest_query_url, 'resturl' => $rest_base_url, 'ajaxurl_ajax' => ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url, ]; // Only provide adblock bypass URL when the rewrite rule is active. // The rewrite rule is only registered for 'adblock_bypass' transport, // so this URL would 404 for other transports — a dead fallback. if ('adblock_bypass' === $method) { $adblock_hash = \SlimStat\Providers\RestApiManager::getSecureAdblockHash(); $params['ajaxurl_adblock'] = home_url(sprintf('request/%s/', $adblock_hash)); } // Set the primary ajaxurl based on the selected method if ('rest' === $method) { $params['ajaxurl'] = $rest_url; } elseif ('ajax' === $method) { $params['ajaxurl'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url; } elseif ('adblock_bypass' === $method) { $params['ajaxurl'] = $params['ajaxurl_adblock']; // Also set transport to 'adblock_bypass' for JS clarity $params['transport'] = 'adblock_bypass'; } else { $params['ajaxurl'] = $rest_url; } $baseurl = parse_url(get_home_url()); $params['baseurl'] = empty($baseurl['path']) ? '/' : $baseurl['path']; if (!empty(self::$settings['do_not_track_outbound_classes_rel_href'])) { $params['dnt'] = str_replace(' ', '', self::$settings['do_not_track_outbound_classes_rel_href']); } // Internal GDPR banner is optionally available alongside CMP integrations. if ('on' != self::$settings['javascript_mode']) { if (empty(self::$stat['id']) || intval(self::$stat['id']) < 0) { return false; } $params['id'] = \SlimStat\Tracker\Utils::getValueWithChecksum(intval(self::$stat['id'])); } else { $params['ci'] = \SlimStat\Tracker\Utils::getValueWithChecksum(\SlimStat\Tracker\Utils::base64UrlEncode(wp_json_encode(\SlimStat\Tracker\Utils::getContentInfo()))); } // Always generate wp_rest_nonce (needed for consent banner CSRF protection). // The JS uses is_logged_in to decide whether to send it as X-WP-Nonce header. // Anonymous pages: is_logged_in='0' → no header → no 403 on cached pages. // Admin-cached pages: is_logged_in='1' (stale) → sends nonce → may 403 → retry // without nonce (handled by JS retry logic). This is acceptable since most caches // exclude logged-in users, and the retry adds only one extra request. $params['wp_rest_nonce'] = wp_create_nonce('wp_rest'); $params['is_logged_in'] = is_user_logged_in() ? '1' : '0'; // Expose consent/DNT info to client $params['wp_consent_integration'] = (self::$settings['consent_integration'] ?? '') === 'wp_consent_api' ? 'enabled' : 'disabled'; $params['consent_integration'] = self::$settings['consent_integration'] ?? ''; $params['consent_level_integration'] = (self::$settings['consent_level_integration'] ?? 'statistics'); $params['respect_dnt'] = self::$settings['do_not_track'] ?? 'off'; $gdpr_enabled_setting = strtolower((string) (self::$settings['gdpr_enabled'] ?? 'off')); $params['gdpr_enabled'] = in_array($gdpr_enabled_setting, ['off', 'no', 'false', '0'], true) ? 'off' : 'on'; $params['anonymous_tracking'] = self::$settings['anonymous_tracking'] ?? 'off'; $params['anonymize_ip'] = self::$settings['anonymize_ip'] ?? 'no'; $params['hash_ip'] = self::$settings['hash_ip'] ?? 'no'; $params['set_tracker_cookie'] = self::$settings['set_tracker_cookie'] ?? 'on'; // Mirror the same dual-condition guard used by the PHP banner output (lines 305-306): // banner HTML is only rendered when BOTH gdpr_enabled=on AND use_slimstat_banner=on. // If gdpr_enabled is off, the banner DOM never exists — JS must not enter banner-init mode // or it will set a "ran" lock and silently skip _send_pageview for all visitors. $params['use_slimstat_banner'] = ('on' === $params['gdpr_enabled'] && 'on' === (self::$settings['use_slimstat_banner'] ?? 'off')) ? 'on' : 'off'; if ('on' === $params['use_slimstat_banner']) { // Set GDPR consent endpoint based on tracking method if ('rest' === $method) { $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent'); } elseif ('ajax' === $method) { $params['gdpr_consent_endpoint'] = ('on' == self::$settings['ajax_relative_path']) ? $ajax_url_relative : $ajax_url; } elseif ('adblock_bypass' === $method) { $params['gdpr_consent_endpoint'] = $params['ajaxurl_adblock']; } else { $params['gdpr_consent_endpoint'] = rest_url('slimstat/v1/gdpr/consent'); } $params['gdpr_cookie_name'] = \SlimStat\Services\GDPRService::CONSENT_COOKIE_NAME; $params['gdpr_cookie_path'] = defined('COOKIEPATH') ? COOKIEPATH : '/'; $params['gdpr_cookie_domain'] = defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : ''; $params['gdpr_consent_method'] = $method; } if ('on' === self::$settings['slimstat_debug'] || (defined('WP_DEBUG') && WP_DEBUG)) { $params['slimstat_debug'] = 'on'; } $params = apply_filters('slimstat_js_params', $params); // Add dependencies for consent integrations (e.g., WP Consent API) $dependencies = []; if ((self::$settings['consent_integration'] ?? '') === 'wp_consent_api') { // Only add dependency if the WP Consent API script is actually registered if (wp_script_is('wp-consent-api', 'registered') || wp_script_is('wp-consent-api', 'enqueued')) { $dependencies[] = 'wp-consent-api'; } } // Register the correct script for adblock bypass, CDN, or default $local_script_version = SLIMSTAT_ANALYTICS_VERSION; $local_script_path = plugin_dir_path(__FILE__) . 'wp-slimstat.min.js'; if (file_exists($local_script_path)) { $local_script_version .= '.' . filemtime($local_script_path); } if ('adblock_bypass' === $method) { $hash_js = md5(site_url() . 'slimstat'); wp_register_script('wp_slimstat', home_url(sprintf('/%s.js/', $hash_js)), $dependencies, SLIMSTAT_ANALYTICS_VERSION, true); } elseif ('on' == self::$settings['enable_cdn']) { wp_register_script('wp_slimstat', 'https://cdn.jsdelivr.net/wp/wp-slimstat/tags/' . SLIMSTAT_ANALYTICS_VERSION . '/wp-slimstat.min.js', $dependencies, null, true); } else { wp_register_script('wp_slimstat', plugins_url('/wp-slimstat.min.js', __FILE__), $dependencies, $local_script_version, true); } wp_enqueue_script('wp_slimstat'); /** * Registers the 'wp_slimstat' script as an interactivity module if the registration function exists. * * Ensures compatibility with WordPress Interactivity API by registering the script module and its dependencies. */ if (function_exists('wp_interactivity_register_script_module')) { wp_interactivity_register_script_module('wp_slimstat', [ 'name' => 'wp_slimstat', 'dependencies' => [], ]); } wp_localize_script('wp_slimstat', 'SlimStatParams', $params); return null; } // end enqueue_tracker /** * Enqueue assets for the internal SlimStat GDPR banner. * * @return void */ public static function enqueue_gdpr_assets() { if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) { return; } wp_enqueue_style( 'wp_slimstat_gdpr_banner', plugins_url('/assets/css/gdpr-banner.css', __FILE__), [], SLIMSTAT_ANALYTICS_VERSION ); } /** * Render the SlimStat GDPR banner markup. * * @return void */ public static function render_gdpr_banner() { if ('on' !== (self::$settings['use_slimstat_banner'] ?? 'off')) { return; } if (is_admin() && !wp_doing_ajax()) { return; } $gdpr_service = new \SlimStat\Services\GDPRService(self::$settings); $banner_html = $gdpr_service->getBannerHtml(); if ('' === $banner_html) { return; } echo $banner_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized in GDPRService } public static function add_defer_to_script_tag($_tag, $_handle) { if ('wp_slimstat' === $_handle && false === stripos($_tag, 'defer')) { $_tag = str_replace('