Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 7 additions & 2 deletions config/autoload/local.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,22 @@ return [
* scopes at all; without a token the generator still runs, at a lower rate limit.
* Non-credential settings (ignore list, output path) live in `packages.global.php`.
*/
'github' => [
'github' => [
'userAgent' => '',
'authBearer' => '',
'repoResultFile' => date('Ymd') . "-$org-repos" . '.json',
'propResultFile' => date('Ymd') . "-$org-prop" . '.json',
'cachePath' => realpath(__DIR__ . "/../../data/cache/package-info"),
'org' => $org,
],
'sitemap' => [
'sitemap' => [
'path' => realpath(__DIR__ . '/../../public/sitemap.xml'),
],
'turnstile' => [
'site_key' => '1x00000000000000000000AA',
'secret_key' => '1x0000000000000000000000000000000AA',
'hostname' => '',
],
// `dotkernel-packages-oss-lifecycle` and `contact` are intentionally absent: they have dedicated
// handlers and are routed in Light\App\RoutesDelegator. Re-adding them here registers the path twice.
'routes' => [
Expand Down
2 changes: 1 addition & 1 deletion public/css/app.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/js/app.js

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions src/App/assets/js/components/_contact-turnstile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
const ERROR_CLASS = 'contact-field-error';

const REQUIRED_FIELDS = {
name: 'Name is required.',
email: 'Work email is required.',
message: 'Message is required.',
};
const INVALID_EMAIL = 'Enter a valid email address.';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const TURNSTILE_PENDING = 'Please wait for the verification to finish, then send again.';

function showError(element, message) {
const error = document.createElement('p');
error.className = `contact-error ${ERROR_CLASS}`;
error.textContent = message;
element.insertAdjacentElement('afterend', error);
}

function validateContactForm(form) {
form.querySelectorAll(`.${ERROR_CLASS}, .contact-server-error`).forEach((el) => el.remove());

let firstInvalid = null;
Object.entries(REQUIRED_FIELDS).forEach(([name, message]) => {
const field = form.elements.namedItem(name);
if (!field) {
return;
}
const value = field.value.trim();
let error = null;
if (value === '') {
error = message;
} else if (name === 'email' && !EMAIL_PATTERN.test(value)) {
error = INVALID_EMAIL;
}
if (error) {
showError(field, error);
firstInvalid = firstInvalid || field;
}
});

if (firstInvalid) {
firstInvalid.focus();
return false;
}

const widget = document.getElementById('contact-turnstile');
const token = form.querySelector('[name="cf-turnstile-response"]');
if (widget && (!token || token.value === '')) {
showError(widget, TURNSTILE_PENDING);
return false;
}

return true;
}

document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('[data-contact-form]');
if (!form) {
return;
}

form.addEventListener('submit', (event) => {
if (!validateContactForm(form)) {
event.preventDefault();
return;
}

const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
});
});
1 change: 1 addition & 0 deletions src/App/assets/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ import 'bootstrap/dist/js/bootstrap.bundle';

import './components/_main'
import './components/_contact'
import './components/_contact-turnstile'
9 changes: 6 additions & 3 deletions src/App/src/Handler/GetCreateContactFormHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ class GetCreateContactFormHandler implements RequestHandlerInterface

#[Inject(
TemplateRendererInterface::class,
'config.turnstile.site_key',
)]
public function __construct(
protected TemplateRendererInterface $template,
protected string $turnstileSiteKey,
) {
}

Expand All @@ -30,9 +32,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$query = $request->getQueryParams();

return new HtmlResponse($this->template->render(self::TEMPLATE, [
'topics' => ContactTopicEnum::cases(),
'contact_success' => ($query['contact'] ?? null) === 'sent',
'query' => ContactService::extractQuery($query),
'topics' => ContactTopicEnum::cases(),
'contact_success' => ($query['contact'] ?? null) === 'sent',
'query' => ContactService::extractQuery($query),
'turnstile_site_key' => $this->turnstileSiteKey,
]));
}
}
14 changes: 12 additions & 2 deletions src/App/src/Handler/PostCreateContactHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Psr\Http\Server\RequestHandlerInterface;

use function is_array;
use function is_string;

class PostCreateContactHandler implements RequestHandlerInterface
{
Expand All @@ -37,19 +38,28 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$errors = $this->contactService->validate($data);
$mailFailed = false;

$rawBody = $request->getParsedBody();
$token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null)
? $rawBody['cf-turnstile-response']
: '';
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? null;

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

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

if ($errors !== [] || $mailFailed) {
$rawBody = $request->getParsedBody();

return new HtmlResponse($this->template->render(GetCreateContactFormHandler::TEMPLATE, [
'topics' => ContactTopicEnum::cases(),
'contact_errors' => $errors,
'contact_values' => $data,
'contact_mail_failed' => $mailFailed,
'query' => ContactService::extractQuery(is_array($rawBody) ? $rawBody : []),
'turnstile_site_key' => $this->contactService->turnstileSiteKey(),
]));
}

Expand Down
86 changes: 84 additions & 2 deletions src/App/src/Service/ContactService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
use Psr\Log\LoggerInterface;
use Throwable;

use function file_get_contents;
use function htmlspecialchars;
use function http_build_query;
use function in_array;
use function is_array;
use function is_scalar;
use function is_string;
use function json_decode;
use function nl2br;
use function sprintf;
use function stream_context_create;
use function trim;

use const ENT_QUOTES;
Expand All @@ -35,26 +38,85 @@
* query: string,
* }
*/
final class ContactService
class ContactService
{
private const string TURNSTILE_ACTION = 'contact';

private const array REQUIRED_FIELDS = [
'name' => 'Name',
'email' => 'Work email',
'message' => 'Message',
];

private const array RESERVED_FIELDS = ['topic', 'name', 'email', 'company', 'stack', 'message', 'contact'];
private const array RESERVED_FIELDS = [
'topic',
'name',
'email',
'company',
'stack',
'message',
'contact',
'cf-turnstile-response',
];

/**
* @param array{site_key: string, secret_key: string, hostname: string} $turnstile
*/
#[Inject(
'dot-mail.service.contact',
'dot-log.default_logger',
'config.turnstile',
)]
public function __construct(
private readonly MailServiceInterface $mailService,
private readonly LoggerInterface $logger,
private readonly array $turnstile = ['site_key' => '', 'secret_key' => '', 'hostname' => ''],
) {
}

public function turnstileSiteKey(): string
{
return $this->turnstile['secret_key'] !== '' ? $this->turnstile['site_key'] : '';
}

public function verifyTurnstile(string $token, ?string $remoteIp): bool
{
if ($this->turnstileSiteKey() === '') {
return true;
}

if ($token === '') {
return false;
}

$data = [
'secret' => $this->turnstile['secret_key'],
'response' => $token,
];
if ($remoteIp) {
$data['remoteip'] = $remoteIp;
}

$response = $this->siteverify($data);
if ($response === null) {
return false;
}

$validation = json_decode($response, true);
if (! is_array($validation) || ($validation['success'] ?? false) !== true) {
return false;
}

if (($validation['metadata']['result_with_testing_key'] ?? false) === true) {
return true;
}

$hostname = $this->turnstile['hostname'];

return ($validation['action'] ?? null) === self::TURNSTILE_ACTION
&& ($hostname === '' || ($validation['hostname'] ?? null) === $hostname);
}

/**
* @param array<array-key, mixed> $source
* @return array<string, string>
Expand Down Expand Up @@ -142,6 +204,26 @@ public function send(array $data): bool
return true;
}

/**
* @param array<string, string> $data
*/
protected function siteverify(array $data): ?string
{
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data),
'timeout' => 5,
],
];

$context = stream_context_create($options);
$response = @file_get_contents('https://challenges.cloudflare.com/turnstile/v0/siteverify', false, $context);

return $response === false ? null : $response;
}

/**
* @param array<array-key, mixed> $raw
*/
Expand Down
23 changes: 19 additions & 4 deletions src/Blog/templates/page/contact.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
{% set values = contact_values|default({}) %}
{% set errors = contact_errors|default({}) %}
{% set selected_topic = values.topic|default('') ?: (topics|first).value %}
<form class="contact-form" method="post" action="{{ path('app::create-contact') }}" data-contact-form>
<form class="contact-form" method="post" action="{{ path('app::create-contact') }}#contact-form" novalidate data-contact-form>
<div class="contact-form-head">
<h2>Send a message</h2>
<p>Fields marked with an asterisk are required.</p>
Expand Down Expand Up @@ -100,12 +100,12 @@
<div class="contact-field">
<label for="c-name">Name *</label>
<input id="c-name" name="name" class="contact-input" type="text" autocomplete="name" required placeholder="Your full name" value="{{ values.name|default('') }}">
{% if errors.name is defined %}<p class="contact-error">{{ errors.name }}</p>{% endif %}
{% if errors.name is defined %}<p class="contact-error contact-server-error">{{ errors.name }}</p>{% endif %}
</div>
<div class="contact-field">
<label for="c-email">Work email *</label>
<input id="c-email" name="email" class="contact-input" type="email" autocomplete="email" required placeholder="you@company.com" value="{{ values.email|default('') }}">
{% if errors.email is defined %}<p class="contact-error">{{ errors.email }}</p>{% endif %}
{% if errors.email is defined %}<p class="contact-error contact-server-error">{{ errors.email }}</p>{% endif %}
</div>
<div class="contact-field">
<label for="c-company">Company</label>
Expand All @@ -121,20 +121,26 @@
<label for="c-msg">Message *</label>
<textarea id="c-msg" name="message" class="contact-input" rows="6" required
placeholder="What you are building, where it is stuck, and any deadline.">{{ values.message|default('') }}</textarea>
{% if errors.message is defined %}<p class="contact-error">{{ errors.message }}</p>{% endif %}
{% if errors.message is defined %}<p class="contact-error contact-server-error">{{ errors.message }}</p>{% endif %}
</div>

{% for param_name, param_value in query|default({}) %}
<input type="hidden" name="{{ param_name }}" value="{{ param_value }}">
{% endfor %}

{% if errors.captcha is defined %}<p class="contact-error contact-server-error">{{ errors.captcha }}</p>{% endif %}

<div class="contact-form-foot">
<p>Bug reports and feature requests go to the package's GitHub issues, where the fix is tracked in public.</p>
<button type="submit" class="btn btn-primary">
Send message
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</button>
</div>

{% if turnstile_site_key|default('') %}
<div id="contact-turnstile" class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-action="contact" data-appearance="always"></div>
{% endif %}
</form>
</div>
</div>
Expand Down Expand Up @@ -186,3 +192,12 @@
</div>
</section>
{% endblock %}

{% block javascript %}
{% if contact_errors is defined %}
<script>history.replaceState(null, '', '{{ path('app::create-contact-form') }}#contact-form');</script>
{% endif %}
{% if turnstile_site_key|default('') %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
{% endblock %}
Loading
Loading