'boolean', 'failure_count' => 'integer', 'grace_period_seconds' => 'integer', 'last_received_at' => 'datetime', 'secret_rotated_at' => 'datetime', 'secret' => 'encrypted', 'previous_secret' => 'encrypted', 'settings' => 'array', ]; protected $hidden = [ 'secret', 'previous_secret', ]; protected static function boot(): void { parent::boot(); static::creating(function (UptelligenceWebhook $webhook) { if (empty($webhook->uuid)) { $webhook->uuid = (string) Str::uuid(); } // Generate a secret if not provided if (empty($webhook->secret)) { $webhook->secret = Str::random(64); } // Default grace period: 24 hours if (empty($webhook->grace_period_seconds)) { $webhook->grace_period_seconds = 86400; } }); } // ------------------------------------------------------------------------- // Relationships // ------------------------------------------------------------------------- public function vendor(): BelongsTo { return $this->belongsTo(Vendor::class); } public function deliveries(): HasMany { return $this->hasMany(UptelligenceWebhookDelivery::class, 'webhook_id'); } // ------------------------------------------------------------------------- // Scopes // ------------------------------------------------------------------------- public function scopeActive($query) { return $query->where('is_active', true); } public function scopeForVendor($query, int $vendorId) { return $query->where('vendor_id', $vendorId); } public function scopeByProvider($query, string $provider) { return $query->where('provider', $provider); } // ------------------------------------------------------------------------- // State Checks // ------------------------------------------------------------------------- public function isActive(): bool { return $this->is_active === true; } public function isCircuitBroken(): bool { return $this->failure_count >= self::MAX_FAILURES; } public function isInGracePeriod(): bool { if (empty($this->secret_rotated_at)) { return false; } $rotatedAt = Carbon::parse($this->secret_rotated_at); $gracePeriodSeconds = $this->grace_period_seconds ?? 86400; $graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds); return now()->isBefore($graceEndsAt); } // ------------------------------------------------------------------------- // Signature Verification // ------------------------------------------------------------------------- /** * Verify webhook signature based on provider. * * Supports: * - GitHub: X-Hub-Signature-256 (sha256=...) * - GitLab: X-Gitlab-Token (token comparison) * - npm: npm registry webhooks * - Packagist: Packagist webhooks * - Custom: HMAC-SHA256 */ public function verifySignature(string $payload, ?string $signature): bool { // If no secret configured, skip verification if (empty($this->secret)) { return true; } // Signature required when secret is set if (empty($signature)) { return false; } // Check against current secret if ($this->verifyAgainstSecret($payload, $signature, $this->secret)) { return true; } // Check against previous secret if in grace period if ($this->isInGracePeriod() && ! empty($this->previous_secret)) { if ($this->verifyAgainstSecret($payload, $signature, $this->previous_secret)) { return true; } } return false; } /** * Verify signature against a specific secret. */ protected function verifyAgainstSecret(string $payload, string $signature, string $secret): bool { return match ($this->provider) { self::PROVIDER_GITHUB => $this->verifyGitHubSignature($payload, $signature, $secret), self::PROVIDER_GITLAB => $this->verifyGitLabSignature($signature, $secret), self::PROVIDER_NPM => $this->verifyNpmSignature($payload, $signature, $secret), self::PROVIDER_PACKAGIST => $this->verifyPackagistSignature($payload, $signature, $secret), default => $this->verifyHmacSignature($payload, $signature, $secret), }; } /** * Verify GitHub-style signature (sha256=...). */ protected function verifyGitHubSignature(string $payload, string $signature, string $secret): bool { // Handle sha256= prefix if (str_starts_with($signature, 'sha256=')) { $signature = substr($signature, 7); } $expectedSignature = hash_hmac('sha256', $payload, $secret); return hash_equals($expectedSignature, $signature); } /** * Verify GitLab-style signature (X-Gitlab-Token header). */ protected function verifyGitLabSignature(string $signature, string $secret): bool { return hash_equals($secret, $signature); } /** * Verify npm webhook signature. */ protected function verifyNpmSignature(string $payload, string $signature, string $secret): bool { // npm uses sha256 HMAC $expectedSignature = hash_hmac('sha256', $payload, $secret); return hash_equals($expectedSignature, $signature); } /** * Verify Packagist webhook signature. */ protected function verifyPackagistSignature(string $payload, string $signature, string $secret): bool { // Packagist uses sha1 HMAC $expectedSignature = hash_hmac('sha1', $payload, $secret); return hash_equals($expectedSignature, $signature); } /** * Verify generic HMAC-SHA256 signature. */ protected function verifyHmacSignature(string $payload, string $signature, string $secret): bool { // Handle sha256= prefix if (str_starts_with($signature, 'sha256=')) { $signature = substr($signature, 7); } $expectedSignature = hash_hmac('sha256', $payload, $secret); return hash_equals($expectedSignature, $signature); } // ------------------------------------------------------------------------- // Status Management // ------------------------------------------------------------------------- public function incrementFailureCount(): void { $this->increment('failure_count'); // Auto-disable after too many failures (circuit breaker) if ($this->failure_count >= self::MAX_FAILURES) { $this->update(['is_active' => false]); } } public function resetFailureCount(): void { $this->update([ 'failure_count' => 0, ]); } public function markReceived(): void { $this->update(['last_received_at' => now()]); } // ------------------------------------------------------------------------- // Secret Management // ------------------------------------------------------------------------- /** * Rotate the secret and keep the previous one for grace period. */ public function rotateSecret(): string { $newSecret = Str::random(64); $this->update([ 'previous_secret' => $this->secret, 'secret' => $newSecret, 'secret_rotated_at' => now(), ]); return $newSecret; } /** * Regenerate the secret without keeping the previous one. */ public function regenerateSecret(): string { $newSecret = Str::random(64); $this->update([ 'secret' => $newSecret, 'previous_secret' => null, 'secret_rotated_at' => null, ]); return $newSecret; } // ------------------------------------------------------------------------- // URL Generation // ------------------------------------------------------------------------- /** * Get the webhook endpoint URL. */ public function getEndpointUrl(): string { return route('api.uptelligence.webhooks.receive', ['webhook' => $this->uuid]); } // ------------------------------------------------------------------------- // Utilities // ------------------------------------------------------------------------- public function getRouteKeyName(): string { return 'uuid'; } /** * Get provider label. */ public function getProviderLabel(): string { return match ($this->provider) { self::PROVIDER_GITHUB => 'GitHub', self::PROVIDER_GITLAB => 'GitLab', self::PROVIDER_NPM => 'npm', self::PROVIDER_PACKAGIST => 'Packagist', self::PROVIDER_CUSTOM => 'Custom', default => ucfirst($this->provider), }; } /** * Get provider icon name. */ public function getProviderIcon(): string { return match ($this->provider) { self::PROVIDER_GITHUB => 'code-bracket', self::PROVIDER_GITLAB => 'code-bracket-square', self::PROVIDER_NPM => 'cube', self::PROVIDER_PACKAGIST => 'archive-box', self::PROVIDER_CUSTOM => 'cog-6-tooth', default => 'globe-alt', }; } /** * Get Flux badge colour for status. */ public function getStatusColorAttribute(): string { if (! $this->is_active) { return 'zinc'; } if ($this->isCircuitBroken()) { return 'red'; } if ($this->failure_count > 0) { return 'yellow'; } return 'green'; } /** * Get status label. */ public function getStatusLabelAttribute(): string { if (! $this->is_active) { return 'Disabled'; } if ($this->isCircuitBroken()) { return 'Circuit Open'; } if ($this->failure_count > 0) { return "Active ({$this->failure_count} failures)"; } return 'Active'; } /** * Get time remaining in grace period. */ public function getGraceTimeRemainingAttribute(): ?int { if (! $this->isInGracePeriod()) { return null; } $rotatedAt = Carbon::parse($this->secret_rotated_at); $gracePeriodSeconds = $this->grace_period_seconds ?? 86400; $graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds); return (int) now()->diffInSeconds($graceEndsAt, false); } /** * Get when the grace period ends. */ public function getGraceEndsAtAttribute(): ?Carbon { if (empty($this->secret_rotated_at)) { return null; } $rotatedAt = Carbon::parse($this->secret_rotated_at); $gracePeriodSeconds = $this->grace_period_seconds ?? 86400; return $rotatedAt->copy()->addSeconds($gracePeriodSeconds); } }