| Server IP : 216.238.66.20 / Your IP : 216.73.216.51 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/Providers/ |
Upload File : |
<?php
namespace SlimStat\Providers;
// don't load directly.
if (! defined('ABSPATH')) {
header('Status: 403 Forbidden');
header('HTTP/1.1 403 Forbidden');
exit;
}
use SlimStat\Services\Privacy;
use SlimStat\Utils\Consent;
/**
* IP Hash Provider
*
* Handles IP hashing and anonymization for GDPR compliance.
* Processes IPs based on privacy settings and consent status.
* Hash uses original IP for better uniqueness; anonymization applied after hashing if needed.
*
* @since 5.4.0
*/
class IPHashProvider
{
/**
* Length of the stored IP hash (must fit DB column, 39 chars matches IPv6 max length).
*/
public const HASH_LENGTH = 39;
/**
* Process IP address according to privacy settings and consent status.
*
* This is the main entry point for IP processing in the tracking pipeline.
*
* Processing modes:
* 1. Anonymous tracking WITHOUT consent: Hash only (strictest)
* 2. Anonymous tracking WITH consent: Store full IP (after consent upgrade)
* 3. Standard mode WITHOUT PII consent: Anonymize + Hash
* 4. Standard mode WITH settings: Respect anonymize_ip and hash_ip settings
* 5. Standard mode WITH PII consent: Store full IP (no processing needed)
*
* @param array $stat The slimstat array containing IP data
* @param bool $explicitConsentGiven Optional. Set to true when consent was explicitly granted
* in the current request (e.g., consent upgrade flow).
* @return array Modified slimstat array with processed IP
*/
public static function processIp(array $stat, bool $explicitConsentGiven = false): array
{
if (empty($stat['ip'])) {
return $stat;
}
// Store original IP for processing (never modify this variable)
$originalIp = $stat['ip'];
$originalOtherIp = $stat['other_ip'] ?? '';
// Determine mode and consent status
$isAnonymousTracking = 'on' === (\wp_slimstat::$settings['anonymous_tracking'] ?? 'off');
$piiAllowed = Consent::piiAllowed($explicitConsentGiven);
// Handle consent granted in same request (cookie not set yet or invalid)
$hasCmpConsentButNoCookie = false;
if ($isAnonymousTracking && !$piiAllowed) {
$integrationKey = Consent::getIntegrationKey();
// Check CMP consent only if integration is configured
if (!empty($integrationKey)) {
if ('slimstat_banner' === $integrationKey) {
$gdpr_service = new \SlimStat\Services\GDPRService(\wp_slimstat::$settings);
if ($gdpr_service->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');
}
}