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
+328
View File
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin;
use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Processors\Events\RequestHandlerEvent;
use Grav\Common\Utils;
use Grav\Events\PermissionsRegisterEvent;
use Grav\Framework\Acl\PermissionsReader;
use Grav\Plugin\Api\ApiRouter;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Popularity\PopularityTracker;
use Grav\Plugin\Api\Webhooks\WebhookDispatcher;
use RocketTheme\Toolbox\Event\Event;
class ApiPlugin extends Plugin
{
public $features = [
'blueprints' => 1000,
];
protected $active = false;
protected string $base = '';
protected string $apiRoute = '';
public static function getSubscribedEvents(): array
{
return [
'onPluginsInitialized' => [
['setup', 100000],
['onPluginsInitialized', 1001],
],
'onRequestHandlerInit' => [
['onRequestHandlerInit', 99000],
],
'onBeforeCacheClear' => ['onBeforeCacheClear', 0],
PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000],
];
}
public function autoload(): ClassLoader
{
return require __DIR__ . '/vendor/autoload.php';
}
/**
* Early setup - determine if we're on an API route.
*/
public function setup(): void
{
$route = $this->config->get('plugins.api.route');
if (!$route) {
return;
}
$this->base = '/' . trim($route, '/');
$prefix = $this->config->get('plugins.api.version_prefix', 'v1');
$this->apiRoute = $this->base . '/' . $prefix;
$uri = $this->grav['uri'];
$currentPath = $uri->path();
// On subpath installs (e.g. /sync-testing/grav-c) $uri->path() may
// include Grav's base; strip it before testing the api prefix so
// the plugin still activates and the api router gets installed.
$gravBase = rtrim((string)$uri->rootUrl(false), '/');
if ($gravBase !== '' && str_starts_with($currentPath, $gravBase)) {
$currentPath = substr($currentPath, strlen($gravBase)) ?: '/';
}
if (str_starts_with($currentPath, $this->base)) {
$this->active = true;
}
}
public function onPluginsInitialized(): void
{
// Register webhook event listeners (always active, not just on API routes)
$this->registerWebhookListeners();
// Page-view tracking subscribes for FRONTEND requests only — the
// handler itself short-circuits for admin/API/non-page requests.
if (!$this->active && !$this->isAdmin()) {
$this->enable([
'onPageInitialized' => ['onFrontendPageInitialized', 0],
]);
}
if ($this->active) {
// Disable pages processing for API requests - we don't need Twig/templates
$this->grav['pages']->disablePages();
// Register the plugin's templates path so server-side operations
// that need to render Twig (e.g. password reset emails composed
// by AuthController) can find emails/api/*.html.twig.
$this->enable([
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
]);
return;
}
// Handle admin API key tasks and templates
if ($this->isAdmin()) {
// Intercept API key tasks early, before admin's Flex routing
$this->handleAdminApiKeyTask();
$this->enable([
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
'onTwigExtensions' => ['onTwigExtensions', 0],
]);
}
}
/**
* Register Twig function to read API keys from centralized store.
*/
public function onTwigExtensions(): void
{
$manager = new ApiKeyManager();
$this->grav['twig']->twig()->addFunction(
new \Twig\TwigFunction('api_keys_for_user', function (string $username) use ($manager) {
$accounts = $this->grav['accounts'];
$user = $accounts->load($username);
if (!$user->exists()) {
return [];
}
return $manager->listKeys($user);
})
);
}
/**
* Check for and handle API key admin tasks directly.
* This runs before admin's Flex controller, which doesn't fire onAdminTaskExecute.
*/
protected function handleAdminApiKeyTask(): void
{
$uri = $this->grav['uri'];
$task = $uri->param('task') ?? $_POST['task'] ?? null;
if (!$task || !in_array($task, ['apiKeyGenerate', 'apiKeyRevoke'], true)) {
return;
}
// Validate nonce
$nonce = $uri->param('admin-nonce') ?? $_POST['admin-nonce'] ?? null;
if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
$this->outputJson(['status' => 'error', 'message' => 'Invalid security nonce.']);
}
// Verify admin is logged in
$this->grav['session']->init();
$user = $this->grav['session']->user ?? null;
if (!$user || !$user->authorized || !$user->authorize('admin.login')) {
$this->outputJson(['status' => 'error', 'message' => 'Not authorized.']);
}
match ($task) {
'apiKeyGenerate' => $this->handleApiKeyGenerate(),
'apiKeyRevoke' => $this->handleApiKeyRevoke(),
};
}
protected function handleApiKeyGenerate(): void
{
$post = $_POST;
$username = $this->getAdminRouteUsername();
if (!$username) {
$this->outputJson(['status' => 'error', 'message' => 'Could not determine username.']);
}
$user = $this->grav['accounts']->load($username);
if (!$user->exists()) {
$this->outputJson(['status' => 'error', 'message' => "User '{$username}' not found."]);
}
$name = $post['name'] ?? 'API Key';
$expiryDays = !empty($post['expiry_days']) ? (int) $post['expiry_days'] : null;
$manager = new ApiKeyManager();
$result = $manager->generateKey($user, $name, [], $expiryDays);
$this->outputJson([
'status' => 'success',
'key' => $result['key'],
'id' => $result['id'],
'message' => 'API key generated successfully.',
]);
}
protected function handleApiKeyRevoke(): void
{
$post = $_POST;
$keyId = $post['key_id'] ?? '';
$username = $this->getAdminRouteUsername();
if (!$username || !$keyId) {
$this->outputJson(['status' => 'error', 'message' => 'Missing parameters.']);
}
$user = $this->grav['accounts']->load($username);
if (!$user->exists()) {
$this->outputJson(['status' => 'error', 'message' => "User '{$username}' not found."]);
}
$manager = new ApiKeyManager();
$revoked = $manager->revokeKey($user, $keyId);
$this->outputJson([
'status' => $revoked ? 'success' : 'error',
'message' => $revoked ? 'API key revoked.' : 'API key not found.',
]);
}
/**
* Output JSON and terminate. Used for admin AJAX tasks.
*/
protected function outputJson(array $data): never
{
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($data);
exit;
}
/**
* Extract username from admin route (e.g. /admin/accounts/admin)
*/
protected function getAdminRouteUsername(): ?string
{
$uri = $this->grav['uri'];
$path = $uri->path();
if (preg_match('#/(?:accounts|user)/([^/]+)#', $path, $matches)) {
return $matches[1];
}
return null;
}
/**
* Register plugin templates so admin can find the api_keys field type.
*/
public function onTwigTemplatePaths(): void
{
$this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
}
/**
* Register the API router middleware into Grav's request pipeline.
*/
public function onRequestHandlerInit(RequestHandlerEvent $event): void
{
if (!$this->active) {
return;
}
$route = $event->getRoute();
$path = $route->getRoute();
if (str_starts_with($path, $this->base)) {
$event->addMiddleware('api_router', new ApiRouter($this->grav, $this->config));
}
}
/**
* Register webhook event listeners for all API mutation events.
*/
protected function registerWebhookListeners(): void
{
$events = WebhookDispatcher::getSubscribedEvents();
/** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */
$eventDispatcher = $this->grav['events'];
$webhookDispatcher = null;
foreach ($events as $eventName => [$method, $priority]) {
$eventDispatcher->addListener($eventName, function (Event $event) use ($eventName, &$webhookDispatcher) {
// Lazy-load dispatcher only when first event fires
if ($webhookDispatcher === null) {
$webhookDispatcher = new WebhookDispatcher();
}
$webhookDispatcher->dispatch($eventName, $event->toArray());
}, $priority);
}
}
/**
* Register API-specific permissions.
*/
/**
* Clear the API route cache when Grav cache is cleared.
*/
public function onBeforeCacheClear(\RocketTheme\Toolbox\Event\Event $event): void
{
$locator = $this->grav['locator'];
$cacheDir = $locator->findResource('cache://', true);
if ($cacheDir) {
$apiCachePath = $cacheDir . '/api';
if (is_dir($apiCachePath)) {
$paths = $event['paths'] ?? [];
$paths[] = $apiCachePath;
$event['paths'] = $paths;
}
}
}
public function onRegisterPermissions(PermissionsRegisterEvent $event): void
{
$actions = PermissionsReader::fromYaml("plugin://{$this->name}/permissions.yaml");
$event->permissions->addActions($actions);
}
/**
* Track a frontend page view. Replaces admin-classic's Popularity
* tracker so popularity stats keep working in admin-next-only installs.
*/
public function onFrontendPageInitialized(): void
{
(new PopularityTracker())->trackHit();
}
}