329 lines
10 KiB
PHP
329 lines
10 KiB
PHP
<?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();
|
|
}
|
|
|
|
}
|