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,131 @@
<?php
namespace Grav\Plugin\Form\Captcha;
use Grav\Common\Grav;
use TrilbyMedia\Cap\Cap;
use TrilbyMedia\Cap\Config as CapConfig;
use TrilbyMedia\Cap\Storage\FilesystemStorage;
use TrilbyMedia\Cap\Storage\Psr16Storage;
/**
* Cap (cap.js) proof-of-work captcha provider. Wire-compatible with
* the official @cap.js/widget via the trilbymedia/cap-php library.
*/
class CapProvider implements CaptchaProviderInterface
{
public const ENDPOINT_BASE = '/forms-cap/';
public const CHALLENGE_PATH = '/forms-cap/challenge';
public const REDEEM_PATH = '/forms-cap/redeem';
public const HIDDEN_FIELD_NAME = 'cap-token';
/** @var array */
protected $config;
/** @var Cap|null */
protected static $cap = null;
public function __construct()
{
$this->config = Grav::instance()['config']->get('plugins.form.cap', []);
}
/**
* Get (or build) a shared Cap instance. Storage backend is chosen from
* plugin config: 'grav-cache' (PSR-16 via Grav cache) or 'filesystem'.
*/
public static function getCap(): Cap
{
if (self::$cap !== null) {
return self::$cap;
}
$grav = Grav::instance();
$config = $grav['config']->get('plugins.form.cap', []);
$backend = $config['storage'] ?? 'grav-cache';
if ($backend === 'filesystem') {
$dir = rtrim(GRAV_ROOT, '/') . '/user/data/cap';
$storage = new FilesystemStorage($dir);
} else {
$storage = new Psr16Storage($grav['cache']->getSimpleCache());
}
$capConfig = new CapConfig(
challengeStorage: $storage,
tokenStorage: $storage,
challengeCount: (int)($config['challenge_count'] ?? 50),
challengeSize: (int)($config['challenge_size'] ?? 32),
challengeDifficulty: (int)($config['challenge_difficulty'] ?? 4),
expiresMs: (int)($config['expires_ms'] ?? 600_000),
);
return self::$cap = new Cap($capConfig);
}
/**
* {@inheritdoc}
*/
public function validate(array $form, array $params = []): array
{
$grav = Grav::instance();
try {
$token = $_POST[self::HIDDEN_FIELD_NAME] ?? $form[self::HIDDEN_FIELD_NAME] ?? null;
if (!$token) {
$grav['log']->warning('Cap validation failed: missing token');
return [
'success' => false,
'error' => 'missing-input-response',
'details' => ['error' => 'missing-input-response']
];
}
$ok = self::getCap()->validateToken((string)$token);
if (!$ok) {
return [
'success' => false,
'error' => 'validation-failed',
'details' => ['error' => 'invalid-or-expired-token']
];
}
return ['success' => true];
} catch (\Exception $e) {
$grav['log']->error('Cap validation error: ' . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage(),
'details' => ['exception' => get_class($e)]
];
}
}
/**
* {@inheritdoc}
*/
public function getClientProperties(string $formId, array $field): array
{
$mode = $field['mode'] ?? $this->config['mode'] ?? 'invisible';
if (!in_array($mode, ['invisible', 'checkbox'], true)) {
$mode = 'invisible';
}
return [
'provider' => 'cap',
'mode' => $mode,
'endpoint' => self::ENDPOINT_BASE,
'hiddenFieldName' => self::HIDDEN_FIELD_NAME,
'containerId' => "cap-{$formId}",
];
}
/**
* {@inheritdoc}
*/
public function getTemplateName(): string
{
return 'forms/fields/cap/cap.html.twig';
}
}