WebOrbiton
v1.0.1

PriviMetrics 2

343 lines · 11.1 KB
  1. <?php
  2. declare(strict_types=1);
  3. final class PmafFormatException extends RuntimeException {}
  4. final class PmafStorage
  5. {
  6. private const MAGIC_DATA = 'PMAF';
  7. private const MAGIC_INDEX = 'PMFI';
  8. private const VERSION = 2;
  9. private const HEADER_LEN = 5;
  10. public function __construct(private string $dataDir) {}
  11. private function dataFile(string $siteId): string
  12. {
  13. return $this->dataDir . '/hits_' . $siteId . '.pmaf';
  14. }
  15. private function indexFile(string $siteId): string
  16. {
  17. return $this->dataDir . '/hits_' . $siteId . '.pmafidx';
  18. }
  19. private function lockFile(string $siteId): string
  20. {
  21. return $this->dataDir . '/hits_' . $siteId . '.pmaf.lock';
  22. }
  23. public function appendHit(string $siteId, array $hit, int $ts): void
  24. {
  25. if (!is_dir($this->dataDir)) {
  26. mkdir($this->dataDir, 0755, true);
  27. }
  28. $fp = fopen($this->lockFile($siteId), 'c');
  29. if (!$fp || !flock($fp, LOCK_EX)) {
  30. if ($fp) fclose($fp);
  31. return;
  32. }
  33. try {
  34. $file = $this->dataFile($siteId);
  35. $isNew = !file_exists($file) || filesize($file) === 0;
  36. $fileVersion = self::VERSION;
  37. if (!$isNew) {
  38. $headerByte = @file_get_contents($file, false, null, 4, 1);
  39. if ($headerByte !== false && $headerByte !== '') {
  40. $fileVersion = ord($headerByte);
  41. }
  42. }
  43. $handle = fopen($file, 'ab');
  44. if (!$handle) return;
  45. if ($isNew) {
  46. fwrite($handle, self::MAGIC_DATA . chr(self::VERSION));
  47. }
  48. $offsetOfRecord = fstat($handle)['size'];
  49. $record = $this->encodeRecord($hit, $ts, $fileVersion);
  50. fwrite($handle, $record);
  51. fclose($handle);
  52. $this->indexDayIfNeeded($siteId, $ts, $offsetOfRecord);
  53. } finally {
  54. flock($fp, LOCK_UN);
  55. fclose($fp);
  56. }
  57. }
  58. private function encodeRecord(array $hit, int $ts, int $fileVersion = self::VERSION): string
  59. {
  60. $userHashRaw = @hex2bin((string) ($hit['user_hash'] ?? ''));
  61. if ($userHashRaw === false || strlen($userHashRaw) !== 16) {
  62. $userHashRaw = str_repeat("\x00", 16);
  63. }
  64. $countryCode = substr(str_pad((string) ($hit['country_code'] ?? 'XX'), 2), 0, 2);
  65. $body = pack('N', $ts)
  66. . $userHashRaw
  67. . $this->packVar($hit['ip'] ?? '', 'C')
  68. . $this->packVar($hit['country'] ?? 'Unknown', 'C')
  69. . $countryCode
  70. . $this->packVar($hit['user_agent'] ?? '', 'n')
  71. . $this->packVar($hit['page_url'] ?? '', 'n')
  72. . $this->packVar($hit['page_title'] ?? '', 'n')
  73. . $this->packVar($hit['search_query'] ?? '', 'C')
  74. . $this->packVar($hit['referrer'] ?? '', 'C');
  75. if ($fileVersion >= 2) {
  76. $body .= $this->packVar($hit['utm_source'] ?? '', 'C');
  77. }
  78. return pack('N', strlen($body)) . $body;
  79. }
  80. private function packVar(string $value, string $lenFormat): string
  81. {
  82. $max = $lenFormat === 'C' ? 255 : 65535;
  83. if (strlen($value) > $max) {
  84. $value = substr($value, 0, $max);
  85. }
  86. return pack($lenFormat, strlen($value)) . $value;
  87. }
  88. private function unpackVar(string $data, int &$pos, string $lenFormat): string
  89. {
  90. $size = $lenFormat === 'C' ? 1 : 2;
  91. $len = unpack($lenFormat, substr($data, $pos, $size))[1];
  92. $pos += $size;
  93. $value = substr($data, $pos, $len);
  94. $pos += $len;
  95. return $value;
  96. }
  97. private function indexDayIfNeeded(string $siteId, int $ts, int $offsetOfRecord): void
  98. {
  99. $dayStart = $ts - ($ts % 86400);
  100. $indexFile = $this->indexFile($siteId);
  101. $lastDay = null;
  102. if (file_exists($indexFile) && filesize($indexFile) > self::HEADER_LEN) {
  103. $fp = fopen($indexFile, 'rb');
  104. fseek($fp, -12, SEEK_END);
  105. $tail = fread($fp, 12);
  106. fclose($fp);
  107. $lastDay = unpack('N', substr($tail, 0, 4))[1];
  108. }
  109. if ($lastDay === $dayStart) {
  110. return;
  111. }
  112. $handle = fopen($indexFile, 'ab');
  113. if (!$handle) return;
  114. if (fstat($handle)['size'] === 0) {
  115. fwrite($handle, self::MAGIC_INDEX . chr(self::VERSION));
  116. }
  117. fwrite($handle, pack('N', $dayStart) . $this->packU64($offsetOfRecord));
  118. fclose($handle);
  119. }
  120. private function packU64(int $value): string
  121. {
  122. $hi = ($value >> 32) & 0xFFFFFFFF;
  123. $lo = $value & 0xFFFFFFFF;
  124. return pack('N', $hi) . pack('N', $lo);
  125. }
  126. private function unpackU64(string $bytes): int
  127. {
  128. $parts = unpack('N2', $bytes);
  129. return ($parts[1] << 32) | $parts[2];
  130. }
  131. private function readIndex(string $siteId): array
  132. {
  133. $file = $this->indexFile($siteId);
  134. if (!file_exists($file)) return [];
  135. $data = file_get_contents($file);
  136. if ($data === false || strlen($data) < self::HEADER_LEN) return [];
  137. if (substr($data, 0, 4) !== self::MAGIC_INDEX) return [];
  138. $entries = [];
  139. $pos = self::HEADER_LEN;
  140. $len = strlen($data);
  141. while ($pos + 12 <= $len) {
  142. $day = unpack('N', substr($data, $pos, 4))[1];
  143. $pos += 4;
  144. $offset = $this->unpackU64(substr($data, $pos, 8));
  145. $pos += 8;
  146. $entries[$day] = $offset;
  147. }
  148. return $entries;
  149. }
  150. private function seekOffsetFor(string $siteId, int $start): int
  151. {
  152. $index = $this->readIndex($siteId);
  153. if (!$index) return self::HEADER_LEN;
  154. $dayStart = $start - ($start % 86400);
  155. $best = self::HEADER_LEN;
  156. foreach ($index as $day => $offset) {
  157. if ($day <= $dayStart) {
  158. $best = $offset;
  159. }
  160. }
  161. return $best;
  162. }
  163. public function getHits(string $siteId, int $start, int $end): array
  164. {
  165. $file = $this->dataFile($siteId);
  166. if (!file_exists($file)) return [];
  167. $data = file_get_contents($file);
  168. if ($data === false || strlen($data) < self::HEADER_LEN) return [];
  169. if (substr($data, 0, 4) !== self::MAGIC_DATA) return [];
  170. $fileVersion = ord($data[4]);
  171. $pos = $this->seekOffsetFor($siteId, $start);
  172. $len = strlen($data);
  173. $hits = [];
  174. while ($pos + 4 <= $len) {
  175. $recordLen = unpack('N', substr($data, $pos, 4))[1];
  176. $pos += 4;
  177. $recordEnd = $pos + $recordLen;
  178. if ($recordEnd > $len) break;
  179. $ts = unpack('N', substr($data, $pos, 4))[1];
  180. if ($ts > $end) break;
  181. if ($ts >= $start) {
  182. $fieldPos = $pos + 4;
  183. $userHash = bin2hex(substr($data, $fieldPos, 16));
  184. $fieldPos += 16;
  185. $ipPartial = $this->unpackVar($data, $fieldPos, 'C');
  186. $country = $this->unpackVar($data, $fieldPos, 'C');
  187. $countryCode = substr($data, $fieldPos, 2);
  188. $fieldPos += 2;
  189. $userAgent = $this->unpackVar($data, $fieldPos, 'n');
  190. $pageUrl = $this->unpackVar($data, $fieldPos, 'n');
  191. $pageTitle = $this->unpackVar($data, $fieldPos, 'n');
  192. $searchQuery = $this->unpackVar($data, $fieldPos, 'C');
  193. $referrer = $this->unpackVar($data, $fieldPos, 'C');
  194. $utmSource = $fileVersion >= 2 ? $this->unpackVar($data, $fieldPos, 'C') : '';
  195. $hits[] = [
  196. 'ts' => $ts,
  197. 'user_hash' => $userHash,
  198. 'ip_partial' => $ipPartial,
  199. 'country' => $country,
  200. 'country_code' => $countryCode,
  201. 'user_agent' => $userAgent,
  202. 'page_url' => $pageUrl,
  203. 'page_title' => $pageTitle,
  204. 'search_query' => $searchQuery,
  205. 'referrer' => $referrer,
  206. 'utm_source' => $utmSource,
  207. ];
  208. }
  209. $pos = $recordEnd;
  210. }
  211. return $hits;
  212. }
  213. public function countHits(string $siteId): int
  214. {
  215. $file = $this->dataFile($siteId);
  216. if (!file_exists($file)) return 0;
  217. $data = file_get_contents($file);
  218. if ($data === false || strlen($data) < self::HEADER_LEN) return 0;
  219. if (substr($data, 0, 4) !== self::MAGIC_DATA) return 0;
  220. $pos = self::HEADER_LEN;
  221. $len = strlen($data);
  222. $count = 0;
  223. while ($pos + 4 <= $len) {
  224. $recordLen = unpack('N', substr($data, $pos, 4))[1];
  225. $pos += 4 + $recordLen;
  226. if ($pos > $len) break;
  227. $count++;
  228. }
  229. return $count;
  230. }
  231. public function pruneOlderThan(string $siteId, int $cutoff): void
  232. {
  233. $file = $this->dataFile($siteId);
  234. if (!file_exists($file)) return;
  235. $data = file_get_contents($file);
  236. if ($data === false || strlen($data) < self::HEADER_LEN) return;
  237. if (substr($data, 0, 4) !== self::MAGIC_DATA) return;
  238. $fp = fopen($this->lockFile($siteId), 'c');
  239. if (!$fp || !flock($fp, LOCK_EX)) {
  240. if ($fp) fclose($fp);
  241. return;
  242. }
  243. try {
  244. $originalVersion = ord($data[4]);
  245. $pos = self::HEADER_LEN;
  246. $len = strlen($data);
  247. $kept = self::MAGIC_DATA . chr($originalVersion);
  248. $indexEntries = [];
  249. while ($pos + 4 <= $len) {
  250. $recordLen = unpack('N', substr($data, $pos, 4))[1];
  251. $recordStart = $pos;
  252. $pos += 4;
  253. $recordEnd = $pos + $recordLen;
  254. if ($recordEnd > $len) break;
  255. $ts = unpack('N', substr($data, $pos, 4))[1];
  256. if ($ts >= $cutoff) {
  257. $dayStart = $ts - ($ts % 86400);
  258. if (!isset($indexEntries[$dayStart])) {
  259. $indexEntries[$dayStart] = strlen($kept);
  260. }
  261. $kept .= substr($data, $recordStart, 4 + $recordLen);
  262. }
  263. $pos = $recordEnd;
  264. }
  265. file_put_contents($file, $kept);
  266. $indexData = self::MAGIC_INDEX . chr(self::VERSION);
  267. ksort($indexEntries);
  268. foreach ($indexEntries as $day => $offset) {
  269. $indexData .= pack('N', $day) . $this->packU64($offset);
  270. }
  271. file_put_contents($this->indexFile($siteId), $indexData);
  272. } finally {
  273. flock($fp, LOCK_UN);
  274. fclose($fp);
  275. }
  276. }
  277. public function deleteAll(string $siteId): void
  278. {
  279. @unlink($this->dataFile($siteId));
  280. @unlink($this->indexFile($siteId));
  281. @unlink($this->lockFile($siteId));
  282. }
  283. }
  284. ​