Skip to content

Commit c1dffd6

Browse files
committed
resolved conflicts
Signed-off-by: bidi47 <bidi@apidemia.com>
1 parent b50a56f commit c1dffd6

11 files changed

Lines changed: 444 additions & 16 deletions

File tree

‎config/autoload/local.php.dist‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,22 @@ return [
7272
* scopes at all; without a token the generator still runs, at a lower rate limit.
7373
* Non-credential settings (ignore list, output path) live in `packages.global.php`.
7474
*/
75-
'github' => [
75+
'github' => [
7676
'userAgent' => '',
7777
'authBearer' => '',
7878
'repoResultFile' => date('Ymd') . "-$org-repos" . '.json',
7979
'propResultFile' => date('Ymd') . "-$org-prop" . '.json',
8080
'cachePath' => realpath(__DIR__ . "/../../data/cache/package-info"),
8181
'org' => $org,
8282
],
83-
'sitemap' => [
83+
'sitemap' => [
8484
'path' => realpath(__DIR__ . '/../../public/sitemap.xml'),
8585
],
86+
'turnstile' => [
87+
'site_key' => '1x00000000000000000000AA',
88+
'secret_key' => '1x0000000000000000000000000000000AA',
89+
'hostname' => '',
90+
],
8691
// `dotkernel-packages-oss-lifecycle` and `contact` are intentionally absent: they have dedicated
8792
// handlers and are routed in Light\App\RoutesDelegator. Re-adding them here registers the path twice.
8893
'routes' => [

‎public/js/app.js‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
const ERROR_CLASS = 'contact-field-error';
2+
3+
const REQUIRED_FIELDS = {
4+
name: 'Name is required.',
5+
email: 'Work email is required.',
6+
message: 'Message is required.',
7+
};
8+
const INVALID_EMAIL = 'Enter a valid email address.';
9+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10+
const TURNSTILE_PENDING = 'Please wait for the verification to finish, then send again.';
11+
12+
function showError(element, message) {
13+
const error = document.createElement('p');
14+
error.className = `contact-error ${ERROR_CLASS}`;
15+
error.textContent = message;
16+
element.insertAdjacentElement('afterend', error);
17+
}
18+
19+
function validateContactForm(form) {
20+
form.querySelectorAll(`.${ERROR_CLASS}, .contact-server-error`).forEach((el) => el.remove());
21+
22+
let firstInvalid = null;
23+
Object.entries(REQUIRED_FIELDS).forEach(([name, message]) => {
24+
const field = form.elements.namedItem(name);
25+
if (!field) {
26+
return;
27+
}
28+
const value = field.value.trim();
29+
let error = null;
30+
if (value === '') {
31+
error = message;
32+
} else if (name === 'email' && !EMAIL_PATTERN.test(value)) {
33+
error = INVALID_EMAIL;
34+
}
35+
if (error) {
36+
showError(field, error);
37+
firstInvalid = firstInvalid || field;
38+
}
39+
});
40+
41+
if (firstInvalid) {
42+
firstInvalid.focus();
43+
return false;
44+
}
45+
46+
const widget = document.getElementById('contact-turnstile');
47+
const token = form.querySelector('[name="cf-turnstile-response"]');
48+
if (widget && (!token || token.value === '')) {
49+
showError(widget, TURNSTILE_PENDING);
50+
return false;
51+
}
52+
53+
return true;
54+
}
55+
56+
document.addEventListener('DOMContentLoaded', () => {
57+
const form = document.querySelector('[data-contact-form]');
58+
if (!form) {
59+
return;
60+
}
61+
62+
form.addEventListener('submit', (event) => {
63+
if (!validateContactForm(form)) {
64+
event.preventDefault();
65+
return;
66+
}
67+
68+
const submitButton = form.querySelector('button[type="submit"]');
69+
if (submitButton) {
70+
submitButton.disabled = true;
71+
}
72+
});
73+
});

‎src/App/assets/js/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ import 'bootstrap/dist/js/bootstrap.bundle';
33

44
import './components/_main'
55
import './components/_contact'
6+
import './components/_contact-turnstile'

‎src/App/src/Handler/GetCreateContactFormHandler.php‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ class GetCreateContactFormHandler implements RequestHandlerInterface
1919

2020
#[Inject(
2121
TemplateRendererInterface::class,
22+
'config.turnstile.site_key',
2223
)]
2324
public function __construct(
2425
protected TemplateRendererInterface $template,
26+
protected string $turnstileSiteKey,
2527
) {
2628
}
2729

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

3234
return new HtmlResponse($this->template->render(self::TEMPLATE, [
33-
'topics' => ContactTopicEnum::cases(),
34-
'contact_success' => ($query['contact'] ?? null) === 'sent',
35-
'query' => ContactService::extractQuery($query),
35+
'topics' => ContactTopicEnum::cases(),
36+
'contact_success' => ($query['contact'] ?? null) === 'sent',
37+
'query' => ContactService::extractQuery($query),
38+
'turnstile_site_key' => $this->turnstileSiteKey,
3639
]));
3740
}
3841
}

‎src/App/src/Handler/PostCreateContactHandler.php‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Psr\Http\Server\RequestHandlerInterface;
1717

1818
use function is_array;
19+
use function is_string;
1920

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

41+
$rawBody = $request->getParsedBody();
42+
$token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null)
43+
? $rawBody['cf-turnstile-response']
44+
: '';
45+
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? null;
46+
47+
if (! $this->contactService->verifyTurnstile($token, is_string($ip) ? $ip : null)) {
48+
$errors['captcha'] = "We couldn't verify you're human. Please try again.";
49+
}
50+
4051
if ($errors === [] && ! $this->contactService->send($data)) {
4152
$mailFailed = true;
4253
}
4354

4455
if ($errors !== [] || $mailFailed) {
45-
$rawBody = $request->getParsedBody();
46-
4756
return new HtmlResponse($this->template->render(GetCreateContactFormHandler::TEMPLATE, [
4857
'topics' => ContactTopicEnum::cases(),
4958
'contact_errors' => $errors,
5059
'contact_values' => $data,
5160
'contact_mail_failed' => $mailFailed,
5261
'query' => ContactService::extractQuery(is_array($rawBody) ? $rawBody : []),
62+
'turnstile_site_key' => $this->contactService->turnstileSiteKey(),
5363
]));
5464
}
5565

‎src/App/src/Service/ContactService.php‎

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@
1212
use Psr\Log\LoggerInterface;
1313
use Throwable;
1414

15+
use function file_get_contents;
1516
use function htmlspecialchars;
1617
use function http_build_query;
1718
use function in_array;
1819
use function is_array;
1920
use function is_scalar;
2021
use function is_string;
22+
use function json_decode;
2123
use function nl2br;
2224
use function sprintf;
25+
use function stream_context_create;
2326
use function trim;
2427

2528
use const ENT_QUOTES;
@@ -35,26 +38,85 @@
3538
* query: string,
3639
* }
3740
*/
38-
final class ContactService
41+
class ContactService
3942
{
43+
private const string TURNSTILE_ACTION = 'contact';
44+
4045
private const array REQUIRED_FIELDS = [
4146
'name' => 'Name',
4247
'email' => 'Work email',
4348
'message' => 'Message',
4449
];
4550

46-
private const array RESERVED_FIELDS = ['topic', 'name', 'email', 'company', 'stack', 'message', 'contact'];
51+
private const array RESERVED_FIELDS = [
52+
'topic',
53+
'name',
54+
'email',
55+
'company',
56+
'stack',
57+
'message',
58+
'contact',
59+
'cf-turnstile-response',
60+
];
4761

62+
/**
63+
* @param array{site_key: string, secret_key: string, hostname: string} $turnstile
64+
*/
4865
#[Inject(
4966
'dot-mail.service.contact',
5067
'dot-log.default_logger',
68+
'config.turnstile',
5169
)]
5270
public function __construct(
5371
private readonly MailServiceInterface $mailService,
5472
private readonly LoggerInterface $logger,
73+
private readonly array $turnstile = ['site_key' => '', 'secret_key' => '', 'hostname' => ''],
5574
) {
5675
}
5776

77+
public function turnstileSiteKey(): string
78+
{
79+
return $this->turnstile['secret_key'] !== '' ? $this->turnstile['site_key'] : '';
80+
}
81+
82+
public function verifyTurnstile(string $token, ?string $remoteIp): bool
83+
{
84+
if ($this->turnstileSiteKey() === '') {
85+
return true;
86+
}
87+
88+
if ($token === '') {
89+
return false;
90+
}
91+
92+
$data = [
93+
'secret' => $this->turnstile['secret_key'],
94+
'response' => $token,
95+
];
96+
if ($remoteIp) {
97+
$data['remoteip'] = $remoteIp;
98+
}
99+
100+
$response = $this->siteverify($data);
101+
if ($response === null) {
102+
return false;
103+
}
104+
105+
$validation = json_decode($response, true);
106+
if (! is_array($validation) || ($validation['success'] ?? false) !== true) {
107+
return false;
108+
}
109+
110+
if (($validation['metadata']['result_with_testing_key'] ?? false) === true) {
111+
return true;
112+
}
113+
114+
$hostname = $this->turnstile['hostname'];
115+
116+
return ($validation['action'] ?? null) === self::TURNSTILE_ACTION
117+
&& ($hostname === '' || ($validation['hostname'] ?? null) === $hostname);
118+
}
119+
58120
/**
59121
* @param array<array-key, mixed> $source
60122
* @return array<string, string>
@@ -142,6 +204,26 @@ public function send(array $data): bool
142204
return true;
143205
}
144206

207+
/**
208+
* @param array<string, string> $data
209+
*/
210+
protected function siteverify(array $data): ?string
211+
{
212+
$options = [
213+
'http' => [
214+
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
215+
'method' => 'POST',
216+
'content' => http_build_query($data),
217+
'timeout' => 5,
218+
],
219+
];
220+
221+
$context = stream_context_create($options);
222+
$response = @file_get_contents('https://challenges.cloudflare.com/turnstile/v0/siteverify', false, $context);
223+
224+
return $response === false ? null : $response;
225+
}
226+
145227
/**
146228
* @param array<array-key, mixed> $raw
147229
*/

‎src/Blog/templates/page/contact.html.twig‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
{% set values = contact_values|default({}) %}
7373
{% set errors = contact_errors|default({}) %}
7474
{% set selected_topic = values.topic|default('') ?: (topics|first).value %}
75-
<form class="contact-form" method="post" action="{{ path('app::create-contact') }}" data-contact-form>
75+
<form class="contact-form" method="post" action="{{ path('app::create-contact') }}#contact-form" novalidate data-contact-form>
7676
<div class="contact-form-head">
7777
<h2>Send a message</h2>
7878
<p>Fields marked with an asterisk are required.</p>
@@ -100,12 +100,12 @@
100100
<div class="contact-field">
101101
<label for="c-name">Name *</label>
102102
<input id="c-name" name="name" class="contact-input" type="text" autocomplete="name" required placeholder="Your full name" value="{{ values.name|default('') }}">
103-
{% if errors.name is defined %}<p class="contact-error">{{ errors.name }}</p>{% endif %}
103+
{% if errors.name is defined %}<p class="contact-error contact-server-error">{{ errors.name }}</p>{% endif %}
104104
</div>
105105
<div class="contact-field">
106106
<label for="c-email">Work email *</label>
107107
<input id="c-email" name="email" class="contact-input" type="email" autocomplete="email" required placeholder="you@company.com" value="{{ values.email|default('') }}">
108-
{% if errors.email is defined %}<p class="contact-error">{{ errors.email }}</p>{% endif %}
108+
{% if errors.email is defined %}<p class="contact-error contact-server-error">{{ errors.email }}</p>{% endif %}
109109
</div>
110110
<div class="contact-field">
111111
<label for="c-company">Company</label>
@@ -121,20 +121,26 @@
121121
<label for="c-msg">Message *</label>
122122
<textarea id="c-msg" name="message" class="contact-input" rows="6" required
123123
placeholder="What you are building, where it is stuck, and any deadline.">{{ values.message|default('') }}</textarea>
124-
{% if errors.message is defined %}<p class="contact-error">{{ errors.message }}</p>{% endif %}
124+
{% if errors.message is defined %}<p class="contact-error contact-server-error">{{ errors.message }}</p>{% endif %}
125125
</div>
126126

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

131+
{% if errors.captcha is defined %}<p class="contact-error contact-server-error">{{ errors.captcha }}</p>{% endif %}
132+
131133
<div class="contact-form-foot">
132134
<p>Bug reports and feature requests go to the package's GitHub issues, where the fix is tracked in public.</p>
133135
<button type="submit" class="btn btn-primary">
134136
Send message
135137
<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>
136138
</button>
137139
</div>
140+
141+
{% if turnstile_site_key|default('') %}
142+
<div id="contact-turnstile" class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-action="contact" data-appearance="always"></div>
143+
{% endif %}
138144
</form>
139145
</div>
140146
</div>
@@ -186,3 +192,12 @@
186192
</div>
187193
</section>
188194
{% endblock %}
195+
196+
{% block javascript %}
197+
{% if contact_errors is defined %}
198+
<script>history.replaceState(null, '', '{{ path('app::create-contact-form') }}#contact-form');</script>
199+
{% endif %}
200+
{% if turnstile_site_key|default('') %}
201+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
202+
{% endif %}
203+
{% endblock %}

0 commit comments

Comments
 (0)