feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Popularity;
use Grav\Common\Grav;
/**
* Single-file flat-JSON storage for page-view popularity data.
*
* Replaces admin-classic's four-JSON-file scheme (daily.json, monthly.json,
* totals.json, visitors.json) with one combined `popularity.json` guarded
* by an exclusive flock(). Wins vs. the old design:
*
* - One file open + one lock per hit, vs. four uncoordinated read/writes
* that could race and silently corrupt each other.
* - `pages` (formerly `totals.json`) is capped at PAGES_CAP entries, so
* it can no longer grow unbounded with every URL ever visited.
* - ISO date keys (YYYY-MM-DD / YYYY-MM) sort lexicographically and are
* locale-stable, fixing the old `d-m-Y` ordering bug.
*
* On first construction in a site that still has the old four JSONs but no
* combined file yet, the store imports them once and renames them to
* `*.migrated` so nothing is lost and a re-run won't double-count.
*/
class PopularityStore
{
private const SCHEMA_VERSION = 2;
private const COMBINED_FILE = 'popularity.json';
private const PAGES_CAP = 500;
private const LEGACY_FILES = [
'daily' => 'daily.json',
'monthly' => 'monthly.json',
'totals' => 'totals.json',
'visitors' => 'visitors.json',
];
private string $dataDir;
private string $filePath;
public function __construct(?string $dataDir = null)
{
$this->dataDir = $dataDir ?? Grav::instance()['locator']
->findResource('log://popularity', true, true);
$this->filePath = $this->dataDir . '/' . self::COMBINED_FILE;
}
/**
* Record a single page hit. All four counters update inside one locked
* read-modify-write cycle, so a concurrent hit can't tear the file or
* lose updates.
*/
public function recordHit(
string $route,
string $ipHash,
?int $now = null,
int $dailyHistory = 30,
int $monthlyHistory = 12,
int $visitorHistory = 20,
): void {
$now ??= time();
$today = date('Y-m-d', $now);
$month = date('Y-m', $now);
$this->withLock(function (array $data) use (
$route, $ipHash, $now, $today, $month,
$dailyHistory, $monthlyHistory, $visitorHistory,
): array {
$data['daily'][$today] = ($data['daily'][$today] ?? 0) + 1;
$data['monthly'][$month] = ($data['monthly'][$month] ?? 0) + 1;
$data['pages'][$route] = ($data['pages'][$route] ?? 0) + 1;
$data['visitors'][$ipHash] = $now;
return $this->prune($data, $dailyHistory, $monthlyHistory, $visitorHistory);
});
}
public function getDaily(int $limit = 365): array
{
$data = $this->read();
$daily = $data['daily'] ?? [];
ksort($daily);
return array_slice($daily, -$limit, $limit, true);
}
public function getMonthly(int $limit = 24): array
{
$data = $this->read();
$monthly = $data['monthly'] ?? [];
ksort($monthly);
return array_slice($monthly, -$limit, $limit, true);
}
public function getTopPages(int $limit = 10): array
{
$data = $this->read();
$pages = $data['pages'] ?? [];
arsort($pages);
return array_slice($pages, 0, $limit, true);
}
public function getRecentVisitors(int $limit = 20): array
{
$data = $this->read();
$visitors = $data['visitors'] ?? [];
arsort($visitors);
return array_slice($visitors, 0, $limit, true);
}
public function flush(): void
{
$this->withLock(fn() => $this->emptyData());
}
/**
* Trim each section to its configured retention. Daily/monthly are
* trimmed by date threshold (not just count) so an old, never-pruned
* file gets cleaned up promptly. `pages` is capped to PAGES_CAP by
* descending views — pages with no recent traffic naturally fall off.
*/
private function prune(
array $data,
int $dailyHistory,
int $monthlyHistory,
int $visitorHistory,
): array {
$cutDay = date('Y-m-d', strtotime("-{$dailyHistory} days"));
$data['daily'] = array_filter(
$data['daily'] ?? [],
static fn($_, $k) => $k >= $cutDay,
ARRAY_FILTER_USE_BOTH,
);
$cutMonth = date('Y-m', strtotime("-{$monthlyHistory} months"));
$data['monthly'] = array_filter(
$data['monthly'] ?? [],
static fn($_, $k) => $k >= $cutMonth,
ARRAY_FILTER_USE_BOTH,
);
$pages = $data['pages'] ?? [];
if (count($pages) > self::PAGES_CAP) {
arsort($pages);
$pages = array_slice($pages, 0, self::PAGES_CAP, true);
}
$data['pages'] = $pages;
$visitors = $data['visitors'] ?? [];
if (count($visitors) > $visitorHistory) {
arsort($visitors);
$visitors = array_slice($visitors, 0, $visitorHistory, true);
}
$data['visitors'] = $visitors;
return $data;
}
/**
* Acquire exclusive lock, read current state (importing legacy files
* the first time), apply the mutator, write atomically.
*/
private function withLock(callable $mutator): void
{
if (!is_dir($this->dataDir)) {
mkdir($this->dataDir, 0755, true);
}
$fp = fopen($this->filePath, 'c+');
if ($fp === false) {
return;
}
try {
if (!flock($fp, LOCK_EX)) {
return;
}
$contents = stream_get_contents($fp);
$data = $this->decodeOrMigrate($contents);
$data = $mutator($data);
$data['version'] = self::SCHEMA_VERSION;
$encoded = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return;
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $encoded);
fflush($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
private function read(): array
{
if (!is_file($this->filePath)) {
// Trigger migration if legacy files exist but combined doesn't
if ($this->legacyFilesExist()) {
$this->withLock(static fn(array $d) => $d);
} else {
return $this->emptyData();
}
}
$fp = @fopen($this->filePath, 'r');
if ($fp === false) {
return $this->emptyData();
}
try {
if (!flock($fp, LOCK_SH)) {
return $this->emptyData();
}
$contents = stream_get_contents($fp);
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
return $this->decodeOrMigrate($contents);
}
private function decodeOrMigrate(string $contents): array
{
$data = $contents !== '' ? json_decode($contents, true) : null;
if (is_array($data) && isset($data['version'])) {
return $this->ensureSections($data);
}
// Either empty file, malformed JSON, or unversioned legacy state.
// Try to import legacy four-file data once.
return $this->importLegacy();
}
private function importLegacy(): array
{
$data = $this->emptyData();
$imported = false;
foreach (self::LEGACY_FILES as $type => $name) {
$path = $this->dataDir . '/' . $name;
if (!is_file($path)) {
continue;
}
$raw = @file_get_contents($path);
$legacy = $raw === false ? null : json_decode($raw, true);
if (!is_array($legacy)) {
@rename($path, $path . '.migrated');
continue;
}
switch ($type) {
case 'daily':
foreach ($legacy as $key => $count) {
$iso = self::convertDailyKey((string) $key);
if ($iso !== null) {
$data['daily'][$iso] = (int) $count;
}
}
break;
case 'monthly':
foreach ($legacy as $key => $count) {
$iso = self::convertMonthlyKey((string) $key);
if ($iso !== null) {
$data['monthly'][$iso] = (int) $count;
}
}
break;
case 'totals':
foreach ($legacy as $route => $count) {
$data['pages'][(string) $route] = (int) $count;
}
break;
case 'visitors':
foreach ($legacy as $hash => $ts) {
$data['visitors'][(string) $hash] = (int) $ts;
}
break;
}
@rename($path, $path . '.migrated');
$imported = true;
}
if ($imported) {
ksort($data['daily']);
ksort($data['monthly']);
}
return $data;
}
private function legacyFilesExist(): bool
{
foreach (self::LEGACY_FILES as $name) {
if (is_file($this->dataDir . '/' . $name)) {
return true;
}
}
return false;
}
private function emptyData(): array
{
return [
'version' => self::SCHEMA_VERSION,
'daily' => [],
'monthly' => [],
'pages' => [],
'visitors' => [],
];
}
private function ensureSections(array $data): array
{
return array_merge($this->emptyData(), $data);
}
private static function convertDailyKey(string $key): ?string
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $key)) {
return $key;
}
// Legacy d-m-Y → Y-m-d
if (preg_match('/^(\d{2})-(\d{2})-(\d{4})$/', $key, $m)) {
return $m[3] . '-' . $m[2] . '-' . $m[1];
}
return null;
}
private static function convertMonthlyKey(string $key): ?string
{
if (preg_match('/^\d{4}-\d{2}$/', $key)) {
return $key;
}
// Legacy m-Y → Y-m
if (preg_match('/^(\d{2})-(\d{4})$/', $key, $m)) {
return $m[2] . '-' . $m[1];
}
return null;
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Popularity;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Yaml;
/**
* Records page views into PopularityStore. Mirrors the behaviour of
* admin-classic's tracker (bot/DNT respect, configurable ignore globs)
* but writes to a SQLite database instead of four JSON files.
*/
class PopularityTracker
{
private Config $config;
private PopularityStore $store;
public function __construct(?PopularityStore $store = null)
{
$this->config = Grav::instance()['config'];
$this->store = $store ?? new PopularityStore();
}
public function trackHit(): void
{
if (!$this->config->get('plugins.api.popularity.enabled', true)) {
return;
}
$grav = Grav::instance();
if (!$grav['browser']->isHuman()) {
return;
}
if (!$grav['browser']->isTrackable()) {
return;
}
/** @var \Grav\Common\Page\Interfaces\PageInterface|null $page */
$page = $grav['page'] ?? null;
if ($page === null || !$page->route()) {
return;
}
if ($page->template() === 'error') {
return;
}
$route = $page->route();
$url = (string) str_replace($grav['base_url_relative'], '', $page->url());
foreach ((array) $this->config->get('plugins.api.popularity.ignore', []) as $ignore) {
if (fnmatch((string) $ignore, $url)) {
return;
}
}
try {
// Keyed HMAC over the visitor IP with a server-private salt.
// GDPR Recital 26 / Art. 4(1): plain sha1(ip) is reversible via
// a precomputed rainbow table of the ~4.3B IPv4 space (trivial
// on a modern GPU), so the hash remains personal data. Keying
// with a per-install secret the attacker can't compute against
// breaks that re-identification path while preserving stable
// bucketing for the unique-visitor counter.
$ipHash = hash_hmac('sha256', (string) $grav['uri']->ip(), $this->getSalt());
// Pruning happens inside recordHit() under the same lock — every
// write trims to the configured retention window, so the file
// can never grow beyond bounded size between hits.
$this->store->recordHit(
$route,
$ipHash,
null,
(int) $this->config->get('plugins.api.popularity.history.daily', 30),
(int) $this->config->get('plugins.api.popularity.history.monthly', 12),
(int) $this->config->get('plugins.api.popularity.history.visitors', 20),
);
} catch (\Throwable) {
// Tracking must never break the page response — swallow.
}
}
/**
* Read the popularity HMAC salt from config, auto-generating + persisting
* one on first use. The salt MUST stay stable across requests so the
* unique-visitor bucket for a given IP stays the same; regenerating per
* request would balloon the visitors map with duplicate entries.
*
* Stored under plugins.api.popularity.salt in user/config/plugins/api.yaml.
* Never shipped with a default — a committed salt would be globally known
* and defeat the keyed-hash protection entirely.
*/
private function getSalt(): string
{
$salt = (string) $this->config->get('plugins.api.popularity.salt', '');
if ($salt !== '') {
return $salt;
}
$salt = bin2hex(random_bytes(32));
$this->config->set('plugins.api.popularity.salt', $salt);
// Persist so subsequent requests reuse the same salt. If we can't
// write the file (perms, missing config stream), fall through with
// the in-memory salt — tracking still works for this request and we
// retry on the next hit.
$grav = Grav::instance();
$locator = $grav['locator'];
$file = $locator->findResource('config://plugins/api.yaml');
if (!$file) {
$configDir = $locator->findResource('config://', true);
if (!$configDir) {
if (isset($grav['log'])) {
$grav['log']->warning('api.popularity: could not resolve config:// stream to persist popularity salt; visitor counts may double until salt is configured.');
}
return $salt;
}
$file = $configDir . '/plugins/api.yaml';
}
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
if (isset($grav['log'])) {
$grav['log']->warning(sprintf('api.popularity: could not create %s to persist popularity salt.', $dir));
}
return $salt;
}
$yaml = Yaml::parse(file_exists($file) ? (string) file_get_contents($file) : '') ?? [];
$yaml['popularity']['salt'] = $salt;
if (@file_put_contents($file, Yaml::dump($yaml)) === false) {
if (isset($grav['log'])) {
$grav['log']->warning(sprintf('api.popularity: could not write popularity salt to %s — visitor counts may double until next successful write.', $file));
}
}
return $salt;
}
}