= 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] = '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('