feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Middleware;
|
||||
|
||||
use Grav\Common\Config\Config;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Plugin\Api\Auth\ApiKeyAuthenticator;
|
||||
use Grav\Plugin\Api\Auth\AuthenticatorInterface;
|
||||
use Grav\Plugin\Api\Auth\JwtAuthenticator;
|
||||
use Grav\Plugin\Api\Auth\SessionAuthenticator;
|
||||
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
/** @var AuthenticatorInterface[] */
|
||||
protected array $authenticators = [];
|
||||
|
||||
public function __construct(
|
||||
protected readonly Grav $grav,
|
||||
protected readonly Config $config,
|
||||
) {
|
||||
$this->buildAuthenticatorChain();
|
||||
}
|
||||
|
||||
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
|
||||
{
|
||||
// Try each authenticator in order
|
||||
foreach ($this->authenticators as $authenticator) {
|
||||
$user = $authenticator->authenticate($request);
|
||||
if ($user !== null) {
|
||||
return $request->withAttribute('api_user', $user);
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
'No valid authentication credentials provided. Use an API key, JWT token, or active session.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic authentication for public routes: attach api_user when valid
|
||||
* credentials are supplied, continue as guest otherwise. Lets public
|
||||
* endpoints return richer, permission-filtered responses to logged-in
|
||||
* callers without requiring auth from anonymous ones.
|
||||
*/
|
||||
public function processOptional(ServerRequestInterface $request): ServerRequestInterface
|
||||
{
|
||||
foreach ($this->authenticators as $authenticator) {
|
||||
$user = $authenticator->authenticate($request);
|
||||
if ($user !== null) {
|
||||
return $request->withAttribute('api_user', $user);
|
||||
}
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
protected function buildAuthenticatorChain(): void
|
||||
{
|
||||
// API Key is fastest to check - try first
|
||||
if ($this->config->get('plugins.api.auth.api_keys_enabled', true)) {
|
||||
$this->authenticators[] = new ApiKeyAuthenticator($this->grav);
|
||||
}
|
||||
|
||||
// JWT is next
|
||||
if ($this->config->get('plugins.api.auth.jwt_enabled', true)) {
|
||||
$this->authenticators[] = new JwtAuthenticator($this->grav, $this->config);
|
||||
}
|
||||
|
||||
// Session passthrough is last (requires existing session)
|
||||
if ($this->config->get('plugins.api.auth.session_enabled', true)) {
|
||||
$this->authenticators[] = new SessionAuthenticator($this->grav);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Middleware;
|
||||
|
||||
use Grav\Common\Config\Config;
|
||||
use Grav\Framework\Psr7\Response;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class CorsMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Config $config,
|
||||
) {}
|
||||
|
||||
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
|
||||
{
|
||||
// Nothing to modify on the request, CORS is response-side
|
||||
return $request;
|
||||
}
|
||||
|
||||
public function addHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
if (!$this->config->get('plugins.api.cors.enabled', true)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$origin = $request->getHeaderLine('Origin');
|
||||
if (!$origin) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$allowedOrigins = (array) $this->config->get('plugins.api.cors.origins', ['*']);
|
||||
|
||||
if (in_array('*', $allowedOrigins, true)) {
|
||||
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
|
||||
} elseif (in_array($origin, $allowedOrigins, true)) {
|
||||
$response = $response
|
||||
->withHeader('Access-Control-Allow-Origin', $origin)
|
||||
->withHeader('Vary', 'Origin');
|
||||
} else {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$credentials = $this->config->get('plugins.api.cors.credentials', false);
|
||||
if ($credentials) {
|
||||
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
$exposeHeaders = (array) $this->config->get('plugins.api.cors.expose_headers', []);
|
||||
// Always expose X-Invalidates so the client can read cache invalidation tags
|
||||
if (!in_array('X-Invalidates', $exposeHeaders)) {
|
||||
$exposeHeaders[] = 'X-Invalidates';
|
||||
}
|
||||
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $exposeHeaders));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function createPreflightResponse(): ResponseInterface
|
||||
{
|
||||
$headers = [];
|
||||
|
||||
$allowedOrigins = (array) $this->config->get('plugins.api.cors.origins', ['*']);
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
|
||||
|
||||
if (in_array('*', $allowedOrigins, true)) {
|
||||
$headers['Access-Control-Allow-Origin'] = '*';
|
||||
} elseif (in_array($origin, $allowedOrigins, true)) {
|
||||
$headers['Access-Control-Allow-Origin'] = $origin;
|
||||
$headers['Vary'] = 'Origin';
|
||||
}
|
||||
|
||||
$methods = (array) $this->config->get('plugins.api.cors.methods', ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']);
|
||||
$headers['Access-Control-Allow-Methods'] = implode(', ', $methods);
|
||||
|
||||
$allowHeaders = (array) $this->config->get('plugins.api.cors.headers', []);
|
||||
if ($allowHeaders) {
|
||||
$headers['Access-Control-Allow-Headers'] = implode(', ', $allowHeaders);
|
||||
}
|
||||
|
||||
$maxAge = $this->config->get('plugins.api.cors.max_age', 86400);
|
||||
$headers['Access-Control-Max-Age'] = (string) $maxAge;
|
||||
|
||||
$credentials = $this->config->get('plugins.api.cors.credentials', false);
|
||||
if ($credentials) {
|
||||
$headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
}
|
||||
|
||||
$headers['Content-Length'] = '0';
|
||||
|
||||
return new Response(204, $headers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Middleware;
|
||||
|
||||
use Grav\Plugin\Api\Exceptions\ValidationException;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class JsonBodyParserMiddleware
|
||||
{
|
||||
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
|
||||
{
|
||||
$contentType = $request->getHeaderLine('Content-Type');
|
||||
|
||||
if (!str_contains($contentType, 'application/json')) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
$body = (string) $request->getBody();
|
||||
if ($body === '') {
|
||||
return $request->withAttribute('json_body', []);
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new ValidationException('Invalid JSON in request body: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $request->withAttribute('json_body', $decoded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Middleware;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Transparent POST → {DELETE,PATCH,PUT} rewrite for clients behind restrictive
|
||||
* reverse proxies that reject non-standard HTTP verbs.
|
||||
*
|
||||
* Some managed nginx configurations (notably shared-hosting providers) strip
|
||||
* or 405 DELETE/PATCH before the request reaches PHP. This middleware lets the
|
||||
* admin-next client keep using semantic methods internally but fall back to
|
||||
* `POST + X-HTTP-Method-Override: <METHOD>` when it detects a proxy block. The
|
||||
* header is only honored on POST (other methods pass through untouched), and
|
||||
* only for the safelisted mutation verbs — no route should ever see an
|
||||
* "overridden GET", which would sidestep CSRF-shaped assumptions baked into
|
||||
* the routing layer.
|
||||
*/
|
||||
class MethodOverrideMiddleware
|
||||
{
|
||||
private const ALLOWED_OVERRIDES = ['DELETE', 'PATCH', 'PUT'];
|
||||
|
||||
public function processRequest(ServerRequestInterface $request): ServerRequestInterface
|
||||
{
|
||||
if (strtoupper($request->getMethod()) !== 'POST') {
|
||||
return $request;
|
||||
}
|
||||
|
||||
$override = strtoupper(trim($request->getHeaderLine('X-HTTP-Method-Override')));
|
||||
if ($override === '' || !in_array($override, self::ALLOWED_OVERRIDES, true)) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
return $request->withMethod($override);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Middleware;
|
||||
|
||||
use Grav\Common\Config\Config;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* File-based token bucket rate limiter.
|
||||
* Cloud-safe: each Grav instance has its own cache directory.
|
||||
*/
|
||||
class RateLimitMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Config $config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check rate limit for the current request.
|
||||
*
|
||||
* @return array{limited: bool, limit: int, remaining: int, reset: int}
|
||||
*/
|
||||
public function check(ServerRequestInterface $request): array
|
||||
{
|
||||
$enabled = $this->config->get('plugins.api.rate_limit.enabled', true);
|
||||
$limit = (int) $this->config->get('plugins.api.rate_limit.requests', 120);
|
||||
$window = (int) $this->config->get('plugins.api.rate_limit.window', 60);
|
||||
|
||||
if (!$enabled) {
|
||||
return [
|
||||
'limited' => false,
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => time() + $window,
|
||||
];
|
||||
}
|
||||
|
||||
// Path-prefix exclusions. Used to keep high-frequency API
|
||||
// surfaces (collab polling, etc.) out of the per-user bucket so a
|
||||
// single typing user doesn't trip the global anti-abuse limit.
|
||||
// Defaults to excluding the sync plugin's endpoints; operators can
|
||||
// override via plugins.api.rate_limit.excluded_paths.
|
||||
$excluded = (array) $this->config->get('plugins.api.rate_limit.excluded_paths', ['/sync/']);
|
||||
$path = $request->getUri()->getPath();
|
||||
foreach ($excluded as $prefix) {
|
||||
if (!is_string($prefix) || $prefix === '') continue;
|
||||
if (str_contains($path, $prefix)) {
|
||||
return [
|
||||
'limited' => false,
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => time() + $window,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$identifier = $this->getIdentifier($request);
|
||||
$storageDir = $this->getStorageDir();
|
||||
|
||||
if (!is_dir($storageDir)) {
|
||||
@mkdir($storageDir, 0775, true);
|
||||
}
|
||||
|
||||
$file = $storageDir . '/' . md5($identifier) . '.json';
|
||||
|
||||
return $this->checkLimit($file, $limit, $window);
|
||||
}
|
||||
|
||||
protected function getIdentifier(ServerRequestInterface $request): string
|
||||
{
|
||||
// Use authenticated user if available, otherwise fall back to IP
|
||||
$user = $request->getAttribute('api_user');
|
||||
if ($user) {
|
||||
return 'user:' . $user->username;
|
||||
}
|
||||
|
||||
return 'ip:' . ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown');
|
||||
}
|
||||
|
||||
protected function checkLimit(string $file, int $limit, int $window): array
|
||||
{
|
||||
$now = time();
|
||||
$data = ['tokens' => $limit, 'last_refill' => $now];
|
||||
|
||||
// Use file locking for concurrency safety
|
||||
$fp = fopen($file, 'c+');
|
||||
if (!$fp) {
|
||||
// If we can't open the file, allow the request
|
||||
return ['limited' => false, 'limit' => $limit, 'remaining' => $limit, 'reset' => $now + $window];
|
||||
}
|
||||
|
||||
flock($fp, LOCK_EX);
|
||||
|
||||
$contents = stream_get_contents($fp);
|
||||
if ($contents) {
|
||||
$data = json_decode($contents, true) ?: $data;
|
||||
}
|
||||
|
||||
// Refill tokens based on elapsed time
|
||||
$elapsed = $now - ($data['last_refill'] ?? $now);
|
||||
$refillRate = $limit / $window;
|
||||
$data['tokens'] = min($limit, ($data['tokens'] ?? $limit) + ($elapsed * $refillRate));
|
||||
$data['last_refill'] = $now;
|
||||
|
||||
// Try to consume a token
|
||||
$limited = $data['tokens'] < 1;
|
||||
if (!$limited) {
|
||||
$data['tokens'] -= 1;
|
||||
}
|
||||
|
||||
// Write back
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
$remaining = max(0, (int) floor($data['tokens']));
|
||||
$reset = $now + (int) ceil(($limit - $data['tokens']) / $refillRate);
|
||||
|
||||
return [
|
||||
'limited' => $limited,
|
||||
'limit' => $limit,
|
||||
'remaining' => $remaining,
|
||||
'reset' => $reset,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getStorageDir(): string
|
||||
{
|
||||
$locator = \Grav\Common\Grav::instance()['locator'];
|
||||
return $locator->findResource('cache://api/ratelimit', true, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user