feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Webhooks;
|
||||
|
||||
use Grav\Common\HTTP\Response;
|
||||
|
||||
class WebhookDispatcher
|
||||
{
|
||||
/**
|
||||
* Map of internal event names to webhook event names.
|
||||
*/
|
||||
private const EVENT_MAP = [
|
||||
'onApiPageCreated' => 'page.created',
|
||||
'onApiPageUpdated' => 'page.updated',
|
||||
'onApiPageDeleted' => 'page.deleted',
|
||||
'onApiPageMoved' => 'page.moved',
|
||||
'onApiPageTranslated' => 'page.translated',
|
||||
'onApiPagesReordered' => 'pages.reordered',
|
||||
'onApiMediaUploaded' => 'media.uploaded',
|
||||
'onApiMediaDeleted' => 'media.deleted',
|
||||
'onApiUserCreated' => 'user.created',
|
||||
'onApiUserUpdated' => 'user.updated',
|
||||
'onApiUserDeleted' => 'user.deleted',
|
||||
'onApiConfigUpdated' => 'config.updated',
|
||||
'onApiPackageInstalled' => 'gpm.installed',
|
||||
'onApiPackageRemoved' => 'gpm.removed',
|
||||
'onApiGravUpgraded' => 'grav.upgraded',
|
||||
];
|
||||
|
||||
private WebhookManager $manager;
|
||||
|
||||
public function __construct(?WebhookManager $manager = null)
|
||||
{
|
||||
$this->manager = $manager ?? new WebhookManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of subscribed events for the plugin.
|
||||
*/
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
$events = [];
|
||||
foreach (array_keys(self::EVENT_MAP) as $eventName) {
|
||||
$events[$eventName] = ['dispatch', -100]; // Low priority - run after main handlers
|
||||
}
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhooks for an event.
|
||||
*/
|
||||
public function dispatch(string $internalEvent, array $eventData): void
|
||||
{
|
||||
$webhookEvent = self::EVENT_MAP[$internalEvent] ?? null;
|
||||
if (!$webhookEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhooks = $this->manager->getForEvent($webhookEvent);
|
||||
if (empty($webhooks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = $this->buildPayload($webhookEvent, $eventData);
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
$this->send($webhook, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test payload to a webhook.
|
||||
*/
|
||||
public function sendTest(array $webhook): array
|
||||
{
|
||||
$payload = $this->buildPayload('test', [
|
||||
'message' => 'This is a test webhook delivery.',
|
||||
]);
|
||||
|
||||
return $this->send($webhook, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the webhook payload.
|
||||
*/
|
||||
private function buildPayload(string $event, array $data): array
|
||||
{
|
||||
// Serialize objects in data to arrays
|
||||
$cleanData = $this->serializeEventData($data);
|
||||
|
||||
return [
|
||||
'event' => $event,
|
||||
'timestamp' => date('c'),
|
||||
'data' => $cleanData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a webhook HTTP request and record the delivery.
|
||||
*/
|
||||
private function send(array $webhook, array $payload): array
|
||||
{
|
||||
$payload['webhook_id'] = $webhook['id'];
|
||||
$jsonPayload = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// Generate HMAC signature
|
||||
$signature = hash_hmac('sha256', $jsonPayload, $webhook['secret'] ?? '');
|
||||
|
||||
$headers = array_merge(
|
||||
[
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Grav-Signature' => $signature,
|
||||
'X-Grav-Event' => $payload['event'],
|
||||
'X-Grav-Delivery' => 'dlv_' . bin2hex(random_bytes(8)),
|
||||
'User-Agent' => 'Grav-Webhook/1.0',
|
||||
],
|
||||
$webhook['headers'] ?? []
|
||||
);
|
||||
|
||||
$delivery = [
|
||||
'id' => $headers['X-Grav-Delivery'],
|
||||
'event' => $payload['event'],
|
||||
'url' => $webhook['url'],
|
||||
'request_headers' => $headers,
|
||||
'request_body' => $payload,
|
||||
'created' => time(),
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$response = $this->httpPost($webhook['url'], $jsonPayload, $headers);
|
||||
$duration = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
$delivery['status_code'] = $response['status_code'];
|
||||
$delivery['response_body'] = mb_substr($response['body'] ?? '', 0, 1000);
|
||||
$delivery['duration_ms'] = $duration;
|
||||
$delivery['success'] = $response['status_code'] >= 200 && $response['status_code'] < 300;
|
||||
|
||||
if ($delivery['success']) {
|
||||
$this->manager->resetFailureCount($webhook['id']);
|
||||
} else {
|
||||
$this->manager->recordFailure($webhook['id']);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$duration = (int) ((microtime(true) - $startTime) * 1000);
|
||||
$delivery['status_code'] = 0;
|
||||
$delivery['error'] = $e->getMessage();
|
||||
$delivery['duration_ms'] = $duration;
|
||||
$delivery['success'] = false;
|
||||
|
||||
$this->manager->recordFailure($webhook['id']);
|
||||
}
|
||||
|
||||
$this->manager->recordDelivery($webhook['id'], $delivery);
|
||||
|
||||
return $delivery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP POST request.
|
||||
*/
|
||||
private function httpPost(string $url, string $body, array $headers): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new \RuntimeException('Failed to initialize cURL');
|
||||
}
|
||||
|
||||
$headerLines = [];
|
||||
foreach ($headers as $key => $value) {
|
||||
$headerLines[] = "{$key}: {$value}";
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_HTTPHEADER => $headerLines,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
]);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new \RuntimeException('Webhook request failed: ' . $error);
|
||||
}
|
||||
|
||||
return [
|
||||
'status_code' => $statusCode,
|
||||
'body' => is_string($responseBody) ? $responseBody : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert event data objects to serializable arrays.
|
||||
*/
|
||||
private function serializeEventData(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_object($value)) {
|
||||
// Try common serialization methods
|
||||
if (method_exists($value, 'route')) {
|
||||
$result[$key] = [
|
||||
'route' => $value->route(),
|
||||
'title' => method_exists($value, 'title') ? $value->title() : null,
|
||||
'slug' => method_exists($value, 'slug') ? $value->slug() : null,
|
||||
];
|
||||
} elseif (method_exists($value, 'toArray')) {
|
||||
$result[$key] = $value->toArray();
|
||||
} elseif (method_exists($value, 'jsonSerialize')) {
|
||||
$result[$key] = $value->jsonSerialize();
|
||||
} else {
|
||||
$result[$key] = '(object)';
|
||||
}
|
||||
} elseif (is_array($value)) {
|
||||
$result[$key] = $this->serializeEventData($value);
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Webhooks;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use RocketTheme\Toolbox\File\YamlFile;
|
||||
|
||||
class WebhookManager
|
||||
{
|
||||
private string $storagePath;
|
||||
private string $deliveryPath;
|
||||
private ?array $webhooksCache = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$this->storagePath = $grav['locator']->findResource('user://data/api', true, true);
|
||||
$this->deliveryPath = $this->storagePath . '/webhook-deliveries';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all webhooks.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a webhook by ID.
|
||||
*/
|
||||
public function get(string $id): ?array
|
||||
{
|
||||
$webhooks = $this->load();
|
||||
foreach ($webhooks as $webhook) {
|
||||
if ($webhook['id'] === $id) {
|
||||
return $webhook;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new webhook.
|
||||
*/
|
||||
public function create(array $data): array
|
||||
{
|
||||
$webhook = [
|
||||
'id' => 'wh_' . bin2hex(random_bytes(12)),
|
||||
'url' => $data['url'],
|
||||
'secret' => 'whsec_' . bin2hex(random_bytes(24)),
|
||||
'events' => $data['events'] ?? ['*'],
|
||||
'enabled' => $data['enabled'] ?? true,
|
||||
'headers' => $data['headers'] ?? [],
|
||||
'created' => time(),
|
||||
'failure_count' => 0,
|
||||
];
|
||||
|
||||
$webhooks = $this->load();
|
||||
$webhooks[] = $webhook;
|
||||
$this->save($webhooks);
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a webhook.
|
||||
*/
|
||||
public function update(string $id, array $data): ?array
|
||||
{
|
||||
$webhooks = $this->load();
|
||||
|
||||
foreach ($webhooks as &$webhook) {
|
||||
if ($webhook['id'] === $id) {
|
||||
if (isset($data['url'])) {
|
||||
$webhook['url'] = $data['url'];
|
||||
}
|
||||
if (isset($data['events'])) {
|
||||
$webhook['events'] = $data['events'];
|
||||
}
|
||||
if (isset($data['enabled'])) {
|
||||
$webhook['enabled'] = (bool) $data['enabled'];
|
||||
}
|
||||
if (isset($data['headers'])) {
|
||||
$webhook['headers'] = $data['headers'];
|
||||
}
|
||||
|
||||
$this->save($webhooks);
|
||||
return $webhook;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook.
|
||||
*/
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$webhooks = $this->load();
|
||||
$filtered = array_values(array_filter($webhooks, fn($w) => $w['id'] !== $id));
|
||||
|
||||
if (count($filtered) === count($webhooks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->save($filtered);
|
||||
|
||||
// Clean up delivery logs
|
||||
$deliveryDir = $this->deliveryPath . '/' . $id;
|
||||
if (is_dir($deliveryDir)) {
|
||||
$files = glob($deliveryDir . '/*.yaml');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($deliveryDir);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a delivery log entry.
|
||||
*/
|
||||
public function recordDelivery(string $webhookId, array $delivery): void
|
||||
{
|
||||
$dir = $this->deliveryPath . '/' . $webhookId;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$file = $dir . '/' . $delivery['id'] . '.yaml';
|
||||
$yamlFile = YamlFile::instance($file);
|
||||
$yamlFile->content($delivery);
|
||||
$yamlFile->save();
|
||||
|
||||
// Keep only last 50 deliveries
|
||||
$this->pruneDeliveries($webhookId, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delivery history for a webhook.
|
||||
*/
|
||||
public function getDeliveries(string $webhookId, int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
$dir = $this->deliveryPath . '/' . $webhookId;
|
||||
if (!is_dir($dir)) {
|
||||
return ['deliveries' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$files = glob($dir . '/*.yaml');
|
||||
// Sort by modification time descending
|
||||
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
|
||||
|
||||
$total = count($files);
|
||||
$slice = array_slice($files, $offset, $limit);
|
||||
|
||||
$deliveries = [];
|
||||
foreach ($slice as $file) {
|
||||
$yamlFile = YamlFile::instance($file);
|
||||
$deliveries[] = $yamlFile->content();
|
||||
}
|
||||
|
||||
return ['deliveries' => $deliveries, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhooks matching a specific event.
|
||||
*/
|
||||
public function getForEvent(string $event): array
|
||||
{
|
||||
$webhooks = $this->load();
|
||||
return array_filter($webhooks, function ($webhook) use ($event) {
|
||||
if (!($webhook['enabled'] ?? true)) {
|
||||
return false;
|
||||
}
|
||||
$events = $webhook['events'] ?? ['*'];
|
||||
return in_array('*', $events, true) || in_array($event, $events, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failure count and auto-disable if threshold reached.
|
||||
*/
|
||||
public function recordFailure(string $id): void
|
||||
{
|
||||
$webhooks = $this->load();
|
||||
foreach ($webhooks as &$webhook) {
|
||||
if ($webhook['id'] === $id) {
|
||||
$webhook['failure_count'] = ($webhook['failure_count'] ?? 0) + 1;
|
||||
if ($webhook['failure_count'] >= 5) {
|
||||
$webhook['enabled'] = false;
|
||||
$webhook['disabled_reason'] = 'Auto-disabled after 5 consecutive failures';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->save($webhooks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure count on successful delivery.
|
||||
*/
|
||||
public function resetFailureCount(string $id): void
|
||||
{
|
||||
$webhooks = $this->load();
|
||||
foreach ($webhooks as &$webhook) {
|
||||
if ($webhook['id'] === $id) {
|
||||
$webhook['failure_count'] = 0;
|
||||
unset($webhook['disabled_reason']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->save($webhooks);
|
||||
}
|
||||
|
||||
private function load(): array
|
||||
{
|
||||
if ($this->webhooksCache !== null) {
|
||||
return $this->webhooksCache;
|
||||
}
|
||||
|
||||
$file = YamlFile::instance($this->storagePath . '/webhooks.yaml');
|
||||
$content = $file->content();
|
||||
$this->webhooksCache = $content['webhooks'] ?? [];
|
||||
|
||||
return $this->webhooksCache;
|
||||
}
|
||||
|
||||
private function save(array $webhooks): void
|
||||
{
|
||||
$file = YamlFile::instance($this->storagePath . '/webhooks.yaml');
|
||||
$file->content(['webhooks' => array_values($webhooks)]);
|
||||
$file->save();
|
||||
$this->webhooksCache = array_values($webhooks);
|
||||
}
|
||||
|
||||
private function pruneDeliveries(string $webhookId, int $keep): void
|
||||
{
|
||||
$dir = $this->deliveryPath . '/' . $webhookId;
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($dir . '/*.yaml');
|
||||
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
|
||||
|
||||
$toDelete = array_slice($files, $keep);
|
||||
foreach ($toDelete as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user