HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.naniguide.com/app/Http/Middleware/Ads/VerifyAdsWebhookSignature.php
<?php

namespace App\Http\Middleware\Ads;

use App\Services\Ads\AdCredentialResolver;
use App\Services\Ads\WebhookSignatureVerifier;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

/**
 * Guards `/rui/ads/webhooks/{platform}/*` POST routes.
 *
 * 1. Compute HMAC-SHA256(body, app_secret) and constant-time compare against
 *    the platform's signature header.
 * 2. On mismatch:
 *    - `ads.webhooks.enforce_signature = true` → 401
 *    - `ads.webhooks.enforce_signature = false` → log + allow through
 *      (observation mode for staging / Meta test events).
 * 3. Always run replay-protection: same event id within
 *    `ads.webhooks.replay_protection_ttl_seconds` → return 204 No Content
 *    immediately, don't re-process.
 */
class VerifyAdsWebhookSignature
{
    public function __construct(private WebhookSignatureVerifier $verifier) {}

    public function handle(Request $request, Closure $next): Response
    {
        $platform = $this->platformFromRoute($request);
        if ($platform === null) {
            // No known platform in the route — reject outright to avoid
            // routing malformed URLs into the webhook pipeline.
            abort(404);
        }

        $body = $request->getContent();
        $signatureHeader = $this->signatureHeader($request, $platform);
        $appSecret = $this->appSecret($platform);

        $valid = match ($platform) {
            'meta' => $this->verifier->verifyMeta($body, $signatureHeader, $appSecret),
            default => !(bool) config('ads.webhooks.enforce_signature', false),
        };

        if (!$valid) {
            Log::channel((string) config('ads.observability.log_channel', 'stack'))->warning(
                'ads.webhook.signature_rejected',
                [
                    'platform' => $platform,
                    'ip' => $request->ip(),
                    'url' => $request->fullUrl(),
                ]
            );
            abort(401, 'Invalid webhook signature');
        }

        $payload = $request->json()->all() ?: [];
        $eventId = match ($platform) {
            'meta' => $this->verifier->metaEventId($payload, $body),
            default => substr(hash('sha256', $body), 0, 32),
        };

        if (!$this->verifier->recordAndCheckFresh($platform, $eventId)) {
            // Already seen — ACK without re-processing so the platform stops retrying.
            return response()->noContent();
        }

        return $next($request);
    }

    private function platformFromRoute(Request $request): ?string
    {
        $route = $request->route();
        if (!$route) {
            return null;
        }
        $platform = $route->parameter('platform');
        if (is_string($platform) && in_array($platform, ['meta', 'google', 'tiktok', 'linkedin'], true)) {
            return $platform;
        }
        // Fall back to URI segment when the route param isn't named
        $segments = $request->segments();
        foreach (['meta', 'google', 'tiktok', 'linkedin'] as $candidate) {
            if (in_array($candidate, $segments, true)) {
                return $candidate;
            }
        }
        return null;
    }

    private function signatureHeader(Request $request, string $platform): ?string
    {
        return match ($platform) {
            'meta' => $request->header('X-Hub-Signature-256'),
            'tiktok' => $request->header('X-Signature'),
            default => null,
        };
    }

    private function appSecret(string $platform): ?string
    {
        $secret = AdCredentialResolver::resolve($platform, 'app_secret');
        if (is_string($secret) && $secret !== '') {
            return $secret;
        }
        return null;
    }
}