From 9cc9e4a178729a57aa07b902dd0c82e5a24d5d97 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 27 Jan 2026 10:17:54 +0000 Subject: [PATCH] feat(api): add webhook template manager and secret rotation - Add WebhookPayloadTemplate model with builtin template support - Add WebhookTemplateService for template rendering (Mustache, JSON) - Add WebhookSecretRotationService with grace period handling - Add WebhookTemplateController and WebhookSecretController API endpoints - Add WebhookTemplateManager Livewire component for admin UI - Add CleanupExpiredSecrets console command - Add BuiltinTemplateType and WebhookTemplateFormat enums - Add migrations for api tables and secret rotation fields Co-Authored-By: Claude Opus 4.5 --- src/Mod/Api/Boot.php | 45 ++ .../Commands/CleanupExpiredSecrets.php | 141 ++++ src/Mod/Api/Contracts/WebhookEvent.php | 35 + .../Api/WebhookSecretController.php | 268 ++++++++ .../Api/WebhookTemplateController.php | 369 ++++++++++ src/Mod/Api/Enums/BuiltinTemplateType.php | 144 ++++ src/Mod/Api/Enums/WebhookTemplateFormat.php | 73 ++ .../0001_01_01_000001_create_api_tables.php | 65 ++ ...000_add_webhook_secret_rotation_fields.php | 58 ++ src/Mod/Api/Models/WebhookPayloadTemplate.php | 321 +++++++++ src/Mod/Api/Routes/admin.php | 19 + .../Services/WebhookSecretRotationService.php | 308 +++++++++ .../Api/Services/WebhookTemplateService.php | 629 ++++++++++++++++++ .../admin/webhook-template-manager.blade.php | 353 ++++++++++ .../Modal/Admin/WebhookTemplateManager.php | 442 ++++++++++++ 15 files changed, 3270 insertions(+) create mode 100644 src/Mod/Api/Console/Commands/CleanupExpiredSecrets.php create mode 100644 src/Mod/Api/Contracts/WebhookEvent.php create mode 100644 src/Mod/Api/Controllers/Api/WebhookSecretController.php create mode 100644 src/Mod/Api/Controllers/Api/WebhookTemplateController.php create mode 100644 src/Mod/Api/Enums/BuiltinTemplateType.php create mode 100644 src/Mod/Api/Enums/WebhookTemplateFormat.php create mode 100644 src/Mod/Api/Migrations/0001_01_01_000001_create_api_tables.php create mode 100644 src/Mod/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php create mode 100644 src/Mod/Api/Models/WebhookPayloadTemplate.php create mode 100644 src/Mod/Api/Routes/admin.php create mode 100644 src/Mod/Api/Services/WebhookSecretRotationService.php create mode 100644 src/Mod/Api/Services/WebhookTemplateService.php create mode 100644 src/Mod/Api/View/Blade/admin/webhook-template-manager.blade.php create mode 100644 src/Mod/Api/View/Modal/Admin/WebhookTemplateManager.php diff --git a/src/Mod/Api/Boot.php b/src/Mod/Api/Boot.php index e02e0b6..7733aad 100644 --- a/src/Mod/Api/Boot.php +++ b/src/Mod/Api/Boot.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace Core\Mod\Api; +use Core\Events\AdminPanelBooting; use Core\Events\ApiRoutesRegistering; use Core\Events\ConsoleBooting; use Core\Mod\Api\Documentation\DocumentationServiceProvider; use Core\Mod\Api\RateLimit\RateLimitService; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Cache\Repository as CacheRepository; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -32,6 +36,7 @@ class Boot extends ServiceProvider * @var array */ public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', ApiRoutesRegistering::class => 'onApiRoutes', ConsoleBooting::class => 'onConsole', ]; @@ -51,6 +56,10 @@ class Boot extends ServiceProvider return new RateLimitService($app->make(CacheRepository::class)); }); + // Register webhook services + $this->app->singleton(Services\WebhookTemplateService::class); + $this->app->singleton(Services\WebhookSecretRotationService::class); + // Register API Documentation provider $this->app->register(DocumentationServiceProvider::class); } @@ -61,12 +70,48 @@ class Boot extends ServiceProvider public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/Migrations'); + $this->configureRateLimiting(); + } + + /** + * Configure rate limiters for API endpoints. + */ + protected function configureRateLimiting(): void + { + // Rate limit for webhook template operations: 30 per minute per user + RateLimiter::for('api-webhook-templates', function (Request $request) { + $user = $request->user(); + + return $user + ? Limit::perMinute(30)->by('user:'.$user->id) + : Limit::perMinute(10)->by($request->ip()); + }); + + // Rate limit for template preview/validation: 60 per minute per user + RateLimiter::for('api-template-preview', function (Request $request) { + $user = $request->user(); + + return $user + ? Limit::perMinute(60)->by('user:'.$user->id) + : Limit::perMinute(20)->by($request->ip()); + }); } // ------------------------------------------------------------------------- // Event-driven handlers // ------------------------------------------------------------------------- + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/Routes/admin.php')) { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + + $event->livewire('api.webhook-template-manager', View\Modal\Admin\WebhookTemplateManager::class); + } + public function onApiRoutes(ApiRoutesRegistering $event): void { // Middleware aliases registered via event diff --git a/src/Mod/Api/Console/Commands/CleanupExpiredSecrets.php b/src/Mod/Api/Console/Commands/CleanupExpiredSecrets.php new file mode 100644 index 0000000..c71b565 --- /dev/null +++ b/src/Mod/Api/Console/Commands/CleanupExpiredSecrets.php @@ -0,0 +1,141 @@ + + */ + protected array $webhookModels = [ + 'social' => Webhook::class, + 'content' => ContentWebhookEndpoint::class, + ]; + + public function handle(WebhookSecretRotationService $service): int + { + $dryRun = $this->option('dry-run'); + $modelFilter = $this->option('model'); + + $this->info('Starting webhook secret cleanup...'); + + if ($dryRun) { + $this->warn('DRY RUN MODE - No data will be modified'); + } + + $startTime = microtime(true); + $totalCleaned = 0; + + $modelsToProcess = $this->getModelsToProcess($modelFilter); + + if (empty($modelsToProcess)) { + $this->error('No valid models to process.'); + + return Command::FAILURE; + } + + foreach ($modelsToProcess as $name => $modelClass) { + if (! class_exists($modelClass)) { + $this->warn("Model class {$modelClass} not found, skipping..."); + + continue; + } + + $this->info("Processing {$name} webhooks..."); + + if ($dryRun) { + $count = $this->countExpiredGracePeriods($modelClass, $service); + $this->line(" Would clean up: {$count} webhook(s)"); + $totalCleaned += $count; + } else { + $count = $service->cleanupExpiredGracePeriods($modelClass); + $this->line(" Cleaned up: {$count} webhook(s)"); + $totalCleaned += $count; + } + } + + $elapsed = round(microtime(true) - $startTime, 2); + + $this->newLine(); + $this->info('Cleanup Summary:'); + $this->line(" Total cleaned: {$totalCleaned} webhook(s)"); + $this->line(" Time elapsed: {$elapsed}s"); + + if (! $dryRun && $totalCleaned > 0) { + Log::info('Webhook secret cleanup completed', [ + 'total_cleaned' => $totalCleaned, + 'elapsed_seconds' => $elapsed, + ]); + } + + $this->info('Webhook secret cleanup complete.'); + + return Command::SUCCESS; + } + + /** + * Get the webhook models to process based on the filter. + * + * @return array + */ + protected function getModelsToProcess(?string $filter): array + { + if ($filter === null) { + return $this->webhookModels; + } + + $filter = strtolower($filter); + + if (! isset($this->webhookModels[$filter])) { + $this->error("Invalid model filter: {$filter}"); + $this->line('Available models: '.implode(', ', array_keys($this->webhookModels))); + + return []; + } + + return [$filter => $this->webhookModels[$filter]]; + } + + /** + * Count webhooks with expired grace periods (for dry run). + */ + protected function countExpiredGracePeriods(string $modelClass, WebhookSecretRotationService $service): int + { + $count = 0; + + $modelClass::query() + ->whereNotNull('previous_secret') + ->whereNotNull('secret_rotated_at') + ->chunkById(100, function ($webhooks) use ($service, &$count) { + foreach ($webhooks as $webhook) { + if (! $service->isInGracePeriod($webhook)) { + $count++; + } + } + }); + + return $count; + } +} diff --git a/src/Mod/Api/Contracts/WebhookEvent.php b/src/Mod/Api/Contracts/WebhookEvent.php new file mode 100644 index 0000000..fb9f639 --- /dev/null +++ b/src/Mod/Api/Contracts/WebhookEvent.php @@ -0,0 +1,35 @@ + + */ + public function payload(): array; + + /** + * Get a human-readable message describing the event. + */ + public function message(): string; +} diff --git a/src/Mod/Api/Controllers/Api/WebhookSecretController.php b/src/Mod/Api/Controllers/Api/WebhookSecretController.php new file mode 100644 index 0000000..8128c9e --- /dev/null +++ b/src/Mod/Api/Controllers/Api/WebhookSecretController.php @@ -0,0 +1,268 @@ +user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'nullable|integer|min:300|max:604800', // 5 min to 7 days + ]); + + $newSecret = $this->rotationService->rotateSecret( + $webhook, + $validated['grace_period_seconds'] ?? null + ); + + return response()->json([ + 'success' => true, + 'message' => 'Secret rotated successfully', + 'data' => [ + 'secret' => $newSecret, + 'status' => $this->rotationService->getSecretStatus($webhook->fresh()), + ], + ]); + } + + /** + * Rotate a content webhook endpoint secret. + */ + public function rotateContentSecret(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'nullable|integer|min:300|max:604800', + ]); + + $newSecret = $this->rotationService->rotateSecret( + $endpoint, + $validated['grace_period_seconds'] ?? null + ); + + return response()->json([ + 'success' => true, + 'message' => 'Secret rotated successfully', + 'data' => [ + 'secret' => $newSecret, + 'status' => $this->rotationService->getSecretStatus($endpoint->fresh()), + ], + ]); + } + + /** + * Get secret rotation status for a social webhook. + */ + public function socialSecretStatus(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + return response()->json([ + 'data' => $this->rotationService->getSecretStatus($webhook), + ]); + } + + /** + * Get secret rotation status for a content webhook endpoint. + */ + public function contentSecretStatus(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + return response()->json([ + 'data' => $this->rotationService->getSecretStatus($endpoint), + ]); + } + + /** + * Invalidate the previous secret for a social webhook. + */ + public function invalidateSocialPreviousSecret(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + $this->rotationService->invalidatePreviousSecret($webhook); + + return response()->json([ + 'success' => true, + 'message' => 'Previous secret invalidated', + ]); + } + + /** + * Invalidate the previous secret for a content webhook endpoint. + */ + public function invalidateContentPreviousSecret(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + $this->rotationService->invalidatePreviousSecret($endpoint); + + return response()->json([ + 'success' => true, + 'message' => 'Previous secret invalidated', + ]); + } + + /** + * Update the grace period for a social webhook. + */ + public function updateSocialGracePeriod(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'required|integer|min:300|max:604800', + ]); + + $this->rotationService->updateGracePeriod($webhook, $validated['grace_period_seconds']); + + return response()->json([ + 'success' => true, + 'message' => 'Grace period updated', + 'data' => [ + 'grace_period_seconds' => $webhook->fresh()->grace_period_seconds, + ], + ]); + } + + /** + * Update the grace period for a content webhook endpoint. + */ + public function updateContentGracePeriod(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'required|integer|min:300|max:604800', + ]); + + $this->rotationService->updateGracePeriod($endpoint, $validated['grace_period_seconds']); + + return response()->json([ + 'success' => true, + 'message' => 'Grace period updated', + 'data' => [ + 'grace_period_seconds' => $endpoint->fresh()->grace_period_seconds, + ], + ]); + } +} diff --git a/src/Mod/Api/Controllers/Api/WebhookTemplateController.php b/src/Mod/Api/Controllers/Api/WebhookTemplateController.php new file mode 100644 index 0000000..0a59b18 --- /dev/null +++ b/src/Mod/Api/Controllers/Api/WebhookTemplateController.php @@ -0,0 +1,369 @@ +user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $query = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->active() + ->ordered(); + + // Optional filtering + if ($request->has('builtin')) { + $request->boolean('builtin') + ? $query->builtin() + : $query->custom(); + } + + $templates = $query->get()->map(fn ($template) => $this->formatTemplate($template)); + + return response()->json([ + 'data' => $templates, + 'meta' => [ + 'total' => $templates->count(), + ], + ]); + } + + /** + * Get a single template by UUID. + */ + public function show(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + return response()->json([ + 'data' => $this->formatTemplate($template, true), + ]); + } + + /** + * Create a new template. + */ + public function store(Request $request): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + 'format' => 'required|in:simple,mustache,json', + 'template' => 'required|string|max:65535', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]); + + // Validate template syntax + $format = WebhookTemplateFormat::from($validated['format']); + $validation = $this->templateService->validateTemplate($validated['template'], $format); + + if (! $validation['valid']) { + return response()->json([ + 'error' => 'Invalid template', + 'errors' => $validation['errors'], + ], 422); + } + + $template = WebhookPayloadTemplate::create([ + 'uuid' => Str::uuid()->toString(), + 'workspace_id' => $workspace->id, + 'namespace_id' => $workspace->default_namespace_id ?? null, + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'format' => $validated['format'], + 'template' => $validated['template'], + 'is_default' => $validated['is_default'] ?? false, + 'is_active' => $validated['is_active'] ?? true, + ]); + + return response()->json([ + 'data' => $this->formatTemplate($template, true), + ], 201); + } + + /** + * Update an existing template. + */ + public function update(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'description' => 'nullable|string|max:1000', + 'format' => 'sometimes|in:simple,mustache,json', + 'template' => 'sometimes|string|max:65535', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]); + + // Validate template syntax if template is being updated + if (isset($validated['template'])) { + $format = WebhookTemplateFormat::from($validated['format'] ?? $template->format->value); + $validation = $this->templateService->validateTemplate($validated['template'], $format); + + if (! $validation['valid']) { + return response()->json([ + 'error' => 'Invalid template', + 'errors' => $validation['errors'], + ], 422); + } + } + + // Don't allow modifying builtin templates' format + if ($template->isBuiltin()) { + unset($validated['format']); + } + + $template->update($validated); + + return response()->json([ + 'data' => $this->formatTemplate($template->fresh(), true), + ]); + } + + /** + * Delete a template. + */ + public function destroy(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + // Don't allow deleting builtin templates + if ($template->isBuiltin()) { + return response()->json(['error' => 'Built-in templates cannot be deleted'], 403); + } + + $template->delete(); + + return response()->json(null, 204); + } + + /** + * Validate a template without saving. + */ + public function validate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'format' => 'required|in:simple,mustache,json', + 'template' => 'required|string|max:65535', + ]); + + $format = WebhookTemplateFormat::from($validated['format']); + $validation = $this->templateService->validateTemplate($validated['template'], $format); + + return response()->json([ + 'valid' => $validation['valid'], + 'errors' => $validation['errors'], + ]); + } + + /** + * Preview a template with sample data. + */ + public function preview(Request $request): JsonResponse + { + $validated = $request->validate([ + 'format' => 'required|in:simple,mustache,json', + 'template' => 'required|string|max:65535', + 'event_type' => 'nullable|string|max:100', + ]); + + $format = WebhookTemplateFormat::from($validated['format']); + $result = $this->templateService->previewPayload( + $validated['template'], + $format, + $validated['event_type'] ?? null + ); + + return response()->json($result); + } + + /** + * Duplicate an existing template. + */ + public function duplicate(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + $newName = $request->input('name', $template->name.' (copy)'); + $duplicate = $template->duplicate($newName); + + return response()->json([ + 'data' => $this->formatTemplate($duplicate, true), + ], 201); + } + + /** + * Set a template as the workspace default. + */ + public function setDefault(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + $template->setAsDefault(); + + return response()->json([ + 'data' => $this->formatTemplate($template->fresh(), true), + ]); + } + + /** + * Get available template variables. + */ + public function variables(Request $request): JsonResponse + { + $eventType = $request->input('event_type'); + $variables = $this->templateService->getAvailableVariables($eventType); + + return response()->json([ + 'data' => $variables, + ]); + } + + /** + * Get available template filters. + */ + public function filters(): JsonResponse + { + $filters = $this->templateService->getAvailableFilters(); + + return response()->json([ + 'data' => $filters, + ]); + } + + /** + * Get builtin template definitions. + */ + public function builtins(): JsonResponse + { + $templates = $this->templateService->getBuiltinTemplates(); + + return response()->json([ + 'data' => $templates, + ]); + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Format a template for API response. + */ + protected function formatTemplate(WebhookPayloadTemplate $template, bool $includeContent = false): array + { + $data = [ + 'uuid' => $template->uuid, + 'name' => $template->name, + 'description' => $template->description, + 'format' => $template->format->value, + 'is_default' => $template->is_default, + 'is_active' => $template->is_active, + 'is_builtin' => $template->isBuiltin(), + 'builtin_type' => $template->builtin_type?->value, + 'created_at' => $template->created_at?->toIso8601String(), + 'updated_at' => $template->updated_at?->toIso8601String(), + ]; + + if ($includeContent) { + $data['template'] = $template->template; + $data['example_output'] = $template->example_output; + } + + return $data; + } +} diff --git a/src/Mod/Api/Enums/BuiltinTemplateType.php b/src/Mod/Api/Enums/BuiltinTemplateType.php new file mode 100644 index 0000000..3e71bb2 --- /dev/null +++ b/src/Mod/Api/Enums/BuiltinTemplateType.php @@ -0,0 +1,144 @@ + 'Full payload', + self::MINIMAL => 'Minimal payload', + self::SLACK => 'Slack message', + self::DISCORD => 'Discord message', + }; + } + + /** + * Get description for the type. + */ + public function description(): string + { + return match ($this) { + self::FULL => 'Sends all event data in a structured format.', + self::MINIMAL => 'Sends only essential fields: event type, ID, and timestamp.', + self::SLACK => 'Formats payload for Slack incoming webhooks with blocks.', + self::DISCORD => 'Formats payload for Discord webhooks with embeds.', + }; + } + + /** + * Get the default template content for this type. + */ + public function template(): string + { + return match ($this) { + self::FULL => <<<'JSON' +{ + "event": "{{event.type}}", + "timestamp": "{{timestamp}}", + "timestamp_unix": {{timestamp_unix}}, + "data": {{data | json}} +} +JSON, + self::MINIMAL => <<<'JSON' +{ + "event": "{{event.type}}", + "id": "{{data.id}}", + "timestamp": "{{timestamp}}" +} +JSON, + self::SLACK => <<<'JSON' +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "{{event.name}}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{message}}" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Event:* `{{event.type}}` | *Time:* {{timestamp | iso8601}}" + } + ] + } + ] +} +JSON, + self::DISCORD => <<<'JSON' +{ + "embeds": [ + { + "title": "{{event.name}}", + "description": "{{message}}", + "color": 5814783, + "fields": [ + { + "name": "Event Type", + "value": "`{{event.type}}`", + "inline": true + }, + { + "name": "ID", + "value": "{{data.id | default:N/A}}", + "inline": true + } + ], + "timestamp": "{{timestamp}}" + } + ] +} +JSON, + }; + } + + /** + * Get the template format for this type. + */ + public function format(): WebhookTemplateFormat + { + return WebhookTemplateFormat::JSON; + } +} diff --git a/src/Mod/Api/Enums/WebhookTemplateFormat.php b/src/Mod/Api/Enums/WebhookTemplateFormat.php new file mode 100644 index 0000000..129a6e3 --- /dev/null +++ b/src/Mod/Api/Enums/WebhookTemplateFormat.php @@ -0,0 +1,73 @@ + 'Simple (variable substitution)', + self::MUSTACHE => 'Mustache (conditionals and loops)', + self::JSON => 'JSON (structured template)', + }; + } + + /** + * Get description for the format. + */ + public function description(): string + { + return match ($this) { + self::SIMPLE => 'Basic {{variable}} replacement. Best for simple payloads.', + self::MUSTACHE => 'Full Mustache syntax with {{#if}}, {{#each}}, and filters.', + self::JSON => 'JSON template with embedded {{variables}}. Validates structure.', + }; + } + + /** + * Get example template for the format. + */ + public function example(): string + { + return match ($this) { + self::SIMPLE => '{"event": "{{event.type}}", "id": "{{data.id}}"}', + self::MUSTACHE => '{"event": "{{event.type}}"{{#if data.user}}, "user": "{{data.user.name}}"{{/if}}}', + self::JSON => <<<'JSON' +{ + "event": "{{event.type}}", + "timestamp": "{{timestamp | iso8601}}", + "data": { + "id": "{{data.id}}", + "name": "{{data.name}}" + } +} +JSON, + }; + } +} diff --git a/src/Mod/Api/Migrations/0001_01_01_000001_create_api_tables.php b/src/Mod/Api/Migrations/0001_01_01_000001_create_api_tables.php new file mode 100644 index 0000000..d8123b5 --- /dev/null +++ b/src/Mod/Api/Migrations/0001_01_01_000001_create_api_tables.php @@ -0,0 +1,65 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('namespace_id')->nullable()->constrained('namespaces')->nullOnDelete(); + + $table->string('name'); + $table->text('description')->nullable(); + + // Template format: simple, mustache, json + $table->string('format', 20)->default('simple'); + + // The actual template content (JSON/Twig-like syntax) + $table->text('template'); + + // Example rendered output for preview + $table->text('example_output')->nullable(); + + // Template metadata + $table->boolean('is_default')->default(false); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + + // Built-in template type (null for custom templates) + // Values: full, minimal, slack, discord + $table->string('builtin_type', 20)->nullable(); + + $table->timestamps(); + + $table->index(['workspace_id', 'is_active']); + $table->index(['workspace_id', 'is_default']); + $table->index(['workspace_id', 'sort_order']); + $table->index('builtin_type'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::dropIfExists('api_webhook_payload_templates'); + } +}; diff --git a/src/Mod/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php b/src/Mod/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php new file mode 100644 index 0000000..aefe65a --- /dev/null +++ b/src/Mod/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php @@ -0,0 +1,58 @@ +text('previous_secret')->nullable()->after('secret'); + $table->timestamp('secret_rotated_at')->nullable()->after('previous_secret'); + $table->unsignedInteger('grace_period_seconds')->default(86400)->after('secret_rotated_at'); // 24 hours + }); + } + + // Add grace period fields to content_webhook_endpoints + if (Schema::hasTable('content_webhook_endpoints')) { + Schema::table('content_webhook_endpoints', function (Blueprint $table) { + $table->text('previous_secret')->nullable()->after('secret'); + $table->timestamp('secret_rotated_at')->nullable()->after('previous_secret'); + $table->unsignedInteger('grace_period_seconds')->default(86400)->after('secret_rotated_at'); // 24 hours + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasTable('social_webhooks')) { + Schema::table('social_webhooks', function (Blueprint $table) { + $table->dropColumn(['previous_secret', 'secret_rotated_at', 'grace_period_seconds']); + }); + } + + if (Schema::hasTable('content_webhook_endpoints')) { + Schema::table('content_webhook_endpoints', function (Blueprint $table) { + $table->dropColumn(['previous_secret', 'secret_rotated_at', 'grace_period_seconds']); + }); + } + } +}; diff --git a/src/Mod/Api/Models/WebhookPayloadTemplate.php b/src/Mod/Api/Models/WebhookPayloadTemplate.php new file mode 100644 index 0000000..c7b94bf --- /dev/null +++ b/src/Mod/Api/Models/WebhookPayloadTemplate.php @@ -0,0 +1,321 @@ + WebhookTemplateFormat::class, + 'is_default' => 'boolean', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + 'builtin_type' => BuiltinTemplateType::class, + ]; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (WebhookPayloadTemplate $template) { + if (empty($template->uuid)) { + $template->uuid = (string) Str::uuid(); + } + }); + + // Ensure only one default template per workspace + static::saving(function (WebhookPayloadTemplate $template) { + if ($template->is_default) { + static::where('workspace_id', $template->workspace_id) + ->where('id', '!=', $template->id ?? 0) + ->where('is_default', true) + ->update(['is_default' => false]); + } + }); + } + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeDefault(Builder $query): Builder + { + return $query->where('is_default', true); + } + + public function scopeBuiltin(Builder $query): Builder + { + return $query->whereNotNull('builtin_type'); + } + + public function scopeCustom(Builder $query): Builder + { + return $query->whereNull('builtin_type'); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order')->orderBy('name'); + } + + public function scopeForWorkspace(Builder $query, int $workspaceId): Builder + { + return $query->where('workspace_id', $workspaceId); + } + + // ------------------------------------------------------------------------- + // State Checks + // ------------------------------------------------------------------------- + + public function isActive(): bool + { + return $this->is_active === true; + } + + public function isDefault(): bool + { + return $this->is_default === true; + } + + public function isBuiltin(): bool + { + return $this->builtin_type !== null; + } + + public function isCustom(): bool + { + return $this->builtin_type === null; + } + + // ------------------------------------------------------------------------- + // Template Methods + // ------------------------------------------------------------------------- + + /** + * Get the template format enum. + */ + public function getFormat(): WebhookTemplateFormat + { + return $this->format ?? WebhookTemplateFormat::SIMPLE; + } + + /** + * Get the builtin type if this is a builtin template. + */ + public function getBuiltinType(): ?BuiltinTemplateType + { + return $this->builtin_type; + } + + /** + * Update the example output preview. + */ + public function updateExampleOutput(string $output): void + { + $this->update(['example_output' => $output]); + } + + /** + * Set this template as the workspace default. + */ + public function setAsDefault(): void + { + $this->update(['is_default' => true]); + } + + /** + * Duplicate this template with a new name. + */ + public function duplicate(?string $newName = null): static + { + $duplicate = $this->replicate(['uuid', 'is_default']); + $duplicate->uuid = (string) Str::uuid(); + $duplicate->name = $newName ?? $this->name.' (copy)'; + $duplicate->is_default = false; + $duplicate->builtin_type = null; // Custom copy + $duplicate->save(); + + return $duplicate; + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'description', 'format', 'template', 'is_default', 'is_active']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs() + ->setDescriptionForEvent(fn (string $eventName) => "Webhook template {$eventName}"); + } + + /** + * Get Flux badge colour for status. + */ + public function getStatusColorAttribute(): string + { + if (! $this->is_active) { + return 'zinc'; + } + + if ($this->is_default) { + return 'green'; + } + + return 'blue'; + } + + /** + * Get status label. + */ + public function getStatusLabelAttribute(): string + { + if (! $this->is_active) { + return 'Inactive'; + } + + if ($this->is_default) { + return 'Default'; + } + + return 'Active'; + } + + /** + * Get icon for template type. + */ + public function getTypeIconAttribute(): string + { + if ($this->isBuiltin()) { + return match ($this->builtin_type) { + BuiltinTemplateType::SLACK => 'slack', + BuiltinTemplateType::DISCORD => 'discord', + BuiltinTemplateType::FULL => 'code-bracket', + BuiltinTemplateType::MINIMAL => 'minus', + default => 'document-text', + }; + } + + return 'document'; + } + + // ------------------------------------------------------------------------- + // Factory Methods + // ------------------------------------------------------------------------- + + /** + * Create all builtin templates for a workspace. + */ + public static function createBuiltinTemplates(int $workspaceId, ?int $namespaceId = null): void + { + $sortOrder = 0; + + foreach (BuiltinTemplateType::cases() as $type) { + static::firstOrCreate( + [ + 'workspace_id' => $workspaceId, + 'builtin_type' => $type, + ], + [ + 'uuid' => (string) Str::uuid(), + 'namespace_id' => $namespaceId, + 'name' => $type->label(), + 'description' => $type->description(), + 'format' => $type->format(), + 'template' => $type->template(), + 'is_default' => $type === BuiltinTemplateType::FULL, + 'sort_order' => $sortOrder++, + 'is_active' => true, + ] + ); + } + } + + /** + * Get or create the default template for a workspace. + */ + public static function getDefaultForWorkspace(int $workspaceId): ?static + { + return static::forWorkspace($workspaceId) + ->active() + ->default() + ->first(); + } +} diff --git a/src/Mod/Api/Routes/admin.php b/src/Mod/Api/Routes/admin.php new file mode 100644 index 0000000..1e6899f --- /dev/null +++ b/src/Mod/Api/Routes/admin.php @@ -0,0 +1,19 @@ +name('hub.api.')->group(function () { + Route::get('/webhook-templates', WebhookTemplateManager::class)->name('webhook-templates'); +}); diff --git a/src/Mod/Api/Services/WebhookSecretRotationService.php b/src/Mod/Api/Services/WebhookSecretRotationService.php new file mode 100644 index 0000000..cb18e3c --- /dev/null +++ b/src/Mod/Api/Services/WebhookSecretRotationService.php @@ -0,0 +1,308 @@ +secret; + + // Determine grace period + $gracePeriod = $gracePeriodSeconds ?? $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD; + $gracePeriod = max(self::MIN_GRACE_PERIOD, min(self::MAX_GRACE_PERIOD, $gracePeriod)); + + DB::transaction(function () use ($webhook, $currentSecret, $newSecret, $gracePeriod) { + $webhook->update([ + 'previous_secret' => $currentSecret, + 'secret' => $newSecret, + 'secret_rotated_at' => now(), + 'grace_period_seconds' => $gracePeriod, + ]); + }); + + Log::info('Webhook secret rotated', [ + 'webhook_id' => $webhook->id, + 'webhook_type' => class_basename($webhook), + 'grace_period_seconds' => $gracePeriod, + ]); + + return $newSecret; + } + + /** + * Verify a signature against both current and previous secrets during grace period. + * + * @param Model $webhook The webhook model + * @param string $payload The raw payload to verify + * @param string|null $signature The provided signature + * @param string $algorithm Hash algorithm (default: sha256) + * @return array{valid: bool, used_previous: bool, message: string} + */ + public function verifySignature( + Model $webhook, + string $payload, + ?string $signature, + string $algorithm = 'sha256' + ): array { + // If no secret configured, skip verification + if (empty($webhook->secret)) { + return [ + 'valid' => true, + 'used_previous' => false, + 'message' => 'No secret configured, verification skipped', + ]; + } + + // Signature required when secret is set + if (empty($signature)) { + return [ + 'valid' => false, + 'used_previous' => false, + 'message' => 'Signature required but not provided', + ]; + } + + // Normalise signature (strip prefix like sha256= if present) + $signature = $this->normaliseSignature($signature, $algorithm); + + // Check against current secret + $expectedSignature = hash_hmac($algorithm, $payload, $webhook->secret); + if (hash_equals($expectedSignature, $signature)) { + return [ + 'valid' => true, + 'used_previous' => false, + 'message' => 'Signature verified with current secret', + ]; + } + + // Check against previous secret if in grace period + if ($this->isInGracePeriod($webhook) && ! empty($webhook->previous_secret)) { + $previousExpectedSignature = hash_hmac($algorithm, $payload, $webhook->previous_secret); + if (hash_equals($previousExpectedSignature, $signature)) { + return [ + 'valid' => true, + 'used_previous' => true, + 'message' => 'Signature verified with previous secret (grace period)', + ]; + } + } + + return [ + 'valid' => false, + 'used_previous' => false, + 'message' => 'Signature verification failed', + ]; + } + + /** + * Check if the webhook is currently in a grace period. + */ + public function isInGracePeriod(Model $webhook): bool + { + if (empty($webhook->secret_rotated_at)) { + return false; + } + + $rotatedAt = Carbon::parse($webhook->secret_rotated_at); + $gracePeriodSeconds = $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD; + $graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds); + + return now()->isBefore($graceEndsAt); + } + + /** + * Get the secret rotation status for a webhook. + * + * @return array{ + * has_previous_secret: bool, + * in_grace_period: bool, + * grace_period_seconds: int, + * rotated_at: ?string, + * grace_ends_at: ?string, + * time_remaining_seconds: ?int, + * time_remaining_human: ?string + * } + */ + public function getSecretStatus(Model $webhook): array + { + $hasPreviousSecret = ! empty($webhook->previous_secret); + $inGracePeriod = $this->isInGracePeriod($webhook); + $gracePeriodSeconds = $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD; + + $rotatedAt = $webhook->secret_rotated_at ? Carbon::parse($webhook->secret_rotated_at) : null; + $graceEndsAt = $rotatedAt ? $rotatedAt->copy()->addSeconds($gracePeriodSeconds) : null; + $timeRemaining = ($inGracePeriod && $graceEndsAt) ? now()->diffInSeconds($graceEndsAt, false) : null; + $timeRemainingHuman = $timeRemaining > 0 ? $this->humanReadableTime($timeRemaining) : null; + + return [ + 'has_previous_secret' => $hasPreviousSecret, + 'in_grace_period' => $inGracePeriod, + 'grace_period_seconds' => $gracePeriodSeconds, + 'rotated_at' => $rotatedAt?->toIso8601String(), + 'grace_ends_at' => $graceEndsAt?->toIso8601String(), + 'time_remaining_seconds' => $timeRemaining > 0 ? (int) $timeRemaining : null, + 'time_remaining_human' => $timeRemainingHuman, + ]; + } + + /** + * Immediately invalidate the previous secret. + * + * Use this to end the grace period early (e.g., if the old secret was compromised). + */ + public function invalidatePreviousSecret(Model $webhook): void + { + $webhook->update([ + 'previous_secret' => null, + 'secret_rotated_at' => null, + ]); + + Log::info('Webhook previous secret invalidated', [ + 'webhook_id' => $webhook->id, + 'webhook_type' => class_basename($webhook), + ]); + } + + /** + * Clean up expired grace periods for a specific model class. + * + * @param string $modelClass The webhook model class to clean up + * @return int Number of webhooks cleaned up + */ + public function cleanupExpiredGracePeriods(string $modelClass): int + { + $count = 0; + + $modelClass::query() + ->whereNotNull('previous_secret') + ->whereNotNull('secret_rotated_at') + ->chunkById(100, function ($webhooks) use (&$count) { + foreach ($webhooks as $webhook) { + if (! $this->isInGracePeriod($webhook)) { + $webhook->update([ + 'previous_secret' => null, + 'secret_rotated_at' => null, + ]); + $count++; + } + } + }); + + if ($count > 0) { + Log::info('Cleaned up expired webhook secret grace periods', [ + 'model_class' => $modelClass, + 'count' => $count, + ]); + } + + return $count; + } + + /** + * Update the grace period duration for a webhook. + */ + public function updateGracePeriod(Model $webhook, int $gracePeriodSeconds): void + { + $gracePeriodSeconds = max(self::MIN_GRACE_PERIOD, min(self::MAX_GRACE_PERIOD, $gracePeriodSeconds)); + + $webhook->update([ + 'grace_period_seconds' => $gracePeriodSeconds, + ]); + } + + /** + * Normalise a signature by removing common prefixes. + */ + protected function normaliseSignature(string $signature, string $algorithm): string + { + // Handle sha256=... format (GitHub, WordPress) + $prefix = $algorithm.'='; + if (str_starts_with($signature, $prefix)) { + return substr($signature, strlen($prefix)); + } + + return $signature; + } + + /** + * Convert seconds to human-readable time string. + */ + protected function humanReadableTime(int $seconds): string + { + if ($seconds < 60) { + return $seconds.' second'.($seconds !== 1 ? 's' : ''); + } + + if ($seconds < 3600) { + $minutes = (int) floor($seconds / 60); + + return $minutes.' minute'.($minutes !== 1 ? 's' : ''); + } + + if ($seconds < 86400) { + $hours = (int) floor($seconds / 3600); + $minutes = (int) floor(($seconds % 3600) / 60); + + $result = $hours.' hour'.($hours !== 1 ? 's' : ''); + if ($minutes > 0) { + $result .= ' '.$minutes.' minute'.($minutes !== 1 ? 's' : ''); + } + + return $result; + } + + $days = (int) floor($seconds / 86400); + $hours = (int) floor(($seconds % 86400) / 3600); + + $result = $days.' day'.($days !== 1 ? 's' : ''); + if ($hours > 0) { + $result .= ' '.$hours.' hour'.($hours !== 1 ? 's' : ''); + } + + return $result; + } +} diff --git a/src/Mod/Api/Services/WebhookTemplateService.php b/src/Mod/Api/Services/WebhookTemplateService.php new file mode 100644 index 0000000..5a808d5 --- /dev/null +++ b/src/Mod/Api/Services/WebhookTemplateService.php @@ -0,0 +1,629 @@ + 'formatIso8601', + 'timestamp' => 'formatTimestamp', + 'currency' => 'formatCurrency', + 'json' => 'formatJson', + 'upper' => 'formatUpper', + 'lower' => 'formatLower', + 'default' => 'formatDefault', + 'truncate' => 'formatTruncate', + 'escape' => 'formatEscape', + 'urlencode' => 'formatUrlencode', + ]; + + /** + * Render a template with the given event data. + * + * @param WebhookPayloadTemplate $template The template containing the pattern + * @param WebhookEvent $event The event providing data + * @return array The rendered payload + * + * @throws \InvalidArgumentException If template is invalid + */ + public function render(WebhookPayloadTemplate $template, WebhookEvent $event): array + { + $templateContent = $template->template; + $format = $template->getFormat(); + $context = $this->buildContext($event); + + return $this->renderTemplate($templateContent, $format, $context); + } + + /** + * Render a template string with context data. + * + * @param string $templateContent The template content + * @param WebhookTemplateFormat $format The template format + * @param array $context The context data + * @return array The rendered payload + * + * @throws \InvalidArgumentException If template renders to invalid JSON + */ + public function renderTemplate(string $templateContent, WebhookTemplateFormat $format, array $context): array + { + $rendered = match ($format) { + WebhookTemplateFormat::SIMPLE => $this->renderSimple($templateContent, $context), + WebhookTemplateFormat::MUSTACHE => $this->renderMustache($templateContent, $context), + WebhookTemplateFormat::JSON => $this->renderJson($templateContent, $context), + }; + + // Parse as JSON if it's a string + if (is_string($rendered)) { + $decoded = json_decode($rendered, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('Template rendered to invalid JSON: '.json_last_error_msg()); + } + + return $decoded; + } + + return $rendered; + } + + /** + * Build the default payload structure for an event. + */ + public function buildDefaultPayload(WebhookEvent $event): array + { + return [ + 'event' => $event::name(), + 'data' => $event->payload(), + 'timestamp' => now()->toIso8601String(), + ]; + } + + /** + * Validate a template for syntax errors. + * + * @param string $template The template content + * @param WebhookTemplateFormat $format The template format + * @return array{valid: bool, errors: array} + */ + public function validateTemplate(string $template, WebhookTemplateFormat $format): array + { + $errors = []; + + // Check for empty template + if (empty(trim($template))) { + return ['valid' => false, 'errors' => ['Template cannot be empty.']]; + } + + // Format-specific validation + $errors = match ($format) { + WebhookTemplateFormat::SIMPLE => $this->validateSimple($template), + WebhookTemplateFormat::MUSTACHE => $this->validateMustache($template), + WebhookTemplateFormat::JSON => $this->validateJson($template), + }; + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * Get available variables for an event type. + * + * @param string|null $eventType The event type (e.g., 'post.published') + * @return array + */ + public function getAvailableVariables(?string $eventType = null): array + { + // Base variables available for all events + $variables = [ + 'event.type' => [ + 'type' => 'string', + 'description' => 'The event identifier', + 'example' => $eventType ?? 'resource.action', + ], + 'event.name' => [ + 'type' => 'string', + 'description' => 'Human-readable event name', + 'example' => 'Resource Updated', + ], + 'message' => [ + 'type' => 'string', + 'description' => 'Human-readable event message', + 'example' => 'A resource was updated successfully.', + ], + 'timestamp' => [ + 'type' => 'datetime', + 'description' => 'When the event occurred (ISO 8601)', + 'example' => now()->toIso8601String(), + ], + 'timestamp_unix' => [ + 'type' => 'integer', + 'description' => 'Unix timestamp of the event', + 'example' => now()->timestamp, + ], + 'data' => [ + 'type' => 'object', + 'description' => 'Event-specific data payload', + 'example' => ['id' => 1, 'name' => 'Example'], + ], + 'data.id' => [ + 'type' => 'mixed', + 'description' => 'Primary identifier of the resource', + 'example' => 123, + ], + 'data.uuid' => [ + 'type' => 'string', + 'description' => 'UUID of the resource (if available)', + 'example' => '550e8400-e29b-41d4-a716-446655440000', + ], + ]; + + return $variables; + } + + /** + * Get available filters for template variables. + * + * @return array + */ + public function getAvailableFilters(): array + { + return [ + 'iso8601' => 'Format datetime as ISO 8601', + 'timestamp' => 'Format datetime as Unix timestamp', + 'currency' => 'Format number as currency (2 decimal places)', + 'json' => 'Encode value as JSON', + 'upper' => 'Convert to uppercase', + 'lower' => 'Convert to lowercase', + 'default' => 'Provide default value if empty (e.g., {{value | default:N/A}})', + 'truncate' => 'Truncate to specified length (e.g., {{text | truncate:100}})', + 'escape' => 'HTML escape the value', + 'urlencode' => 'URL encode the value', + ]; + } + + /** + * Preview a template with sample data. + * + * @param string $template The template content + * @param WebhookTemplateFormat $format The template format + * @param string|null $eventType The event type for sample data + * @return array{success: bool, output: mixed, errors: array} + */ + public function previewPayload(string $template, WebhookTemplateFormat $format, ?string $eventType = null): array + { + // Validate first + $validation = $this->validateTemplate($template, $format); + if (! $validation['valid']) { + return [ + 'success' => false, + 'output' => null, + 'errors' => $validation['errors'], + ]; + } + + // Build sample context + $context = $this->buildSampleContext($eventType); + + try { + $rendered = match ($format) { + WebhookTemplateFormat::SIMPLE => $this->renderSimple($template, $context), + WebhookTemplateFormat::MUSTACHE => $this->renderMustache($template, $context), + WebhookTemplateFormat::JSON => $this->renderJson($template, $context), + }; + + // Parse as JSON + if (is_string($rendered)) { + $decoded = json_decode($rendered, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return [ + 'success' => false, + 'output' => $rendered, + 'errors' => ['Rendered template is not valid JSON: '.json_last_error_msg()], + ]; + } + + return [ + 'success' => true, + 'output' => $decoded, + 'errors' => [], + ]; + } + + return [ + 'success' => true, + 'output' => $rendered, + 'errors' => [], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => null, + 'errors' => [$e->getMessage()], + ]; + } + } + + /** + * Get builtin template content by type. + */ + public function getBuiltinTemplate(BuiltinTemplateType $type): string + { + return $type->template(); + } + + /** + * Get all builtin templates. + * + * @return array + */ + public function getBuiltinTemplates(): array + { + $templates = []; + + foreach (BuiltinTemplateType::cases() as $type) { + $templates[$type->value] = [ + 'name' => $type->label(), + 'description' => $type->description(), + 'template' => $type->template(), + 'format' => $type->format(), + ]; + } + + return $templates; + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Build template context from an event. + */ + protected function buildContext(WebhookEvent $event): array + { + return [ + 'event' => [ + 'type' => $event::name(), + 'name' => $event::nameLocalised(), + ], + 'data' => $event->payload(), + 'message' => $event->message(), + 'timestamp' => now()->toIso8601String(), + 'timestamp_unix' => now()->timestamp, + ]; + } + + /** + * Build sample context for preview. + */ + protected function buildSampleContext(?string $eventType): array + { + $variables = $this->getAvailableVariables($eventType); + $context = []; + + foreach ($variables as $path => $info) { + Arr::set($context, $path, $info['example']); + } + + // Add message + $context['message'] = 'Sample webhook event message'; + + return $context; + } + + /** + * Render simple template with variable substitution. + */ + protected function renderSimple(string $template, array $context): string + { + // Match {{variable}} or {{variable | filter}} or {{variable | filter:arg}} + return preg_replace_callback( + '/\{\{\s*([a-zA-Z0-9_\.]+)(?:\s*\|\s*([a-zA-Z0-9_]+)(?::([^\}]+))?)?\s*\}\}/', + function ($matches) use ($context) { + $path = $matches[1]; + $filter = $matches[2] ?? null; + $filterArg = $matches[3] ?? null; + + $value = Arr::get($context, $path); + + // Apply filter if specified + if ($filter && isset(self::FILTERS[$filter])) { + $method = self::FILTERS[$filter]; + $value = $this->$method($value, $filterArg); + } + + // Convert arrays/objects to JSON strings + if (is_array($value) || is_object($value)) { + return json_encode($value); + } + + return (string) ($value ?? ''); + }, + $template + ); + } + + /** + * Render mustache-style template. + */ + protected function renderMustache(string $template, array $context): string + { + // Process conditionals: {{#if variable}}...{{/if}} + $template = preg_replace_callback( + '/\{\{#if\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/if\}\}/s', + function ($matches) use ($context) { + $path = $matches[1]; + $content = $matches[2]; + $value = Arr::get($context, $path); + + // Check if value is truthy + if ($value && (! is_array($value) || ! empty($value))) { + return $this->renderMustache($content, $context); + } + + return ''; + }, + $template + ); + + // Process negative conditionals: {{#unless variable}}...{{/unless}} + $template = preg_replace_callback( + '/\{\{#unless\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/unless\}\}/s', + function ($matches) use ($context) { + $path = $matches[1]; + $content = $matches[2]; + $value = Arr::get($context, $path); + + // Check if value is falsy + if (! $value || (is_array($value) && empty($value))) { + return $this->renderMustache($content, $context); + } + + return ''; + }, + $template + ); + + // Process loops: {{#each variable}}...{{/each}} + $template = preg_replace_callback( + '/\{\{#each\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/each\}\}/s', + function ($matches) use ($context) { + $path = $matches[1]; + $content = $matches[2]; + $items = Arr::get($context, $path, []); + + if (! is_array($items)) { + return ''; + } + + $output = ''; + foreach ($items as $index => $item) { + $itemContext = array_merge($context, [ + 'this' => $item, + '@index' => $index, + '@first' => $index === 0, + '@last' => $index === count($items) - 1, + ]); + + // Also allow direct access to item properties + if (is_array($item)) { + $itemContext = array_merge($itemContext, $item); + } + + $output .= $this->renderMustache($content, $itemContext); + } + + return $output; + }, + $template + ); + + // Finally, do simple variable replacement + return $this->renderSimple($template, $context); + } + + /** + * Render JSON template. + */ + protected function renderJson(string $template, array $context): string + { + // For JSON format, we do simple rendering then validate the result + return $this->renderSimple($template, $context); + } + + /** + * Validate simple template syntax. + */ + protected function validateSimple(string $template): array + { + $errors = []; + + // Check for unclosed braces + $openCount = substr_count($template, '{{'); + $closeCount = substr_count($template, '}}'); + + if ($openCount !== $closeCount) { + $errors[] = 'Mismatched template braces. Found '.$openCount.' opening and '.$closeCount.' closing.'; + } + + // Check for invalid variable names + preg_match_all('/\{\{\s*([^}|]+)/', $template, $matches); + foreach ($matches[1] as $varName) { + $varName = trim($varName); + // Allow #if, #unless, #each, /if, /unless, /each for mustache compatibility + if (! preg_match('/^[#\/]?[a-zA-Z0-9_\.@]+$/', $varName)) { + $errors[] = "Invalid variable name: {$varName}"; + } + } + + // Check for unknown filters + preg_match_all('/\|\s*([a-zA-Z0-9_]+)/', $template, $filterMatches); + foreach ($filterMatches[1] as $filter) { + if (! isset(self::FILTERS[$filter])) { + $errors[] = "Unknown filter: {$filter}. Available: ".implode(', ', array_keys(self::FILTERS)); + } + } + + return $errors; + } + + /** + * Validate mustache template syntax. + */ + protected function validateMustache(string $template): array + { + $errors = $this->validateSimple($template); + + // Check for unclosed blocks + $blocks = ['if', 'unless', 'each']; + foreach ($blocks as $block) { + $openCount = preg_match_all('/\{\{#'.$block.'\s/', $template); + $closeCount = preg_match_all('/\{\{\/'.$block.'\}\}/', $template); + + if ($openCount !== $closeCount) { + $errors[] = "Unclosed {{#{$block}}} block. Found {$openCount} opening and {$closeCount} closing."; + } + } + + return $errors; + } + + /** + * Validate JSON template syntax. + */ + protected function validateJson(string $template): array + { + $errors = $this->validateSimple($template); + + // Try to parse as JSON after replacing variables with placeholders + $testTemplate = preg_replace('/\{\{[^}]+\}\}/', '"__placeholder__"', $template); + + json_decode($testTemplate); + if (json_last_error() !== JSON_ERROR_NONE) { + $errors[] = 'Template is not valid JSON structure: '.json_last_error_msg(); + } + + return $errors; + } + + // ------------------------------------------------------------------------- + // Filter methods + // ------------------------------------------------------------------------- + + protected function formatIso8601(mixed $value, ?string $arg = null): string + { + if ($value instanceof Carbon) { + return $value->toIso8601String(); + } + + if (is_numeric($value)) { + return Carbon::createFromTimestamp($value)->toIso8601String(); + } + + if (is_string($value)) { + try { + return Carbon::parse($value)->toIso8601String(); + } catch (\Exception) { + return (string) $value; + } + } + + return (string) $value; + } + + protected function formatTimestamp(mixed $value, ?string $arg = null): int + { + if ($value instanceof Carbon) { + return $value->timestamp; + } + + if (is_numeric($value)) { + return (int) $value; + } + + if (is_string($value)) { + try { + return Carbon::parse($value)->timestamp; + } catch (\Exception) { + return 0; + } + } + + return 0; + } + + protected function formatCurrency(mixed $value, ?string $arg = null): string + { + $decimals = $arg ? (int) $arg : 2; + + return number_format((float) $value, $decimals); + } + + protected function formatJson(mixed $value, ?string $arg = null): string + { + return json_encode($value) ?: '""'; + } + + protected function formatUpper(mixed $value, ?string $arg = null): string + { + return mb_strtoupper((string) $value); + } + + protected function formatLower(mixed $value, ?string $arg = null): string + { + return mb_strtolower((string) $value); + } + + protected function formatDefault(mixed $value, ?string $arg = null): mixed + { + if ($value === null || $value === '' || (is_array($value) && empty($value))) { + return $arg ?? ''; + } + + return $value; + } + + protected function formatTruncate(mixed $value, ?string $arg = null): string + { + $length = $arg ? (int) $arg : 100; + $string = (string) $value; + + if (mb_strlen($string) <= $length) { + return $string; + } + + return mb_substr($string, 0, $length - 3).'...'; + } + + protected function formatEscape(mixed $value, ?string $arg = null): string + { + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + protected function formatUrlencode(mixed $value, ?string $arg = null): string + { + return urlencode((string) $value); + } +} diff --git a/src/Mod/Api/View/Blade/admin/webhook-template-manager.blade.php b/src/Mod/Api/View/Blade/admin/webhook-template-manager.blade.php new file mode 100644 index 0000000..0b67ff5 --- /dev/null +++ b/src/Mod/Api/View/Blade/admin/webhook-template-manager.blade.php @@ -0,0 +1,353 @@ +
+ {{-- Header --}} +
+
+ Webhook templates + + Reusable templates for customising webhook payload shapes. + +
+ + + Create template + +
+ + {{-- Filters and Search --}} +
+
+ + + + + + + +
+ +
+ +
+
+ + {{-- Templates List --}} + + @if($this->templates->isEmpty()) +
+ + No templates found + + @if($search) + No templates match your search criteria. + @else + Create a custom template or use one of the built-in templates. + @endif + +
+ @else +
+ @foreach($this->templates as $template) +
+
+ {{-- Icon --}} +
+ +
+ + {{-- Info --}} +
+
+ {{ $template->name }} + @if($template->isBuiltin()) + Built-in + @endif + @if($template->is_default) + Default + @endif + @if(!$template->is_active) + Inactive + @endif +
+

+ {{ $template->description ?? 'No description' }} +

+

+ Format: {{ $template->format->label() }} +

+
+
+ + {{-- Actions --}} +
+ + Edit + + + + + + + @if(!$template->is_default) + + Set as default + + @endif + + + Duplicate + + + + {{ $template->is_active ? 'Disable' : 'Enable' }} + + + @if(!$template->isBuiltin()) + + + Delete + + @endif + + +
+
+ @endforeach +
+ + {{-- Pagination --}} + @if($this->templates->hasPages()) +
+ {{ $this->templates->links() }} +
+ @endif + @endif +
+ + {{-- Delete Confirmation Modal --}} + @if($deletingId) + + Delete template + + Are you sure you want to delete this template? This action cannot be undone. + + +
+ + Cancel + + + Delete + +
+
+ @endif + + {{-- Editor Modal --}} + @if($showEditor) + +
+ + {{ $editingId ? 'Edit template' : 'Create template' }} + + +
+ {{-- Main editor (2/3 width) --}} +
+ {{-- Name --}} +
+ Template name + + @error('name') + {{ $message }} + @enderror +
+ + {{-- Description --}} +
+ Description + +
+ + {{-- Format selector --}} +
+ Template format + + @foreach($this->templateFormats as $value => $label) + + @endforeach + + + {{ $this->templateFormatDescriptions[$format] ?? '' }} + +
+ + {{-- Template textarea --}} +
+ Template content + + @error('template') + {{ $message }} + @enderror +
+ + {{-- Template errors --}} + @if($templateErrors) +
+

Template errors:

+
    + @foreach($templateErrors as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + {{-- Action buttons --}} +
+ + Preview output + + + Validate + + + Reset to default + +
+ + {{-- Preview output --}} + @if($templatePreview) +
+

Preview output:

+
{{ json_encode($templatePreview, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif + + {{-- Options --}} +
+ + +
+
+ + {{-- Sidebar (1/3 width) --}} +
+ {{-- Load from builtin --}} +
+ Load from built-in template +
+ @foreach($this->builtinTemplates as $type => $info) + + @endforeach +
+
+ + {{-- Available variables --}} +
+ Available variables +
+ @foreach($this->availableVariables as $variable => $info) + + @endforeach +
+

Click to insert at cursor position.

+
+ + {{-- Available filters --}} +
+ Available filters +
+ @foreach($this->availableFilters as $filter => $description) +
+ | {{ $filter }} + {{ $description }} +
+ @endforeach +
+
+ + {{-- Syntax help --}} +
+

Syntax reference

+
+

@{{ '{{variable}}' }} - Simple value

+

@{{ '{{data.nested}}' }} - Nested value

+

@{{ '{{value | filter}}' }} - With filter

+ @if($format === 'mustache') +

@{{ '{{#if var}}...{{/if}}' }} - Conditional

+

@{{ '{{#each arr}}...{{/each}}' }} - Loop

+ @endif +
+
+
+
+ + {{-- Footer actions --}} +
+ + Cancel + + + {{ $editingId ? 'Update template' : 'Create template' }} + +
+
+
+ @endif +
diff --git a/src/Mod/Api/View/Modal/Admin/WebhookTemplateManager.php b/src/Mod/Api/View/Modal/Admin/WebhookTemplateManager.php new file mode 100644 index 0000000..17272cf --- /dev/null +++ b/src/Mod/Api/View/Modal/Admin/WebhookTemplateManager.php @@ -0,0 +1,442 @@ +user()?->defaultHostWorkspace(); + } + + #[Computed] + public function templates() + { + if (! $this->workspace) { + return collect(); + } + + $query = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id); + + // Apply search + if ($this->search) { + $escapedSearch = $this->escapeLikeWildcards($this->search); + $query->where(function ($q) use ($escapedSearch) { + $q->where('name', 'like', "%{$escapedSearch}%") + ->orWhere('description', 'like', "%{$escapedSearch}%"); + }); + } + + // Apply filter + $query = match ($this->filter) { + 'custom' => $query->custom(), + 'builtin' => $query->builtin(), + 'active' => $query->active(), + 'inactive' => $query->where('is_active', false), + default => $query, + }; + + return $query + ->ordered() + ->paginate(20); + } + + #[Computed] + public function templateFormats(): array + { + return [ + 'simple' => WebhookTemplateFormat::SIMPLE->label(), + 'mustache' => WebhookTemplateFormat::MUSTACHE->label(), + 'json' => WebhookTemplateFormat::JSON->label(), + ]; + } + + #[Computed] + public function templateFormatDescriptions(): array + { + return [ + 'simple' => WebhookTemplateFormat::SIMPLE->description(), + 'mustache' => WebhookTemplateFormat::MUSTACHE->description(), + 'json' => WebhookTemplateFormat::JSON->description(), + ]; + } + + #[Computed] + public function availableVariables(): array + { + $service = app(WebhookTemplateService::class); + + return $service->getAvailableVariables($this->previewEventType); + } + + #[Computed] + public function availableFilters(): array + { + $service = app(WebhookTemplateService::class); + + return $service->getAvailableFilters(); + } + + #[Computed] + public function builtinTemplates(): array + { + $service = app(WebhookTemplateService::class); + + return $service->getBuiltinTemplates(); + } + + public function mount(): void + { + // Ensure builtin templates exist for this workspace + if ($this->workspace) { + WebhookPayloadTemplate::createBuiltinTemplates( + $this->workspace->id, + $this->workspace->default_namespace_id ?? null + ); + } + } + + // ------------------------------------------------------------------------- + // List Actions + // ------------------------------------------------------------------------- + + public function confirmDelete(string $uuid): void + { + $this->deletingId = $uuid; + } + + public function cancelDelete(): void + { + $this->deletingId = null; + } + + public function delete(): void + { + if (! $this->deletingId || ! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $this->deletingId) + ->first(); + + if ($template) { + // Don't allow deleting builtin templates + if ($template->isBuiltin()) { + $this->dispatch('notify', type: 'error', message: 'Built-in templates cannot be deleted.'); + $this->deletingId = null; + + return; + } + + $template->delete(); + $this->dispatch('notify', type: 'success', message: 'Template deleted.'); + unset($this->templates); + } + + $this->deletingId = null; + } + + public function toggleActive(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $template->update(['is_active' => ! $template->is_active]); + unset($this->templates); + $this->dispatch('notify', type: 'success', message: $template->is_active ? 'Template enabled.' : 'Template disabled.'); + } + } + + public function setDefault(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $template->setAsDefault(); + unset($this->templates); + $this->dispatch('notify', type: 'success', message: 'Default template updated.'); + } + } + + public function duplicate(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $template->duplicate(); + unset($this->templates); + $this->dispatch('notify', type: 'success', message: 'Template duplicated.'); + } + } + + // ------------------------------------------------------------------------- + // Editor Actions + // ------------------------------------------------------------------------- + + public function create(): void + { + $this->resetEditor(); + $this->template = $this->getDefaultTemplateContent(); + $this->showEditor = true; + } + + public function edit(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $this->editingId = $uuid; + $this->name = $template->name; + $this->description = $template->description ?? ''; + $this->format = $template->format->value; + $this->template = $template->template; + $this->isDefault = $template->is_default; + $this->isActive = $template->is_active; + $this->templatePreview = null; + $this->templateErrors = null; + $this->showEditor = true; + } + } + + public function closeEditor(): void + { + $this->showEditor = false; + $this->resetEditor(); + } + + public function save(): void + { + // Validate template first + $this->validateTemplate(); + if (! empty($this->templateErrors)) { + return; + } + + $this->validate(); + + if (! $this->workspace) { + return; + } + + $data = [ + 'name' => $this->name, + 'description' => $this->description ?: null, + 'format' => $this->format, + 'template' => $this->template, + 'is_default' => $this->isDefault, + 'is_active' => $this->isActive, + ]; + + if ($this->editingId) { + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $this->editingId) + ->first(); + + if ($template) { + // Don't allow modifying builtin templates' core properties + if ($template->isBuiltin()) { + unset($data['format']); + } + + $template->update($data); + $this->dispatch('notify', type: 'success', message: 'Template updated.'); + } + } else { + $data['uuid'] = Str::uuid()->toString(); + $data['workspace_id'] = $this->workspace->id; + $data['namespace_id'] = $this->workspace->default_namespace_id ?? null; + + WebhookPayloadTemplate::create($data); + $this->dispatch('notify', type: 'success', message: 'Template created.'); + } + + unset($this->templates); + $this->closeEditor(); + } + + public function validateTemplate(): void + { + $this->templateErrors = null; + + if (empty($this->template)) { + $this->templateErrors = ['Template cannot be empty.']; + + return; + } + + $service = app(WebhookTemplateService::class); + $format = WebhookTemplateFormat::tryFrom($this->format) ?? WebhookTemplateFormat::SIMPLE; + + $result = $service->validateTemplate($this->template, $format); + + if (! $result['valid']) { + $this->templateErrors = $result['errors']; + } + } + + public function previewTemplate(): void + { + $this->templatePreview = null; + $this->templateErrors = null; + + if (empty($this->template)) { + $this->templateErrors = ['Template cannot be empty.']; + + return; + } + + $service = app(WebhookTemplateService::class); + $format = WebhookTemplateFormat::tryFrom($this->format) ?? WebhookTemplateFormat::SIMPLE; + + $result = $service->previewPayload($this->template, $format, $this->previewEventType); + + if ($result['success']) { + $this->templatePreview = $result['output']; + $this->templateErrors = null; + } else { + $this->templatePreview = null; + $this->templateErrors = $result['errors']; + } + } + + public function insertVariable(string $variable): void + { + $this->dispatch('insert-variable', variable: '{{'.$variable.'}}'); + } + + public function loadBuiltinTemplate(string $type): void + { + $builtinType = BuiltinTemplateType::tryFrom($type); + if ($builtinType) { + $this->template = $builtinType->template(); + $this->format = $builtinType->format()->value; + $this->templatePreview = null; + $this->templateErrors = null; + } + } + + public function resetTemplate(): void + { + $this->template = $this->getDefaultTemplateContent(); + $this->templatePreview = null; + $this->templateErrors = null; + } + + public function render() + { + return view('api::admin.webhook-template-manager'); + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + protected function resetEditor(): void + { + $this->editingId = null; + $this->name = ''; + $this->description = ''; + $this->format = 'json'; + $this->template = ''; + $this->isDefault = false; + $this->isActive = true; + $this->templatePreview = null; + $this->templateErrors = null; + } + + protected function getDefaultTemplateContent(): string + { + return <<<'JSON' +{ + "event": "{{event.type}}", + "timestamp": "{{timestamp}}", + "data": {{data | json}} +} +JSON; + } + + protected function escapeLikeWildcards(string $value): string + { + return str_replace(['%', '_'], ['\\%', '\\_'], $value); + } +}