v1.0.1
PriviMetrics 2
- <?php
- declare(strict_types=1);
- final class PmafFormatException extends RuntimeException {}
- final class PmafStorage
- {
- private const MAGIC_DATA = 'PMAF';
- private const MAGIC_INDEX = 'PMFI';
- private const VERSION = 2;
- private const HEADER_LEN = 5;
- public function __construct(private string $dataDir) {}
- private function dataFile(string $siteId): string
- {
- return $this->dataDir . '/hits_' . $siteId . '.pmaf';
- }
- private function indexFile(string $siteId): string
- {
- return $this->dataDir . '/hits_' . $siteId . '.pmafidx';
- }
- private function lockFile(string $siteId): string
- {
- return $this->dataDir . '/hits_' . $siteId . '.pmaf.lock';
- }
- public function appendHit(string $siteId, array $hit, int $ts): void
- {
- if (!is_dir($this->dataDir)) {
- mkdir($this->dataDir, 0755, true);
- }
- $fp = fopen($this->lockFile($siteId), 'c');
- if (!$fp || !flock($fp, LOCK_EX)) {
- if ($fp) fclose($fp);
- return;
- }
- try {
- $file = $this->dataFile($siteId);
- $isNew = !file_exists($file) || filesize($file) === 0;
- $fileVersion = self::VERSION;
- if (!$isNew) {
- $headerByte = @file_get_contents($file, false, null, 4, 1);
- if ($headerByte !== false && $headerByte !== '') {
- $fileVersion = ord($headerByte);
- }
- }
- $handle = fopen($file, 'ab');
- if (!$handle) return;
- if ($isNew) {
- fwrite($handle, self::MAGIC_DATA . chr(self::VERSION));
- }
- $offsetOfRecord = fstat($handle)['size'];
- $record = $this->encodeRecord($hit, $ts, $fileVersion);
- fwrite($handle, $record);
- fclose($handle);
- $this->indexDayIfNeeded($siteId, $ts, $offsetOfRecord);
- } finally {
- flock($fp, LOCK_UN);
- fclose($fp);
- }
- }
- private function encodeRecord(array $hit, int $ts, int $fileVersion = self::VERSION): string
- {
- $userHashRaw = @hex2bin((string) ($hit['user_hash'] ?? ''));
- if ($userHashRaw === false || strlen($userHashRaw) !== 16) {
- $userHashRaw = str_repeat("\x00", 16);
- }
- $countryCode = substr(str_pad((string) ($hit['country_code'] ?? 'XX'), 2), 0, 2);
- $body = pack('N', $ts)
- . $userHashRaw
- . $this->packVar($hit['ip'] ?? '', 'C')
- . $this->packVar($hit['country'] ?? 'Unknown', 'C')
- . $countryCode
- . $this->packVar($hit['user_agent'] ?? '', 'n')
- . $this->packVar($hit['page_url'] ?? '', 'n')
- . $this->packVar($hit['page_title'] ?? '', 'n')
- . $this->packVar($hit['search_query'] ?? '', 'C')
- . $this->packVar($hit['referrer'] ?? '', 'C');
- if ($fileVersion >= 2) {
- $body .= $this->packVar($hit['utm_source'] ?? '', 'C');
- }
- return pack('N', strlen($body)) . $body;
- }
- private function packVar(string $value, string $lenFormat): string
- {
- $max = $lenFormat === 'C' ? 255 : 65535;
- if (strlen($value) > $max) {
- $value = substr($value, 0, $max);
- }
- return pack($lenFormat, strlen($value)) . $value;
- }
- private function unpackVar(string $data, int &$pos, string $lenFormat): string
- {
- $size = $lenFormat === 'C' ? 1 : 2;
- $len = unpack($lenFormat, substr($data, $pos, $size))[1];
- $pos += $size;
- $value = substr($data, $pos, $len);
- $pos += $len;
- return $value;
- }
- private function indexDayIfNeeded(string $siteId, int $ts, int $offsetOfRecord): void
- {
- $dayStart = $ts - ($ts % 86400);
- $indexFile = $this->indexFile($siteId);
- $lastDay = null;
- if (file_exists($indexFile) && filesize($indexFile) > self::HEADER_LEN) {
- $fp = fopen($indexFile, 'rb');
- fseek($fp, -12, SEEK_END);
- $tail = fread($fp, 12);
- fclose($fp);
- $lastDay = unpack('N', substr($tail, 0, 4))[1];
- }
- if ($lastDay === $dayStart) {
- return;
- }
- $handle = fopen($indexFile, 'ab');
- if (!$handle) return;
- if (fstat($handle)['size'] === 0) {
- fwrite($handle, self::MAGIC_INDEX . chr(self::VERSION));
- }
- fwrite($handle, pack('N', $dayStart) . $this->packU64($offsetOfRecord));
- fclose($handle);
- }
- private function packU64(int $value): string
- {
- $hi = ($value >> 32) & 0xFFFFFFFF;
- $lo = $value & 0xFFFFFFFF;
- return pack('N', $hi) . pack('N', $lo);
- }
- private function unpackU64(string $bytes): int
- {
- $parts = unpack('N2', $bytes);
- return ($parts[1] << 32) | $parts[2];
- }
- private function readIndex(string $siteId): array
- {
- $file = $this->indexFile($siteId);
- if (!file_exists($file)) return [];
- $data = file_get_contents($file);
- if ($data === false || strlen($data) < self::HEADER_LEN) return [];
- if (substr($data, 0, 4) !== self::MAGIC_INDEX) return [];
- $entries = [];
- $pos = self::HEADER_LEN;
- $len = strlen($data);
- while ($pos + 12 <= $len) {
- $day = unpack('N', substr($data, $pos, 4))[1];
- $pos += 4;
- $offset = $this->unpackU64(substr($data, $pos, 8));
- $pos += 8;
- $entries[$day] = $offset;
- }
- return $entries;
- }
- private function seekOffsetFor(string $siteId, int $start): int
- {
- $index = $this->readIndex($siteId);
- if (!$index) return self::HEADER_LEN;
- $dayStart = $start - ($start % 86400);
- $best = self::HEADER_LEN;
- foreach ($index as $day => $offset) {
- if ($day <= $dayStart) {
- $best = $offset;
- }
- }
- return $best;
- }
- public function getHits(string $siteId, int $start, int $end): array
- {
- $file = $this->dataFile($siteId);
- if (!file_exists($file)) return [];
- $data = file_get_contents($file);
- if ($data === false || strlen($data) < self::HEADER_LEN) return [];
- if (substr($data, 0, 4) !== self::MAGIC_DATA) return [];
- $fileVersion = ord($data[4]);
- $pos = $this->seekOffsetFor($siteId, $start);
- $len = strlen($data);
- $hits = [];
- while ($pos + 4 <= $len) {
- $recordLen = unpack('N', substr($data, $pos, 4))[1];
- $pos += 4;
- $recordEnd = $pos + $recordLen;
- if ($recordEnd > $len) break;
- $ts = unpack('N', substr($data, $pos, 4))[1];
- if ($ts > $end) break;
- if ($ts >= $start) {
- $fieldPos = $pos + 4;
- $userHash = bin2hex(substr($data, $fieldPos, 16));
- $fieldPos += 16;
- $ipPartial = $this->unpackVar($data, $fieldPos, 'C');
- $country = $this->unpackVar($data, $fieldPos, 'C');
- $countryCode = substr($data, $fieldPos, 2);
- $fieldPos += 2;
- $userAgent = $this->unpackVar($data, $fieldPos, 'n');
- $pageUrl = $this->unpackVar($data, $fieldPos, 'n');
- $pageTitle = $this->unpackVar($data, $fieldPos, 'n');
- $searchQuery = $this->unpackVar($data, $fieldPos, 'C');
- $referrer = $this->unpackVar($data, $fieldPos, 'C');
- $utmSource = $fileVersion >= 2 ? $this->unpackVar($data, $fieldPos, 'C') : '';
- $hits[] = [
- 'ts' => $ts,
- 'user_hash' => $userHash,
- 'ip_partial' => $ipPartial,
- 'country' => $country,
- 'country_code' => $countryCode,
- 'user_agent' => $userAgent,
- 'page_url' => $pageUrl,
- 'page_title' => $pageTitle,
- 'search_query' => $searchQuery,
- 'referrer' => $referrer,
- 'utm_source' => $utmSource,
- ];
- }
- $pos = $recordEnd;
- }
- return $hits;
- }
- public function countHits(string $siteId): int
- {
- $file = $this->dataFile($siteId);
- if (!file_exists($file)) return 0;
- $data = file_get_contents($file);
- if ($data === false || strlen($data) < self::HEADER_LEN) return 0;
- if (substr($data, 0, 4) !== self::MAGIC_DATA) return 0;
- $pos = self::HEADER_LEN;
- $len = strlen($data);
- $count = 0;
- while ($pos + 4 <= $len) {
- $recordLen = unpack('N', substr($data, $pos, 4))[1];
- $pos += 4 + $recordLen;
- if ($pos > $len) break;
- $count++;
- }
- return $count;
- }
- public function pruneOlderThan(string $siteId, int $cutoff): void
- {
- $file = $this->dataFile($siteId);
- if (!file_exists($file)) return;
- $data = file_get_contents($file);
- if ($data === false || strlen($data) < self::HEADER_LEN) return;
- if (substr($data, 0, 4) !== self::MAGIC_DATA) return;
- $fp = fopen($this->lockFile($siteId), 'c');
- if (!$fp || !flock($fp, LOCK_EX)) {
- if ($fp) fclose($fp);
- return;
- }
- try {
- $originalVersion = ord($data[4]);
- $pos = self::HEADER_LEN;
- $len = strlen($data);
- $kept = self::MAGIC_DATA . chr($originalVersion);
- $indexEntries = [];
- while ($pos + 4 <= $len) {
- $recordLen = unpack('N', substr($data, $pos, 4))[1];
- $recordStart = $pos;
- $pos += 4;
- $recordEnd = $pos + $recordLen;
- if ($recordEnd > $len) break;
- $ts = unpack('N', substr($data, $pos, 4))[1];
- if ($ts >= $cutoff) {
- $dayStart = $ts - ($ts % 86400);
- if (!isset($indexEntries[$dayStart])) {
- $indexEntries[$dayStart] = strlen($kept);
- }
- $kept .= substr($data, $recordStart, 4 + $recordLen);
- }
- $pos = $recordEnd;
- }
- file_put_contents($file, $kept);
- $indexData = self::MAGIC_INDEX . chr(self::VERSION);
- ksort($indexEntries);
- foreach ($indexEntries as $day => $offset) {
- $indexData .= pack('N', $day) . $this->packU64($offset);
- }
- file_put_contents($this->indexFile($siteId), $indexData);
- } finally {
- flock($fp, LOCK_UN);
- fclose($fp);
- }
- }
- public function deleteAll(string $siteId): void
- {
- @unlink($this->dataFile($siteId));
- @unlink($this->indexFile($siteId));
- @unlink($this->lockFile($siteId));
- }
- }
-