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,427 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\HTTP\Response;
use Grav\Plugin\Api\Response\ApiResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\YamlFile;
class DashboardController extends AbstractApiController
{
/**
* GET /dashboard/notifications - Get system notifications.
*/
public function notifications(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$query = $request->getQueryParams();
$force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$user = $this->getUser($request);
$username = $user->get('username');
// Load cached notifications (v2 schema — see notifications2.md on getgrav.org)
$cacheFile = $this->grav['locator']->findResource(
'user://data/notifications/' . md5($username) . '_v2.yaml',
true,
true
);
$userStatusFile = $this->grav['locator']->findResource(
'user://data/notifications/' . $username . '.yaml',
true,
true
);
$notificationsFile = YamlFile::instance($cacheFile);
$notificationsContent = (array) $notificationsFile->content();
$userStatusContent = file_exists($userStatusFile)
? (array) YamlFile::instance($userStatusFile)->content()
: [];
$lastChecked = $notificationsContent['last_checked'] ?? null;
$notifications = $notificationsContent['data'] ?? [];
$timeout = $this->grav['config']->get('system.session.timeout', 1800);
// Refresh from remote if needed
if ($force || !$lastChecked || empty($notifications) || (time() - $lastChecked > $timeout)) {
try {
$body = Response::get('https://getgrav.org/notifications2.json?' . time());
$rawNotifications = json_decode($body, true);
if (is_array($rawNotifications)) {
// Sort by date descending
usort($rawNotifications, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? ''));
// Group by location
$notifications = [];
foreach ($rawNotifications as $notification) {
foreach ($notification['location'] ?? [] as $location) {
$notifications[$location][] = $notification;
}
}
$notificationsFile->content(['last_checked' => time(), 'data' => $notifications]);
$notificationsFile->save();
}
} catch (\Exception $e) {
// Use cached data on failure
}
}
// Let plugins contribute notifications (grouped by location: `top`,
// `dashboard`, `feed`). Fired after the remote refresh so plugin notices
// are merged fresh every request (never cached) yet still flow through
// the dismiss + reappear_after handling below — a plugin-provided `id`
// is dismissed via the same /notifications/{id}/hide endpoint. This is
// how a plugin can raise a persistent, dismissible admin banner.
$event = new Event([
'notifications' => $notifications,
'user' => $user,
'force' => $force,
]);
$this->grav->fireEvent('onApiDashboardNotifications', $event);
$contributed = $event['notifications'];
if (is_array($contributed)) {
$notifications = $contributed;
}
// Filter out hidden notifications
foreach ($notifications as $location => &$list) {
$list = array_values(array_filter($list, function ($notification) use ($userStatusContent) {
$hidden = $userStatusContent[$notification['id']] ?? null;
if ($hidden === null) {
return true;
}
// Check reappear_after
if (isset($notification['reappear_after'])) {
$now = new \DateTime();
$hiddenOn = new \DateTime($hidden);
$hiddenOn->modify($notification['reappear_after']);
return $now >= $hiddenOn;
}
return false;
}));
}
unset($list);
// Filter by location if requested
$filter = $query['location'] ?? null;
if ($filter) {
$notifications = [$filter => $notifications[$filter] ?? []];
}
return ApiResponse::create([
'notifications' => $notifications,
'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
]);
}
/**
* POST /dashboard/notifications/{id}/hide - Dismiss a notification.
*/
public function hideNotification(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.write');
$id = $this->getRouteParam($request, 'id');
$user = $this->getUser($request);
$username = $user->get('username');
$userStatusFile = $this->grav['locator']->findResource(
'user://data/notifications/' . $username . '.yaml',
true,
true
);
$file = YamlFile::instance($userStatusFile);
$content = (array) $file->content();
$content[$id] = date('Y-m-d H:i:s');
$file->content($content);
$file->save();
return ApiResponse::noContent();
}
/**
* GET /dashboard/feed - Get getgrav.org news feed as JSON.
*/
public function feed(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$query = $request->getQueryParams();
$force = filter_var($query['force'] ?? false, FILTER_VALIDATE_BOOLEAN);
$user = $this->getUser($request);
$username = $user->get('username');
$cacheFile = $this->grav['locator']->findResource(
'user://data/feed/' . md5($username) . '.yaml',
true,
true
);
$feedFile = YamlFile::instance($cacheFile);
$feedContent = (array) $feedFile->content();
$lastChecked = $feedContent['last_checked'] ?? null;
$feed = $feedContent['data'] ?? [];
$timeout = $this->grav['config']->get('system.session.timeout', 1800);
// Refresh from remote if needed
if ($force || !$lastChecked || empty($feed) || (time() - $lastChecked > $timeout)) {
try {
$body = Response::get('https://getgrav.org/blog.atom');
$xml = simplexml_load_string($body);
if ($xml) {
$feed = [];
$count = 0;
foreach ($xml->entry as $entry) {
if ($count >= 10) break;
$feed[] = [
'title' => (string) $entry->title,
'url' => (string) $entry->link['href'],
'date' => (string) $entry->updated,
'summary' => (string) ($entry->summary ?? ''),
];
$count++;
}
$feedFile->content(['last_checked' => time(), 'data' => $feed]);
$feedFile->save();
}
} catch (\Exception $e) {
// Use cached data on failure
}
}
return ApiResponse::create([
'feed' => $feed,
'last_checked' => $lastChecked ? date('c', $lastChecked) : null,
]);
}
/**
* GET /dashboard/stats - Dashboard statistics snapshot.
*/
public function stats(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
// Count pages
$pages = $this->grav['pages'];
$pages->enablePages();
$allPages = $pages->instances();
$totalPages = 0;
$publishedPages = 0;
foreach ($allPages as $page) {
// Skip the virtual pages-root container (no file on disk); the
// home page IS a real file-backed page with route '/'.
if (!$page->route() || !$page->exists()) {
continue;
}
$totalPages++;
if ($page->published()) {
$publishedPages++;
}
}
// Count users
$accountDir = $this->grav['locator']->findResource('account://', true);
$totalUsers = 0;
if ($accountDir && is_dir($accountDir)) {
$totalUsers = count(glob($accountDir . '/*.yaml'));
}
// Count plugins
$plugins = $this->grav['plugins']->all();
$activePlugins = 0;
foreach ($plugins as $name => $plugin) {
if ($this->grav['config']->get("plugins.{$name}.enabled", false)) {
$activePlugins++;
}
}
// Count themes
$themes = $this->grav['themes']->all();
$totalThemes = is_countable($themes) ? count($themes) : 0;
// Active theme
$activeTheme = $this->grav['config']->get('system.pages.theme');
// Count media files
$mediaDir = $this->grav['locator']->findResource('user://media', true)
?: $this->grav['locator']->findResource('user://images', true);
$totalMedia = 0;
if ($mediaDir && is_dir($mediaDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($mediaDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$totalMedia++;
}
}
}
// Last backup
$backupsDir = $this->grav['locator']->findResource('backup://', true);
$lastBackup = null;
if ($backupsDir && is_dir($backupsDir)) {
$backups = glob($backupsDir . '/*.zip');
if (!empty($backups)) {
$latest = max(array_map('filemtime', $backups));
$lastBackup = date('c', $latest);
}
}
$data = [
'pages' => [
'total' => $totalPages,
'published' => $publishedPages,
],
'users' => [
'total' => $totalUsers,
],
'plugins' => [
'total' => count($plugins),
'active' => $activePlugins,
],
'themes' => [
'total' => $totalThemes,
],
'media' => [
'total' => $totalMedia,
],
'theme' => $activeTheme,
'grav_version' => GRAV_VERSION,
'php_version' => PHP_VERSION,
'last_backup' => $lastBackup,
];
return ApiResponse::create($data);
}
/**
* GET /dashboard/security/exposure-probe
*
* Returns the public URL of a sentinel file under user/data plus the
* random token it contains. The dashboard fetches that URL directly from
* the browser: a 200 whose body matches the token means the sensitive
* user/ folders are reachable over the web (a misconfigured webserver),
* while a 403/404 means they are correctly blocked.
*
* The sentinel uses a `.dat` extension on purpose — that extension is not
* in the legacy per-extension blocklist, so it is only refused when the
* folder-wide block (Grav 2.0 / 1.7.53+) is actually in place. A plain
* `.txt`/`.yaml` probe would read as "safe" on installs that still expose
* certificates, keys and databases stored with other extensions.
*/
public function securityProbe(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$dataDir = $this->grav['locator']->findResource('user://data', true, true);
$available = false;
$token = '';
if ($dataDir) {
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0770, true);
}
$probeFile = $dataDir . '/grav-security-probe.dat';
// Reuse a stable token so concurrent dashboards don't race each
// other into writing different tokens.
if (is_file($probeFile)) {
$existing = trim((string) @file_get_contents($probeFile));
if (preg_match('/^[a-f0-9]{32,}$/', $existing)) {
$token = $existing;
}
}
if ($token === '') {
$token = bin2hex(random_bytes(16));
@file_put_contents($probeFile, $token);
}
$available = is_file($probeFile);
}
// Public URL to the sentinel, relative to the site web root (honours a
// custom GRAV_USER_PATH and a subfolder install).
$userPath = defined('GRAV_USER_PATH') ? trim(GRAV_USER_PATH, '/') : 'user';
$rootUrl = rtrim($this->grav['uri']->rootUrl(true), '/');
$url = $rootUrl . '/' . $userPath . '/data/grav-security-probe.dat';
return ApiResponse::create([
'url' => $url,
'token' => $token,
'available' => $available,
]);
}
/**
* GET /dashboard/popularity - Page view statistics.
*
* Reads from PopularityStore (single-file flat JSON, ISO date keys).
* On first read after an upgrade from admin-classic, the store imports
* the legacy four-JSON-file layout transparently.
*/
public function popularity(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.system.read');
$store = new \Grav\Plugin\Api\Popularity\PopularityStore();
$daily = $store->getDaily(365);
$monthly = $store->getMonthly(24);
$todayKey = date('Y-m-d');
$thisMonthKey = date('Y-m');
$todayViews = (int) ($daily[$todayKey] ?? 0);
// Sum last 7 days from ISO-keyed daily map
$weekViews = 0;
for ($i = 0; $i < 7; $i++) {
$day = date('Y-m-d', strtotime("-{$i} days"));
$weekViews += (int) ($daily[$day] ?? 0);
}
$monthViews = (int) ($monthly[$thisMonthKey] ?? 0);
// 14-day chart, oldest first
$chartData = [];
for ($i = 13; $i >= 0; $i--) {
$day = date('Y-m-d', strtotime("-{$i} days"));
$chartData[] = [
'date' => date('M j', strtotime("-{$i} days")),
'views' => (int) ($daily[$day] ?? 0),
];
}
$topPages = [];
foreach ($store->getTopPages(10) as $route => $views) {
$topPages[] = ['route' => $route, 'views' => (int) $views];
}
return ApiResponse::create([
'summary' => [
'today' => $todayViews,
'week' => $weekViews,
'month' => $monthViews,
],
'chart' => $chartData,
'top_pages' => $topPages,
]);
}
}