Files
crewli/api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php
bert.hausmans b3eab6e0c8 feat(form-builder): add core services (schema, field, submission, value, field-access, locale, tag-sync, filter, webhook, anonymisation)
S2b Phase 1 per ARCH-FORM-BUILDER.md §20.2. Ten services + supporting
exceptions, jobs, and the organisations.default_locale column needed by
FormLocaleResolver. All services log via spatie/laravel-activitylog, write
operations are transactional, queued jobs are idempotent.

- FormSchemaService: CRUD, slug, version bump, duplicate, edit-lock,
  public_token rotation (7-day grace window), typed-confirmation delete.
- FormFieldService: CRUD, reorder, insertFromLibrary, binding-change guard
  (§6.5), conditional_logic + section cycle detection (§8, §4.8.1),
  is_filterable toggle triggers BackfillFormValueIndexedJob (§7.2, §22.10).
- FormSubmissionService: createDraft with idempotency, saveDraft (auto-save),
  submit with schema snapshot + signature hash computation (§9), review,
  delegate/revoke, soft delete. Fires S1 domain events (§17.1).
- FormValueService: bulk upsert with FieldAccessService RBAC (§24.2),
  Pattern A/C entity mirror writes (§6.1, §6.6) with cross-entity graceful
  skip for person.user_id=null.
- FieldAccessService: canRead/canWrite/filterVisibleFields honouring
  role_restrictions + subject-self (§18.3, §24.1).
- FormLocaleResolver: submitter → schema → org.default_locale → 'nl' (§16.2).
- FormTagSyncService: rebuildForPerson — replaces legacy TagSyncService
  deleted in S2a (§31.10).
- FilterQueryBuilder: generic filter applier for entity_column / tags /
  form_field sources (§7.4–§7.5).
- FormWebhookDispatcher + DeliverFormWebhookJob: HMAC-signed delivery with
  SSRF protection, exponential backoff {1m,5m,30m,2h,8h}, max 5 attempts,
  dead-letter on exhaustion (§17.5).
- FormSubmissionAnonymisationService: per-field anonymisation with separate
  activity log entries (§13.3, §23.4).

MigrationRollbackTest: pin the S2a drop migration by filename so future
migrations don't shift the step offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 20:47:39 +02:00

188 lines
6.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Jobs\FormBuilder;
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
use App\Models\FormBuilder\FormWebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* HTTP delivery with HMAC signing, SSRF protection, exponential backoff
* retries, and dead-letter on exhaustion (ARCH §17.5.3§17.5.4, §22.10).
*/
final class DeliverFormWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var array<int, int> Retry delays in seconds per attempt. */
public array $backoff = [60, 300, 1800, 7200, 28800];
public int $timeout = 30;
public int $tries = 5;
public function __construct(public readonly string $deliveryId) {}
public function handle(): void
{
/** @var FormWebhookDelivery|null $delivery */
$delivery = FormWebhookDelivery::query()->find($this->deliveryId);
if ($delivery === null) {
return;
}
$webhook = $delivery->webhook;
if ($webhook === null || ! $webhook->is_active) {
$delivery->status = FormWebhookDeliveryStatus::FAILED->value;
$delivery->failed_permanently_at = now();
$delivery->save();
return;
}
$url = (string) $webhook->url;
if (! $this->urlIsSafe($url)) {
$delivery->status = FormWebhookDeliveryStatus::FAILED->value;
$delivery->failed_permanently_at = now();
$delivery->response_body_excerpt = 'SSRF protection: URL rejected.';
$delivery->save();
return;
}
$payload = (array) ($delivery->payload_snapshot ?? []);
$body = json_encode($payload, JSON_THROW_ON_ERROR);
$headers = ['Content-Type' => 'application/json'];
if (! empty($webhook->secret)) {
$headers['X-Crewli-Signature'] = 'sha256='.hash_hmac('sha256', $body, (string) $webhook->secret);
}
$delivery->attempts = (int) $delivery->attempts + 1;
$delivery->last_attempt_at = now();
try {
$response = Http::withHeaders($headers)
->timeout((int) config('form_builder.webhooks.timeout_seconds', 10))
->withBody($body, 'application/json')
->post($url);
$delivery->response_status = $response->status();
$delivery->response_body_excerpt = mb_substr((string) $response->body(), 0, 1000);
if ($response->successful()) {
$delivery->status = FormWebhookDeliveryStatus::DELIVERED->value;
$delivery->delivered_at = now();
$delivery->save();
return;
}
if ($this->isRetriable($response->status()) && $delivery->attempts < $this->tries) {
$delivery->status = FormWebhookDeliveryStatus::PENDING->value;
$delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800);
$delivery->save();
$this->release($this->backoff[$delivery->attempts - 1] ?? 28800);
return;
}
$delivery->status = $delivery->attempts >= $this->tries
? FormWebhookDeliveryStatus::DEAD_LETTER->value
: FormWebhookDeliveryStatus::FAILED->value;
$delivery->failed_permanently_at = now();
$delivery->save();
} catch (\Throwable $e) {
Log::warning('form-webhook.delivery.exception', [
'delivery_id' => $delivery->id,
'message' => $e->getMessage(),
]);
if ($delivery->attempts < $this->tries) {
$delivery->status = FormWebhookDeliveryStatus::PENDING->value;
$delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800);
$delivery->save();
$this->release($this->backoff[$delivery->attempts - 1] ?? 28800);
return;
}
$delivery->status = FormWebhookDeliveryStatus::DEAD_LETTER->value;
$delivery->failed_permanently_at = now();
$delivery->save();
}
}
private function urlIsSafe(string $url): bool
{
$parts = parse_url($url);
if ($parts === false || ! isset($parts['host'], $parts['scheme'])) {
return false;
}
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = (string) $parts['host'];
$allowlist = (array) config('form_builder.webhooks.allowlist_domains', []);
if ($allowlist !== [] && ! in_array(strtolower($host), array_map('strtolower', $allowlist), true)) {
return false;
}
$ips = @gethostbynamel($host) ?: [$host];
$blocklist = (array) config('form_builder.webhooks.blocklist_ips', []);
foreach ($ips as $ip) {
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
continue;
}
foreach ($blocklist as $cidr) {
if ($this->ipInCidr($ip, (string) $cidr)) {
return false;
}
}
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false;
}
}
return true;
}
private function ipInCidr(string $ip, string $cidr): bool
{
if (! str_contains($cidr, '/')) {
return $ip === $cidr;
}
[$subnet, $mask] = explode('/', $cidr, 2);
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return false;
}
$maskLong = -1 << (32 - (int) $mask);
return (($ipLong & $maskLong) === ($subnetLong & $maskLong));
}
private function isRetriable(int $status): bool
{
if ($status >= 500) {
return true;
}
return in_array($status, [408, 425, 429], true);
}
}