Skip to content

Commit 7d11bb4

Browse files
[Release] Image-Scaling, Error-Handling, Anti-Spam
* feat: add anti-spam measures to form submission - Added honeypot field (hidden 'fax' input) to catch bots that fill all fields - Implemented time-based validation to detect submissions that are too fast (minimum 2 seconds between first interaction and submit) * [L10N] Update translations (#253) Co-authored-by: Michael-Schaer <Michael-Schaer@users.noreply.github.com> * Improve responsive image sizing for cover images - Increase JPEG quality from 85 to 90 for better image quality - Add `data-cover` attribute support to detect cover images (object-fit: cover) - Set `sizes` attribute to `200vw` for cover images to account for ~2x zoom from aspect ratio mismatch - Remove JavaScript-based dynamic sizes calculation in favor of static sizing * Fix: SyncProcessor missing form id * feat: improve Mailchimp error handling and queue processing logging - Added response body logging to Mailchimp API errors for better debugging - Implemented graceful handling of Mailchimp compliance state errors (unsubscribed, bounced emails) to prevent unnecessary retries - Improved queue processing error logging to show retry message instead of reporting all errors - Enhanced max attempts notification email with readable JSON format and extracted CrmFieldData values - Added helper method to extract readable key
1 parent c3f308f commit 7d11bb4

13 files changed

Lines changed: 156 additions & 27 deletions

File tree

wordpress/wp-config.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
define( 'BLOG_ID_CURRENT_SITE', 1 );
9898

9999
define( 'SUPT_FORM_ASYNC', ! ( (bool) getenv( 'WORDPRESS_DEBUG' ) ) );
100+
define( 'SUPT_FORM_MIN_SUBMIT_TIME', 2 ); // Minimum seconds between first interaction and submit
100101

101102
// Mailchimp-Service configuration (leave empty to disable integration)
102103
define( 'MAILCHIMP_SERVICE_ENDPOINT', getenv('MAILCHIMP_SERVICE_ENDPOINT') ?: '' );
0 Bytes
Binary file not shown.

wordpress/wp-content/themes/les-verts/languages/de_DE.po

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ msgid ""
22
msgstr ""
33
"Project-Id-Version: 2018gruenech\n"
44
"POT-Creation-Date: 2025-07-15 13:27+0200\n"
5-
"PO-Revision-Date: 2025-07-15 11:58\n"
5+
"PO-Revision-Date: 2026-02-12 16:25\n"
66
"Last-Translator: Cyrill Bolliger <bolliger@gmx.ch>\n"
77
"Language-Team: German\n"
88
"MIME-Version: 1.0\n"
@@ -2885,7 +2885,7 @@ msgstr "Symbol Schließen"
28852885
#: templates/molecules/m-person.twig:48
28862886
#, php-format
28872887
msgid "Visit the Bluesky profile of %s"
2888-
msgstr "Besuche das Twitterprofil von %s"
2888+
msgstr "Besuche das Blueskyprofil von %s"
28892889

28902890
#: templates/molecules/m-person.twig:55
28912891
#, php-format

wordpress/wp-content/themes/les-verts/lib/form/include/MailchimpSaver.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,32 @@ public static function send_to_mailchimp($data) {
3636

3737
$status_code = wp_remote_retrieve_response_code($response);
3838
if ($status_code < 200 || $status_code >= 300) {
39-
Util::debug_log("msg=Mailchimp API error: HTTP " . $status_code . " - " . wp_remote_retrieve_response_message($response));
39+
$response_body = wp_remote_retrieve_body($response);
40+
Util::debug_log("msg=Mailchimp API error: HTTP " . $status_code . " - " . wp_remote_retrieve_response_message($response) . " - Response body: " . $response_body);
41+
42+
// Handle compliance state errors gracefully (don't retry)
43+
if ($status_code === 400 && self::is_compliance_state_error($response_body)) {
44+
Util::debug_log("msg=Mailchimp compliance state error (expected, not retrying)");
45+
return;
46+
}
47+
4048
throw new \Exception('Mailchimp API error: HTTP ' . $status_code . ' - ' . wp_remote_retrieve_response_message($response));
4149
}
4250
}
4351

52+
/**
53+
* Check if the error is due to email compliance state (unsubscribed, bounced, etc.)
54+
* These are expected errors and should not trigger retries
55+
*
56+
* @param string $response_body The response body from Mailchimp
57+
* @return bool True if this is a compliance state error
58+
*/
59+
private static function is_compliance_state_error($response_body) {
60+
return stripos($response_body, 'compliance state') !== false ||
61+
stripos($response_body, 'unsubscribe') !== false ||
62+
stripos($response_body, 'bounce') !== false;
63+
}
64+
4465
public static function has_mailchimp_api_key() {
4566
return defined('MAILCHIMP_SERVICE_ENDPOINT') && MAILCHIMP_SERVICE_ENDPOINT;
4667
}

wordpress/wp-content/themes/les-verts/lib/form/include/SyncProcessor.php

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require_once __DIR__ . '/QueueDao.php';
1010
require_once __DIR__ . '/CrmQueueItem.php';
1111
require_once __DIR__ . '/CrmMaxSyncsException.php';
12+
require_once __DIR__ . '/FormModel.php';
1213

1314
class SyncProcessor {
1415
const CRON_HOOK_CRM_MC_SAVE = 'supt_form_save_to_crm';
@@ -78,7 +79,8 @@ public static function process_queue() {
7879
}
7980

8081
if (!$item->has_data()) {
81-
Util::report_form_error('sync queue process', $item, new \Exception(self::MSG_ITEM_NO_DATA), null);
82+
$form = $processor->get_form_from_item($item);
83+
Util::report_form_error('sync queue process', $item, new \Exception(self::MSG_ITEM_NO_DATA), $form);
8284
$processor->remove_from_queue($item);
8385
continue;
8486
}
@@ -93,7 +95,7 @@ public static function process_queue() {
9395
Util::debug_log("submissionId=" . $item->get_submission_id() . " msg=Too many CRM syncs per run. Ending this run.");
9496
return;
9597
} catch (\Exception $e) {
96-
Util::report_form_error('sync queue process', $item, $e, null);
98+
Util::debug_log("submissionId=" . $item->get_submission_id() . " msg=Processing failed, will retry: " . $e->getMessage());
9799
$processor->update_queue($item);
98100
}
99101
}
@@ -123,6 +125,24 @@ public static function get_queue(): QueueDao {
123125
return new QueueDao(SyncEnqueuer::QUEUE_KEY);
124126
}
125127

128+
/**
129+
* Get the form object from a CrmQueueItem, or null if it can't be retrieved
130+
*
131+
* @param CrmQueueItem $item The queue item
132+
* @return FormModel|null The form object or null if not found
133+
*/
134+
private function get_form_from_item(CrmQueueItem $item): ?FormModel {
135+
try {
136+
$form_id = $item->get_form_id();
137+
if ($form_id && $form_id !== CrmQueueItem::ID_INVALID_FORM) {
138+
return new FormModel($form_id);
139+
}
140+
} catch (\Exception $e) {
141+
// Form can't be retrieved, return null
142+
}
143+
return null;
144+
}
145+
126146
/**
127147
* Try to acquire the processing lock
128148
*
@@ -169,9 +189,13 @@ private function restructure_field_data(array $crm_field_data_objects): array {
169189
private function too_many_attempts(CrmQueueItem $item) {
170190
if ( $item->get_attempts() >= self::MAX_SAVE_ATTEMPTS ) {
171191
$this->remove_from_queue($item);
192+
193+
// Extract readable data from CrmFieldData objects for the email
194+
$readable_data = $this->extract_readable_data($item->get_data());
195+
172196
Util::send_mail_to_admin(
173-
self::SUBJECT_ITEM_MAX_ATTEMPTS . "id=" . $item->get_submission_id(),
174-
self::MSG_ITEM_MAX_ATTEMPTS . "\n\n" . json_encode($item->get_data())
197+
self::SUBJECT_ITEM_MAX_ATTEMPTS . " id=" . $item->get_submission_id(),
198+
self::MSG_ITEM_MAX_ATTEMPTS . "\n\n" . json_encode($readable_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
175199
);
176200
Util::debug_log("submissionId=" . $item->get_submission_id() . " msg=Too many attempts. Removed from queue.");
177201
return true;
@@ -180,6 +204,22 @@ private function too_many_attempts(CrmQueueItem $item) {
180204
return false;
181205
}
182206

207+
/**
208+
* Extract readable key-value data from CrmFieldData objects
209+
*
210+
* @param array $crm_field_data_objects Array of CrmFieldData objects
211+
* @return array Simple key-value array with actual values
212+
*/
213+
private function extract_readable_data(array $crm_field_data_objects): array {
214+
$data = array();
215+
foreach ($crm_field_data_objects as $crm_field_data) {
216+
if ($crm_field_data instanceof CrmFieldData) {
217+
$data[$crm_field_data->get_key()] = $crm_field_data->get_value();
218+
}
219+
}
220+
return $data;
221+
}
222+
183223
/**
184224
* Check if the item should be skipped because it was processed too recently
185225
*

wordpress/wp-content/themes/les-verts/lib/form/submission.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ public static function save_to_crm_or_mc() {
175175
*/
176176
public function handle_submit() {
177177
$this->abort_if_limit_exceeded();
178+
$this->abort_if_honeypot_filled();
179+
$this->abort_if_too_fast();
178180
$this->add_submission_metadata();
179181
$this->abort_if_invalid_header();
180182
$this->abort_if_invalid_nonce();
@@ -190,6 +192,39 @@ public function handle_submit() {
190192
$this->send_response();
191193
}
192194

195+
/**
196+
* Check if the honeypot field was filled (only bots fill hidden fields).
197+
*/
198+
private function abort_if_honeypot_filled() {
199+
if ( ! empty( $_POST['fax'] ) ) {
200+
$this->respond_with_general_error( 400, 'Submission not valid.' );
201+
}
202+
}
203+
204+
/**
205+
* Check if the form was submitted too quickly (bots submit in milliseconds).
206+
*
207+
* The hidden field is populated by JS on first user interaction.
208+
* If the field is empty or missing, JS didn't run (likely a bot).
209+
* If the time delta is too small, the form was filled inhumanly fast.
210+
*/
211+
private function abort_if_too_fast() {
212+
$interaction_time = isset( $_POST['form_interaction_duration'] )
213+
? (int) $_POST['form_interaction_duration']
214+
: 0;
215+
216+
if ( ! $interaction_time ) {
217+
$this->respond_with_general_error( 400, 'Submission not valid.' );
218+
219+
return;
220+
}
221+
222+
$min_time = defined( 'SUPT_FORM_MIN_SUBMIT_TIME' ) ? \SUPT_FORM_MIN_SUBMIT_TIME : 2;
223+
if ( $interaction_time < $min_time ) {
224+
$this->respond_with_general_error( 400, 'Submission not valid.' );
225+
}
226+
}
227+
193228
/**
194229
* Limit form submissions per ip and ip user agent combination.
195230
*

wordpress/wp-content/themes/les-verts/lib/twig/functions/image-filters.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ private function buildHtmlAttributes(Image $image, $src, $size, $attr) {
9696
'src' => esc_url($src),
9797
'srcset' => $srcset, // Standard srcset for non-lazy images
9898
'data-srcset' => $srcset, // Data-srcset for lazy loading JavaScript
99-
'sizes' => '100vw',
10099
'loading' => 'lazy',
101100
'alt' => !empty($attr['alt']) ? $attr['alt'] : ''
102101
];
@@ -106,6 +105,16 @@ private function buildHtmlAttributes(Image $image, $src, $size, $attr) {
106105

107106
$attributes = array_merge($attributes, $attr);
108107

108+
// For cover images (object-fit: cover), use 200vw to account for ~2x zoom from aspect ratio mismatch
109+
// This ensures browser downloads high enough resolution before cropping
110+
// Set this AFTER merge to prevent it from being overwritten
111+
$sizes_value = !empty($attr['data-cover']) ? '200vw' : '100vw';
112+
$attributes['sizes'] = $sizes_value;
113+
$attributes['data-sizes'] = $sizes_value; // Also set data-sizes for lazy loading script
114+
115+
// Remove data-cover from final attributes (it's only used for logic, not as HTML attribute)
116+
unset($attributes['data-cover']);
117+
109118
$html_attributes = [];
110119
foreach ($attributes as $name => $value) {
111120
if ($value !== null && $value !== '') {

wordpress/wp-content/themes/les-verts/lib/twig/functions/image-handling.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ function les_verts_image_handling_setup() {
8787
add_image_size('wpseo-opengraph', 1200, 630, true);
8888

8989
// Set default image quality
90-
add_filter('jpeg_quality', function() { return 85; });
91-
add_filter('wp_editor_set_quality', function() { return 85; });
90+
add_filter('jpeg_quality', function() { return 90; });
91+
add_filter('wp_editor_set_quality', function() { return 90; });
9292
}
9393

9494
/**
@@ -141,8 +141,8 @@ function skip_png_scaling($sizes, $metadata) {
141141

142142
$twig->addFilter(
143143
new TwigFilter( 'get_timber_image_responsive',
144-
function (Environment $env, $image, $size = 'medium') use ($image_filters) {
145-
return $image_filters->getTimberImageResponsive($env, $image, $size);
144+
function (Environment $env, $image, $size = 'medium', $attr = []) use ($image_filters) {
145+
return $image_filters->getTimberImageResponsive($env, $image, $size, $attr);
146146
},
147147
['needs_environment' => true]
148148
)

wordpress/wp-content/themes/les-verts/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Theme Name: Les Verts
33
* Description: Custom theme for the GREENS of Switzerland. Designed by superhuit.ch, built by gruene.ch on top of superhuit's stack.
44
* Author: superhuit.ch & gruene.ch
5-
* Version: 0.42.0
5+
* Version: 0.42.2
66
* Requires PHP: 7.4
77
* Requires at least: 5.0
88
* Theme URI: https://github.com/grueneschweiz/2018.gruene.ch/

wordpress/wp-content/themes/les-verts/styleguide/src/components/atoms/a-image/a-image-cover.js

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export default class AImageCover extends BaseView {
2222
super.bind();
2323

2424
this.on( 'afterReplaceImage', () => {
25-
this.setSizes();
2625
this.objectFit();
2726
} );
2827
}
@@ -75,15 +74,4 @@ export default class AImageCover extends BaseView {
7574

7675
return '';
7776
}
78-
79-
setSizes() {
80-
const img = this.getScopedElement( COVER_IMAGE_SELECTOR );
81-
const cDims = this.element.getBoundingClientRect();
82-
const cRatio = cDims.width / cDims.height;
83-
const iRatio = img.naturalWidth / img.naturalHeight;
84-
85-
const zoom = iRatio > cRatio ? iRatio / cRatio : cRatio / iRatio;
86-
87-
img.sizes = Math.min(cDims.width * zoom, window.innerWidth) + 'px';
88-
}
8977
}

0 commit comments

Comments
 (0)