Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"php": "~8.5.0",
"ext-curl": "*",
"ext-dom": "*",
"akrabat/ip-address-middleware": "^2.6",
"doctrine/data-fixtures": "^2.2",
"doctrine/doctrine-fixtures-bundle": "^4.3",
"dotkernel/dot-cache": "^4.0",
Expand Down
31 changes: 31 additions & 0 deletions config/autoload/ip-address.global.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

// return [
Comment thread
OStefan2001 marked this conversation as resolved.
Resolved
// 'rka' => [
// 'ip_address' => [
// 'check_proxy_headers' => false,
// 'trusted_proxies' => [],
// 'attribute_name' => 'ip_address',
// 'headers_to_inspect' => [],
Comment thread
OStefan2001 marked this conversation as resolved.
Resolved
// ],
// ],
// ];

return [
'rka' => [
'ip_address' => [
Comment thread
arhimede marked this conversation as resolved.
Resolved
'check_proxy_headers' => true,
'trusted_proxies' => [],
'attribute_name' => 'ip_address',
'headers_to_inspect' => [
'Forwarded',
'X-Forwarded-For',
'X-Forwarded',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
],
];
1 change: 1 addition & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
\Mezzio\Router\ConfigProvider::class,
\Mezzio\Router\FastRouteRouter\ConfigProvider::class,
\Mezzio\Twig\ConfigProvider::class,
\RKA\Middleware\Mezzio\ConfigProvider::class,

// Dotkernel packages
\Dot\DependencyInjection\ConfigProvider::class,
Expand Down
3 changes: 3 additions & 0 deletions config/pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Mezzio\Router\Middleware\MethodNotAllowedMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use RKA\Middleware\IpAddress;

return function (Application $app): void {
// The error handler should be the first (most outer) middleware to catch
Expand Down Expand Up @@ -63,6 +64,8 @@
// - route-based validation
// - etc.

$app->pipe(IpAddress::class);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move the middleare to the route
we do not want to have this costly analysis at every request


// Register the dispatch middleware in the middleware pipeline
$app->pipe(DispatchMiddleware::class);

Expand Down
19 changes: 16 additions & 3 deletions src/App/src/Handler/PostCreateContactHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,30 @@
use function is_array;
use function is_string;

/**
* @phpstan-type IpAddressConfigType array{
* check_proxy_headers: bool,
* trusted_proxies: array<non-empty-string>,
* attribute_name: non-empty-string,
* headers_to_inspect: array<non-empty-string>,
* }
*/
class PostCreateContactHandler implements RequestHandlerInterface
{
/**
* @param IpAddressConfigType $ipAddressConfig
*/
#[Inject(
TemplateRendererInterface::class,
UrlHelper::class,
ContactService::class,
'config.rka.ip_address',
)]
public function __construct(
protected TemplateRendererInterface $template,
protected UrlHelper $urlHelper,
protected ContactService $contactService,
protected array $ipAddressConfig,
) {
}

Expand All @@ -42,13 +55,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null)
? $rawBody['cf-turnstile-response']
: '';
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? null;
$ip = $request->getAttribute($this->ipAddressConfig['attribute_name']);

if (! $this->contactService->verifyTurnstile($token, is_string($ip) ? $ip : null)) {
if (! $this->contactService->verifyTurnstile($token, $ip)) {
$errors['captcha'] = "We couldn't verify you're human. Please try again.";
}

if ($errors === [] && ! $this->contactService->send($data)) {
if ($errors === [] && ! $this->contactService->send($data, $ip)) {
$mailFailed = true;
}

Expand Down
7 changes: 4 additions & 3 deletions src/App/src/Service/ContactService.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ public function validate(array $data): array
/**
* @param ContactData $data
*/
public function send(array $data): bool
public function send(array $data, ?string $ipAddress = null): bool
{
$this->mailService->getMessage()->setReplyTo($data['email'], $data['name']);
$this->mailService->setSubject('New contact form submission on dotkernel.com');
$this->mailService->setBody($this->buildBody($data));
$this->mailService->setBody($this->buildBody($data, $ipAddress));

try {
$result = $this->mailService->send();
Expand Down Expand Up @@ -237,7 +237,7 @@ private function stringValue(array $raw, string $key): string
/**
* @param ContactData $data
*/
private function buildBody(array $data): string
private function buildBody(array $data, ?string $ipAddress): string
{
$rows = [
'Topic' => ContactTopicEnum::tryFrom($data['topic'])?->label() ?? '',
Expand All @@ -247,6 +247,7 @@ private function buildBody(array $data): string
'Current stack' => $data['stack'],
'Message' => $data['message'],
'Query params' => $data['query'],
'IP address' => $ipAddress ?? '',
Comment thread
OStefan2001 marked this conversation as resolved.
Resolved
];

$html = '';
Expand Down
83 changes: 77 additions & 6 deletions test/Unit/App/Handler/PostCreateContactHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@

class PostCreateContactHandlerTest extends TestCase
{
/**
* @return array{
* check_proxy_headers: bool,
* trusted_proxies: array<non-empty-string>,
* attribute_name: non-empty-string,
* headers_to_inspect: array<non-empty-string>,
* }
*/
private function ipAddressConfig(): array
{
return [
'check_proxy_headers' => false,
'trusted_proxies' => [],
'attribute_name' => 'ip_address',
'headers_to_inspect' => [],
];
}

/**
* @return array<string, string>
*/
Expand Down Expand Up @@ -74,7 +92,12 @@ public function testValidSubmissionRedirectsOnSuccess(): void
->with('app::create-contact-form', [], ['contact' => 'sent'], 'contact-form')
->willReturn('/contact/?contact=sent#contact-form');

$handler = new PostCreateContactHandler($template, $urlHelper, $this->service(new MailResult(true)));
$handler = new PostCreateContactHandler(
$template,
$urlHelper,
$this->service(new MailResult(true)),
$this->ipAddressConfig()
);
$response = $handler->handle($request);

$this->assertInstanceOf(RedirectResponse::class, $response);
Expand Down Expand Up @@ -113,7 +136,8 @@ public function testMissingRequiredFieldsRerendersWithErrors(): void
$handler = new PostCreateContactHandler(
$template,
$this->createStub(UrlHelper::class),
$this->service(new MailResult(true))
$this->service(new MailResult(true)),
$this->ipAddressConfig()
);
$response = $handler->handle($request);

Expand Down Expand Up @@ -150,7 +174,8 @@ public function testRerenderKeepsAnyExtraQueryParamsAsDynamicHiddenFields(): voi
$handler = new PostCreateContactHandler(
$template,
$this->createStub(UrlHelper::class),
$this->service(new MailResult(true))
$this->service(new MailResult(true)),
$this->ipAddressConfig()
);
$handler->handle($request);
}
Expand Down Expand Up @@ -179,7 +204,8 @@ public function testMailTransportFailureRerendersWithMailFailedFlag(): void
$handler = new PostCreateContactHandler(
$template,
$this->createStub(UrlHelper::class),
$this->service(new RuntimeException('SMTP connection refused'))
$this->service(new RuntimeException('SMTP connection refused')),
$this->ipAddressConfig()
);
$response = $handler->handle($request);

Expand All @@ -197,7 +223,7 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v

$request = $this->createStub(ServerRequestInterface::class);
$request->method('getParsedBody')->willReturn($data);
$request->method('getServerParams')->willReturn(['REMOTE_ADDR' => '203.0.113.5']);
$request->method('getAttribute')->willReturnMap([['ip_address', null, '203.0.113.5']]);

$contactService = $this->createMock(ContactService::class);
$contactService->method('normalize')->willReturn([
Expand Down Expand Up @@ -232,10 +258,55 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v
)
->willReturn('<html></html>');

$handler = new PostCreateContactHandler($template, $this->createStub(UrlHelper::class), $contactService);
$handler = new PostCreateContactHandler(
$template,
$this->createStub(UrlHelper::class),
$contactService,
$this->ipAddressConfig()
);
$response = $handler->handle($request);

$this->assertInstanceOf(HtmlResponse::class, $response);
$this->assertSame(200, $response->getStatusCode());
}

/**
* @throws Exception
*/
public function testValidSubmissionSendsTheIpAddressFromTheRequestAttribute(): void
{
$request = $this->createStub(ServerRequestInterface::class);
$request->method('getParsedBody')->willReturn($this->validData());
$request->method('getAttribute')->willReturnMap([['ip_address', null, '203.0.113.5']]);

$contactService = $this->createMock(ContactService::class);
$contactService->method('normalize')->willReturn([
'topic' => 'migration',
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'company' => 'Acme Inc',
'stack' => 'ZF3, PHP 7.4',
'message' => 'We would like to talk about migrating our platform.',
'query' => '',
]);
$contactService->method('validate')->willReturn([]);
$contactService->method('verifyTurnstile')->willReturn(true);
$contactService
->expects($this->once())
->method('send')
->with($this->anything(), '203.0.113.5')
->willReturn(true);

$urlHelper = $this->createStub(UrlHelper::class);
$urlHelper->method('generate')->willReturn('/contact/?contact=sent#contact-form');

$response = (new PostCreateContactHandler(
$this->createStub(TemplateRendererInterface::class),
$urlHelper,
$contactService,
$this->ipAddressConfig()
))->handle($request);

$this->assertInstanceOf(RedirectResponse::class, $response);
}
}
32 changes: 32 additions & 0 deletions test/Unit/App/Service/ContactServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,38 @@ public function testSendReturnsFalseWhenResultIsInvalid(): void
$this->assertFalse($service->send($this->validData()));
}

public function testSendIncludesTheIpAddressInBody(): void
{
$message = $this->createStub(Email::class);

$mailService = $this->createMock(MailServiceInterface::class);
$mailService->method('getMessage')->willReturn($message);
$mailService
->expects($this->once())
->method('setBody')
->with($this->callback(
fn (string $body): bool => str_contains($body, '<p><strong>IP address:</strong><br>203.0.113.5</p>')
));
$mailService->method('send')->willReturn(new MailResult(true));

(new ContactService($mailService, new NullLogger()))->send($this->validData(), '203.0.113.5');
}

public function testSendOmitsTheIpAddressRowWhenUnknown(): void
{
$message = $this->createStub(Email::class);

$mailService = $this->createMock(MailServiceInterface::class);
$mailService->method('getMessage')->willReturn($message);
$mailService
->expects($this->once())
->method('setBody')
->with($this->callback(fn (string $body): bool => ! str_contains($body, 'IP address')));
$mailService->method('send')->willReturn(new MailResult(true));

(new ContactService($mailService, new NullLogger()))->send($this->validData());
}

public function testSendIncludesQueryParamsInBodyWhenPresent(): void
{
$message = $this->createStub(Email::class);
Expand Down
Loading