Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
21 / 22
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubmitPublicContactAction
95.45% covered (success)
95.45%
21 / 22
66.67% covered (warning)
66.67%
2 / 3
6
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 clientIp
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Contact;
6
7use App\Domain\Contact\Service\ContactService;
8use App\Domain\Contact\Service\TurnstileVerifierInterface;
9use App\Domain\Exception\ValidationException;
10use App\Renderer\JsonRenderer;
11use App\Support\Row;
12use Psr\Http\Message\ResponseInterface;
13use Psr\Http\Message\ServerRequestInterface;
14
15use function explode;
16use function is_string;
17use function trim;
18
19/**
20 * Public (unauthenticated) contact-form submission. Verifies the Cloudflare
21 * Turnstile token before storing — the challenge replaces the auth gate that
22 * protects {@see SubmitContactAction}.
23 */
24final readonly class SubmitPublicContactAction
25{
26    public function __construct(
27        private ContactService $contactService,
28        private TurnstileVerifierInterface $turnstileVerifier,
29        private JsonRenderer $renderer,
30    ) {}
31
32    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
33    {
34        $body = (array)$request->getParsedBody();
35
36        $token = Row::nullableString($body, 'turnstileToken') ?? '';
37        if (!$this->turnstileVerifier->verify($token, $this->clientIp($request))) {
38            throw new ValidationException('CAPTCHA verification failed. Please try again.');
39        }
40
41        $name = Row::nullableString($body, 'name') ?? '';
42        $email = Row::nullableString($body, 'email') ?? '';
43        $subject = Row::nullableString($body, 'subject') ?? '';
44        $message = Row::nullableString($body, 'message') ?? '';
45
46        $stored = $this->contactService->submitPublic($name, $email, $subject, $message);
47
48        return $this->renderer->json($response, [
49            'success' => true,
50            'message' => 'Your message has been sent. We will respond shortly.',
51            'data' => [
52                'contactMessage' => $stored->toArray(),
53            ],
54        ], 201);
55    }
56
57    private function clientIp(ServerRequestInterface $request): ?string
58    {
59        $forwarded = $request->getHeaderLine('X-Forwarded-For');
60        if ($forwarded !== '') {
61            // First hop is the originating client; the rest are proxies.
62            return trim(explode(',', $forwarded)[0]);
63        }
64
65        $remote = $request->getServerParams()['REMOTE_ADDR'] ?? null;
66
67        return is_string($remote) ? $remote : null;
68    }
69}