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