| Server IP : 216.238.66.20 / Your IP : 216.73.216.199 Web Server : nginx/1.30.4 System : Linux woropds 5.15.0-187-generic #197-Ubuntu SMP Fri Jul 17 19:17:01 UTC 2026 x86_64 User : root ( 0) PHP Version : 8.2.33 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/sweetheart.mx/htdocs/wp-content/plugins/wp-slimstat/src/Tracker/ |
Upload File : |
<?php
namespace SlimStat\Tracker;
use SlimStat\Utils\Query;
use SlimStat\Services\Browscap;
use SlimStat\Services\Privacy;
use SlimStat\Services\Geolocation\GeolocationService;
use SlimStat\Providers\IPHashProvider;
use SlimStat\Utils\Consent;
class Processor
{
/**
* Schemes accepted for the stored referer. Anything else is treated as an XSS
* attempt and dropped. Used both as the protocols allow-list for sanitize_url()
* at ingestion (Ajax::sanitizeReferer + the HTTP_REFERER fallback below) and by
* the post-storage scheme check in process(), so the list never drifts. `android-app`
* is included so Google Discover / Android-app referers survive. See #306.
*/
public const REFERER_ALLOWED_SCHEMES = ['http', 'https', 'android-app'];
/**
* Check if the current WordPress user should be excluded from tracking.
*
* Uses wp_get_current_user() defensively to ensure the user object is fully
* resolved, even in edge-case environments where $GLOBALS['current_user']
* may not be initialized by the 'wp' hook (e.g., object caching plugins,
* multisite, or custom authentication flows).
*
* Returns null when the user is not excluded (or not logged in).
* Returns the WP_User object when excluded, so callers can reuse
* the resolved user without calling wp_get_current_user() again.
*
* @since 5.4.5
* @return \WP_User|object|null The excluded user, or null if not excluded.
*/
public static function getExcludedUser()
{
$user = function_exists('wp_get_current_user') ? wp_get_current_user() : null;
if (empty($user) || empty($user->ID)) {
return null;
}
// Check "Exclude all WP Users" toggle
if ('on' == \wp_slimstat::$settings['ignore_wp_users']) {
return $user;
}
// Check role/capability blacklist — matches both role slugs (e.g. "editor")
// and capability keys (e.g. "manage_options", "edit_posts") so admins can
// exclude users by either mechanism via the ignore_capabilities setting.
$capSetting = \wp_slimstat::$settings['ignore_capabilities'] ?? '';
if (!empty($capSetting)) {
if (!empty($user->roles)) {
foreach ($user->roles as $role) {
if (Utils::isBlacklisted($role, $capSetting)) {
return $user;
}
}
}
if (!empty($user->allcaps) && is_array($user->allcaps)) {
foreach ($user->allcaps as $cap => $granted) {
if ($granted && Utils::isBlacklisted($cap, $capSetting)) {
return $user;
}
}
}
}
// Check username blacklist
if (!empty(\wp_slimstat::$settings['ignore_users'])
&& !empty($user->data->user_login)
&& Utils::isBlacklisted($user->data->user_login, \wp_slimstat::$settings['ignore_users'])) {
return $user;
}
return null;
}
/**
* @deprecated Use getExcludedUser() !== null instead.
*/
public static function isUserExcluded(): bool
{
return self::getExcludedUser() !== null;
}
public static function process()
{
// Consent gate: delegate to external CMPs via filter; SlimStat does not manage consent.
if (!Consent::canTrack()) {
return Utils::logError(301);
}
// Get current stat with validation
$stat = \wp_slimstat::get_stat();
$stat['dt'] = \wp_slimstat::date_i18n('U');
if (empty($stat['notes'])) {
$stat['notes'] = [];
}
// Set processing timestamp for Query builder caching (avoids race conditions at midnight)
Query::setProcessingTimestamp($stat['dt']);
$stat = apply_filters('slimstat_filter_pageview_stat_init', $stat);
if ($stat === [] || empty($stat['dt'])) {
// Clear processing timestamp context if processing is aborted
Query::setProcessingTimestamp(null);
return Utils::logError(302);
}
unset($stat['id']);
// Remove legacy cookie-based opt-in/opt-out handling. CMPs should control tracking via hooks.
[$stat['ip'], $stat['other_ip']] = Utils::getRemoteIp();
if (empty($stat['ip']) || '0.0.0.0' == $stat['ip']) {
Query::setProcessingTimestamp(null);
return Utils::logError(202);
}
foreach (\wp_slimstat::string_to_array(\wp_slimstat::$settings['ignore_ip']) as $ipRange) {
$ipToIgnore = $ipRange;
if (false !== strpos($ipToIgnore, '/')) {
[$ipToIgnore, $cidr_mask] = explode('/', trim($ipToIgnore));
} else {
$cidr_mask = Utils::getMaskLength($ipToIgnore);
}
$longMaskedToIgnore = substr(Utils::dtrPton($ipToIgnore), 0, $cidr_mask);
$longMaskedUserIp = substr(Utils::dtrPton($stat['ip']), 0, $cidr_mask);
$longMaskedUserOther = substr(Utils::dtrPton($stat['other_ip']), 0, $cidr_mask);
if ($longMaskedUserIp === $longMaskedToIgnore || $longMaskedUserOther === $longMaskedToIgnore) {
Query::setProcessingTimestamp(null);
return Utils::logError(304);
}
}
// Store original IP for GeoIP lookup (before hashing)
$originalIpForGeo = !empty($stat['other_ip']) ? $stat['other_ip'] : $stat['ip'];
// Cloudflare: prefer CF-Connecting-IP for geolocation when request is verified
// as coming through CF (CF-Ray header present). This handles the edge case where
// mod_remoteip restores real IP to REMOTE_ADDR but other_ip picks up the CF edge IP.
$cfIp = Utils::getCfClientIp();
if ($cfIp) {
$originalIpForGeo = $cfIp;
}
// Store original IP before processing (needed for consent upgrade lookup)
$originalIpBeforeProcessing = $stat['ip'];
$originalOtherIpBeforeProcessing = $stat['other_ip'] ?? '';
// Check if this is a consent upgrade request (needed for session management)
$data_js = \wp_slimstat::get_data_js();
$isConsentUpgrade = !empty($data_js['consent_upgrade']) && '1' === $data_js['consent_upgrade'];
// Determine if PII is allowed based on cookie and CMP consent
// This ensures that after consent is granted, subsequent requests (without consent_upgrade=1)
// still use full IP instead of hashing
// Pass $isConsentUpgrade to piiAllowed so it knows this is an explicit consent signal
// But also check if PII is already allowed via cookie/CMP (for subsequent requests)
$piiAllowedForIp = Consent::piiAllowed($isConsentUpgrade);
// Process IP address with anonymization and hashing (for GDPR compliance)
// Pass explicit consent flag: true if this is a consent upgrade request OR if PII is already allowed via cookie/CMP
// This ensures IP is not hashed in subsequent requests after consent is granted
$explicitConsentForIp = $isConsentUpgrade || $piiAllowedForIp;
$stat = IPHashProvider::processIp($stat, $explicitConsentForIp);
if (!isset($stat['resource'])) {
$stat['resource'] = \wp_slimstat::get_request_uri();
}
$stat['resource'] = sanitize_text_field(urldecode($stat['resource']));
$stat['resource'] = preg_replace_callback('/[^\x20-\x7E]/', function ($m) {
return '%' . bin2hex($m[0]);
}, $stat['resource']);
$parsed_url = parse_url($stat['resource'] ?? '');
if (!$parsed_url) {
Query::setProcessingTimestamp(null);
return Utils::logError(203);
}
$stat['resource'] = $parsed_url['path'] . (empty($parsed_url['query']) ? '' : '?' . $parsed_url['query']) . (empty($parsed_url['fragment']) ? '' : '#' . $parsed_url['fragment']);
if (!empty(\wp_slimstat::$settings['ignore_resources']) && Utils::isBlacklisted($stat['resource'], \wp_slimstat::$settings['ignore_resources'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(305);
}
if (empty($stat['referer']) && !empty($_SERVER['HTTP_REFERER'])) {
// sanitize_url() with android-app added to the allow-list: app-scheme referers
// (android-app://com.google.android.googlequicksearchbox/, Google Discover) survive,
// disallowed schemes (javascript:, data:) are emptied at the boundary, and — unlike
// sanitize_text_field — percent-encoded query octets are preserved so getSearchTerms()
// below can still decode non-Latin / spaced search terms. See #306.
$stat['referer'] = sanitize_url(wp_unslash($_SERVER['HTTP_REFERER']), self::REFERER_ALLOWED_SCHEMES);
}
if (!empty($stat['referer'])) {
$parsed_url = parse_url($stat['referer'] ?? '');
if (!$parsed_url) {
Query::setProcessingTimestamp(null);
return Utils::logError(201);
}
if (isset($parsed_url['scheme']) && ('' !== $parsed_url['scheme'] && '0' !== $parsed_url['scheme']) && !in_array(strtolower($parsed_url['scheme']), self::REFERER_ALLOWED_SCHEMES)) {
$stat['notes'][] = sprintf(__('Attempted XSS Injection: %s', 'wp-slimstat'), $stat['referer']);
unset($stat['referer']);
}
if (!empty(\wp_slimstat::$settings['ignore_referers']) && Utils::isBlacklisted($stat['referer'], \wp_slimstat::$settings['ignore_referers'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(306);
}
$stat['searchterms'] = Utils::getSearchTerms($stat['referer']);
$parsed_site_url = parse_url(get_site_url(), PHP_URL_HOST);
if (isset($parsed_url['host']) && ('' !== $parsed_url['host'] && '0' !== $parsed_url['host']) && $parsed_url['host'] == $parsed_site_url && 'on' != \wp_slimstat::$settings['track_same_domain_referers']) {
unset($stat['referer']);
}
}
if (empty($stat['searchterms']) && !empty($_POST['s'])) {
$stat['searchterms'] = sanitize_text_field(str_replace('\\', '', wp_unslash($_POST['s'])));
}
if (!isset($stat['content_type'])) {
$content_info = Utils::getContentInfo();
if (is_array($content_info)) {
$stat += $content_info;
}
}
// Check content_type exclusions AFTER content_type is resolved — whether
// from server-side detection (above) or from the JS tracker's ci payload
// (Ajax.php). This ensures ignore_content_types works in both modes (#236).
$ignore_content_types = \wp_slimstat::$settings['ignore_content_types'] ?? '';
if ('' !== $ignore_content_types) {
// Normalize legacy ignore_content_types=attachment to match the
// cpt:attachment format introduced in the #236 fix.
$ignore_content_types = self::normalizeLegacySetting($ignore_content_types);
}
if (!empty($ignore_content_types) && !empty($stat['content_type']) && Utils::isBlacklisted($stat['content_type'], $ignore_content_types)) {
Query::setProcessingTimestamp(null);
return Utils::logError(307);
}
if ((is_archive() || is_search()) && !empty($GLOBALS['wp_query']->found_posts)) {
$stat['notes'][] = 'results:' . intval($GLOBALS['wp_query']->found_posts);
}
if ((isset($stat['resource']) && ($stat['resource'] !== '' && $stat['resource'] !== '0') && false !== strpos($stat['resource'], 'wp-admin/admin-ajax.php')) || (!empty($_GET['page']) && false !== strpos($_GET['page'], 'slimview'))) {
Query::setProcessingTimestamp(null);
return Utils::logError(308);
}
// Only collect PII (username, email) if consent allows it
// If this is a consent upgrade request, pass explicit consent flag
$piiAllowed = Consent::piiAllowed($isConsentUpgrade);
// User exclusion: uses wp_get_current_user() defensively to ensure the
// user object is resolved even in edge-case environments (#246).
// getExcludedUser() returns the WP_User if excluded, null otherwise,
// so we can reuse the resolved user object for PII collection below.
$excludedUser = self::getExcludedUser();
if ($excludedUser !== null) {
Query::setProcessingTimestamp(null);
return Utils::logError(309);
}
$user = function_exists('wp_get_current_user') ? wp_get_current_user() : null;
if (!empty($user) && !empty($user->ID)) {
// Only store username/email if PII is allowed (consent granted in anonymous mode)
if ($piiAllowed) {
$stat['username'] = $user->data->user_login;
$stat['email'] = $user->data->user_email;
$stat['notes'][] = 'user:' . $user->data->ID;
}
$not_spam = true;
} elseif ($piiAllowed && isset($_COOKIE['comment_author_' . COOKIEHASH])) {
// Only check comment cookies if PII is allowed
// Use original IP (before hashing) for spam check with Query builder
$spam_comment = Query::select('comment_author, comment_author_email, COUNT(*) as comment_count')
->from(DB_NAME . '.' . $GLOBALS['wpdb']->comments)
->where('comment_author_IP', '=', $originalIpForGeo)
->where('comment_approved', '=', 'spam')
->groupBy('comment_author')
->limit(1)
->getRow();
if (!empty($spam_comment)) {
if ('on' == \wp_slimstat::$settings['ignore_spammers']) {
Query::setProcessingTimestamp(null);
return Utils::logError(317);
}
$stat['notes'][] = 'spam:yes';
$stat['username'] = $spam_comment->comment_author;
$stat['email'] = $spam_comment->comment_author_email;
} else {
if (!empty($_COOKIE['comment_author_' . COOKIEHASH])) {
$stat['username'] = sanitize_user($_COOKIE['comment_author_' . COOKIEHASH]);
}
if (!empty($_COOKIE['comment_author_email_' . COOKIEHASH])) {
$stat['email'] = sanitize_email($_COOKIE['comment_author_email_' . COOKIEHASH]);
}
}
}
$stat['language'] = Utils::getLanguage();
if (!empty($stat['language']) && !empty(\wp_slimstat::$settings['ignore_languages']) && false !== stripos(\wp_slimstat::$settings['ignore_languages'], (string) $stat['language'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(310);
}
// GeoIP lookup requires PII consent (GeoIP data is PII)
$provider = \wp_slimstat::resolve_geolocation_provider();
if (false !== $provider && Consent::piiAllowed()) {
try {
$precision = \wp_slimstat::get_geolocation_precision();
$geoService = new GeolocationService($provider, ['precision' => $precision]);
$geolocation_data = $geoService->locate($originalIpForGeo);
} catch (\Exception $e) {
// GeoIP failure is non-fatal — record pageview without geo data.
// Log the error for diagnostics but continue processing.
// This prevents GeoIP issues (missing DB, filesystem errors, etc.)
// from silently killing ALL tracking on the site.
$geoip_message = Utils::getTrackerCodeLabel(205);
if ('' === $geoip_message) {
$geoip_message = __('GeoIP database file is missing or corrupt. Please go to Settings -> Tracker and click on the "Update Database" button to download a fresh copy.', 'wp-slimstat');
}
Utils::logGeoIpError($geoip_message);
}
if (!empty($geolocation_data) && !empty($geolocation_data['country_code']) && 'xx' != $geolocation_data['country_code']) {
$stat['country'] = strtolower($geolocation_data['country_code']);
if (!empty($geolocation_data['city'])) {
$stat['city'] = $geolocation_data['city'];
}
if (!empty($geolocation_data['subdivision']) && !empty($stat['city'])) {
$stat['city'] .= ' (' . $geolocation_data['subdivision'] . ')';
}
if (!empty($geolocation_data['latitude']) && !empty($geolocation_data['longitude'])) {
$stat['location'] = $geolocation_data['latitude'] . ',' . $geolocation_data['longitude'];
}
}
if (isset($stat['country']) && ($stat['country'] !== '' && $stat['country'] !== '0') && !empty(\wp_slimstat::$settings['ignore_countries']) && false !== stripos(\wp_slimstat::$settings['ignore_countries'], (string) $stat['country'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(311);
}
}
if ((isset($_SERVER['HTTP_X_MOZ']) && ('prefetch' === strtolower($_SERVER['HTTP_X_MOZ']))) || (isset($_SERVER['HTTP_X_PURPOSE']) && ('preview' === strtolower($_SERVER['HTTP_X_PURPOSE'])))) {
if ('on' == \wp_slimstat::$settings['ignore_prefetch']) {
Query::setProcessingTimestamp(null);
return Utils::logError(312);
} else {
$stat['notes'][] = 'pre:yes';
}
}
$browser = Browscap::get_browser();
if ('on' == \wp_slimstat::$settings['ignore_bots'] && 1 == $browser['browser_type']) {
Query::setProcessingTimestamp(null);
return Utils::logError(313);
}
if (!empty(\wp_slimstat::$settings['ignore_browsers']) && Utils::isBlacklisted([$browser['browser'], $browser['user_agent']], \wp_slimstat::$settings['ignore_browsers'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(314);
}
if (!empty(\wp_slimstat::$settings['ignore_platforms']) && Utils::isBlacklisted($browser['platform'], \wp_slimstat::$settings['ignore_platforms'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(315);
}
$stat += $browser;
// Update stat before ensureVisitId (which may need to read it)
\wp_slimstat::set_stat($stat);
// If this is a consent upgrade request, only force assignment if no cookie exists
// If cookie exists, use it to maintain session continuity
$forceVisitIdAssign = false;
if ($isConsentUpgrade && !isset($_COOKIE['slimstat_tracking_code'])) {
// No cookie exists, force assignment to create new visit_id and set cookie
$forceVisitIdAssign = true;
}
$cookie_has_been_set = Session::ensureVisitId($forceVisitIdAssign);
$stat = \wp_slimstat::get_stat(); // Get updated stat after ensureVisitId
$stat = apply_filters('slimstat_filter_pageview_stat', $stat);
do_action('slimstat_track_pageview', $stat);
if ($stat === [] || empty($stat['dt'])) {
Query::setProcessingTimestamp(null);
return Utils::logError(316);
}
if (isset($stat['notes']) && $stat['notes'] !== []) {
$stat['notes'] = '[' . implode('][', $stat['notes']) . ']';
}
$stat = array_filter($stat);
// Update before insert
\wp_slimstat::set_stat($stat);
// consent_upgrade already checked above, reuse the variable
// If this is a consent upgrade request, try to find and upgrade existing anonymous pageview
try {
if ($isConsentUpgrade) {
$isAnonymousTracking = ('on' === (\wp_slimstat::$settings['anonymous_tracking'] ?? 'off'));
$piiAllowed = Consent::piiAllowed(true);
// Allow explicit visit_id from client to target original anonymous record
// Security: Only accept visit_id with valid checksum to prevent targeting arbitrary records
$requestedVisitId = 0;
if (!empty($_REQUEST['visit_id'])) {
$visitIdRaw = sanitize_text_field(wp_unslash($_REQUEST['visit_id']));
$visitIdValue = Utils::getValueWithoutChecksum($visitIdRaw);
if (false !== $visitIdValue) {
$requestedVisitId = intval($visitIdValue);
}
} elseif (!empty($data_js['visit_id'])) {
$visitIdValue = Utils::getValueWithoutChecksum($data_js['visit_id']);
if (false !== $visitIdValue) {
$requestedVisitId = intval($visitIdValue);
}
}
if ($requestedVisitId > 0) {
$stat['visit_id'] = $requestedVisitId;
\wp_slimstat::set_stat($stat);
}
// Only upgrade if we're in anonymous mode and consent is now granted
if ($isAnonymousTracking && $piiAllowed) {
$table = $GLOBALS['wpdb']->prefix . 'slim_stats';
// Use ID from request if available (most reliable for upgrade), otherwise fallback to visit_id
$requestId = 0;
if (!empty($data_js['id'])) {
// ID in data_js usually has checksum, verify and strip it
$requestId = Utils::getValueWithoutChecksum($data_js['id']);
}
$query = Query::select('id, ip, visit_id, fingerprint, username, email, notes')
->from($table);
if ($requestId > 0) {
// Lookup by specific Pageview ID
$query->where('id', '=', $requestId);
} else {
// Fallback: Lookup by session attributes (less reliable if VisitID changed)
$session_duration = !empty(\wp_slimstat::$settings['session_duration']) ? intval(\wp_slimstat::$settings['session_duration']) : 1800;
$min_timestamp = $stat['dt'] - $session_duration;
$shouldFilterByVisitId = !empty($stat['visit_id']) && !$isConsentUpgrade;
if ($requestedVisitId > 0) {
$query->where('visit_id', '=', $requestedVisitId);
} elseif ($shouldFilterByVisitId) {
$query->where('visit_id', '=', $stat['visit_id']);
}
// Filter by IP to ensure we find the correct record
// We need to reconstruct the IP as it was stored (hashed in anonymous mode)
$searchIp = $stat['ip'];
$hashedIp = '';
// If IP is not hashed (looks like real IP), hash it to match anonymous records
if (!empty($searchIp) && strlen($searchIp) < 30) {
$hashedStat = ['ip' => $originalIpBeforeProcessing, 'other_ip' => $originalOtherIpBeforeProcessing];
$hashedStat = IPHashProvider::hashIP($hashedStat, $originalIpBeforeProcessing, $originalOtherIpBeforeProcessing);
$hashedIp = $hashedStat['ip'] ?? '';
$searchIp = $hashedIp;
}
// Calculate the expected Anonymous Visit ID based on current IP/UA
// This helps find the session even if IP hashing doesn't match perfectly or if lookup needs to be more robust
$anonymousVisitId = Session::generateAnonymousVisitId();
// Build complex WHERE clause: (ID match) OR (VisitID match) OR (IP match)
// Note: We effectively group conditions here
$whereClause = [];
if ($requestedVisitId > 0) {
$whereClause[] = $GLOBALS['wpdb']->prepare("visit_id = %d", $requestedVisitId);
} elseif ($shouldFilterByVisitId) {
$whereClause[] = $GLOBALS['wpdb']->prepare("visit_id = %d", $stat['visit_id']);
}
if ($anonymousVisitId > 0) {
// Use %s to handle large integers correctly on all platforms
$whereClause[] = $GLOBALS['wpdb']->prepare("visit_id = %s", (string)$anonymousVisitId);
}
if (!empty($searchIp)) {
$whereClause[] = $GLOBALS['wpdb']->prepare("ip = %s", $searchIp);
}
// Apply the OR conditions for Identity
if (!empty($whereClause)) {
$query->whereRaw('(' . implode(' OR ', $whereClause) . ')');
}
if (!empty($stat['resource'])) {
$query->where('resource', '=', $stat['resource']);
}
$query->where('dt', '>=', $min_timestamp)
->where('dt', '<=', $stat['dt']);
// If fingerprint is available, also check it
if (!empty($stat['fingerprint'])) {
$query->where('fingerprint', '=', $stat['fingerprint']);
}
}
$existing_record = $query->orderBy('dt', 'DESC')
->limit(1)
->getRow();
if (!empty($existing_record)) {
// Found existing anonymous pageview - upgrade it
$existing_id = intval($existing_record->id);
// Use visit_id from existing record if not provided in request
if (empty($requestedVisitId) && !empty($existing_record->visit_id)) {
$stat['visit_id'] = intval($existing_record->visit_id);
\wp_slimstat::set_stat($stat);
}
// Get real IP (before hashing) for upgrade
[$realIp, $realOtherIp] = Utils::getRemoteIp();
// Prepare update data
$update_data = [];
// Check hash_ip setting to determine if we should upgrade IP
$hashIpEnabled = ('on' === (\wp_slimstat::$settings['hash_ip'] ?? 'off'));
$anonymizeIpEnabled = ('on' === (\wp_slimstat::$settings['anonymize_ip'] ?? 'off'));
// In anonymous mode, IP was hashed. After consent, we upgrade to real IP
// UNLESS hash_ip setting is explicitly enabled (user wants to keep hashing even with consent)
if (!$hashIpEnabled) {
// Upgrade from hash to real IP (or anonymized if anonymize_ip is enabled)
if ($anonymizeIpEnabled) {
// Anonymize IP if setting is enabled
$update_data['ip'] = IPHashProvider::anonymizeIP($realIp);
if (!empty($realOtherIp)) {
$update_data['other_ip'] = IPHashProvider::anonymizeIP($realOtherIp);
} else {
$update_data['other_ip'] = '';
}
} else {
// Store full real IP (consent granted, no anonymization needed)
$update_data['ip'] = $realIp;
if (!empty($realOtherIp)) {
$update_data['other_ip'] = $realOtherIp;
} else {
$update_data['other_ip'] = '';
}
}
}
// Add username and email if logged in and PII allowed
// Use wp_get_current_user() defensively (#246) — $GLOBALS['current_user']
// may not be resolved in edge-case environments (object caching, multisite).
$upgradeUser = function_exists('wp_get_current_user') ? wp_get_current_user() : null;
if (!empty($upgradeUser) && !empty($upgradeUser->ID)) {
$update_data['username'] = $upgradeUser->data->user_login;
$update_data['email'] = $upgradeUser->data->user_email;
$user_note = '[user:' . $upgradeUser->data->ID . ']';
if (empty($existing_record->notes) || false === strpos($existing_record->notes, $user_note)) {
$update_data['notes'] = $user_note;
}
}
// Update fingerprint if available (may not have been sent in anonymous mode)
if (!empty($stat['fingerprint'])) {
$update_data['fingerprint'] = $stat['fingerprint'];
}
// Perform GeoIP lookup if enabled and PII allowed
// Only do GeoIP lookup if we're updating IP (not keeping hash)
if (!$hashIpEnabled && !empty($update_data['ip'])) {
$provider = \wp_slimstat::resolve_geolocation_provider();
if (false !== $provider) {
try {
$precision = \wp_slimstat::get_geolocation_precision();
// Prefer CF-Connecting-IP (verified by Cloudflare) when available,
// else fall back to the best proxy-header IP, then REMOTE_ADDR.
$geoIp = Utils::getCfClientIp() ?? (!empty($realOtherIp) ? $realOtherIp : $realIp);
$geoService = new GeolocationService($provider, ['precision' => $precision]);
$geolocation_data = $geoService->locate($geoIp);
if (!empty($geolocation_data) && !empty($geolocation_data['country_code']) && 'xx' != $geolocation_data['country_code']) {
$update_data['country'] = strtolower($geolocation_data['country_code']);
if (!empty($geolocation_data['city'])) {
$update_data['city'] = $geolocation_data['city'];
}
if (!empty($geolocation_data['subdivision']) && !empty($update_data['city'])) {
$update_data['city'] .= ' (' . $geolocation_data['subdivision'] . ')';
}
if (!empty($geolocation_data['latitude']) && !empty($geolocation_data['longitude'])) {
$update_data['location'] = $geolocation_data['latitude'] . ',' . $geolocation_data['longitude'];
}
}
} catch (\Exception $e) {
// Ignore GeoIP errors
}
}
}
// Use atomic counter for thread-safe visit ID generation (O(1) instead of O(n))
$next_visit_id = VisitIdGenerator::generateNextVisitId();
if ($next_visit_id <= 0) {
$next_visit_id = time();
}
$stat['visit_id'] = intval($next_visit_id);
// Sync visit_id to ensure session continuity
if (!empty($stat['visit_id']) && isset($existing_record->visit_id) && $stat['visit_id'] != $existing_record->visit_id) {
$update_data['visit_id'] = $stat['visit_id'];
}
// Update the existing record
if (!empty($update_data)) {
$update_query = Query::update($table);
// Handle notes separately (append if needed)
if (!empty($update_data['notes'])) {
$notes_to_append = $update_data['notes'];
unset($update_data['notes']);
$update_query->setRaw('notes', "CONCAT(IFNULL(notes, ''), %s)", [$notes_to_append]);
}
if (!empty($update_data)) {
$update_query->set($update_data);
}
$session_duration = !empty(\wp_slimstat::$settings['session_duration']) ? intval(\wp_slimstat::$settings['session_duration']) : 1800;
$update_min_ts = $stat['dt'] - $session_duration;
if (!empty($existing_record->visit_id) && $existing_record->visit_id > 0) {
// Upgrade ALL records for this session (same visit_id + same anonymous IP)
$update_query->where('visit_id', '=', intval($existing_record->visit_id));
$update_query->where('ip', '=', $existing_record->ip);
$update_query->where('dt', '>=', $update_min_ts);
} else {
// Fallback: Upgrade only this specific record
$update_query->where('id', '=', $existing_id);
}
$update_query->execute();
// Return existing ID (upgraded record)
$stat['id'] = $existing_id;
\wp_slimstat::set_stat($stat);
Query::setProcessingTimestamp(null);
// Ensure tracking cookie is set after upgrade
if (empty($stat['visit_id']) && !empty($stat['id'])) {
Session::setTrackingCookie($stat['id'], 'id', 2678400);
}
if ($isConsentUpgrade) {
/**
* Fires when a consent upgrade request is processed.
*
* @param int $pageview_id Upgraded pageview ID.
* @param array $stat Current tracking stat array.
*/
do_action('slimstat_consent_granted', $stat['id'], $stat);
}
return $stat['id'];
} else {
// No update data but record found - just return existing ID
$stat['id'] = $existing_id;
\wp_slimstat::set_stat($stat);
Query::setProcessingTimestamp(null);
if ($isConsentUpgrade) {
do_action('slimstat_consent_granted', $stat['id'], $stat);
}
return $stat['id'];
}
}
// If no existing record found, continue with normal insert flow below
}
}
} catch (\Exception $e) {
// Ignore consent upgrade errors to avoid breaking tracking.
}
// Duplicate check for anonymous mode without PII (no cookies available)
$isAnonymousTracking = ('on' === (\wp_slimstat::$settings['anonymous_tracking'] ?? 'off'));
$piiAllowed = Consent::piiAllowed();
if ($isAnonymousTracking && !$piiAllowed && !empty($stat['visit_id']) && !empty($stat['resource'])) {
// Use session_duration (default 30 minutes) to match normal tracking behavior
// In normal mode, cookies persist for session_duration, so we replicate that here
$session_duration = !empty(\wp_slimstat::$settings['session_duration']) ? intval(\wp_slimstat::$settings['session_duration']) : 1800;
$table = $GLOBALS['wpdb']->prefix . 'slim_stats';
$min_timestamp = $stat['dt'] - $session_duration;
// Check for duplicate within session duration
$query = Query::select('id, dt')
->from($table)
->where('visit_id', '=', $stat['visit_id'])
->where('resource', '=', $stat['resource'])
->where('dt', '>=', $min_timestamp)
->where('dt', '<=', $stat['dt']);
if (!empty($stat['fingerprint'])) {
$query->where('fingerprint', '=', $stat['fingerprint']);
}
$existing_record = $query->orderBy('dt', 'DESC')
->limit(1)
->getRow();
if (!empty($existing_record)) {
$stat['id'] = intval($existing_record->id);
\wp_slimstat::set_stat($stat);
Query::setProcessingTimestamp(null);
return $stat['id'];
}
}
$stat['id'] = Storage::insertRow($stat, $GLOBALS['wpdb']->prefix . 'slim_stats');
if (false === $stat['id']) {
include_once(SLIMSTAT_ANALYTICS_DIR . 'admin/index.php');
\wp_slimstat_admin::init_environment();
$stat['id'] = Storage::insertRow($stat, $GLOBALS['wpdb']->prefix . 'slim_stats');
if (false === $stat['id']) {
Query::setProcessingTimestamp(null);
// Store DB error detail for admin diagnostics
$dbError = (string) $GLOBALS['wpdb']->last_error;
\wp_slimstat::update_option('slimstat_tracker_error_detail', sanitize_text_field($dbError));
return Utils::logError(200);
}
}
// Clear stale DB error detail on successful insert
\wp_slimstat::update_option('slimstat_tracker_error_detail', '');
// Update stat after getting ID
\wp_slimstat::set_stat($stat);
// Handle cookie setting using centralized Session class
// Cookies are ONLY set when consent allows (handled internally by Session::setTrackingCookie)
if (empty($stat['visit_id']) && !empty($stat['id'])) {
// No visit ID assigned yet - set cookie with pageview ID
// Cookie expires in 31 days (2678400 seconds)
Session::setTrackingCookie($stat['id'], 'id', 2678400);
} elseif (!$cookie_has_been_set && 'on' == \wp_slimstat::$settings['extend_session'] && $stat['visit_id'] > 0) {
// Extend existing session cookie
Session::setTrackingCookie($stat['visit_id'], 'visit');
}
// Clear processing timestamp context after successful processing
Query::setProcessingTimestamp(null);
if ($isConsentUpgrade) {
do_action('slimstat_consent_granted', $stat['id'], $stat);
}
return $stat['id'];
}
public static function updateContentType($status = 301, $location = '')
{
if ($status >= 300 && $status < 400) {
$stat = \wp_slimstat::get_stat();
$stat['content_type'] = 'redirect:' . intval($status);
\wp_slimstat::set_stat($stat);
Storage::updateRow($stat);
}
return $status;
}
/**
* Normalize a legacy ignore_content_types setting so bare "attachment"
* also matches the "cpt:attachment" content_type format (#236).
*
* @param string $setting Comma-separated content type exclusion list.
* @return string Normalized comma-separated list.
*/
public static function normalizeLegacySetting($setting)
{
return implode(',', array_unique(array_merge(
\wp_slimstat::string_to_array($setting),
array_map(
function ($v) { return 'cpt:' . $v; },
array_filter(
\wp_slimstat::string_to_array($setting),
function ($v) { return 'attachment' === $v; }
)
)
)));
}
}