v1.0.0
PriviMetrics 2
- <?php
-
- declare(strict_types=1);
-
- define('PM_ROOT', __DIR__);
- define('PM_DATA_DIR', PM_ROOT . '/data');
- define('PM_ENV_DIR', dirname(PM_ROOT));
- define('PM_ENV_FILE', PM_ENV_DIR . '/.pm-env');
-
- function envLoad(bool $forceReload = false): array
- {
- static $cache = null;
- if ($cache !== null && !$forceReload) return $cache;
-
- $cache = [];
- if (!file_exists(PM_ENV_FILE)) return $cache;
-
- $lines = file(PM_ENV_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
- foreach ($lines as $line) {
- $line = trim($line);
- if ($line === '' || str_starts_with($line, '#')) continue;
- if (!str_contains($line, '=')) continue;
-
- [$key, $value] = explode('=', $line, 2);
- $key = trim($key);
- $value = trim($value);
-
- if (strlen($value) >= 2) {
- $first = $value[0];
- $last = $value[strlen($value) - 1];
- if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
- $value = substr($value, 1, -1);
- }
- }
- $cache[$key] = $value;
- }
- return $cache;
- }
-
- function env(string $key, $default = null)
- {
- $env = envLoad();
- return $env[$key] ?? $default;
- }
-
- function envSet(string $key, string $value): bool
- {
- $lines = [];
- if (file_exists(PM_ENV_FILE)) {
- $raw = file(PM_ENV_FILE, FILE_IGNORE_NEW_LINES);
- if ($raw !== false) $lines = $raw;
- }
-
- $needsQuotes = $value === '' || preg_match('/[\s#"\']/', $value) === 1;
- $escaped = str_replace('"', '\\"', $value);
- $newLine = $key . '=' . ($needsQuotes ? '"' . $escaped . '"' : $escaped);
-
- $found = false;
- foreach ($lines as &$line) {
- $trimmed = trim($line);
- if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) continue;
- [$k] = explode('=', $trimmed, 2);
- if (trim($k) === $key) {
- $line = $newLine;
- $found = true;
- break;
- }
- }
- unset($line);
-
- if (!$found) {
- $lines[] = $newLine;
- }
-
- $lockFile = PM_ENV_FILE . '.lock';
- $fp = fopen($lockFile, 'c');
- $ok = false;
- if ($fp && flock($fp, LOCK_EX)) {
- $ok = file_put_contents(PM_ENV_FILE, implode("\n", $lines) . "\n", LOCK_EX) !== false;
- flock($fp, LOCK_UN);
- fclose($fp);
- }
-
- envLoad_resetCache();
-
- return $ok;
- }
-
- function envLoad_resetCache(): void
- {
- envLoad(true);
- }
-
- function envSetMany(array $updates): bool
- {
- $lines = [];
- if (file_exists(PM_ENV_FILE)) {
- $raw = file(PM_ENV_FILE, FILE_IGNORE_NEW_LINES);
- if ($raw !== false) $lines = $raw;
- }
- $seen = [];
-
- foreach ($lines as $i => $line) {
- $trimmed = trim($line);
- if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) continue;
- [$key] = explode('=', $trimmed, 2);
- $key = trim($key);
- if (array_key_exists($key, $updates)) {
- $val = (string) $updates[$key];
- $needsQuotes = $val === '' || preg_match('/\s|#/', $val) === 1;
- $lines[$i] = $key . '=' . ($needsQuotes ? '"' . str_replace('"', '\\"', $val) . '"' : $val);
- $seen[$key] = true;
- }
- }
-
- foreach ($updates as $k => $v) {
- if (isset($seen[$k])) continue;
- $v = (string) $v;
- $needsQuotes = $v === '' || preg_match('/\s|#/', $v) === 1;
- $lines[] = $k . '=' . ($needsQuotes ? '"' . str_replace('"', '\\"', $v) . '"' : $v);
- }
-
- $ok = file_put_contents(PM_ENV_FILE, implode("\n", $lines) . "\n") !== false;
- envLoad_resetCache();
- return $ok;
- }
-
- function envIsInstalled(): bool
- {
- return file_exists(PM_ENV_FILE) && env('APP_INSTALLED') === 'true';
- }
-
- function startSecureSession(): void
- {
- if (session_status() === PHP_SESSION_NONE) {
- ini_set('session.cookie_httponly', '1');
- ini_set('session.use_only_cookies', '1');
- ini_set('session.cookie_samesite', 'Lax');
- ini_set('session.cookie_lifetime', '0');
- ini_set('session.gc_maxlifetime', '86400');
-
- if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
- ini_set('session.cookie_secure', '1');
- }
-
- session_name('PRIVIMETRICS_SESSION');
- session_start();
-
- if (!isset($_SESSION['created'])) {
- $_SESSION['created'] = time();
- } elseif (time() - $_SESSION['created'] > 1800) {
- session_regenerate_id(true);
- $_SESSION['created'] = time();
- }
- }
- }
-
- function requireLogin(): void
- {
- startSecureSession();
- if (empty($_SESSION['admin_logged_in']) || $_SESSION['admin_logged_in'] !== true) {
- header('Location: login.php');
- exit;
- }
- $timeout = (int) env('SESSION_TIMEOUT', '86400');
- if (isset($_SESSION['login_time']) && (time() - $_SESSION['login_time']) > $timeout) {
- session_unset();
- session_destroy();
- header('Location: login.php?timeout=1');
- exit;
- }
- $_SESSION['last_activity'] = time();
- }
-
- function generateCSRFToken(): string
- {
- startSecureSession();
- if (empty($_SESSION['csrf_token'])) {
- $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
- }
- return $_SESSION['csrf_token'];
- }
-
- function verifyCSRFToken(?string $token): bool
- {
- startSecureSession();
- return !empty($_SESSION['csrf_token']) && is_string($token) && hash_equals($_SESSION['csrf_token'], $token);
- }
-
- function sanitize($input): string
- {
- return htmlspecialchars(strip_tags(trim((string) $input)), ENT_QUOTES, 'UTF-8');
- }
-
- function anonymizeIP(string $ip): string
- {
- if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
- return preg_replace('/\.\d+$/', '.0', $ip);
- } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
- return preg_replace('/:[^:]+:[^:]+$/', ':0:0', $ip);
- }
- return '0.0.0.0';
- }
-
- function getClientIP(): string
- {
- return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
- }
-
- function pmDailySalt(): string
- {
- static $salt = null;
- if ($salt !== null) return $salt;
-
- $today = gmdate('Y-m-d');
- $dir = PM_DATA_DIR . '/cache';
- if (!is_dir($dir)) @mkdir($dir, 0755, true);
-
- $fp = @fopen($dir . '/daily-salt.json', 'c+');
- if (!$fp || !flock($fp, LOCK_EX)) {
- if ($fp) fclose($fp);
- return $salt = bin2hex(random_bytes(32));
- }
-
- $data = json_decode((string) stream_get_contents($fp), true);
- if (is_array($data) && ($data['date'] ?? '') === $today && preg_match('/^[a-f0-9]{64}$/', (string) ($data['salt'] ?? ''))) {
- $salt = $data['salt'];
- } else {
- $salt = bin2hex(random_bytes(32));
- ftruncate($fp, 0);
- rewind($fp);
- fwrite($fp, json_encode(['date' => $today, 'salt' => $salt]));
- fflush($fp);
- }
- flock($fp, LOCK_UN);
- fclose($fp);
- return $salt;
- }
-
- function pmVisitorHash(string $ip, string $userAgent, string $domain): string
- {
- return substr(hash_hmac('sha256', $ip . '|' . $userAgent . '|' . strtolower($domain), pmDailySalt()), 0, 32);
- }
-
- function getUserAgent(): string
- {
- return sanitize($_SERVER['HTTP_USER_AGENT'] ?? 'Unknown');
- }
-
- function isDNTEnabled(): bool
- {
- return isset($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1';
- }
-
- function hashPassword(string $password): string
- {
- return password_hash($password, PASSWORD_ARGON2ID);
- }
-
- function verifyPassword(string $password, string $hash): bool
- {
- return password_verify($password, $hash);
- }
-
- function generateID(): string
- {
- return bin2hex(random_bytes(4)) . uniqid('', true);
- }
-
- function formatNumber($number): string
- {
- $number = (float) $number;
- if ($number >= 1000000) return round($number / 1000000, 1) . 'm';
- if ($number >= 1000) return round($number / 1000, 1) . 'k';
- return (string) (int) $number;
- }
-
- function validateDomain(string $domain): bool
- {
- $domain = strtolower(trim($domain));
- return filter_var('http://' . $domain, FILTER_VALIDATE_URL) !== false;
- }
-
- function checkDomainMatch(string $allowed, string $current, string $mode): bool
- {
- $allowed = strtolower(trim($allowed));
- $current = strtolower(trim($current));
-
- $allowed = preg_replace('#^https?://#', '', $allowed);
- $allowed = preg_replace('#/.*$#', '', $allowed);
- $current = preg_replace('#^https?://#', '', $current);
- $current = preg_replace('#/.*$#', '', $current);
-
- switch ($mode) {
- case 'full':
- return $current === $allowed || (bool) preg_match('/\.' . preg_quote($allowed, '/') . '$/', $current);
- case 'main':
- return $current === $allowed;
- case 'none':
- return true;
- default:
- return false;
- }
- }
-
- function checkRateLimit(string $identifier, int $maxAttempts = 60, int $timeWindow = 60): bool
- {
- $cacheDir = PM_DATA_DIR . '/cache';
- if (!is_dir($cacheDir)) {
- mkdir($cacheDir, 0755, true);
- }
- $cacheFile = $cacheDir . '/rate_' . md5($identifier) . '.txt';
-
- $data = null;
- if (file_exists($cacheFile)) {
- $raw = file_get_contents($cacheFile);
- $data = $raw !== false ? json_decode($raw, true) : null;
- }
-
- if ($data && isset($data['time'], $data['count']) && $data['time'] > time() - $timeWindow) {
- if ($data['count'] >= $maxAttempts) {
- return false;
- }
- $data['count']++;
- } else {
- $data = ['count' => 1, 'time' => time()];
- }
-
- file_put_contents($cacheFile, json_encode($data), LOCK_EX);
- return true;
- }
-
- function getCountryFromIP(string $ip): array
- {
- $provider = env('GEO_PROVIDER', 'privacy-friendly');
- $modulePath = PM_ROOT . "/getCountryFrom/{$provider}.php";
-
- if (file_exists($modulePath)) {
- require_once $modulePath;
- if (function_exists('pm_fetch_location')) {
- $data = pm_fetch_location($ip);
- return [
- 'country' => $data['country'] ?? 'Unknown',
- 'code' => $data['code'] ?? 'XX',
- ];
- }
- }
-
- return ['country' => 'Unknown', 'code' => 'XX'];
- }
-
- function getDateRange(string $range = '7d'): array
- {
- $now = time();
- $end = $now;
-
- switch ($range) {
- case '24h':
- $start = strtotime('today 00:00:00');
- $end = strtotime('tomorrow 00:00:00') - 1;
- break;
- case '7d':
- $start = strtotime('-6 days 00:00:00');
- break;
- case '30d':
- $start = strtotime('-29 days 00:00:00');
- break;
- case '90d':
- $start = strtotime('-89 days 00:00:00');
- break;
- case '1y':
- $start = strtotime('first day of January ' . date('Y') . ' 00:00:00');
- $end = strtotime('last day of December ' . date('Y') . ' 23:59:59');
- break;
- default:
- $start = strtotime('-6 days 00:00:00');
- }
-
- return ['start' => $start, 'end' => $end];
- }
-
- function pmPixel(): string
- {
- return base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
- }
-
- function pmEmitPixelAndExit(): void
- {
- header('Content-Type: image/gif');
- header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
- header('Pragma: no-cache');
- echo pmPixel();
- exit;
- }
-
- function pmRateLimitFor(string $storageType): array
- {
- $limits = [
- 'xml' => ['requests' => (int) env('RATE_LIMIT_XML', '2'), 'window' => 1],
- 'mysql' => ['requests' => (int) env('RATE_LIMIT_MYSQL', '5'), 'window' => 1],
- 'pmaf' => ['requests' => (int) env('RATE_LIMIT_PMAF', '5'), 'window' => 1],
- ];
- return $limits[$storageType] ?? ['requests' => 2, 'window' => 1];
- }
-
- function pmDbParamsError(string $host, string $port, string $name): ?string
- {
- if (!preg_match('/^[A-Za-z0-9._:\[\]-]+$/', $host)) {
- return 'Invalid MySQL host.';
- }
- if (!ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) {
- return 'Invalid MySQL port.';
- }
- if (!preg_match('/^[A-Za-z0-9_$-]+$/', $name)) {
- return 'Invalid MySQL database name (allowed: letters, digits, _ $ -).';
- }
- return null;
- }
-
- function pmSecurityHeaders(): void
- {
- header('X-Content-Type-Options: nosniff');
- header('X-Frame-Options: DENY');
- header('Referrer-Policy: same-origin');
- }
-
- function extractUtmSource(string $pageUrl): string
- {
- $query = parse_url($pageUrl, PHP_URL_QUERY);
- if (!$query) return '';
- parse_str($query, $params);
- if (empty($params['utm_source'])) return '';
- return sanitize($params['utm_source']);
- }
-
- const PM_TOGGLEABLE_FEATURES = ['traffic_sources', 'visitor_countries', 'search_queries'];
-
- function pmFeatureGlobalEnabled(string $feature): bool
- {
- $map = [
- 'traffic_sources' => 'FEATURE_TRAFFIC_SOURCES',
- 'visitor_countries' => 'FEATURE_VISITOR_COUNTRIES',
- 'search_queries' => 'FEATURE_SEARCH_QUERIES',
- 'device_type' => 'FEATURE_DEVICE_TYPE',
- ];
- $key = $map[$feature] ?? null;
- if (!$key) return true;
- return env($key, 'true') === 'true';
- }
-
- function pmFeatureEnabledForSite(?array $site, string $feature): bool
- {
- $global = pmFeatureGlobalEnabled($feature);
- if (!$site) return $global;
-
- $overrideKey = $feature . '_mode';
- $mode = $site[$overrideKey] ?? 'inherit';
-
- if ($mode === 'on') return true;
- if ($mode === 'off') return false;
- return $global;
- }
-
- function pmValidFeatureMode(?string $mode): string
- {
- return in_array($mode, ['inherit', 'on', 'off'], true) ? $mode : 'inherit';
- }
-