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(); } }