PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / form-views.php
sureforms / inc Last commit date
abilities 13 hours ago admin 3 months ago ai-form-builder 1 month ago blocks 2 months ago compatibility 3 weeks ago database 13 hours ago email 3 weeks ago fields 3 weeks ago global-settings 13 hours ago lib 1 month ago migrator 2 months ago page-builders 3 weeks ago payments 13 hours ago single-form-settings 2 months ago traits 2 months ago activator.php 1 year ago admin-ajax.php 13 hours ago background-process.php 9 months ago client-logger.php 13 hours ago create-new-form.php 3 months ago duplicate-form.php 13 hours ago entries.php 3 weeks ago events-scheduler.php 2 years ago export.php 13 hours ago field-validation.php 3 weeks ago form-restriction.php 2 months ago form-styling.php 1 month ago form-submit.php 13 hours ago form-views.php 13 hours ago forms-data.php 13 hours ago frontend-assets.php 13 hours ago generate-form-markup.php 13 hours ago gutenberg-hooks.php 2 weeks ago helper.php 13 hours ago learn.php 4 months ago onboarding.php 2 months ago post-types.php 13 hours ago rest-api.php 13 hours ago smart-tags.php 1 week ago submit-token.php 13 hours ago translatable.php 1 month ago updater-callbacks.php 3 weeks ago updater.php 3 weeks ago
form-views.php
704 lines
1 <?php
2 /**
3 * SureForms Form Views tracker.
4 *
5 * Cache-safe, per-page-load form view (impression) tracking used to power the
6 * Views and Conversion Rate columns on the Forms listing table.
7 *
8 * Views are recorded by a client-side beacon (see assets/js/unminified/form-submit.js)
9 * that fires when a form actually becomes visible, so counting works even on
10 * fully page-cached pages and non-JS crawlers are excluded naturally. The beacon
11 * posts to the public `sureforms/v1/forms/track-view` route, which is authenticated with
12 * the same HMAC Submit_Token used by form submission (not a nonce, which would break
13 * under full-page caching).
14 *
15 * @package sureforms
16 * @since 2.12.6
17 */
18
19 namespace SRFM\Inc;
20
21 use SRFM\Inc\Traits\Get_Instance;
22 use WP_Error;
23 use WP_REST_Request;
24 use WP_REST_Response;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit; // Exit if accessed directly.
28 }
29
30 /**
31 * Form Views tracker.
32 *
33 * @since 2.12.6
34 */
35 class Form_Views {
36 use Get_Instance;
37
38 /**
39 * Post meta key that stores the accumulated view count for a form.
40 */
41 public const META_KEY = '_srfm_form_views';
42
43 /**
44 * General-settings key that shows or hides the Views and Conversion Rate
45 * columns. Lives in the `srfm_general_settings_options` option and is absent
46 * until switched on, which reads as off — the feature is opt-in.
47 */
48 public const SETTING_KEY = 'srfm_form_views_tracking';
49
50 /**
51 * Option holding the unix time from which view counting has been running.
52 *
53 * Conversion rate divides entries by views, and views only start accruing when
54 * tracking is switched on — so counting entries from the beginning of time would
55 * compare two different periods and report a wildly inflated rate on any form
56 * that existed beforehand. Entries are therefore counted from this moment too.
57 *
58 * Deliberately its own option rather than a key inside
59 * `srfm_general_settings_options`: that array is rebuilt from an allowlist when
60 * settings are saved, so an unrecognised key added to it would be silently
61 * dropped on the next save.
62 */
63 public const TRACKING_STARTED_OPTION = 'srfm_form_views_tracking_started_at';
64
65 /**
66 * Max beacon hits accepted per visitor network + form within the rate-limit window.
67 */
68 private const RATE_LIMIT_MAX = 20;
69
70 /**
71 * Max beacon hits accepted for one form from all sources within the window.
72 *
73 * The per-network bucket alone cannot bound anything: an attacker on an IPv6 /64
74 * — standard on any VPS — gets a fresh bucket per address, and the first request
75 * to each new bucket is always allowed. This ceiling is what actually caps a
76 * distributed flood, both for the counter's accuracy and for the number of
77 * buckets that can be minted.
78 */
79 private const RATE_LIMIT_FORM_MAX = 200;
80
81 /**
82 * Cache group for the beacon's rate-limit counters.
83 */
84 private const RATE_LIMIT_GROUP = 'srfm_form_views';
85
86 /**
87 * Constructor.
88 *
89 * @since 2.12.6
90 */
91 public function __construct() {
92 add_filter( 'srfm_rest_api_endpoints', [ $this, 'register_route' ] );
93 // Localize on wp_footer (before wp_print_footer_scripts at priority 20) rather than
94 // wp_enqueue_scripts: forms embedded via page builders, widget shortcodes, or a late
95 // do_blocks() pass enqueue srfm-form-submit AFTER wp_enqueue_scripts has run, and
96 // those would otherwise never receive the beacon flag.
97 add_action( 'wp_footer', [ $this, 'localize_beacon' ], 5 );
98
99 // Both hooks: update_option_* does not fire when the option row does not exist
100 // yet, which is exactly the state of a fresh install saving settings for the
101 // first time — the case this stamp exists for.
102 add_action( 'update_option_srfm_general_settings_options', [ $this, 'maybe_start_tracking' ], 10, 2 );
103 add_action( 'add_option_srfm_general_settings_options', [ $this, 'maybe_start_tracking' ], 10, 2 );
104
105 // Repairs a toggle that was switched on by a route that fires neither hook.
106 add_action( 'admin_init', [ $this, 'maybe_repair_tracking_window' ] );
107 }
108
109 /**
110 * Unix time from which views have been counted, or 0 if counting never started.
111 *
112 * This is a pure read. The stamp is written by maybe_start_tracking() the first
113 * time an administrator switches the feature on, and never rewritten — so it is
114 * also the answer to "has counting ever started", which is what should_track()
115 * uses. A lazy write here would open the window on any read, including one from
116 * a site that never enabled the feature.
117 *
118 * Never re-stamped on a later toggle. Counting does not stop when the columns are
119 * hidden, so the stamp always matches the period the stored view counts cover;
120 * moving it forward would measure those views against a shorter entry window.
121 *
122 * @since 2.12.6
123 * @return int Unix timestamp, or 0 when tracking has never been enabled.
124 */
125 public function get_tracking_started_at() {
126 return Helper::get_integer_value( get_option( self::TRACKING_STARTED_OPTION, 0 ) );
127 }
128
129 /**
130 * Open the counting window the first time the feature is switched on.
131 *
132 * Hooked to the General-settings option write rather than to the REST handler, so
133 * it fires for every route that flips the setting — the settings screen, the
134 * abilities/MCP update endpoint, or a direct update_option() from WP-CLI.
135 *
136 * add_option() rather than update_option(): it only creates the row when absent,
137 * so two concurrent saves cannot move a window that is already open, and a later
138 * off/on cycle leaves the original stamp intact. Not autoloaded — it is read on
139 * the Forms list screen and by the beacon, not on every request.
140 *
141 * The new value is read from the SECOND parameter because that is where both
142 * hooks put it, despite their first parameters differing:
143 * `do_action( "update_option_{$option}", $old_value, $value, $option )` and
144 * `do_action( "add_option_{$option}", $option, $value )`. Taking the first
145 * argument would read the pre-save value on one hook and the option name on
146 * the other.
147 *
148 * @param mixed $unused Previous value on update, option name on add. Unused.
149 * @param mixed $value The general settings array being saved.
150 * @since 2.12.6
151 * @return void
152 */
153 public function maybe_start_tracking( $unused, $value ) {
154 unset( $unused );
155
156 if ( ! is_array( $value ) || empty( $value[ self::SETTING_KEY ] ) ) {
157 return;
158 }
159
160 add_option( self::TRACKING_STARTED_OPTION, time(), '', false );
161 }
162
163 /**
164 * Register the public `forms/track-view` REST route on the SureForms endpoints array.
165 *
166 * @param array<string,mixed> $endpoints Existing endpoint definitions.
167 * @since 2.12.6
168 * @return array<string,mixed> Endpoints with the track-view route added.
169 */
170 public function register_route( $endpoints ) {
171 if ( ! is_array( $endpoints ) ) {
172 return $endpoints;
173 }
174
175 $endpoints['forms/track-view'] = [
176 'methods' => 'POST',
177 'callback' => [ $this, 'track_view' ],
178 'permission_callback' => [ $this, 'permissions_check' ],
179 'args' => [
180 'form_id' => [
181 'type' => 'integer',
182 'required' => true,
183 'minimum' => 1,
184 ],
185 ],
186 ];
187
188 return $endpoints;
189 }
190
191 /**
192 * Permission check: proof-of-origin via the HMAC Submit_Token header (cache-safe).
193 *
194 * @param WP_REST_Request<array<string,mixed>> $request REST request.
195 * @since 2.12.6
196 * @return true|WP_Error
197 */
198 public function permissions_check( $request ) {
199 $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) );
200 $form_id = absint( Helper::get_integer_value( $request->get_param( 'form_id' ) ) );
201
202 if ( ! Submit_Token::verify( $token, $form_id, Submit_Token::NAMESPACE_VIEW ) ) {
203 return new WP_Error(
204 'srfm_view_token_invalid',
205 __( 'Security verification failed.', 'sureforms' ),
206 [ 'status' => 403 ]
207 );
208 }
209
210 return true;
211 }
212
213 /**
214 * Record a view for the given form.
215 *
216 * Silently no-ops (200) for excluded contexts so the beacon never surfaces an
217 * error to visitors; returns 429 only when the IP + form rate limit is exceeded.
218 *
219 * @param WP_REST_Request<array<string,mixed>> $request REST request.
220 * @since 2.12.6
221 * @return WP_REST_Response
222 */
223 public function track_view( $request ) {
224 $form_id = absint( Helper::get_integer_value( $request->get_param( 'form_id' ) ) );
225
226 // Publicly viewable, not merely the right post type: get_post_type() answers
227 // the same for a draft or trashed form, and a token minted while the form was
228 // live stays valid for up to 48 hours afterwards. Without this, views keep
229 // accruing against content nobody can reach — and the analytics denominator
230 // counts published forms only, so those views skew the site-wide figures.
231 if ( ! $form_id || SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) || ! is_post_publicly_viewable( $form_id ) ) {
232 return new WP_REST_Response( [ 'counted' => false ], 200 );
233 }
234
235 // Exclude previews and logged-in privileged users (author/editor/admin).
236 if ( ! $this->should_track( $request ) ) {
237 return new WP_REST_Response( [ 'counted' => false ], 200 );
238 }
239
240 if ( $this->is_rate_limited( $form_id ) ) {
241 return new WP_REST_Response( [ 'counted' => false ], 429 );
242 }
243
244 $this->increment_views( $form_id );
245
246 return new WP_REST_Response( [ 'counted' => true ], 200 );
247 }
248
249 /**
250 * Localize the beacon enable flag onto the (already enqueued) form-submit script.
251 *
252 * @since 2.12.6
253 * @return void
254 */
255 public function localize_beacon() {
256 if ( ! wp_script_is( 'srfm-form-submit', 'enqueued' ) ) {
257 return;
258 }
259
260 wp_localize_script(
261 'srfm-form-submit',
262 'srfm_view_beacon',
263 // '1'/'0' strings, not a raw bool: WP_Scripts::localize() casts every
264 // scalar with (string), so false arrives as '' and true as '1'. That
265 // happens to work with a truthiness check today, but anyone later
266 // 'tidying' this to send '0' would invert the gate, because '0' is a
267 // truthy JS string. The JS side compares with '1' !== rather than
268 // truthiness for exactly that reason.
269 [
270 // Gated on the tracking-started stamp, NOT on should_track(). This value
271 // is printed into HTML that a full-page cache stores and replays to
272 // everyone, so anything request-specific here is frozen at whichever
273 // request happened to populate the cache: a page first cached while an
274 // editor was logged in would bake in '0' and silently stop counting for
275 // every visitor until the cache was purged. The stamp is site-wide and
276 // only ever flips once, so it is safe to cache. Per-request exclusions
277 // (privileged users, live previews) stay server-side in track_view(),
278 // which re-checks should_track() on every call and cannot go stale.
279 'enabled' => $this->get_tracking_started_at() > 0 ? '1' : '0',
280 // Fully resolved so the beacon can use a plain fetch(). rest_url()
281 // handles plain permalinks (?rest_route=), subdirectory installs and
282 // multisite domain mapping — the only thing wp.apiFetch offered here.
283 'url' => esc_url_raw( rest_url( 'sureforms/v1/forms/track-view' ) ),
284 ]
285 );
286 }
287
288 /**
289 * Read the current view count for a form.
290 *
291 * @param int $form_id Form post ID.
292 * @since 2.12.6
293 * @return int
294 */
295 public function get_views( $form_id ) {
296 return max( 0, Helper::get_integer_value( get_post_meta( $form_id, self::META_KEY, true ) ) );
297 }
298
299 /**
300 * Whether the Views and Conversion Rate columns are shown on the Forms list.
301 *
302 * Despite the setting key's name this governs display, not counting. Counting is
303 * gated on the tracking-started stamp instead: nothing is counted until the
304 * feature is first switched on, and once the window is open, hiding the columns
305 * again only hides them — so switching back on reveals the period rather than a
306 * gap. See should_track().
307 * Off until switched on. Deny is the fallthrough: a missing option, a corrupted
308 * non-array value, and an absent key all return false, so the columns only ever
309 * appear after a deliberate opt-in.
310 *
311 * @since 2.12.6
312 * @return bool
313 */
314 public function is_tracking_enabled() {
315 $general = get_option( 'srfm_general_settings_options', [] );
316
317 if ( ! is_array( $general ) || ! isset( $general[ self::SETTING_KEY ] ) ) {
318 return false;
319 }
320
321 return (bool) $general[ self::SETTING_KEY ];
322 }
323
324 /**
325 * Repair a toggle that is on while the counting window was never opened.
326 *
327 * The maybe_start_tracking() hooks cover every route that goes through
328 * update_option(), but the setting can also arrive by a path that fires no hook —
329 * a settings import, a partial restore, a direct $wpdb write. Left alone that state
330 * shows the columns while nothing ever counts, and re-saving the identical array
331 * would not repair it because update_option() short-circuits on an unchanged
332 * value.
333 *
334 * Deliberately hooked to admin_init rather than folded into is_tracking_enabled().
335 * That getter is reached from REST GETs and from the weekly analytics cron, so
336 * repairing there stamped the window at "whenever a reader happened to run first"
337 * — on an imported site, most likely a cron pass days later — and silently made a
338 * getter write. Here the write happens in a request that is already administrative
339 * and the stamp keeps meaning "when an administrator had this switched on".
340 *
341 * @since 2.12.6
342 * @return void
343 */
344 public function maybe_repair_tracking_window() {
345 if ( ! $this->is_tracking_enabled() ) {
346 return;
347 }
348
349 if ( $this->get_tracking_started_at() > 0 ) {
350 return;
351 }
352
353 add_option( self::TRACKING_STARTED_OPTION, time(), '', false );
354 }
355
356 /**
357 * Whether the current request should be counted as a view.
358 *
359 * Excludes logged-in users who can edit content (author/editor/admin) and
360 * form-builder / Instant Form live previews.
361 *
362 * Gated on the tracking-started stamp, not on the display toggle. The stamp is
363 * written once, when the feature is first switched on, so:
364 *
365 * - a site that has never enabled it counts nothing, which is what "off by
366 * default" has to mean for a counter — a hidden column that was silently
367 * accumulating data was never really off;
368 * - once opened, the window stays open. Hiding the columns again only hides
369 * them, so switching back on reveals the period rather than a gap, and the
370 * stored counts always cover exactly the period the stamp claims.
371 *
372 * @param WP_REST_Request<array<string,mixed>>|null $request The beacon request, when called from track_view().
373 * @since 2.12.6
374 * @return bool
375 */
376 private function should_track( ?WP_REST_Request $request = null ) {
377 if ( $this->get_tracking_started_at() <= 0 ) {
378 return false;
379 }
380
381 // Not is_user_logged_in()/current_user_can(): core's rest_cookie_check_errors()
382 // calls wp_set_current_user( 0 ) for any cookie-bearing REST request that
383 // carries no nonce, and the beacon deliberately sends only the HMAC token.
384 // Reading the current user therefore sees 0 for an administrator browsing
385 // their own site and counts them as a visitor. get_submitting_user_id()
386 // falls back to wp_validate_auth_cookie(), which the reset does not touch.
387 $user_id = Helper::get_submitting_user_id();
388
389 if ( $user_id > 0 && user_can( $user_id, 'edit_posts' ) ) {
390 return false;
391 }
392
393 // Live previews cannot be detected from $_GET here: should_track() runs on the
394 // separate beacon POST, whose request carries none of the previewed page's
395 // query string. The client derives the signal from the previewed page's own
396 // live_mode and forwards it on the beacon, so read it off this request. Absent
397 // or '0' means a normal front-end view.
398 if ( $request instanceof WP_REST_Request ) {
399 $live_preview = Helper::get_string_value( $request->get_param( 'live_preview' ) );
400 if ( '' !== $live_preview && '0' !== $live_preview ) {
401 return false;
402 }
403 }
404
405 return true;
406 }
407
408 /**
409 * Atomically increment the stored view count for a form.
410 *
411 * @param int $form_id Form post ID.
412 * @since 2.12.6
413 * @return void
414 */
415 private function increment_views( $form_id ) {
416 global $wpdb;
417
418 // CAST rather than a bare + 1: under STRICT_TRANS_TABLES a non-numeric
419 // meta_value (left by an import or a mistaken update_post_meta) makes the
420 // arithmetic an error, the query returns false, and this form would stop
421 // counting permanently. CAST yields 0 for garbage and the count recovers.
422 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
423 $updated = $wpdb->query(
424 $wpdb->prepare(
425 "UPDATE {$wpdb->postmeta} SET meta_value = CAST( meta_value AS UNSIGNED ) + 1 WHERE post_id = %d AND meta_key = %s",
426 $form_id,
427 self::META_KEY
428 )
429 );
430
431 // Distinguish 0 (no such row) from false (query failed): treating a failure as
432 // a missing row sends it down the insert path, where it fails again silently.
433 if ( 0 === $updated ) {
434 // First view for this form. add_post_meta()'s $unique flag is a SELECT
435 // followed by an INSERT and wp_postmeta carries no unique index, so two
436 // concurrent first-views can both insert — after which the UPDATE above
437 // would increment both rows forever while get_post_meta() reads only one.
438 // Re-run the UPDATE afterwards: if a racing request already created the
439 // row, that call increments it and this one adds nothing.
440 if ( ! add_post_meta( $form_id, self::META_KEY, 1, true ) ) {
441 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
442 $wpdb->query(
443 $wpdb->prepare(
444 "UPDATE {$wpdb->postmeta} SET meta_value = CAST( meta_value AS UNSIGNED ) + 1 WHERE post_id = %d AND meta_key = %s",
445 $form_id,
446 self::META_KEY
447 )
448 );
449 } else {
450 // The insert succeeded, but so may a racing one: the $unique flag is a
451 // SELECT then an INSERT and wp_postmeta has no unique index on
452 // ( post_id, meta_key ). Two surviving rows are unrecoverable on their
453 // own — every later UPDATE increments both while get_post_meta() reads
454 // only the first, so the form silently reports about half its views for
455 // the rest of its life. Collapse them now; this runs once per form.
456 self::collapse_duplicate_view_rows( $form_id );
457 }
458 }
459
460 // Keep the post-meta cache consistent after the direct write.
461 wp_cache_delete( $form_id, 'post_meta' );
462 }
463
464 /**
465 * Fold duplicate view-counter rows for a form back into a single row.
466 *
467 * Only ever reachable when two first-views raced each other into add_post_meta().
468 * The surviving row keeps the SUM, so no counted view is discarded.
469 *
470 * @param int $form_id Form post ID.
471 * @since 2.12.6
472 * @return void
473 */
474 private static function collapse_duplicate_view_rows( $form_id ) {
475 global $wpdb;
476
477 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
478 $meta_ids = $wpdb->get_col(
479 $wpdb->prepare(
480 "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC",
481 $form_id,
482 self::META_KEY
483 )
484 );
485
486 if ( ! is_array( $meta_ids ) || count( $meta_ids ) < 2 ) {
487 return;
488 }
489
490 $total = 0;
491 foreach ( $meta_ids as $meta_id ) {
492 $meta = get_metadata_by_mid( 'post', $meta_id );
493
494 // Returns false for a row that has gone since the ids were read. `??`
495 // does not cover that: reading a property on false is a warning in its
496 // own right, logged under WP_DEBUG_LOG even though the total stays correct.
497 if ( ! is_object( $meta ) || ! isset( $meta->meta_value ) ) {
498 continue;
499 }
500
501 $total += Helper::get_integer_value( $meta->meta_value );
502 }
503
504 $keep = array_shift( $meta_ids );
505
506 foreach ( $meta_ids as $meta_id ) {
507 delete_metadata_by_mid( 'post', $meta_id );
508 }
509
510 update_metadata_by_mid( 'post', $keep, (string) $total );
511 }
512
513 /**
514 * Per visitor-IP + form rate limit. Fails closed when the IP is undeterminable.
515 *
516 * @param int $form_id Form post ID.
517 * @since 2.12.6
518 * @return bool True when the request should be blocked.
519 */
520 private function is_rate_limited( $form_id ) {
521 // Use the connection's REMOTE_ADDR, not Helper::get_visitor_ip() (which trusts
522 // client-supplied X-Forwarded-For / Client-IP headers). Otherwise an attacker
523 // could send a fresh spoofed forwarded IP per request, get a new rate-limit
524 // bucket each time, and inflate the view counter without bound.
525 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
526
527 /**
528 * Filters the client IP used to bucket view-tracking rate limits.
529 *
530 * REMOTE_ADDR is the only value that cannot be spoofed by the client, so it is
531 * the default. Behind a CDN or load balancer it is the proxy's address and is
532 * identical for every visitor, which collapses the whole site into one bucket
533 * and caps counted views at RATE_LIMIT_MAX per form per minute. Sites that
534 * terminate at a trusted proxy can return the real client address here — only
535 * do so when the header it comes from is set by infrastructure you control.
536 *
537 * @since 2.12.6
538 * @param string $ip The connection's REMOTE_ADDR.
539 */
540 $ip = (string) apply_filters( 'srfm_form_views_client_ip', $ip );
541
542 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
543 return true; // Fail closed if IP cannot be determined.
544 }
545
546 // Bucket on the network, not the exact address. A single attacker routinely
547 // controls every address in an IPv6 /64, and a per-address bucket would hand
548 // them a fresh allowance — plus a fresh wp_options row — for each one.
549 $bucket = self::network_bucket( $ip );
550
551 // The caller's own bucket is charged FIRST, and a caller that is over its own
552 // limit returns here without touching the shared per-form counter. Charging
553 // the shared ceiling first let one address spend the whole form's budget and
554 // then keep spending it: every rejected request still incremented it, so from
555 // request 201 onward every genuine visitor was refused for the rest of the
556 // window, and each refusal still cost two wp_options writes.
557 /**
558 * Filters the per-network counted-view ceiling (per form, per minute).
559 *
560 * A high-traffic form behind a single NAT or CDN edge can organically exceed
561 * the default; raise it only when the extra writes are acceptable.
562 *
563 * @since 2.12.6
564 * @param int $max The default ceiling.
565 */
566 $network_max = Helper::get_integer_value( apply_filters( 'srfm_form_views_rate_limit_max', self::RATE_LIMIT_MAX ) );
567
568 if ( self::hit_counter( $bucket . '_' . $form_id ) > $network_max ) {
569 return true;
570 }
571
572 /**
573 * Filters the per-form counted-view ceiling (summed across visitors, per minute).
574 *
575 * A genuinely popular landing-page form can organically pass the default and
576 * silently drop real views past it; raise it for such forms.
577 *
578 * @since 2.12.6
579 * @param int $max The default ceiling.
580 */
581 $form_max = Helper::get_integer_value( apply_filters( 'srfm_form_views_rate_limit_form_max', self::RATE_LIMIT_FORM_MAX ) );
582
583 // The per-form ceiling still bounds a distributed flood, where rotating
584 // networks defeats the per-network counter above.
585 return self::hit_counter( 'form_' . $form_id ) > $form_max;
586 }
587
588 /**
589 * Collapse an IP to the network an attacker would have to rotate out of.
590 *
591 * /24 for IPv4 and /64 for IPv6 — the smallest blocks normally allocated to a
592 * single subscriber, so this bounds one actor without pooling unrelated visitors
593 * any harder than a shared NAT already does.
594 *
595 * @param string $ip Validated IP address.
596 * @since 2.12.6
597 * @return string Opaque bucket key.
598 */
599 private static function network_bucket( $ip ) {
600 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
601 $packed = inet_pton( $ip );
602 // First 8 bytes = the /64 prefix.
603 $prefix = false === $packed ? $ip : substr( $packed, 0, 8 );
604 } else {
605 $parts = explode( '.', $ip );
606 $prefix = count( $parts ) === 4 ? $parts[0] . '.' . $parts[1] . '.' . $parts[2] : $ip;
607 }
608
609 return md5( (string) $prefix );
610 }
611
612 /**
613 * Increment a rate-limit counter and return its new value.
614 *
615 * Atomic where it matters. The previous implementation read a transient,
616 * compared, then wrote it back — so N concurrent requests all read the same
617 * value and all wrote value+1, and the limit only ever constrained sequential
618 * traffic. That mattered here because this counter is the only thing standing
619 * between an anonymous caller and an unbounded write loop.
620 *
621 * With a persistent object cache, wp_cache_add() + wp_cache_incr() is atomic.
622 * Without one, wp_cache_* is request-local, so the value is carried in a
623 * transient instead; that path is still not atomic under concurrency, but it
624 * keeps the per-form ceiling meaningful across sequential requests and avoids
625 * pretending to a guarantee the storage cannot make.
626 *
627 * @param string $key Counter key, unique per bucket and form.
628 * @since 2.12.6
629 * @return int The counter value after this hit.
630 */
631 private static function hit_counter( $key ) {
632 if ( wp_using_ext_object_cache() ) {
633 // add() only succeeds for the first caller, so exactly one request seeds
634 // the window and every other one increments atomically.
635 wp_cache_add( $key, 0, self::RATE_LIMIT_GROUP, MINUTE_IN_SECONDS );
636
637 $count = wp_cache_incr( $key, 1, self::RATE_LIMIT_GROUP );
638
639 // incr() returns false when the backend errors or the key was evicted
640 // between the add() and here. Returning 0 for that would report "no hits
641 // yet" and admit every request, so a broken cache would silently remove
642 // the limiter. Report the ceiling instead: an unavailable backend denies.
643 if ( false === $count ) {
644 return self::RATE_LIMIT_FORM_MAX + 1;
645 }
646
647 return Helper::get_integer_value( $count );
648 }
649
650 global $wpdb;
651
652 $transient_key = 'srfm_view_' . md5( $key );
653
654 // The get-then-set below is not atomic on its own, so a burst of concurrent
655 // beacons could each read the same pre-increment value and all admit, lifting
656 // the ceiling this limiter exists to enforce. Serialize the critical section on
657 // a MySQL advisory lock. timeout 0 so a lone visitor never waits.
658 $lock = substr( 'srfm_view_' . md5( $key ), 0, 64 );
659 $locked = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Advisory lock; nothing to cache.
660
661 // '0' means another request holds the lock right now — active contention, i.e.
662 // the burst this guards against — so deny, the safe direction for a limiter.
663 // NULL/anything else means the host does not support GET_LOCK; fall through to
664 // the best-effort non-atomic path rather than deny every view on such a host.
665 if ( '0' === (string) $locked ) {
666 return self::RATE_LIMIT_FORM_MAX + 1;
667 }
668
669 $got_lock = '1' === (string) $locked;
670
671 try {
672 $count = Helper::get_integer_value( get_transient( $transient_key ) ) + 1;
673
674 // Only extend the window when opening it, so a steady stream just under the
675 // limit cannot hold a bucket open indefinitely by refreshing its own TTL.
676 if ( 1 === $count ) {
677 set_transient( $transient_key, $count, MINUTE_IN_SECONDS );
678 } else {
679 set_transient( $transient_key, $count, self::remaining_window( $transient_key ) );
680 }
681 } finally {
682 if ( $got_lock ) {
683 $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Releasing the advisory lock; nothing to cache.
684 }
685 }
686
687 return $count;
688 }
689
690 /**
691 * Seconds left on an open rate-limit window, floored at one second.
692 *
693 * @param string $transient_key Transient holding the counter.
694 * @since 2.12.6
695 * @return int
696 */
697 private static function remaining_window( $transient_key ) {
698 $timeout = Helper::get_integer_value( get_option( '_transient_timeout_' . $transient_key, 0 ) );
699 $remaining = $timeout - time();
700
701 return $remaining > 0 ? $remaining : 1;
702 }
703 }
704