Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion admin/config/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@
'type' => 'text',
'before_input_field' => '',
'after_input_field' => '',
'description' => __('Leave empty to use the default "Deny" text.', 'wp-slimstat'),
'description' => __('Leave empty to use the default "Decline" text.', 'wp-slimstat'),
'conditional' => [
'field' => 'gdpr_enabled,consent_integration',
'type' => 'checked,equals',
Expand Down
110 changes: 99 additions & 11 deletions admin/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,13 @@ public static function init_environment()
}
update_option('slimstat_dt_platform_indexed', 'yes');

// --- Add (dt, visit_id) covering index for visitor counter queries ---
$dt_visit_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%sstats_dt_visit_idx'", $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix));
if (empty($dt_visit_index)) {
$my_wpdb->query(sprintf('CREATE INDEX %sstats_dt_visit_idx ON %sslim_stats (dt, visit_id)', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix));
}
update_option('slimstat_dt_visit_indexed', 'yes');

return true;
}

Expand Down Expand Up @@ -584,7 +591,8 @@ public static function init_tables($_wpdb = '')
INDEX {$GLOBALS['wpdb']->prefix}stats_resource_idx( resource( 20 ) ),
INDEX {$GLOBALS['wpdb']->prefix}stats_browser_idx( browser( 10 ) ),
INDEX {$GLOBALS['wpdb']->prefix}stats_searchterms_idx( searchterms( 15 ) ),
INDEX {$GLOBALS['wpdb']->prefix}stats_fingerprint_idx( fingerprint( 20 ) )
INDEX {$GLOBALS['wpdb']->prefix}stats_fingerprint_idx( fingerprint( 20 ) ),
INDEX {$GLOBALS['wpdb']->prefix}stats_dt_visit_idx (dt, visit_id)
) COLLATE utf8_general_ci {$use_innodb}";

// This table will track outbound links (clicks on links to external sites)
Expand Down Expand Up @@ -746,6 +754,28 @@ public static function update_tables_and_options()
wp_slimstat::$settings['use_separate_menu'] = 'on';
}

// --- Updates for version 5.4.3 ---
if (version_compare(wp_slimstat::$settings['version'], '5.4.3', '<')) {
// Add (dt, visit_id) covering index for visitor counter queries
$idx_name = $GLOBALS['wpdb']->prefix . 'stats_dt_visit_idx';
$check = $my_wpdb->get_results(sprintf(
"SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'",
$GLOBALS['wpdb']->prefix, $idx_name
));
if (empty($check)) {
$result = $my_wpdb->query(sprintf(
'CREATE INDEX %s ON %sslim_stats (dt, visit_id)',
$idx_name, $GLOBALS['wpdb']->prefix
));
if ($result !== false) {
update_option('slimstat_dt_visit_indexed', 'yes');
}
// If fails (large table timeout), show_indexes_notice() surfaces a retry button
} else {
update_option('slimstat_dt_visit_indexed', 'yes');
}
}

// Now we can update the version stored in the database
wp_slimstat::$settings['version'] = SLIMSTAT_ANALYTICS_VERSION;
wp_slimstat::$settings['notice_latest_news'] = 'on';
Expand Down Expand Up @@ -1092,9 +1122,9 @@ public static function add_menu_to_adminbar()
$yesterday_start = $today_start - 86400;
$yesterday_end = $today_start - 1;

// Visitors Today (unique IPs)
$visitors_today = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(DISTINCT ip) FROM {$table} WHERE dt >= %d",
// Sessions Today (unique sessions - using visit_id for anonymous/hashed IP compatibility)
$sessions_today = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(DISTINCT visit_id) FROM {$table} WHERE dt >= %d AND visit_id > 0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This silently changes the admin-bar metric from unique visitors to visits. Elsewhere the plugin already defines visit_id as a visit/session counter (admin/view/wp-slimstat-db.php labels it "Visits" and notes that returning visitors are counted multiple times within a day), while unique visitors are still reported via ip. With this change, a returning user who starts multiple sessions in one day will be double-counted under the unchanged Visitors Today label, so the headline analytics number becomes materially misleading.

$today_start
));

Expand All @@ -1104,9 +1134,9 @@ public static function add_menu_to_adminbar()
$today_start
));

// Yesterday's visitors
$visitors_yesterday = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(DISTINCT ip) FROM {$table} WHERE dt BETWEEN %d AND %d",
// Yesterday's sessions (unique sessions - using visit_id for anonymous/hashed IP compatibility)
$sessions_yesterday = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(DISTINCT visit_id) FROM {$table} WHERE dt BETWEEN %d AND %d AND visit_id > 0",
$yesterday_start, $yesterday_end
));

Expand Down Expand Up @@ -1243,12 +1273,12 @@ public static function add_menu_to_adminbar()
. '<span class="slimstat-adminbar__realtime-pulse"></span> '
. esc_html__('Realtime', 'wp-slimstat') . '</div>'
. '</div>'
// Visitors Today (top right)
// Sessions Today (top right)
. '<div class="slimstat-adminbar__stat-card">'
. '<div class="slimstat-adminbar__stat-title">' . esc_html__('Visitors Today', 'wp-slimstat') . '</div>'
. '<div class="slimstat-adminbar__stat-count">' . number_format_i18n($visitors_today) . '</div>'
. '<div class="slimstat-adminbar__stat-title">' . esc_html__('Sessions Today', 'wp-slimstat') . '</div>'
. '<div class="slimstat-adminbar__stat-count">' . number_format_i18n($sessions_today) . '</div>'
. '<div class="slimstat-adminbar__stat-comparison">'
. sprintf(esc_html__('was %s last day', 'wp-slimstat'), number_format_i18n($visitors_yesterday))
. sprintf(esc_html__('was %s last day', 'wp-slimstat'), number_format_i18n($sessions_yesterday))
. '</div></div>'
// Views Today (bottom left) - blur for non-Pro
. '<div class="slimstat-adminbar__stat-card' . $blur_class . '">'
Expand Down Expand Up @@ -2307,6 +2337,11 @@ public static function add_header()
public static function ajax_add_country_dt_index()
{
check_ajax_referer('slimstat_add_country_dt_index');

if (!current_user_can('manage_options')) {
wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
}

global $wpdb;
$table = $wpdb->prefix . 'slim_stats';
$has_index = $wpdb->get_results(sprintf("SHOW INDEX FROM %s WHERE Key_name = 'idx_country_dt'", $table));
Expand All @@ -2331,6 +2366,11 @@ public static function register_country_dt_index_hooks()
public static function ajax_add_dt_screen_index()
{
check_ajax_referer('slimstat_add_dt_screen_index');

if (!current_user_can('manage_options')) {
wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
}

global $wpdb;
$table = $wpdb->prefix . 'slim_stats';
$index_name = 'idx_dt_screen_width_screen_height';
Expand All @@ -2356,6 +2396,11 @@ public static function register_dt_screen_index_hooks()
public static function ajax_add_dt_browser_index()
{
check_ajax_referer('slimstat_add_dt_browser_index');

if (!current_user_can('manage_options')) {
wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
}

global $wpdb;
$table = $wpdb->prefix . 'slim_stats';
$index_name = 'idx_dt_browser_browser_version';
Expand All @@ -2381,6 +2426,11 @@ public static function register_dt_browser_index_hooks()
public static function ajax_add_dt_platform_index()
{
check_ajax_referer('slimstat_add_dt_platform_index');

if (!current_user_can('manage_options')) {
wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
}

global $wpdb;
$table = $wpdb->prefix . 'slim_stats';
$index_name = 'idx_dt_platform';
Expand Down Expand Up @@ -2408,6 +2458,10 @@ public static function ajax_add_dt_out_index()
global $wpdb;
check_ajax_referer('slimstat_add_dt_out_index');

if (!current_user_can('manage_options')) {
wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
}

$table = $wpdb->prefix . 'slim_stats';
$index_name = 'idx_dt_out';
$has_index = $wpdb->get_results(sprintf("SHOW INDEX FROM %s WHERE Key_name = '%s'", $table, $index_name));
Expand All @@ -2424,9 +2478,34 @@ public static function ajax_add_dt_out_index()
wp_send_json_error(__('Unable to add index or it already exists.', 'wp-slimstat'));
}

public static function ajax_add_dt_visit_index()
{
check_ajax_referer('slimstat_add_dt_visit_index');

if (!current_user_can('manage_options')) {
wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
}

global $wpdb;
$table = $wpdb->prefix . 'slim_stats';
$index_name = $wpdb->prefix . 'stats_dt_visit_idx';
$exists = $wpdb->get_results(sprintf("SHOW INDEX FROM %s WHERE Key_name = '%s'", $table, $index_name));
if (!empty($exists)) {
update_option('slimstat_dt_visit_indexed', 'yes');
wp_send_json_success(__('Index already exists.', 'wp-slimstat'));
}
$result = $wpdb->query(sprintf('CREATE INDEX %s ON %s (dt, visit_id)', $index_name, $table));
if (false !== $result) {
update_option('slimstat_dt_visit_indexed', 'yes');
wp_send_json_success(__('Index added successfully.', 'wp-slimstat'));
}
wp_send_json_error(__('Unable to add index.', 'wp-slimstat'));
}

public static function register_dt_out_index_hooks()
{
add_action('wp_ajax_slimstat_add_dt_out_index', [self::class, 'ajax_add_dt_out_index']);
add_action('wp_ajax_slimstat_add_dt_visit_index', [self::class, 'ajax_add_dt_visit_index']);
}

public static function show_indexes_notice()
Expand Down Expand Up @@ -2485,6 +2564,15 @@ public static function show_indexes_notice()
'ajax' => 'slimstat_add_dt_platform_index',
'btn' => __('Apply', 'wp-slimstat'),
],
[
'option' => 'slimstat_dt_visit_indexed',
'id' => 'dt-visit',
'label' => __('Visitor Counter Performance', 'wp-slimstat'),
'desc' => __('Index on <code>dt</code>, <code>visit_id</code>', 'wp-slimstat'),
'key' => $GLOBALS['wpdb']->prefix . 'stats_dt_visit_idx',
'ajax' => 'slimstat_add_dt_visit_index',
'btn' => __('Apply', 'wp-slimstat'),
],
];

$pending = array_filter($indexes, function ($idx) {
Expand Down
2 changes: 1 addition & 1 deletion src/Controllers/Rest/ConsentChangeRestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ private function getPreviousConsentState(): array
} elseif ('wp_consent_api' === $integration_key && function_exists('wp_has_consent')) {
$category = \wp_slimstat::$settings['consent_level_integration'] ?? 'statistics';
try {
if (wp_has_consent($category)) {
if (\SlimStat\Utils\Consent::wpHasConsentSafe($category)) {
$default['statistics'] = 'allow';
}
} catch (\Throwable $e) {
Expand Down
2 changes: 1 addition & 1 deletion src/Controllers/Rest/ConsentHealthRestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public function handle_health_check(\WP_REST_Request $request): \WP_REST_Respons
if (function_exists('wp_has_consent')) {
$category = $settings['consent_level_integration'] ?? 'statistics';
try {
$has_consent = wp_has_consent($category);
$has_consent = \SlimStat\Utils\Consent::wpHasConsentSafe($category);
$health['integrations']['wp_consent_api']['current_consent'] = $has_consent;
} catch (\Throwable $e) {
$health['integrations']['wp_consent_api']['error'] = $e->getMessage();
Expand Down
2 changes: 1 addition & 1 deletion src/Providers/IPHashProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public static function processIp(array $stat, bool $explicitConsentGiven = false
} elseif ('wp_consent_api' === $integrationKey && function_exists('wp_has_consent')) {
$wpConsentCategory = (string) (\wp_slimstat::$settings['consent_level_integration'] ?? 'statistics');
try {
if ((bool) \wp_has_consent($wpConsentCategory)) {
if (\SlimStat\Utils\Consent::wpHasConsentSafe($wpConsentCategory)) {
$hasCmpConsentButNoCookie = true;
}
} catch (\Throwable $e) {
Expand Down
2 changes: 1 addition & 1 deletion src/Services/GDPRService.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ function ($matches) {
? __('Accept', 'wp-slimstat')
: $this->translateString($this->settings['gdpr_accept_button_text'], 'gdpr_accept_button_text');
$denyText = empty($this->settings['gdpr_decline_button_text'])
? __('Deny', 'wp-slimstat')
? __('Decline', 'wp-slimstat')
: $this->translateString($this->settings['gdpr_decline_button_text'], 'gdpr_decline_button_text');

$acceptButton = sprintf(
Expand Down
2 changes: 1 addition & 1 deletion src/Tracker/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public static function ensureVisitId($forceAssign = false)
} elseif ('wp_consent_api' === $integrationKey && function_exists('wp_has_consent')) {
$wpConsentCategory = (string) (\wp_slimstat::$settings['consent_level_integration'] ?? 'statistics');
try {
$hasCmpConsent = (bool) \wp_has_consent($wpConsentCategory);
$hasCmpConsent = Consent::wpHasConsentSafe($wpConsentCategory);
} catch (\Throwable $e) {
// Ignore errors
}
Expand Down
49 changes: 38 additions & 11 deletions src/Utils/Consent.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ public static function getIntegrationKey(): string
return $integrationKey;
}

/**
* Safe wrapper around wp_has_consent() that ensures wp_get_consent_type() is set.
*
* When no CMP has registered a consent type, wp_has_consent() defaults to true
* regardless of the user's actual consent cookie. This helper temporarily sets
* the consent type to 'optin' if unregistered, then cleans up the filter.
*
* @param string $category Consent category (e.g. 'statistics').
* @return bool Whether the user has granted consent for the given category.
*/
public static function wpHasConsentSafe(string $category): bool
{
if (!function_exists('wp_has_consent')) {
return false;
}

$callback = null;
$needsFilter = function_exists('wp_get_consent_type') && ! wp_get_consent_type();

if ($needsFilter) {
$callback = static function () {
return 'optin';
};
add_filter('wp_get_consent_type', $callback, 10, 1);
}

try {
return (bool) \wp_has_consent($category);
} finally {
if ($needsFilter && $callback !== null) {
remove_filter('wp_get_consent_type', $callback, 10);
}
}
}

/**
* Normalize consent data from various CMP formats to a standard structure.
*
Expand Down Expand Up @@ -239,8 +274,7 @@ public static function canTrack(): bool
if ('wp_consent_api' === $integrationKey && function_exists('wp_has_consent')) {
$wpConsentCategory = (string) ($settings['consent_level_integration'] ?? 'statistics');
try {
// Check consent status - if not granted, block tracking
if (!\wp_has_consent($wpConsentCategory)) {
if (!self::wpHasConsentSafe($wpConsentCategory)) {
$default = false;
}
} catch (\Throwable $e) {
Expand Down Expand Up @@ -361,14 +395,7 @@ public static function piiAllowed(bool $explicitConsentGiven = false): bool
} elseif ('wp_consent_api' === $integrationKey && function_exists('wp_has_consent')) {
$wpConsentCategory = (string) ($settings['consent_level_integration'] ?? 'statistics');
try {

if( ! wp_get_consent_type() ) {
add_filter('wp_get_consent_type', function($type) {
return 'optin';
}, 10, 1);
}

if ((bool) \wp_has_consent($wpConsentCategory)) {
if (self::wpHasConsentSafe($wpConsentCategory)) {
$hasCmpConsent = true;
}
} catch (\Throwable $e) {
Expand Down Expand Up @@ -482,7 +509,7 @@ public static function piiAllowed(bool $explicitConsentGiven = false): bool
if ('wp_consent_api' === $integrationKey && function_exists('wp_has_consent')) {
$wpConsentCategory = (string) ($settings['consent_level_integration'] ?? 'statistics');
try {
return (bool) \wp_has_consent($wpConsentCategory);
return self::wpHasConsentSafe($wpConsentCategory);
} catch (\Throwable $e) {
// Consent API error - be conservative, deny PII
return false;
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { chromium, FullConfig } from '@playwright/test';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { BASE_URL } from './helpers/env';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -41,7 +42,7 @@ async function loginAndSave(
}

export default async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = 'http://localhost:10003';
const baseURL = BASE_URL;

fs.mkdirSync(AUTH_DIR, { recursive: true });

Expand Down
31 changes: 31 additions & 0 deletions tests/e2e/helpers/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Centralized environment configuration for E2E tests.
* All machine-specific values read from env vars with sensible defaults.
*/
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/** WordPress site base URL */
export const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:10003';

/** MySQL unix socket path */
export const MYSQL_SOCKET = process.env.MYSQL_SOCKET || '/tmp/mysql.sock';

/** WordPress installation root */
export const WP_ROOT = process.env.WP_ROOT || '/tmp/wordpress';

/** wp-slimstat plugin directory (derived from this file's location) */
export const PLUGIN_DIR = path.resolve(__dirname, '..', '..', '..');

/** MySQL connection config */
export const MYSQL_CONFIG = {
socketPath: MYSQL_SOCKET,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || 'root',
database: process.env.MYSQL_DATABASE || 'local',
waitForConnections: true,
connectionLimit: 5,
};
Loading