feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user