$endpoints Existing endpoint definitions. * @since 2.12.6 * @return array Endpoints with the track-view route added. */ public function register_route( $endpoints ) { if ( ! is_array( $endpoints ) ) { return $endpoints; } $endpoints['forms/track-view'] = [ 'methods' => 'POST', 'callback' => [ $this, 'track_view' ], 'permission_callback' => [ $this, 'permissions_check' ], 'args' => [ 'form_id' => [ 'type' => 'integer', 'required' => true, 'minimum' => 1, ], ], ]; return $endpoints; } /** * Permission check: proof-of-origin via the HMAC Submit_Token header (cache-safe). * * @param WP_REST_Request> $request REST request. * @since 2.12.6 * @return true|WP_Error */ public function permissions_check( $request ) { $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) ); $form_id = absint( Helper::get_integer_value( $request->get_param( 'form_id' ) ) ); if ( ! Submit_Token::verify( $token, $form_id, Submit_Token::NAMESPACE_VIEW ) ) { return new WP_Error( 'srfm_view_token_invalid', __( 'Security verification failed.', 'sureforms' ), [ 'status' => 403 ] ); } return true; } /** * Record a view for the given form. * * Silently no-ops (200) for excluded contexts so the beacon never surfaces an * error to visitors; returns 429 only when the IP + form rate limit is exceeded. * * @param WP_REST_Request> $request REST request. * @since 2.12.6 * @return WP_REST_Response */ public function track_view( $request ) { $form_id = absint( Helper::get_integer_value( $request->get_param( 'form_id' ) ) ); // Publicly viewable, not merely the right post type: get_post_type() answers // the same for a draft or trashed form, and a token minted while the form was // live stays valid for up to 48 hours afterwards. Without this, views keep // accruing against content nobody can reach — and the analytics denominator // counts published forms only, so those views skew the site-wide figures. if ( ! $form_id || SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) || ! is_post_publicly_viewable( $form_id ) ) { return new WP_REST_Response( [ 'counted' => false ], 200 ); } // Exclude previews and logged-in privileged users (author/editor/admin). if ( ! $this->should_track( $request ) ) { return new WP_REST_Response( [ 'counted' => false ], 200 ); } if ( $this->is_rate_limited( $form_id ) ) { return new WP_REST_Response( [ 'counted' => false ], 429 ); } $this->increment_views( $form_id ); return new WP_REST_Response( [ 'counted' => true ], 200 ); } /** * Localize the beacon enable flag onto the (already enqueued) form-submit script. * * @since 2.12.6 * @return void */ public function localize_beacon() { if ( ! wp_script_is( 'srfm-form-submit', 'enqueued' ) ) { return; } wp_localize_script( 'srfm-form-submit', 'srfm_view_beacon', // '1'/'0' strings, not a raw bool: WP_Scripts::localize() casts every // scalar with (string), so false arrives as '' and true as '1'. That // happens to work with a truthiness check today, but anyone later // 'tidying' this to send '0' would invert the gate, because '0' is a // truthy JS string. The JS side compares with '1' !== rather than // truthiness for exactly that reason. [ // Gated on the tracking-started stamp, NOT on should_track(). This value // is printed into HTML that a full-page cache stores and replays to // everyone, so anything request-specific here is frozen at whichever // request happened to populate the cache: a page first cached while an // editor was logged in would bake in '0' and silently stop counting for // every visitor until the cache was purged. The stamp is site-wide and // only ever flips once, so it is safe to cache. Per-request exclusions // (privileged users, live previews) stay server-side in track_view(), // which re-checks should_track() on every call and cannot go stale. 'enabled' => $this->get_tracking_started_at() > 0 ? '1' : '0', // Fully resolved so the beacon can use a plain fetch(). rest_url() // handles plain permalinks (?rest_route=), subdirectory installs and // multisite domain mapping — the only thing wp.apiFetch offered here. 'url' => esc_url_raw( rest_url( 'sureforms/v1/forms/track-view' ) ), ] ); } /** * Read the current view count for a form. * * @param int $form_id Form post ID. * @since 2.12.6 * @return int */ public function get_views( $form_id ) { return max( 0, Helper::get_integer_value( get_post_meta( $form_id, self::META_KEY, true ) ) ); } /** * Whether the Views and Conversion Rate columns are shown on the Forms list. * * Despite the setting key's name this governs display, not counting. Counting is * gated on the tracking-started stamp instead: nothing is counted until the * feature is first switched on, and once the window is open, hiding the columns * again only hides them — so switching back on reveals the period rather than a * gap. See should_track(). * Off until switched on. Deny is the fallthrough: a missing option, a corrupted * non-array value, and an absent key all return false, so the columns only ever * appear after a deliberate opt-in. * * @since 2.12.6 * @return bool */ public function is_tracking_enabled() { $general = get_option( 'srfm_general_settings_options', [] ); if ( ! is_array( $general ) || ! isset( $general[ self::SETTING_KEY ] ) ) { return false; } return (bool) $general[ self::SETTING_KEY ]; } /** * Repair a toggle that is on while the counting window was never opened. * * The maybe_start_tracking() hooks cover every route that goes through * update_option(), but the setting can also arrive by a path that fires no hook — * a settings import, a partial restore, a direct $wpdb write. Left alone that state * shows the columns while nothing ever counts, and re-saving the identical array * would not repair it because update_option() short-circuits on an unchanged * value. * * Deliberately hooked to admin_init rather than folded into is_tracking_enabled(). * That getter is reached from REST GETs and from the weekly analytics cron, so * repairing there stamped the window at "whenever a reader happened to run first" * — on an imported site, most likely a cron pass days later — and silently made a * getter write. Here the write happens in a request that is already administrative * and the stamp keeps meaning "when an administrator had this switched on". * * @since 2.12.6 * @return void */ public function maybe_repair_tracking_window() { if ( ! $this->is_tracking_enabled() ) { return; } if ( $this->get_tracking_started_at() > 0 ) { return; } add_option( self::TRACKING_STARTED_OPTION, time(), '', false ); } /** * Whether the current request should be counted as a view. * * Excludes logged-in users who can edit content (author/editor/admin) and * form-builder / Instant Form live previews. * * Gated on the tracking-started stamp, not on the display toggle. The stamp is * written once, when the feature is first switched on, so: * * - a site that has never enabled it counts nothing, which is what "off by * default" has to mean for a counter — a hidden column that was silently * accumulating data was never really off; * - once opened, the window stays open. Hiding the columns again only hides * them, so switching back on reveals the period rather than a gap, and the * stored counts always cover exactly the period the stamp claims. * * @param WP_REST_Request>|null $request The beacon request, when called from track_view(). * @since 2.12.6 * @return bool */ private function should_track( ?WP_REST_Request $request = null ) { if ( $this->get_tracking_started_at() <= 0 ) { return false; } // Not is_user_logged_in()/current_user_can(): core's rest_cookie_check_errors() // calls wp_set_current_user( 0 ) for any cookie-bearing REST request that // carries no nonce, and the beacon deliberately sends only the HMAC token. // Reading the current user therefore sees 0 for an administrator browsing // their own site and counts them as a visitor. get_submitting_user_id() // falls back to wp_validate_auth_cookie(), which the reset does not touch. $user_id = Helper::get_submitting_user_id(); if ( $user_id > 0 && user_can( $user_id, 'edit_posts' ) ) { return false; } // Live previews cannot be detected from $_GET here: should_track() runs on the // separate beacon POST, whose request carries none of the previewed page's // query string. The client derives the signal from the previewed page's own // live_mode and forwards it on the beacon, so read it off this request. Absent // or '0' means a normal front-end view. if ( $request instanceof WP_REST_Request ) { $live_preview = Helper::get_string_value( $request->get_param( 'live_preview' ) ); if ( '' !== $live_preview && '0' !== $live_preview ) { return false; } } return true; } /** * Atomically increment the stored view count for a form. * * @param int $form_id Form post ID. * @since 2.12.6 * @return void */ private function increment_views( $form_id ) { global $wpdb; // CAST rather than a bare + 1: under STRICT_TRANS_TABLES a non-numeric // meta_value (left by an import or a mistaken update_post_meta) makes the // arithmetic an error, the query returns false, and this form would stop // counting permanently. CAST yields 0 for garbage and the count recovers. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_value = CAST( meta_value AS UNSIGNED ) + 1 WHERE post_id = %d AND meta_key = %s", $form_id, self::META_KEY ) ); // Distinguish 0 (no such row) from false (query failed): treating a failure as // a missing row sends it down the insert path, where it fails again silently. if ( 0 === $updated ) { // First view for this form. add_post_meta()'s $unique flag is a SELECT // followed by an INSERT and wp_postmeta carries no unique index, so two // concurrent first-views can both insert — after which the UPDATE above // would increment both rows forever while get_post_meta() reads only one. // Re-run the UPDATE afterwards: if a racing request already created the // row, that call increments it and this one adds nothing. if ( ! add_post_meta( $form_id, self::META_KEY, 1, true ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_value = CAST( meta_value AS UNSIGNED ) + 1 WHERE post_id = %d AND meta_key = %s", $form_id, self::META_KEY ) ); } else { // The insert succeeded, but so may a racing one: the $unique flag is a // SELECT then an INSERT and wp_postmeta has no unique index on // ( post_id, meta_key ). Two surviving rows are unrecoverable on their // own — every later UPDATE increments both while get_post_meta() reads // only the first, so the form silently reports about half its views for // the rest of its life. Collapse them now; this runs once per form. self::collapse_duplicate_view_rows( $form_id ); } } // Keep the post-meta cache consistent after the direct write. wp_cache_delete( $form_id, 'post_meta' ); } /** * Fold duplicate view-counter rows for a form back into a single row. * * Only ever reachable when two first-views raced each other into add_post_meta(). * The surviving row keeps the SUM, so no counted view is discarded. * * @param int $form_id Form post ID. * @since 2.12.6 * @return void */ private static function collapse_duplicate_view_rows( $form_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $meta_ids = $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC", $form_id, self::META_KEY ) ); if ( ! is_array( $meta_ids ) || count( $meta_ids ) < 2 ) { return; } $total = 0; foreach ( $meta_ids as $meta_id ) { $meta = get_metadata_by_mid( 'post', $meta_id ); // Returns false for a row that has gone since the ids were read. `??` // does not cover that: reading a property on false is a warning in its // own right, logged under WP_DEBUG_LOG even though the total stays correct. if ( ! is_object( $meta ) || ! isset( $meta->meta_value ) ) { continue; } $total += Helper::get_integer_value( $meta->meta_value ); } $keep = array_shift( $meta_ids ); foreach ( $meta_ids as $meta_id ) { delete_metadata_by_mid( 'post', $meta_id ); } update_metadata_by_mid( 'post', $keep, (string) $total ); } /** * Per visitor-IP + form rate limit. Fails closed when the IP is undeterminable. * * @param int $form_id Form post ID. * @since 2.12.6 * @return bool True when the request should be blocked. */ private function is_rate_limited( $form_id ) { // Use the connection's REMOTE_ADDR, not Helper::get_visitor_ip() (which trusts // client-supplied X-Forwarded-For / Client-IP headers). Otherwise an attacker // could send a fresh spoofed forwarded IP per request, get a new rate-limit // bucket each time, and inflate the view counter without bound. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filters the client IP used to bucket view-tracking rate limits. * * REMOTE_ADDR is the only value that cannot be spoofed by the client, so it is * the default. Behind a CDN or load balancer it is the proxy's address and is * identical for every visitor, which collapses the whole site into one bucket * and caps counted views at RATE_LIMIT_MAX per form per minute. Sites that * terminate at a trusted proxy can return the real client address here — only * do so when the header it comes from is set by infrastructure you control. * * @since 2.12.6 * @param string $ip The connection's REMOTE_ADDR. */ $ip = (string) apply_filters( 'srfm_form_views_client_ip', $ip ); if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return true; // Fail closed if IP cannot be determined. } // Bucket on the network, not the exact address. A single attacker routinely // controls every address in an IPv6 /64, and a per-address bucket would hand // them a fresh allowance — plus a fresh wp_options row — for each one. $bucket = self::network_bucket( $ip ); // The caller's own bucket is charged FIRST, and a caller that is over its own // limit returns here without touching the shared per-form counter. Charging // the shared ceiling first let one address spend the whole form's budget and // then keep spending it: every rejected request still incremented it, so from // request 201 onward every genuine visitor was refused for the rest of the // window, and each refusal still cost two wp_options writes. /** * Filters the per-network counted-view ceiling (per form, per minute). * * A high-traffic form behind a single NAT or CDN edge can organically exceed * the default; raise it only when the extra writes are acceptable. * * @since 2.12.6 * @param int $max The default ceiling. */ $network_max = Helper::get_integer_value( apply_filters( 'srfm_form_views_rate_limit_max', self::RATE_LIMIT_MAX ) ); if ( self::hit_counter( $bucket . '_' . $form_id ) > $network_max ) { return true; } /** * Filters the per-form counted-view ceiling (summed across visitors, per minute). * * A genuinely popular landing-page form can organically pass the default and * silently drop real views past it; raise it for such forms. * * @since 2.12.6 * @param int $max The default ceiling. */ $form_max = Helper::get_integer_value( apply_filters( 'srfm_form_views_rate_limit_form_max', self::RATE_LIMIT_FORM_MAX ) ); // The per-form ceiling still bounds a distributed flood, where rotating // networks defeats the per-network counter above. return self::hit_counter( 'form_' . $form_id ) > $form_max; } /** * Collapse an IP to the network an attacker would have to rotate out of. * * /24 for IPv4 and /64 for IPv6 — the smallest blocks normally allocated to a * single subscriber, so this bounds one actor without pooling unrelated visitors * any harder than a shared NAT already does. * * @param string $ip Validated IP address. * @since 2.12.6 * @return string Opaque bucket key. */ private static function network_bucket( $ip ) { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $packed = inet_pton( $ip ); // First 8 bytes = the /64 prefix. $prefix = false === $packed ? $ip : substr( $packed, 0, 8 ); } else { $parts = explode( '.', $ip ); $prefix = count( $parts ) === 4 ? $parts[0] . '.' . $parts[1] . '.' . $parts[2] : $ip; } return md5( (string) $prefix ); } /** * Increment a rate-limit counter and return its new value. * * Atomic where it matters. The previous implementation read a transient, * compared, then wrote it back — so N concurrent requests all read the same * value and all wrote value+1, and the limit only ever constrained sequential * traffic. That mattered here because this counter is the only thing standing * between an anonymous caller and an unbounded write loop. * * With a persistent object cache, wp_cache_add() + wp_cache_incr() is atomic. * Without one, wp_cache_* is request-local, so the value is carried in a * transient instead; that path is still not atomic under concurrency, but it * keeps the per-form ceiling meaningful across sequential requests and avoids * pretending to a guarantee the storage cannot make. * * @param string $key Counter key, unique per bucket and form. * @since 2.12.6 * @return int The counter value after this hit. */ private static function hit_counter( $key ) { if ( wp_using_ext_object_cache() ) { // add() only succeeds for the first caller, so exactly one request seeds // the window and every other one increments atomically. wp_cache_add( $key, 0, self::RATE_LIMIT_GROUP, MINUTE_IN_SECONDS ); $count = wp_cache_incr( $key, 1, self::RATE_LIMIT_GROUP ); // incr() returns false when the backend errors or the key was evicted // between the add() and here. Returning 0 for that would report "no hits // yet" and admit every request, so a broken cache would silently remove // the limiter. Report the ceiling instead: an unavailable backend denies. if ( false === $count ) { return self::RATE_LIMIT_FORM_MAX + 1; } return Helper::get_integer_value( $count ); } global $wpdb; $transient_key = 'srfm_view_' . md5( $key ); // The get-then-set below is not atomic on its own, so a burst of concurrent // beacons could each read the same pre-increment value and all admit, lifting // the ceiling this limiter exists to enforce. Serialize the critical section on // a MySQL advisory lock. timeout 0 so a lone visitor never waits. $lock = substr( 'srfm_view_' . md5( $key ), 0, 64 ); $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. // '0' means another request holds the lock right now — active contention, i.e. // the burst this guards against — so deny, the safe direction for a limiter. // NULL/anything else means the host does not support GET_LOCK; fall through to // the best-effort non-atomic path rather than deny every view on such a host. if ( '0' === (string) $locked ) { return self::RATE_LIMIT_FORM_MAX + 1; } $got_lock = '1' === (string) $locked; try { $count = Helper::get_integer_value( get_transient( $transient_key ) ) + 1; // Only extend the window when opening it, so a steady stream just under the // limit cannot hold a bucket open indefinitely by refreshing its own TTL. if ( 1 === $count ) { set_transient( $transient_key, $count, MINUTE_IN_SECONDS ); } else { set_transient( $transient_key, $count, self::remaining_window( $transient_key ) ); } } finally { if ( $got_lock ) { $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. } } return $count; } /** * Seconds left on an open rate-limit window, floored at one second. * * @param string $transient_key Transient holding the counter. * @since 2.12.6 * @return int */ private static function remaining_window( $transient_key ) { $timeout = Helper::get_integer_value( get_option( '_transient_timeout_' . $transient_key, 0 ) ); $remaining = $timeout - time(); return $remaining > 0 ? $remaining : 1; } }