hasConsent()) { $hasCmpConsentButNoCookie = true; } } elseif ('wp_consent_api' === $integrationKey && function_exists('wp_has_consent')) { $wpConsentCategory = (string) (\wp_slimstat::$settings['consent_level_integration'] ?? 'statistics'); try { if (\SlimStat\Utils\Consent::wpHasConsentSafe($wpConsentCategory)) { $hasCmpConsentButNoCookie = true; } } catch (\Throwable $e) { // Ignore errors } } elseif ('real_cookie_banner' === $integrationKey) { // Real Cookie Banner fallback: try to read consent from cookie // This handles race conditions where tracking cookie isn't set yet but RCB cookie is present $wpConsentCategory = (string) (\wp_slimstat::$settings['consent_level_integration'] ?? 'statistics'); $rcbCookies = ['real_cookie_banner', 'rcb_consent', 'rcb_acceptance', 'real_cookie_consent', 'rcb-consent']; foreach ($_COOKIE as $name => $value) { $isMatch = false; foreach ($rcbCookies as $rcbName) { if (strpos($name, $rcbName) === 0) { $isMatch = true; break; } } if ($isMatch) { // Try to decode value: handle both URL encoded and raw JSON // WP cookies are often slashed, so strip slashes first $sanitized_value = wp_unslash($value); $rawJson = stripslashes($sanitized_value); $data = json_decode($rawJson, true); if (json_last_error() !== JSON_ERROR_NONE) { // If failed, try urldecode first $data = json_decode(stripslashes(urldecode($sanitized_value)), true); } if (is_array($data)) { // Check various structures based on RCB versions // Structure 1: { "groups": { "statistics": true } } if (isset($data['groups'][$wpConsentCategory]) && true === $data['groups'][$wpConsentCategory]) { $hasCmpConsentButNoCookie = true; break; } // Structure 2: { "decision": { "statistics": true } } OR { "decision": "all" } if (isset($data['decision'])) { if ('all' === $data['decision']) { $hasCmpConsentButNoCookie = true; break; } if (is_array($data['decision']) && isset($data['decision'][$wpConsentCategory]) && true === $data['decision'][$wpConsentCategory]) { $hasCmpConsentButNoCookie = true; break; } } // Structure 3: { "statistics": true } (Legacy/Simplified) if (isset($data[$wpConsentCategory]) && true === $data[$wpConsentCategory]) { $hasCmpConsentButNoCookie = true; break; } } } } } } } // Anonymous mode without consent: hash IP (strictest privacy) if ($isAnonymousTracking && !$piiAllowed && !$hasCmpConsentButNoCookie) { $stat = self::hashIP($stat, $originalIp, $originalOtherIp); // Validate hash (39 chars, hex, different from original) $hashSucceeded = !empty($stat['ip']) && strlen($stat['ip']) === self::HASH_LENGTH && ctype_xdigit($stat['ip']) && $stat['ip'] !== $originalIp; if (!$hashSucceeded) { $anonymizedIp = self::anonymizeIP($originalIp); if (!empty($anonymizedIp) && $anonymizedIp !== $originalIp) { $stat['ip'] = $anonymizedIp; } else { $stat['ip'] = ''; } // Handle other_ip only if present if (!empty($originalOtherIp)) { $anonymizedOtherIp = self::anonymizeIP($originalOtherIp); // Validate anonymization succeeded if (!empty($anonymizedOtherIp) && $anonymizedOtherIp !== $originalOtherIp) { $stat['other_ip'] = $anonymizedOtherIp; } else { $stat['other_ip'] = ''; } } } return $stat; } // MODE 2: Anonymous tracking mode WITH consent // Consent was granted - but still respect anonymize_ip setting // Also handle case where CMP consent exists but tracking cookie hasn't been set yet if ($isAnonymousTracking && ($piiAllowed || $hasCmpConsentButNoCookie)) { // Check if anonymize_ip setting is enabled $shouldAnonymize = 'on' === (\wp_slimstat::$settings['anonymize_ip'] ?? 'off'); if ($shouldAnonymize) { // Anonymize IP even with consent if setting is enabled $stat['ip'] = self::anonymizeIP($originalIp); if (!empty($originalOtherIp)) { $stat['other_ip'] = self::anonymizeIP($originalOtherIp); } else { $stat['other_ip'] = ''; } } else { // Keep original IPs if anonymize_ip is not enabled } // Cookie will be set by ensureVisitId() in the same request return $stat; } if ($isAnonymousTracking) { // This should never happen, but as a safety fallback, hash the IP // This ensures GDPR compliance even if there's a logic error $stat = self::hashIP($stat, $originalIp, $originalOtherIp); // Validate hash succeeded $hashSucceeded = !empty($stat['ip']) && strlen($stat['ip']) === self::HASH_LENGTH && ctype_xdigit($stat['ip']) && $stat['ip'] !== $originalIp; if (!$hashSucceeded) { // Hash failed - anonymize as fallback $stat['ip'] = self::anonymizeIP($originalIp); if (!empty($originalOtherIp)) { $stat['other_ip'] = self::anonymizeIP($originalOtherIp); } else { $stat['other_ip'] = ''; } } return $stat; } // Get individual privacy settings $shouldAnonymize = 'on' === (\wp_slimstat::$settings['anonymize_ip'] ?? 'off'); $shouldHash = 'on' === (\wp_slimstat::$settings['hash_ip'] ?? 'off'); // If PII is NOT allowed (DNT, consent denied, etc), force maximum privacy if (!$piiAllowed) { $shouldAnonymize = true; $shouldHash = true; } // Apply processing in correct order: // 1. Hash first (if needed) - uses original IP // 2. Anonymize after (if needed) - modifies stored IP or provides fallback if hash failed if ($shouldHash) { // Hash using original IP (before any anonymization) // This replaces the IP with a hash value $stat = self::hashIP($stat, $originalIp, $originalOtherIp); // Check if hashing succeeded // Valid hash must be: 39 chars (truncated SHA-256), hexadecimal, and different from original IP $hashSucceeded = !empty($stat['ip']) && strlen($stat['ip']) === self::HASH_LENGTH && ctype_xdigit($stat['ip']) && $stat['ip'] !== $originalIp; // If hashing failed AND anonymization is enabled, apply anonymization as fallback if (!$hashSucceeded && $shouldAnonymize) { $stat['ip'] = self::anonymizeIP($originalIp); if (!empty($originalOtherIp)) { $stat['other_ip'] = self::anonymizeIP($originalOtherIp); } else { $stat['other_ip'] = ''; } } } elseif ($shouldAnonymize) { // Only anonymize if NOT hashing (hashing already provides privacy) $stat['ip'] = self::anonymizeIP($stat['ip']); if (!empty($stat['other_ip'])) { $stat['other_ip'] = self::anonymizeIP($stat['other_ip']); } } // Note: If neither hash nor anonymize, full IP is stored (requires PII consent) return $stat; } /** * Upgrades the stored IP to the real IP if consent is granted. * Respects anonymize_ip setting even after consent is granted. * * @param array $stat The slimstat array containing IP data * @return array Modified slimstat array with the real IP (or anonymized if setting enabled) */ public static function upgradeToPii(array $stat): array { $isAnonymousTracking = 'on' === (\wp_slimstat::$settings['anonymous_tracking'] ?? 'off'); $piiAllowed = Consent::piiAllowed(true); if (!$isAnonymousTracking || !$piiAllowed) { return $stat; } // Restore the original IP before updating records [$stat['ip'], $stat['other_ip']] = \SlimStat\Tracker\Utils::getRemoteIp(); // Check if anonymize_ip setting is enabled - it should always be respected $shouldAnonymize = 'on' === (\wp_slimstat::$settings['anonymize_ip'] ?? 'off'); if ($shouldAnonymize) { // Anonymize IP even after consent upgrade if setting is enabled $stat['ip'] = self::anonymizeIP($stat['ip']); if (!empty($stat['other_ip'])) { $stat['other_ip'] = self::anonymizeIP($stat['other_ip']); } } // Ensure the anonymous visit ID is carried over to the new cookie-based session $anonymousVisitId = \SlimStat\Tracker\Session::getVisitId(); if ($anonymousVisitId > 0) { // Force set the cookie, as we are in the consent upgrade flow \SlimStat\Tracker\Session::setTrackingCookie($anonymousVisitId, 'visit', null, true); } return $stat; } /** * Anonymize IP address using WordPress privacy function * * @param string $ip The IP address to anonymize * @return string Anonymized IP address */ public static function anonymizeIP(string $ip): string { if (function_exists('wp_privacy_anonymize_ip')) { $anonymized = wp_privacy_anonymize_ip($ip); if (!empty($anonymized)) { return $anonymized; } } // Fallback to Privacy service if WordPress function fails return Privacy::maskIp($ip); } /** * Hash IP address with daily salt for GDPR-compliant visitor identification. * * Creates a one-way hash from the original IP address + user agent + daily salt. * The hash changes daily, preventing long-term visitor tracking while allowing * same-day uniqueness counting. * * Hash formula: * HMAC-SHA256(daily_salt + "|" + original_ip + "|" + user_agent, secret) * * Privacy behavior: * - Always clears other_ip (proxy information) for privacy, regardless of hash success * - On success: IP is replaced with hash * - On failure: IP remains original (caller handles privacy fallback via anonymization) * * Fallback behavior: * - If daily salt fails: use Privacy service (date-based hash) * - If all hashing fails: returns original IP only (other_ip cleared) * * @param array $stat The slimstat array * @param string $originalIp The original IP address (BEFORE any processing) * @param string $originalOtherIp The original other_ip address (if proxy detected) - always cleared for privacy * @return array Modified slimstat array with hashed IP (or original if hash failed), other_ip always cleared */ public static function hashIP(array $stat, string $originalIp, string $originalOtherIp = ''): array { $userAgent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; $secret = \wp_slimstat::$settings['secret'] ?? wp_hash('slimstat'); // Ensure daily salt exists (generate if missing) $dailySalt = self::getDailySalt(); if (empty($dailySalt)) { $dailySalt = self::generateDailySalt(); } // Try to generate hash using daily salt if (!empty($dailySalt)) { $hash = self::hashWithDailySalt($originalIp, $userAgent, $dailySalt, $secret); } else { // Fallback to Privacy service (date-based hash) $hash = self::hashWithPrivacyService($originalIp, $userAgent, $secret); } // Validate hash result if ($hash !== '' && $hash !== '0') { // Hash succeeded - replace IP with hash $stat['ip'] = $hash; } else { // Keep original IP in stat - caller will handle privacy fallback $stat['ip'] = $originalIp; } // Always clear other_ip when hashing is intended (for privacy) // The hash represents the unique visitor; storing proxy IP would leak PII $stat['other_ip'] = ''; return $stat; } /** * Hash IP using daily salt * * @param string $ip Original IP address * @param string $userAgent User agent string * @param string $dailySalt Daily salt value * @param string $secret Secret key * @return string Hashed IP address */ private static function hashWithDailySalt(string $ip, string $userAgent, string $dailySalt, string $secret): string { $data = $dailySalt . '|' . $ip . '|' . $userAgent; $hash = hash_hmac('sha256', $data, $secret); return self::normalizeHashLength($hash); } /** * Hash IP using Privacy service * * @param string $ip Original IP address * @param string $userAgent User agent string * @param string $secret Secret key * @return string Hashed IP address */ private static function hashWithPrivacyService(string $ip, string $userAgent, string $secret): string { // Use start of day timestamp to ensure hash consistency throughout the day $todayTimestamp = strtotime(gmdate('Y-m-d 00:00:00')); $hash = Privacy::computeVisitorId($ip, $userAgent, $todayTimestamp, $secret); return self::normalizeHashLength($hash); } /** * Normalize hash output to the configured length, keeping hexadecimal characters. * * @param string $hash Raw hexadecimal hash string * @return string Hash trimmed to HASH_LENGTH characters */ private static function normalizeHashLength(string $hash): string { if ('' === $hash) { return ''; } return substr($hash, 0, self::HASH_LENGTH); } /** * Generate daily salt for IP hashing * * @return string Daily salt value */ public static function generateDailySalt(): string { $today = gmdate('Y-m-d'); $existingSalt = get_option('slimstat_daily_salt'); $saltDate = get_option('slimstat_daily_salt_date'); // Generate new salt if date changed or no salt exists if ($saltDate !== $today || empty($existingSalt)) { $newSalt = wp_generate_password(32, false); update_option('slimstat_daily_salt', $newSalt); update_option('slimstat_daily_salt_date', $today); return $newSalt; } return $existingSalt; } /** * Get current daily salt (without generating if missing). * * @return string Daily salt or empty string if not set */ public static function getDailySalt(): string { $today = gmdate('Y-m-d'); $existingSalt = get_option('slimstat_daily_salt'); $saltDate = get_option('slimstat_daily_salt_date'); // Return salt only if it's for today if ($saltDate === $today && !empty($existingSalt)) { return $existingSalt; } return ''; } /** * Check if IP hashing is enabled * * @return bool True if IP hashing is enabled */ public static function isHashingEnabled(): bool { return 'on' === (\wp_slimstat::$settings['hash_ip'] ?? 'off'); } /** * Check if IP anonymization is enabled * * @return bool True if IP anonymization is enabled */ public static function isAnonymizationEnabled(): bool { return 'on' === (\wp_slimstat::$settings['anonymize_ip'] ?? 'off'); } }