WebOrbiton
v1.0.1

PriviMetrics 2

592 lines · 21.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. require_once __DIR__ . '/pmaf.php';
  5. ​
  6. class StorageManager
  7. {
  8. private ?PDO $pdo = null;
  9. private PmafStorage $pmaf;
  10. ​
  11. public function __construct()
  12. {
  13. if (env('DB_ENABLED', 'false') === 'true') {
  14. $this->connectMySQL();
  15. if ($this->pdo) {
  16. $this->ensureSchema();
  17. }
  18. }
  19. $this->pmaf = new PmafStorage(PM_DATA_DIR);
  20. }
  21. ​
  22. private function connectMySQL(): void
  23. {
  24. $host = env('DB_HOST', 'localhost');
  25. $port = env('DB_PORT', '3306');
  26. $name = env('DB_NAME', '');
  27. $user = env('DB_USER', '');
  28. $pass = env('DB_PASS', '');
  29. ​
  30. try {
  31. $this->pdo = new PDO(
  32. "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4",
  33. $user,
  34. $pass,
  35. [
  36. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  37. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  38. PDO::ATTR_EMULATE_PREPARES => false,
  39. ]
  40. );
  41. } catch (PDOException $e) {
  42. $this->pdo = null;
  43. error_log('PriviMetrics: MySQL connection failed: ' . $e->getMessage());
  44. }
  45. }
  46. ​
  47. public function isMySQLAvailable(): bool
  48. {
  49. return $this->pdo !== null;
  50. }
  51. ​
  52. public function pdo(): ?PDO
  53. {
  54. return $this->pdo;
  55. }
  56. ​
  57. public function ensureSchema(): void
  58. {
  59. if (!$this->pdo) return;
  60. ​
  61. $this->pdo->exec("
  62. CREATE TABLE IF NOT EXISTS pm_sites (
  63. id VARCHAR(32) PRIMARY KEY,
  64. name VARCHAR(255) NOT NULL,
  65. domain VARCHAR(255) NOT NULL,
  66. tracking_code VARCHAR(64) NOT NULL UNIQUE,
  67. storage ENUM('xml','mysql','pmaf') NOT NULL DEFAULT 'xml',
  68. restriction_mode ENUM('full','main','none') NOT NULL DEFAULT 'full',
  69. traffic_sources_mode ENUM('inherit','on','off') NOT NULL DEFAULT 'inherit',
  70. visitor_countries_mode ENUM('inherit','on','off') NOT NULL DEFAULT 'inherit',
  71. search_queries_mode ENUM('inherit','on','off') NOT NULL DEFAULT 'inherit',
  72. active TINYINT(1) NOT NULL DEFAULT 1,
  73. created_at INT NOT NULL,
  74. INDEX idx_tracking_code (tracking_code)
  75. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  76. ");
  77. ​
  78. foreach (
  79. [
  80. 'traffic_sources_mode' => "ALTER TABLE pm_sites ADD COLUMN traffic_sources_mode ENUM('inherit','on','off') NOT NULL DEFAULT 'inherit'",
  81. 'visitor_countries_mode' => "ALTER TABLE pm_sites ADD COLUMN visitor_countries_mode ENUM('inherit','on','off') NOT NULL DEFAULT 'inherit'",
  82. 'search_queries_mode' => "ALTER TABLE pm_sites ADD COLUMN search_queries_mode ENUM('inherit','on','off') NOT NULL DEFAULT 'inherit'",
  83. ] as $column => $alterSql
  84. ) {
  85. try {
  86. $exists = $this->pdo->query("SHOW COLUMNS FROM pm_sites LIKE '{$column}'")->fetch();
  87. if (!$exists) $this->pdo->exec($alterSql);
  88. } catch (PDOException $e) {
  89. }
  90. }
  91. ​
  92. $this->pdo->exec("
  93. CREATE TABLE IF NOT EXISTS pm_hits (
  94. id BIGINT AUTO_INCREMENT PRIMARY KEY,
  95. site_id VARCHAR(32) NOT NULL,
  96. ts INT NOT NULL,
  97. user_hash CHAR(32) NOT NULL,
  98. ip_partial VARCHAR(45) NOT NULL,
  99. country VARCHAR(100) NOT NULL DEFAULT 'Unknown',
  100. country_code CHAR(2) NOT NULL DEFAULT 'XX',
  101. user_agent VARCHAR(255) NOT NULL DEFAULT '',
  102. page_url VARCHAR(1024) NOT NULL DEFAULT '',
  103. page_title VARCHAR(512) NOT NULL DEFAULT '',
  104. search_query VARCHAR(255) NOT NULL DEFAULT '',
  105. referrer VARCHAR(255) NOT NULL DEFAULT '',
  106. utm_source VARCHAR(255) NOT NULL DEFAULT '',
  107. INDEX idx_site_ts (site_id, ts),
  108. INDEX idx_site_page (site_id, page_url(191))
  109. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  110. ");
  111. ​
  112. try {
  113. $exists = $this->pdo->query("SHOW COLUMNS FROM pm_hits LIKE 'utm_source'")->fetch();
  114. if (!$exists) {
  115. $this->pdo->exec("ALTER TABLE pm_hits ADD COLUMN utm_source VARCHAR(255) NOT NULL DEFAULT ''");
  116. }
  117. } catch (PDOException $e) {
  118. }
  119. ​
  120. $this->pdo->exec("
  121. CREATE TABLE IF NOT EXISTS pm_admin (
  122. id INT AUTO_INCREMENT PRIMARY KEY,
  123. username VARCHAR(100) NOT NULL UNIQUE,
  124. password_hash VARCHAR(255) NOT NULL,
  125. created_at INT NOT NULL
  126. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  127. ");
  128. }
  129. ​
  130. public function sitesBackend(): string
  131. {
  132. return $this->isMySQLAvailable() ? 'mysql' : 'xml';
  133. }
  134. ​
  135. public function listSites(): array
  136. {
  137. if ($this->sitesBackend() === 'mysql') {
  138. $stmt = $this->pdo->query("SELECT * FROM pm_sites ORDER BY created_at DESC");
  139. $sites = $stmt->fetchAll();
  140. } else {
  141. $sites = $this->xmlListSites();
  142. }
  143. ​
  144. return $this->applyCustomOrder($sites);
  145. }
  146. ​
  147. private function applyCustomOrder(array $sites): array
  148. {
  149. $orderRaw = env('SITE_ORDER', '');
  150. if ($orderRaw === '' || $orderRaw === null) {
  151. return $sites;
  152. }
  153. ​
  154. $order = array_filter(array_map('trim', explode(',', $orderRaw)), fn($id) => $id !== '');
  155. if (!$order) {
  156. return $sites;
  157. }
  158. ​
  159. $position = array_flip($order);
  160. $known = [];
  161. $unknown = [];
  162. ​
  163. foreach ($sites as $site) {
  164. if (isset($position[$site['id']])) {
  165. $known[$position[$site['id']]] = $site;
  166. } else {
  167. $unknown[] = $site;
  168. }
  169. }
  170. ​
  171. ksort($known);
  172. return array_merge(array_values($known), $unknown);
  173. }
  174. ​
  175. public function saveSiteOrder(array $orderedIds): bool
  176. {
  177. $validIds = array_column($this->listSitesUnordered(), 'id');
  178. $clean = array_values(array_intersect($orderedIds, $validIds));
  179. if (!$clean) return false;
  180. ​
  181. return envSet('SITE_ORDER', implode(',', $clean));
  182. }
  183. ​
  184. private function listSitesUnordered(): array
  185. {
  186. if ($this->sitesBackend() === 'mysql') {
  187. $stmt = $this->pdo->query("SELECT * FROM pm_sites ORDER BY created_at DESC");
  188. return $stmt->fetchAll();
  189. }
  190. return $this->xmlListSites();
  191. }
  192. ​
  193. public function getSite(string $id): ?array
  194. {
  195. foreach ($this->listSites() as $site) {
  196. if ($site['id'] === $id) return $site;
  197. }
  198. return null;
  199. }
  200. ​
  201. public function findSiteByTrackingCode(string $code): ?array
  202. {
  203. if ($this->sitesBackend() === 'mysql') {
  204. $stmt = $this->pdo->prepare("SELECT * FROM pm_sites WHERE tracking_code = ? AND active = 1 LIMIT 1");
  205. $stmt->execute([$code]);
  206. $row = $stmt->fetch();
  207. return $row ?: null;
  208. }
  209. foreach ($this->xmlListSites() as $site) {
  210. if ($site['tracking_code'] === $code && $site['active']) return $site;
  211. }
  212. return null;
  213. }
  214. ​
  215. public function createSite(
  216. string $name,
  217. string $domain,
  218. string $storage,
  219. string $restrictionMode,
  220. string $trafficSourcesMode = 'inherit',
  221. string $visitorCountriesMode = 'inherit',
  222. string $searchQueriesMode = 'inherit'
  223. ): array {
  224. $site = [
  225. 'id' => generateID(),
  226. 'name' => $name,
  227. 'domain' => $domain,
  228. 'tracking_code' => bin2hex(random_bytes(12)),
  229. 'storage' => $storage,
  230. 'restriction_mode' => $restrictionMode,
  231. 'traffic_sources_mode' => pmValidFeatureMode($trafficSourcesMode),
  232. 'visitor_countries_mode' => pmValidFeatureMode($visitorCountriesMode),
  233. 'search_queries_mode' => pmValidFeatureMode($searchQueriesMode),
  234. 'active' => 1,
  235. 'created_at' => time(),
  236. ];
  237. ​
  238. if ($this->sitesBackend() === 'mysql') {
  239. $stmt = $this->pdo->prepare("
  240. INSERT INTO pm_sites (id, name, domain, tracking_code, storage, restriction_mode, traffic_sources_mode, visitor_countries_mode, search_queries_mode, active, created_at)
  241. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  242. ");
  243. $stmt->execute([
  244. $site['id'],
  245. $site['name'],
  246. $site['domain'],
  247. $site['tracking_code'],
  248. $site['storage'],
  249. $site['restriction_mode'],
  250. $site['traffic_sources_mode'],
  251. $site['visitor_countries_mode'],
  252. $site['search_queries_mode'],
  253. $site['active'],
  254. $site['created_at'],
  255. ]);
  256. } else {
  257. $sites = $this->xmlListSites();
  258. $sites[] = $site;
  259. $this->xmlSaveSites($sites);
  260. }
  261. ​
  262. return $site;
  263. }
  264. ​
  265. public function updateSite(string $id, array $fields): bool
  266. {
  267. if ($this->sitesBackend() === 'mysql') {
  268. $allowed = ['name', 'domain', 'storage', 'restriction_mode', 'active', 'traffic_sources_mode', 'visitor_countries_mode', 'search_queries_mode'];
  269. $sets = [];
  270. $params = [];
  271. foreach ($fields as $k => $v) {
  272. if (!in_array($k, $allowed, true)) continue;
  273. $sets[] = "{$k} = ?";
  274. $params[] = $v;
  275. }
  276. if (!$sets) return false;
  277. $params[] = $id;
  278. $stmt = $this->pdo->prepare("UPDATE pm_sites SET " . implode(', ', $sets) . " WHERE id = ?");
  279. return $stmt->execute($params);
  280. }
  281. ​
  282. $sites = $this->xmlListSites();
  283. $found = false;
  284. foreach ($sites as &$site) {
  285. if ($site['id'] === $id) {
  286. foreach ($fields as $k => $v) {
  287. $site[$k] = $v;
  288. }
  289. $found = true;
  290. break;
  291. }
  292. }
  293. unset($site);
  294. if ($found) $this->xmlSaveSites($sites);
  295. return $found;
  296. }
  297. ​
  298. public function deleteSite(string $id): bool
  299. {
  300. $site = $this->getSite($id);
  301. if (!$site) return false;
  302. ​
  303. if ($this->sitesBackend() === 'mysql') {
  304. $stmt = $this->pdo->prepare("DELETE FROM pm_sites WHERE id = ?");
  305. $stmt->execute([$id]);
  306. } else {
  307. $sites = array_values(array_filter($this->xmlListSites(), fn($s) => $s['id'] !== $id));
  308. $this->xmlSaveSites($sites);
  309. }
  310. ​
  311. $hitsStorage = $site['storage'] ?? 'xml';
  312. if ($hitsStorage === 'mysql' && $this->isMySQLAvailable()) {
  313. $stmt = $this->pdo->prepare("DELETE FROM pm_hits WHERE site_id = ?");
  314. $stmt->execute([$id]);
  315. } elseif ($hitsStorage === 'pmaf') {
  316. $this->pmaf->deleteAll($id);
  317. } else {
  318. $file = PM_DATA_DIR . '/hits_' . $id . '.xml';
  319. if (file_exists($file)) unlink($file);
  320. }
  321. ​
  322. return true;
  323. }
  324. ​
  325. private function xmlSitesFile(): string
  326. {
  327. return PM_DATA_DIR . '/sites.xml';
  328. }
  329. ​
  330. private function xmlListSites(): array
  331. {
  332. $file = $this->xmlSitesFile();
  333. if (!file_exists($file)) return [];
  334. ​
  335. $xml = @simplexml_load_file($file);
  336. if ($xml === false) return [];
  337. ​
  338. $sites = [];
  339. foreach ($xml->site as $s) {
  340. $sites[] = [
  341. 'id' => (string) $s->id,
  342. 'name' => (string) $s->name,
  343. 'domain' => (string) $s->domain,
  344. 'tracking_code' => (string) $s->tracking_code,
  345. 'storage' => (string) $s->storage,
  346. 'restriction_mode' => (string) $s->restriction_mode,
  347. 'traffic_sources_mode' => pmValidFeatureMode((string) ($s->traffic_sources_mode ?? '') ?: 'inherit'),
  348. 'visitor_countries_mode' => pmValidFeatureMode((string) ($s->visitor_countries_mode ?? '') ?: 'inherit'),
  349. 'search_queries_mode' => pmValidFeatureMode((string) ($s->search_queries_mode ?? '') ?: 'inherit'),
  350. 'active' => (string) $s->active === '1' || (string) $s->active === 'true',
  351. 'created_at' => (int) $s->created_at,
  352. ];
  353. }
  354. return $sites;
  355. }
  356. ​
  357. private function xmlSaveSites(array $sites): void
  358. {
  359. if (!is_dir(PM_DATA_DIR)) mkdir(PM_DATA_DIR, 0755, true);
  360. ​
  361. $xml = new SimpleXMLElement('<sites></sites>');
  362. foreach ($sites as $s) {
  363. $node = $xml->addChild('site');
  364. $node->addChild('id', htmlspecialchars((string) $s['id']));
  365. $node->addChild('name', htmlspecialchars((string) $s['name']));
  366. $node->addChild('domain', htmlspecialchars((string) $s['domain']));
  367. $node->addChild('tracking_code', htmlspecialchars((string) $s['tracking_code']));
  368. $node->addChild('storage', htmlspecialchars((string) $s['storage']));
  369. $node->addChild('restriction_mode', htmlspecialchars((string) $s['restriction_mode']));
  370. $node->addChild('traffic_sources_mode', htmlspecialchars(pmValidFeatureMode($s['traffic_sources_mode'] ?? 'inherit')));
  371. $node->addChild('visitor_countries_mode', htmlspecialchars(pmValidFeatureMode($s['visitor_countries_mode'] ?? 'inherit')));
  372. $node->addChild('search_queries_mode', htmlspecialchars(pmValidFeatureMode($s['search_queries_mode'] ?? 'inherit')));
  373. $node->addChild('active', $s['active'] ? '1' : '0');
  374. $node->addChild('created_at', (string) $s['created_at']);
  375. }
  376. ​
  377. $dom = new DOMDocument('1.0', 'UTF-8');
  378. $dom->preserveWhiteSpace = false;
  379. $dom->formatOutput = true;
  380. $dom->loadXML($xml->asXML());
  381. $dom->save($this->xmlSitesFile());
  382. }
  383. ​
  384. public function saveHit(array $site, array $hit): void
  385. {
  386. $storage = $site['storage'] ?? 'xml';
  387. ​
  388. if ($storage === 'mysql' && $this->isMySQLAvailable()) {
  389. $this->mysqlSaveHit($site['id'], $hit);
  390. } elseif ($storage === 'pmaf') {
  391. $this->pmaf->appendHit($site['id'], $hit, time());
  392. } else {
  393. $this->xmlSaveHit($site['id'], $hit);
  394. }
  395. }
  396. ​
  397. private function mysqlSaveHit(string $siteId, array $hit): void
  398. {
  399. try {
  400. $stmt = $this->pdo->prepare("
  401. INSERT INTO pm_hits
  402. (site_id, ts, user_hash, ip_partial, country, country_code, user_agent, page_url, page_title, search_query, referrer, utm_source)
  403. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  404. ");
  405. $stmt->execute([
  406. $siteId,
  407. time(),
  408. $hit['user_hash'],
  409. $hit['ip'],
  410. $hit['country'],
  411. $hit['country_code'],
  412. $hit['user_agent'],
  413. $hit['page_url'],
  414. $hit['page_title'],
  415. $hit['search_query'],
  416. $hit['referrer'],
  417. $hit['utm_source'] ?? '',
  418. ]);
  419. } catch (PDOException $e) {
  420. error_log('PriviMetrics: mysqlSaveHit failed: ' . $e->getMessage());
  421. }
  422. }
  423. ​
  424. private function xmlHitsFile(string $siteId): string
  425. {
  426. return PM_DATA_DIR . '/hits_' . $siteId . '.xml';
  427. }
  428. ​
  429. private function xmlSaveHit(string $siteId, array $hit): void
  430. {
  431. if (!is_dir(PM_DATA_DIR)) mkdir(PM_DATA_DIR, 0755, true);
  432. $file = $this->xmlHitsFile($siteId);
  433. ​
  434. $lockFile = $file . '.lock';
  435. $fp = fopen($lockFile, 'c');
  436. if ($fp && flock($fp, LOCK_EX)) {
  437. if (file_exists($file)) {
  438. $xml = @simplexml_load_file($file);
  439. if ($xml === false) {
  440. $xml = new SimpleXMLElement('<hits></hits>');
  441. }
  442. } else {
  443. $xml = new SimpleXMLElement('<hits></hits>');
  444. }
  445. ​
  446. $node = $xml->addChild('hit');
  447. $node->addChild('ts', (string) time());
  448. $node->addChild('user_hash', htmlspecialchars($hit['user_hash']));
  449. $node->addChild('ip', htmlspecialchars($hit['ip']));
  450. $node->addChild('country', htmlspecialchars($hit['country']));
  451. $node->addChild('country_code', htmlspecialchars($hit['country_code']));
  452. $node->addChild('user_agent', htmlspecialchars($hit['user_agent']));
  453. $node->addChild('page_url', htmlspecialchars($hit['page_url']));
  454. $node->addChild('page_title', htmlspecialchars($hit['page_title']));
  455. $node->addChild('search_query', htmlspecialchars($hit['search_query']));
  456. $node->addChild('referrer', htmlspecialchars($hit['referrer']));
  457. $node->addChild('utm_source', htmlspecialchars($hit['utm_source'] ?? ''));
  458. ​
  459. $dom = new DOMDocument('1.0', 'UTF-8');
  460. $dom->preserveWhiteSpace = false;
  461. $dom->formatOutput = true;
  462. $dom->loadXML($xml->asXML());
  463. $dom->save($file);
  464. ​
  465. flock($fp, LOCK_UN);
  466. fclose($fp);
  467. }
  468. }
  469. ​
  470. public function getHits(array $site, int $start, int $end): array
  471. {
  472. $storage = $site['storage'] ?? 'xml';
  473. if ($storage === 'mysql' && $this->isMySQLAvailable()) {
  474. return $this->mysqlGetHits($site['id'], $start, $end);
  475. }
  476. if ($storage === 'pmaf') {
  477. return $this->pmaf->getHits($site['id'], $start, $end);
  478. }
  479. return $this->xmlGetHits($site['id'], $start, $end);
  480. }
  481. ​
  482. private function mysqlGetHits(string $siteId, int $start, int $end): array
  483. {
  484. $stmt = $this->pdo->prepare("
  485. SELECT * FROM pm_hits WHERE site_id = ? AND ts BETWEEN ? AND ? ORDER BY ts ASC
  486. ");
  487. $stmt->execute([$siteId, $start, $end]);
  488. return $stmt->fetchAll();
  489. }
  490. ​
  491. private function xmlGetHits(string $siteId, int $start, int $end): array
  492. {
  493. $file = $this->xmlHitsFile($siteId);
  494. if (!file_exists($file)) return [];
  495. ​
  496. $xml = @simplexml_load_file($file);
  497. if ($xml === false) return [];
  498. ​
  499. $hits = [];
  500. foreach ($xml->hit as $h) {
  501. $ts = (int) $h->ts;
  502. if ($ts < $start || $ts > $end) continue;
  503. $hits[] = [
  504. 'ts' => $ts,
  505. 'user_hash' => (string) $h->user_hash,
  506. 'ip_partial' => (string) $h->ip,
  507. 'country' => (string) $h->country,
  508. 'country_code' => (string) $h->country_code,
  509. 'user_agent' => (string) $h->user_agent,
  510. 'page_url' => (string) $h->page_url,
  511. 'page_title' => (string) $h->page_title,
  512. 'search_query' => (string) $h->search_query,
  513. 'referrer' => (string) $h->referrer,
  514. 'utm_source' => (string) ($h->utm_source ?? ''),
  515. ];
  516. }
  517. return $hits;
  518. }
  519. ​
  520. public function countAllHits(array $sites): int
  521. {
  522. $total = 0;
  523. foreach ($sites as $site) {
  524. $storage = $site['storage'] ?? 'xml';
  525. if ($storage === 'mysql' && $this->isMySQLAvailable()) {
  526. $stmt = $this->pdo->prepare("SELECT COUNT(*) c FROM pm_hits WHERE site_id = ?");
  527. $stmt->execute([$site['id']]);
  528. $total += (int) $stmt->fetch()['c'];
  529. } elseif ($storage === 'pmaf') {
  530. $total += $this->pmaf->countHits($site['id']);
  531. } else {
  532. $file = $this->xmlHitsFile($site['id']);
  533. if (file_exists($file)) {
  534. $xml = @simplexml_load_file($file);
  535. if ($xml !== false) $total += count($xml->hit);
  536. }
  537. }
  538. }
  539. return $total;
  540. }
  541. ​
  542. public function createAdmin(string $username, string $passwordHash): void
  543. {
  544. if (!$this->pdo) return;
  545. $stmt = $this->pdo->prepare("
  546. INSERT INTO pm_admin (username, password_hash, created_at) VALUES (?, ?, ?)
  547. ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)
  548. ");
  549. $stmt->execute([$username, $passwordHash, time()]);
  550. }
  551. ​
  552. public function cleanOldData(int $days = 365): void
  553. {
  554. $cutoff = time() - ($days * 86400);
  555. ​
  556. if ($this->isMySQLAvailable()) {
  557. $stmt = $this->pdo->prepare("DELETE FROM pm_hits WHERE ts < ?");
  558. $stmt->execute([$cutoff]);
  559. }
  560. ​
  561. foreach (glob(PM_DATA_DIR . '/hits_*.xml') as $file) {
  562. if (filemtime($file) < $cutoff) {
  563. $xml = @simplexml_load_file($file);
  564. if ($xml === false) continue;
  565. $kept = new SimpleXMLElement('<hits></hits>');
  566. foreach ($xml->hit as $h) {
  567. if ((int) $h->ts >= $cutoff) {
  568. $node = $kept->addChild('hit');
  569. foreach ($h->children() as $childName => $childVal) {
  570. $node->addChild($childName, htmlspecialchars((string) $childVal));
  571. }
  572. }
  573. }
  574. $dom = new DOMDocument('1.0', 'UTF-8');
  575. $dom->preserveWhiteSpace = false;
  576. $dom->formatOutput = true;
  577. $dom->loadXML($kept->asXML());
  578. $dom->save($file);
  579. }
  580. }
  581. ​
  582. foreach (glob(PM_DATA_DIR . '/hits_*.pmaf') as $file) {
  583. if (filemtime($file) < $cutoff) {
  584. preg_match('/hits_(.+)\.pmaf$/', $file, $m);
  585. if (isset($m[1])) {
  586. $this->pmaf->pruneOlderThan($m[1], $cutoff);
  587. }
  588. }
  589. }
  590. }
  591. }
  592. ​