WebOrbiton
v1.0.2

PriviMetrics 2

468 lines · 13.2 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. define('PM_ROOT', __DIR__);
  6. define('PM_DATA_DIR', PM_ROOT . '/data');
  7. define('PM_ENV_DIR', dirname(PM_ROOT));
  8. define('PM_ENV_FILE', PM_ENV_DIR . '/.pm-env');
  9. ​
  10. function envLoad(bool $forceReload = false): array
  11. {
  12. static $cache = null;
  13. if ($cache !== null && !$forceReload) return $cache;
  14. ​
  15. $cache = [];
  16. if (!file_exists(PM_ENV_FILE)) return $cache;
  17. ​
  18. $lines = file(PM_ENV_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
  19. foreach ($lines as $line) {
  20. $line = trim($line);
  21. if ($line === '' || str_starts_with($line, '#')) continue;
  22. if (!str_contains($line, '=')) continue;
  23. ​
  24. [$key, $value] = explode('=', $line, 2);
  25. $key = trim($key);
  26. $value = trim($value);
  27. ​
  28. if (strlen($value) >= 2) {
  29. $first = $value[0];
  30. $last = $value[strlen($value) - 1];
  31. if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
  32. $value = substr($value, 1, -1);
  33. }
  34. }
  35. $cache[$key] = $value;
  36. }
  37. return $cache;
  38. }
  39. ​
  40. function env(string $key, $default = null)
  41. {
  42. $env = envLoad();
  43. return $env[$key] ?? $default;
  44. }
  45. ​
  46. function envSet(string $key, string $value): bool
  47. {
  48. $lines = [];
  49. if (file_exists(PM_ENV_FILE)) {
  50. $raw = file(PM_ENV_FILE, FILE_IGNORE_NEW_LINES);
  51. if ($raw !== false) $lines = $raw;
  52. }
  53. ​
  54. $needsQuotes = $value === '' || preg_match('/[\s#"\']/', $value) === 1;
  55. $escaped = str_replace('"', '\\"', $value);
  56. $newLine = $key . '=' . ($needsQuotes ? '"' . $escaped . '"' : $escaped);
  57. ​
  58. $found = false;
  59. foreach ($lines as &$line) {
  60. $trimmed = trim($line);
  61. if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) continue;
  62. [$k] = explode('=', $trimmed, 2);
  63. if (trim($k) === $key) {
  64. $line = $newLine;
  65. $found = true;
  66. break;
  67. }
  68. }
  69. unset($line);
  70. ​
  71. if (!$found) {
  72. $lines[] = $newLine;
  73. }
  74. ​
  75. $lockFile = PM_ENV_FILE . '.lock';
  76. $fp = fopen($lockFile, 'c');
  77. $ok = false;
  78. if ($fp && flock($fp, LOCK_EX)) {
  79. $ok = file_put_contents(PM_ENV_FILE, implode("\n", $lines) . "\n", LOCK_EX) !== false;
  80. flock($fp, LOCK_UN);
  81. fclose($fp);
  82. }
  83. ​
  84. envLoad_resetCache();
  85. ​
  86. return $ok;
  87. }
  88. ​
  89. function envLoad_resetCache(): void
  90. {
  91. envLoad(true);
  92. }
  93. ​
  94. function envSetMany(array $updates): bool
  95. {
  96. $lines = [];
  97. if (file_exists(PM_ENV_FILE)) {
  98. $raw = file(PM_ENV_FILE, FILE_IGNORE_NEW_LINES);
  99. if ($raw !== false) $lines = $raw;
  100. }
  101. $seen = [];
  102. ​
  103. foreach ($lines as $i => $line) {
  104. $trimmed = trim($line);
  105. if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) continue;
  106. [$key] = explode('=', $trimmed, 2);
  107. $key = trim($key);
  108. if (array_key_exists($key, $updates)) {
  109. $val = (string) $updates[$key];
  110. $needsQuotes = $val === '' || preg_match('/\s|#/', $val) === 1;
  111. $lines[$i] = $key . '=' . ($needsQuotes ? '"' . str_replace('"', '\\"', $val) . '"' : $val);
  112. $seen[$key] = true;
  113. }
  114. }
  115. ​
  116. foreach ($updates as $k => $v) {
  117. if (isset($seen[$k])) continue;
  118. $v = (string) $v;
  119. $needsQuotes = $v === '' || preg_match('/\s|#/', $v) === 1;
  120. $lines[] = $k . '=' . ($needsQuotes ? '"' . str_replace('"', '\\"', $v) . '"' : $v);
  121. }
  122. ​
  123. $ok = file_put_contents(PM_ENV_FILE, implode("\n", $lines) . "\n") !== false;
  124. envLoad_resetCache();
  125. return $ok;
  126. }
  127. ​
  128. function envIsInstalled(): bool
  129. {
  130. return file_exists(PM_ENV_FILE) && env('APP_INSTALLED') === 'true';
  131. }
  132. ​
  133. function startSecureSession(): void
  134. {
  135. if (session_status() === PHP_SESSION_NONE) {
  136. ini_set('session.cookie_httponly', '1');
  137. ini_set('session.use_only_cookies', '1');
  138. ini_set('session.cookie_samesite', 'Lax');
  139. ini_set('session.cookie_lifetime', '0');
  140. ini_set('session.gc_maxlifetime', '86400');
  141. ​
  142. if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
  143. ini_set('session.cookie_secure', '1');
  144. }
  145. ​
  146. session_name('PRIVIMETRICS_SESSION');
  147. session_start();
  148. ​
  149. if (!isset($_SESSION['created'])) {
  150. $_SESSION['created'] = time();
  151. } elseif (time() - $_SESSION['created'] > 1800) {
  152. session_regenerate_id(true);
  153. $_SESSION['created'] = time();
  154. }
  155. }
  156. }
  157. ​
  158. function requireLogin(): void
  159. {
  160. startSecureSession();
  161. if (empty($_SESSION['admin_logged_in']) || $_SESSION['admin_logged_in'] !== true) {
  162. header('Location: login.php');
  163. exit;
  164. }
  165. $timeout = (int) env('SESSION_TIMEOUT', '86400');
  166. if (isset($_SESSION['login_time']) && (time() - $_SESSION['login_time']) > $timeout) {
  167. session_unset();
  168. session_destroy();
  169. header('Location: login.php?timeout=1');
  170. exit;
  171. }
  172. $_SESSION['last_activity'] = time();
  173. }
  174. ​
  175. function generateCSRFToken(): string
  176. {
  177. startSecureSession();
  178. if (empty($_SESSION['csrf_token'])) {
  179. $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
  180. }
  181. return $_SESSION['csrf_token'];
  182. }
  183. ​
  184. function verifyCSRFToken(?string $token): bool
  185. {
  186. startSecureSession();
  187. return !empty($_SESSION['csrf_token']) && is_string($token) && hash_equals($_SESSION['csrf_token'], $token);
  188. }
  189. ​
  190. function sanitize($input): string
  191. {
  192. return htmlspecialchars(strip_tags(trim((string) $input)), ENT_QUOTES, 'UTF-8');
  193. }
  194. ​
  195. function anonymizeIP(string $ip): string
  196. {
  197. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
  198. return preg_replace('/\.\d+$/', '.0', $ip);
  199. } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
  200. return preg_replace('/:[^:]+:[^:]+$/', ':0:0', $ip);
  201. }
  202. return '0.0.0.0';
  203. }
  204. ​
  205. function getClientIP(): string
  206. {
  207. return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
  208. }
  209. ​
  210. function pmDailySalt(): string
  211. {
  212. static $salt = null;
  213. if ($salt !== null) return $salt;
  214. ​
  215. $today = gmdate('Y-m-d');
  216. $dir = PM_DATA_DIR . '/cache';
  217. if (!is_dir($dir)) @mkdir($dir, 0755, true);
  218. ​
  219. $fp = @fopen($dir . '/daily-salt.json', 'c+');
  220. if (!$fp || !flock($fp, LOCK_EX)) {
  221. if ($fp) fclose($fp);
  222. return $salt = bin2hex(random_bytes(32));
  223. }
  224. ​
  225. $data = json_decode((string) stream_get_contents($fp), true);
  226. if (is_array($data) && ($data['date'] ?? '') === $today && preg_match('/^[a-f0-9]{64}$/', (string) ($data['salt'] ?? ''))) {
  227. $salt = $data['salt'];
  228. } else {
  229. $salt = bin2hex(random_bytes(32));
  230. ftruncate($fp, 0);
  231. rewind($fp);
  232. fwrite($fp, json_encode(['date' => $today, 'salt' => $salt]));
  233. fflush($fp);
  234. }
  235. flock($fp, LOCK_UN);
  236. fclose($fp);
  237. return $salt;
  238. }
  239. ​
  240. function pmVisitorHash(string $ip, string $userAgent, string $domain): string
  241. {
  242. return substr(hash_hmac('sha256', $ip . '|' . $userAgent . '|' . strtolower($domain), pmDailySalt()), 0, 32);
  243. }
  244. ​
  245. function getUserAgent(): string
  246. {
  247. return sanitize($_SERVER['HTTP_USER_AGENT'] ?? 'Unknown');
  248. }
  249. ​
  250. function isDNTEnabled(): bool
  251. {
  252. return isset($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1';
  253. }
  254. ​
  255. function hashPassword(string $password): string
  256. {
  257. return password_hash($password, PASSWORD_ARGON2ID);
  258. }
  259. ​
  260. function verifyPassword(string $password, string $hash): bool
  261. {
  262. return password_verify($password, $hash);
  263. }
  264. ​
  265. function generateID(): string
  266. {
  267. return bin2hex(random_bytes(4)) . uniqid('', true);
  268. }
  269. ​
  270. function formatNumber($number): string
  271. {
  272. $number = (float) $number;
  273. if ($number >= 1000000) return round($number / 1000000, 1) . 'm';
  274. if ($number >= 1000) return round($number / 1000, 1) . 'k';
  275. return (string) (int) $number;
  276. }
  277. ​
  278. function validateDomain(string $domain): bool
  279. {
  280. $domain = strtolower(trim($domain));
  281. return filter_var('http://' . $domain, FILTER_VALIDATE_URL) !== false;
  282. }
  283. ​
  284. function checkDomainMatch(string $allowed, string $current, string $mode): bool
  285. {
  286. $allowed = strtolower(trim($allowed));
  287. $current = strtolower(trim($current));
  288. ​
  289. $allowed = preg_replace('#^https?://#', '', $allowed);
  290. $allowed = preg_replace('#/.*$#', '', $allowed);
  291. $current = preg_replace('#^https?://#', '', $current);
  292. $current = preg_replace('#/.*$#', '', $current);
  293. ​
  294. switch ($mode) {
  295. case 'full':
  296. return $current === $allowed || (bool) preg_match('/\.' . preg_quote($allowed, '/') . '$/', $current);
  297. case 'main':
  298. return $current === $allowed;
  299. case 'none':
  300. return true;
  301. default:
  302. return false;
  303. }
  304. }
  305. ​
  306. function checkRateLimit(string $identifier, int $maxAttempts = 60, int $timeWindow = 60): bool
  307. {
  308. $cacheDir = PM_DATA_DIR . '/cache';
  309. if (!is_dir($cacheDir)) {
  310. mkdir($cacheDir, 0755, true);
  311. }
  312. $cacheFile = $cacheDir . '/rate_' . md5($identifier) . '.txt';
  313. ​
  314. $data = null;
  315. if (file_exists($cacheFile)) {
  316. $raw = file_get_contents($cacheFile);
  317. $data = $raw !== false ? json_decode($raw, true) : null;
  318. }
  319. ​
  320. if ($data && isset($data['time'], $data['count']) && $data['time'] > time() - $timeWindow) {
  321. if ($data['count'] >= $maxAttempts) {
  322. return false;
  323. }
  324. $data['count']++;
  325. } else {
  326. $data = ['count' => 1, 'time' => time()];
  327. }
  328. ​
  329. file_put_contents($cacheFile, json_encode($data), LOCK_EX);
  330. return true;
  331. }
  332. ​
  333. function getCountryFromIP(string $ip): array
  334. {
  335. $provider = env('GEO_PROVIDER', 'privacy-friendly');
  336. $modulePath = PM_ROOT . "/getCountryFrom/{$provider}.php";
  337. ​
  338. if (file_exists($modulePath)) {
  339. require_once $modulePath;
  340. if (function_exists('pm_fetch_location')) {
  341. $data = pm_fetch_location($ip);
  342. return [
  343. 'country' => $data['country'] ?? 'Unknown',
  344. 'code' => $data['code'] ?? 'XX',
  345. ];
  346. }
  347. }
  348. ​
  349. return ['country' => 'Unknown', 'code' => 'XX'];
  350. }
  351. ​
  352. function getDateRange(string $range = '7d'): array
  353. {
  354. $now = time();
  355. $end = $now;
  356. ​
  357. switch ($range) {
  358. case '24h':
  359. $start = strtotime('today 00:00:00');
  360. $end = strtotime('tomorrow 00:00:00') - 1;
  361. break;
  362. case '7d':
  363. $start = strtotime('-6 days 00:00:00');
  364. break;
  365. case '30d':
  366. $start = strtotime('-29 days 00:00:00');
  367. break;
  368. case '90d':
  369. $start = strtotime('-89 days 00:00:00');
  370. break;
  371. case '1y':
  372. $start = strtotime('first day of January ' . date('Y') . ' 00:00:00');
  373. $end = strtotime('last day of December ' . date('Y') . ' 23:59:59');
  374. break;
  375. default:
  376. $start = strtotime('-6 days 00:00:00');
  377. }
  378. ​
  379. return ['start' => $start, 'end' => $end];
  380. }
  381. ​
  382. function pmPixel(): string
  383. {
  384. return base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
  385. }
  386. ​
  387. function pmEmitPixelAndExit(): void
  388. {
  389. header('Content-Type: image/gif');
  390. header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
  391. header('Pragma: no-cache');
  392. echo pmPixel();
  393. exit;
  394. }
  395. ​
  396. function pmRateLimitFor(string $storageType): array
  397. {
  398. $limits = [
  399. 'xml' => ['requests' => (int) env('RATE_LIMIT_XML', '2'), 'window' => 1],
  400. 'mysql' => ['requests' => (int) env('RATE_LIMIT_MYSQL', '5'), 'window' => 1],
  401. 'pmaf' => ['requests' => (int) env('RATE_LIMIT_PMAF', '5'), 'window' => 1],
  402. ];
  403. return $limits[$storageType] ?? ['requests' => 2, 'window' => 1];
  404. }
  405. ​
  406. function pmDbParamsError(string $host, string $port, string $name): ?string
  407. {
  408. if (!preg_match('/^[A-Za-z0-9._:\[\]-]+$/', $host)) {
  409. return 'Invalid MySQL host.';
  410. }
  411. if (!ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) {
  412. return 'Invalid MySQL port.';
  413. }
  414. if (!preg_match('/^[A-Za-z0-9_$-]+$/', $name)) {
  415. return 'Invalid MySQL database name (allowed: letters, digits, _ $ -).';
  416. }
  417. return null;
  418. }
  419. ​
  420. function pmSecurityHeaders(): void
  421. {
  422. header('X-Content-Type-Options: nosniff');
  423. header('X-Frame-Options: DENY');
  424. header('Referrer-Policy: same-origin');
  425. }
  426. ​
  427. function extractUtmSource(string $pageUrl): string
  428. {
  429. $query = parse_url($pageUrl, PHP_URL_QUERY);
  430. if (!$query) return '';
  431. parse_str($query, $params);
  432. if (empty($params['utm_source'])) return '';
  433. return sanitize($params['utm_source']);
  434. }
  435. ​
  436. const PM_TOGGLEABLE_FEATURES = ['traffic_sources', 'visitor_countries', 'search_queries'];
  437. ​
  438. function pmFeatureGlobalEnabled(string $feature): bool
  439. {
  440. $map = [
  441. 'traffic_sources' => 'FEATURE_TRAFFIC_SOURCES',
  442. 'visitor_countries' => 'FEATURE_VISITOR_COUNTRIES',
  443. 'search_queries' => 'FEATURE_SEARCH_QUERIES',
  444. 'device_type' => 'FEATURE_DEVICE_TYPE',
  445. ];
  446. $key = $map[$feature] ?? null;
  447. if (!$key) return true;
  448. return env($key, 'true') === 'true';
  449. }
  450. ​
  451. function pmFeatureEnabledForSite(?array $site, string $feature): bool
  452. {
  453. $global = pmFeatureGlobalEnabled($feature);
  454. if (!$site) return $global;
  455. ​
  456. $overrideKey = $feature . '_mode';
  457. $mode = $site[$overrideKey] ?? 'inherit';
  458. ​
  459. if ($mode === 'on') return true;
  460. if ($mode === 'off') return false;
  461. return $global;
  462. }
  463. ​
  464. function pmValidFeatureMode(?string $mode): string
  465. {
  466. return in_array($mode, ['inherit', 'on', 'off'], true) ? $mode : 'inherit';
  467. }
  468. ​