Skip to content

Sync master from upstream - #123

Merged
vanshk141999 merged 72 commits into
masterfrom
sync/master-20260902
Sep 2, 2026
Merged

vanshk141999 merged 72 commits into
masterfrom
sync/master-20260902

Conversation

@vanshk141999

Copy link
Copy Markdown
Collaborator

Sync of brainstormforce/sureforms@master into the public mirror.

Range

Upstream range 3f7d8b98..4ffebe71
Commits in range 742
New since last sync (PR #3074) 70 commits / 117 files
Strip commit created yes — 71 paths

The branch is capped with a merge commit whose first parent is master, so this diff is computed against the mirror and contains only real upstream changes — no internal-file deletions.

Cross-check: the 117-file set in this PR is byte-for-byte identical to git diff 24ccb4ff4..4ffebe712 on private master. The strip contributed zero deletions to the public diff, confirming nothing internal was on the mirror to begin with.

Highlights — the 2.12.6 release window

  • #3097 / #3096 — Version Bump 2.12.6, changelog and generated artefacts
  • #3061 — Views & Conversion Rate columns on the Forms list, backed by a cache-safe view tracker
  • #3062 — Edit Form pill no longer overlaps form fields; click analytics; SureForms admin notices no longer stack
  • #3085 — detects a missing srfm_entries table and offers a one-click repair
  • #3086 / #3101 — Enable Logs toggle capturing client-side submission failures to a downloadable file
  • #3091 — payment smart tag IDOR fix (Patchstack #34484)
  • #3093 — defer the plugin_activated referer read to shutdown, avoiding a race
  • #3098 — i18n translation update
  • #3099 / #3100 — 2.12.6 review findings and release-date corrections

Strip

Removed before signing: all CLAUDE.md at any depth, .claude/, docs/, internal-docs/, .scripts/git-hooks/, ARCHITECTURE.md, COMPREHENSIVE_ANALYSIS.md, PRODUCT_ANALYSIS.md, TECHNICAL_OVERVIEW.md, tests/play/specs/TODO.md, the five internal release workflows, and bin/{build-zip.sh,checkout-and-build,i18n.sh}.

Post-strip check confirms the only markdown published is README.md, inc/abilities/ABILITIES.md, modules/gutenberg/readme.md, tests/play/README.md, and third-party inc/lib/**.

Signatures

All 743 commits in the re-signed range carry signatures — verified by grepping gpgsig on every commit object, zero unsigned — under a single committer identity. GitHub reports verified=true, reason=valid on both the branch tip and the strip commit.

vanshk141999 and others added 30 commits August 20, 2026 19:55
Ports the work from #2975 onto next-release. That branch was 288 commits behind
dev and could not be updated: merging dev in is rejected by the org
required_signatures ruleset, because the merge introduces a6346487a (an unsigned
commit from 7 July) into the branch. It predates the rule and already sits on
dev, next-release and master, but the rule will not let it enter a branch that
does not have it — the same rejection comes from git push, gh pr update-branch
and the REST update-branch endpoint, and re-signing it would rewrite 220 commits
across three protected branches.

Squashes the five original signed commits; the net diff is identical to #2975 at
13 files, +849/-37.

- inc/form-views.php: Form_Views, the public sureforms/v1/forms/track-view
  endpoint (HMAC Submit_Token rather than a nonce, so it survives page caching),
  exclusion re-checks, a fail-closed per-IP+form rate limit, and an atomic $wpdb
  increment of _srfm_form_views.
- assets/js/unminified/form-submit.js: IntersectionObserver beacon, once per
  form, so views count on fully cached pages.
- inc/forms-data.php: views and conversion_rate in prepare_form_for_listing(),
  plus the compute-sort-paginate path for sorting on a derived metric.
- src/admin/forms/components/FormsTable.js: the two sortable columns.
- General settings toggle for the feature; _srfm_form_views registered in
  post-types.php; orderby enum extended in rest-api.php.
- tests/unit/inc/test-form-views.php.

Verified on this branch by exit code: PHPCS 0, PHPStan level 9 0, phpinsights
--min-quality=100 --min-architecture=100 --min-style=100 0, ESLint 0, and 11
Form_Views tests (26 assertions) pass.

Manual smoke test and a review of the unauthenticated track-view endpoint are
still outstanding — noted in the PR description.
The General-settings toggle only gated the beacon and the REST endpoint, so
switching tracking off left both columns on the Forms table. With no new views
being recorded they would sit frozen at whatever had been counted before, which
reads as broken data rather than a disabled feature.

- admin/admin.php: expose the same setting to the admin bundle as
  srfm_admin.form_views_tracking, resolved through
  Form_Views::is_tracking_enabled() so there is one source of truth rather than a
  second copy of the option read.
- FormsTable.js: spread the two column definitions in only when the flag is not
  false. Absent flag means shown, matching the server-side default for installs
  that predate the setting.
- inc/forms-data.php: stop computing the metrics when tracking is off. Hiding the
  columns alone would have left a per-form meta read on every listing request for
  values nothing renders, and would still have honoured orderby=views /
  conversion_rate — running the compute-sort-paginate pass to order rows nobody
  can see. That request now falls through to the default ordering.

Verified against the real option rather than by inspection: is_tracking_enabled()
returns true / false / true for on, off and key-absent, and
prepare_form_for_listing() returns views=42 conversion_rate=2.4 with tracking on
and views=0 conversion_rate=0.0 with it off, for the same seeded meta.

PHPCS 0, PHPStan level 9 0, phpinsights --min-quality=100 0, ESLint 0,
build:script 0, 19 tests (46 assertions) pass.
… as a string

The previous commit did not work. WP_Scripts::localize() casts every scalar in
the payload with (string) (wp-includes/class-wp-scripts.php), so the PHP boolean
false reached JS as an empty string and the guard

    false === window.srfm_admin?.form_views_tracking

could never match. The columns stayed visible with tracking off.

My error was verifying one side and assuming the boundary: I checked
is_tracking_enabled() and prepare_form_for_listing() against the real option and
reported it working, without ever reading the value that actually arrives in the
browser.

The contract is now explicit on both sides. PHP sends '1'/'0' rather than a
boolean, with the reason recorded at the call site so nobody 'tidies' it back,
and the JS compares as a string, defaulting a missing flag to '1' so the columns
stay visible if the PHP predates the setting.

Verified in the browser on both states this time, reading the rendered table
headers rather than the source:

  tracking OFF -> flag '0' (typeof string) -> headers: Title, Shortcode,
                  Entries, Date & Time, Actions
  tracking ON  -> flag '1'                 -> headers: ... Entries, Views,
                  Conversion Rate, Date & Time, Actions

PHPCS 0, PHPStan 0, phpinsights --min-quality=100 0, ESLint 0, build 0.
Conversion rate divided all-time entries by views that only began accruing when
this feature shipped, so any form that already existed reported a rate built from
two different periods. The min( 100, ... ) clamp hid the absurdity rather than the
error: a form with 500 entries and 3 views displayed a confident 100%, and since
the column sorts, those forms sorted above genuinely good ones.

Entries are now counted from the same moment as views.

- Form_Views::TRACKING_STARTED_OPTION records when counting opened. Written once,
  on first use, and never rewritten — tracking is on by default so there is no
  toggle event to hang it on, and add_option() means a concurrent request cannot
  move a window that is already open. Kept in its own option rather than inside
  srfm_general_settings_options, because that array is rebuilt from an allowlist
  on save and an unrecognised key would be silently dropped.
- Forms_Data counts entries with created_at >= the window, reusing the where_clause
  the entries table already accepts and the (form_id, created_at, status) index. A
  form created after the window opened measures from its own creation instead, so
  its first days are not diluted by a window it did not exist for.
- conversion_rate is now nullable. More entries than views is impossible, so rather
  than clamping, the value is null and the table shows the dash it already uses for
  'no data yet'. The Entries column is untouched and stays all-time.

Not re-stamped when tracking is switched off and on: the earlier view counts are
kept, and moving the start forward would measure them against a shorter entry
window. Entries arriving while tracking is off skew the rate slightly, which the
entries-exceed-views guard covers when it becomes meaningless.

Verified against real data rather than by inspection. The timestamp is stable
across repeated calls and not autoloaded. With the window opened now, a legacy
form's older entry is excluded and the rate reads 0.0 instead of the previous
100.0; with the window backdated two years the same entry is counted and the rate
reads 25.0 against 4 views; zero views yields null.

PHPCS 0, PHPStan level 9 0, phpinsights --min-quality=100 0, ESLint 0, build 0,
19 tests (46 assertions).
The General-settings toggle now governs display only. should_track() no
longer consults it, so the beacon and REST endpoint keep counting while
the Views and Conversion Rate columns are hidden — switching them back on
reveals the period rather than a gap.

The server-side gates in forms-data.php (payload + orderby) stay, since
they are what hides the data. Settings copy and the is_tracking_enabled()
docblock rewritten to describe display-only semantics, and the
test_is_tracking_enabled assertion inverted: it now fails if the toggle
check is ever put back into should_track().
Closes the check-test-coverage gap on the conversion-rate window stamp.
Pins the three invariants the rate depends on: the stamp is written once
and never drifts, an existing stamp is returned verbatim, and the option
is not autoloaded. Verified by revert — flipping autoload to true fails
the third assertion, dropping the stored-value early return fails the
second.

Also drops the "switched off and on again" paragraph from the docblock:
counting no longer stops, so there is no gap for entries to skew.
The placeholder sat inside a translatable string, and Gruntfile's
plugin_function_comment task rewrites a bare /x\.x\.x/gi across
src/**/*.js at release. That changes the msgid on every version bump, so
every translation of this string would be lost each release. No other
user-facing string in src/ does this — the remaining placeholders are all
@SInCE docblocks.

"when this feature first runs on your site" is also the more accurate
statement: get_tracking_started_at() stamps the window on first use, which
on a site that upgrades late is not the release date.
…icks

The pill was absolutely positioned at the container's top-right, which only
clears the fields when the container happens to have enough top padding. With
default styling it landed on top of the first row's last field.

It now renders in normal flow on its own right-aligned row immediately above
the <form>, so it cannot overlap anything at any width with any theme. The
form shifts down by the pill's height, but only for users who can edit it —
the markup is still absent from the DOM for everyone else.

Analytics: the edit link carries an `srfm_edit_src=embed` marker that
Admin::maybe_track_edit_form_button_click() reads on load-post.php and counts
into `edit_form_button_clicks`, emitted as the cumulative
`edit_form_button_clicked` event. Attributing via the URL keeps the front end
script-free and adds no AJAX endpoint, and measures the editor actually
opening rather than a click that may not land. Every decision is taken from
server state: the post type comes from the resolved post and the capability
from `edit_post` on that form, so an absent, misspelled or reused marker, a
non-form post, and a user without the capability all fall through to no-op.

Also fixes a latent fatal in the same function: the Elementor guard checked
only `class_exists( '\Elementor\Plugin' )`, but Elementor declares
`public static $instance = null` and populates it on boot, so
`null->editor->is_edit_mode()` was reachable. That is a fatal Error, not a
warning, and it was already breaking two tests in this class on dev.
get_edit_form_button_css() changed body without a matching test, which
check-test-coverage flags. It also deserves one on its own terms: DOM order
alone does not stop the overlap if the CSS lifts the pill back out of flow.

Asserts no absolute positioning, no leftover container positioning context,
the flow wrapper, and logical spacing. Revert-tested — restoring
`position: absolute`, restoring the container rule, and swapping
margin-block-end for margin-bottom each fail their own assertion.
Three signals, so the warehouse can tell whether the feature is actually
being used rather than just shipped:

- boolean_values.form_views_columns_enabled — whether the Forms list shows
  the columns. Delegated to Form_Views::is_tracking_enabled() rather than
  read from the option array, because the setting defaults to ON when the
  key has never been saved: `! empty()` on the raw array would report every
  pre-upgrade install as opted out.
- numeric_values.total_form_views — views summed across published forms.
  Drafts and trashed forms are excluded so the numerator cannot outrun a
  denominator that no longer counts them.
- numeric_values.forms_with_views — published forms viewed at least once,
  which separates "the beacon is firing" from "forms exist but are never
  embedded". A total alone cannot: one busy form looks like many quiet ones.

The sum is a direct query (no core alternative for SUM over postmeta),
cached for an hour under the existing sureforms cache group and following
the annotation style of most_used_anti_spam().

Revert-tested: including drafts in the sum, relaxing the views filter to
>= 0, and reading the toggle raw instead of delegating each fail their own
assertion.
The feature now ships off. Counting begins the first time an administrator
switches it on, and from then on the toggle only shows or hides the columns.

- is_tracking_enabled() defaults to false. Deny is the fallthrough: absent
  option, absent key and a corrupted non-array value all return false. The
  other six default sites (global settings, the abilities getter, the React
  settings store, the column gate) flip with it.
- The stamp is written by maybe_start_tracking(), hooked to the general
  settings option write rather than to a single handler, so it fires for the
  settings screen, the abilities endpoint and WP-CLI alike. add_option()
  keeps it write-once, so an off/on cycle cannot move an open window.
  Both update_option_* and add_option_* are hooked, because the former does
  not fire when the row does not exist yet — the fresh-install case this
  exists for. The new value is read from the second parameter, which is where
  both hooks put it despite their first parameters differing.
- get_tracking_started_at() is now a pure read returning 0 when never
  enabled. The old lazy write opened a window on any read, including from a
  site that never enabled the feature.
- should_track() gates on the stamp, not the toggle: nothing is counted
  before the first opt-in, and counting continues once the columns are
  hidden again.
- forms-data.php additionally guards on a non-zero stamp, because the toggle
  and the stamp are written by different paths and gmdate() on a zero
  timestamp would widen the window to 1970 and count every entry ever.
  Dropped the clamp to post_date_gmt: a form cannot have entries from before
  it existed, so it never changed a result.

Revert-tested: defaulting back to ON, dropping the stamp gate in
should_track(), re-stamping with update_option(), stamping while the toggle
is off, and reading the first hook argument instead of the second each fail
their own assertion. The write-once assertion is backdated deliberately —
comparing against a stamp written moments earlier passed even with
update_option(), since both writes land in the same second.
Closes the check-test-coverage gap on the new stamp writer. The other tests
reach it through update_option(), which only exercises one of the two hook
shapes; this calls it directly with both.

Pins the contract that makes both hooks work — the new value is the second
parameter in each, despite their first parameters differing — plus
write-once and the deny-by-default cases (toggle off, key absent, empty
array, non-array, null).

Revert-tested: reading the first argument, swapping add_option for
update_option, and stamping regardless of the toggle each fail their own
assertion.
Sort/display mismatch (the worst of them): the Conversion Rate column sorted
on all-time entries while it displayed entries from the tracking window, so a
form rendering a dash sorted as several hundred percent and landed at the top
of a descending sort. Both paths now go through one calculate_form_metrics(),
with unmeasurable rates sorting last instead of tying with a real 0%.

Beacon hardening:
- The rate limiter was a read-modify-write over a transient, so concurrent
  requests all read the same value and the limit only ever bound sequential
  traffic — on the only control between an anonymous caller and an unbounded
  write loop. Now an atomic wp_cache_add/incr where a persistent cache exists.
- Buckets key on the network (/24, /64), not the exact address, and a
  per-form site-wide ceiling is checked first. Without both, an attacker with
  an IPv6 /64 got a fresh allowance per address and could mint unbounded
  wp_options rows, since the first hit on a new bucket is always free.
- The beacon signs its own 'srfm_view' namespace instead of reusing the
  submission token, so a token scraped from public markup cannot be replayed
  against the submit endpoint and this route is not an oracle for whether a
  submit token is still in an accepted window. NAMESPACE_SUBMIT is the
  historical literal, so tokens in cached HTML keep verifying.
- Views no longer accrue for draft or trashed forms during the token window.

increment_views(): wp_postmeta has no unique index, so add_post_meta()'s
$unique is a SELECT then an INSERT and two concurrent first-views could create
two rows that both increment forever — the comment claiming otherwise was
simply wrong. Re-runs the UPDATE when the insert loses. Also CASTs meta_value
so a non-numeric value does not error under STRICT_TRANS_TABLES and stop that
form counting permanently, and distinguishes a failed query from a missing row.

Correctness and UX:
- Duplicating a form no longer clones its view count; the copy started life
  with impressions it never had and a permanent 0% rate.
- is_tracking_enabled() self-heals the stamp, so a settings import or restore
  cannot leave the columns visible while nothing ever counts.
- The beacon flag is localized as '1'/'0', matching the convention admin.php
  sets. A raw bool worked only by accident of (string) false === ''.
- The column gate reads a flag returned with the rows instead of a page-load
  snapshot, so toggling in another tab cannot render columns over empty data.
  A stale ?orderby=views resets rather than silently date-sorting.
- The metric sort is bounded and announces the skip; the listing primes the
  post cache and skips a redundant entry COUNT; the window boundary is
  resolved against MySQL's clock once per request rather than per row.
- Dropped the analytics cache, which no caller could ever read twice.

Swept six comments that still described the pre-opt-in default, including two
that shipped to wordpress.org stating the opposite of the code.

Tests: calculate_form_metrics, prepare_form_for_listing, window_boundary_sql,
network_bucket, hit_counter, remaining_window, the token namespace split, and
the duplicate-form skip. The duplicate-form test initially passed for the
wrong reason — the helper returns a response array, not an ID — and only the
revert-test exposed it.
The beacon called window.wp.apiFetch, which srfm-form-submit no longer
loads, so it threw a synchronous TypeError and the feature counted
nothing on every install. Switched to plain fetch() against a resolved
REST URL, matching the two submission calls in the same file.

Also:
- Resolve the privileged-user exclusion through the auth cookie, since
  REST resets the current user to 0 on nonce-less requests.
- Dedupe views on form id rather than DOM element, and share one
  IntersectionObserver instead of one per form per re-init.
- Charge the caller's own rate-limit bucket before the shared per-form
  ceiling, and fail closed when the object cache is unavailable.
- Make the localized beacon flag cache-safe, and pass the same metric
  arguments on the sort path as on the render path.
- Collapse duplicate view-counter rows left by a racing first view.
- Reject a non-scalar `srfm_edit_src` before sanitize_key(). isset() is
  satisfied by `?srfm_edit_src[]=x` and sanitize_key() only grew its
  is_scalar() guard after this plugin's minimum WordPress, so that input
  reached strtolower( array ) — the one shape that fatalled instead of
  no-opping.
- Dedup the click counter per editor per form per hour, so a refresh or a
  back-navigation no longer counts again.
- Register the marker in removable_query_args so it leaves the admin URL
  once counted, keeping it out of bookmarks and the Referer header.
- Extend the Elementor null guard to ->editor, which has the same null
  window between plugins_loaded and init that the guard was added for.
- Build the edit link from the raw URL rather than the display-escaped one.
- Drop a condition that the enclosing branch already guarantees.
- Correct the capability comment: edit_post on this CPT resolves to
  manage_options, with no per-post component.
- Tests: assert the full editor href rather than a bare `post=<id>`, cover
  the empty/non-scalar/deleted-post rejection paths, and add a tearDown so
  one failure no longer cascades.
…styling

The Getting Started nudge and the "Finish setting up" prompt were
registered independently, so a user with a freshly imported starter
template saw both stacked. They compete for the same next action, so the
specific one now wins: "finish this form" is a concrete step, "explore the
dashboard" is a tour.

The decision moved into get_displayable_thankyou_prompt(), which both the
renderer and the suppressing notices read, rather than duplicating the
conditions. It costs nothing extra — the underlying query is already
memoized per request.

The rating notice yields to the prompt for the same reason: a user with
three forms who then imports a template would otherwise hit the identical
collision through the other path.

Styling is now one system. All three notices render through
build_srfm_notice_markup() and are painted by one stylesheet scoped to
.srfm-notice, so the Getting Started and rating notices pick up the brand
accent, the compact mark, and the borderless secondary links the prompt
already used. The old builder had no callers left and is gone (-59 lines).
When `{$wpdb->prefix}srfm_entries` goes missing — a host migration, a
partial restore, a changed `$table_prefix`, a manual drop — nothing
recreates it and every submission silently fails to save.
`srfm_database_table_versions` still records the current version, so
`start_db_upgrade()` marks the table non-upgradable and `create()`
early-returns. There was no existence check anywhere in the plugin and
nothing told the site owner.

Adds detection, a self-service repair on two surfaces, and analytics.

Detection (`Base::table_exists()`) uses `SHOW TABLES LIKE` rather than
`get_columns()`, because `SHOW COLUMNS FROM <missing>` is a MySQL error
that cannot distinguish "missing" from "denied". `esc_like()` matters:
`$wpdb->prefix` contains `_`, a LIKE wildcard. A DB-level error reports
the table as present, so a broken connection never raises a false alarm.

Repair prefers adopting the site's own data over manufacturing an empty
table. A changed prefix leaves the rows behind under the old name, and
creating a fresh table there would strand every stored entry while
looking like a successful repair. `find_adoptable_table()` refuses to
guess: exactly one candidate, carrying every column the schema declares,
never another blog's table on multisite, never a suffixed backup. It
RENAMEs rather than copies, so the move is atomic and cannot leave rows
in two places.

Repair reports a fresh existence check, never `create()`'s return value.
Reporting success on `create()` alone could record the version again
while the table stayed missing, hiding the problem permanently.

The copy is deliberately routine maintenance, not an error, and branches
on what the repair will actually do — whether existing entries come back
with it or the table starts empty. Promising the wrong one is how a
maintenance prompt turns into a complaint.

Also guards the analytics payload, which queried the entries table
through two sinks and raised a DB error per send on exactly the sites
whose breakage we most need reported.

Analytics: `database_error_notice_shown` (throttled to once per user per
day, so a site left unfixed cannot dominate the aggregate),
`database_error_notice_cta`, `database_repair_result`, and a
`db_entries_table_missing` daily KPI.
…g entry points

check-test-coverage matches on an exact test_<function_name> prefix, so the
behaviour-named tests did not register against it. Renames the primary test for
each new function and adds real coverage for the entry points that had none:
register_database_repair_notice() (both the missing and healthy paths),
handle_database_repair() (capability check ahead of the nonce and any write),
has_expected_columns(), get_tablename(), create() and get_db_tables().

Also covers register_pro_compatibility_notices(), which the gate attributes to
this diff because the new methods were inserted after it.
…g the notice

The Fix now button reloaded the page bare, so PHP had nothing to key the
confirmation off and the warning simply vanished — indistinguishable from
a failure to anyone watching. The comment claiming the reloaded page
carried the success notice was wrong.

Sets srfm_db_repair=done on the reload, the same arg the admin-post
fallback already redirects with, so both surfaces land identically.

Caught by clicking it: the notice disappeared with nothing in its place.
…a file

Debugging a broken form on a customer site means asking them to open
DevTools and paste what they see. The reCAPTCHA `invalid-input-response`
and the intl-tel-input clobber chased recently were both visible only in
the visitor's browser and left no trace on the server.

Adds an Enable Logs toggle to General settings. While on, the plugin
records form-submission failures reported by the browser — the HTTP
status and duration of the submit request, the raw response body when it
is not JSON, and the error behind the failure — to a single size-capped
file an admin downloads from the settings screen.

The capture points sit inside submitFormData rather than on the existing
CustomEvents, because submitFormData never throws: its catch swallows
everything and returns a synthetic object, so the failure events carry
only the string the visitor already saw. The most valuable line is the
response.clone().text() in parseRestResponse — it is what shows a WAF
block page, a PHP fatal, or a warning printed ahead of the JSON.

Shipping uses a plain fetch, not sendBeacon, because the submit token
travels in a header and sendBeacon cannot set one.

Server-side controls, in order of what actually carries weight:

- The stored setting is the authority and is re-checked on every write.
  The frontend flag is baked into cached HTML and can be a whole cache
  TTL out of date, so the route 404s when logging is off.
- The endpoint accepts a fixed schema and reconstructs the line itself.
  Redaction governs values; schema validation governs shape. Without it
  an anonymous caller writes arbitrary text into a file an admin opens.
- Rate limited per IP and form, reusing the existing transient limiter
  rather than duplicating it, including its fail-closed behaviour.
- The submit token is required for consistency, and the docblock is
  explicit that it is per-form, not per-visitor, and scrapeable from one
  GET — it filters scanners and nothing more.

No IP and no User-Agent are recorded: form-submit.php already blanks the
IP under either the global toggle or the per-form GDPR flag, and a
logger that ignored both would be a compliance regression. Field keys
are kept, values never are, and free text is scrubbed of emails, long
digit runs and URL query strings.

The log caps and stops rather than trimming or rotating — the person who
reproduced the bug is the one whose lines eviction would discard — and
the settings screen says so when it is full. Logging switches itself off
after 7 days via the existing daily action.
check-test-coverage matches on an exact test_<function_name> prefix, so
these four needed named tests in the files it expects.

The ajax tests assert on the emitted body rather than on the request
ending. Both handlers end the request -- on rejection through wp_die(),
on success through wp_send_json_success() -- so a test that only checked
'did it die' stayed green when the capability and nonce checks were
removed. Caught by the revert test, not by the tests passing.
The evidence has to already exist when a support ticket arrives. A
default of off means asking the reporter to enable logging and reproduce
the fault, which is the round trip this feature was meant to remove --
and an expiry meant the log was reliably empty by the time anyone looked.

An absent option key reads as on, so installs whose stored settings
predate the setting get it too rather than silently getting nothing. The
toggle still turns it off.

This costs a healthy site nothing: only failures are ever written, so a
site whose forms work never creates the file.

Worth stating plainly, because it changes the posture: is_enabled() no
longer 404s the ingest route on a default install, so that endpoint is
now reachable everywhere. What holds it down is unchanged -- the fixed
payload schema, the per-IP rate limit, and the 1 MB cap that stops
rather than evicting.

Removes ENABLED_AT_OPTION, MAX_ENABLED_DURATION, set_enabled_at(),
get_expiry(), maybe_expire() and the daily cron hook.
vanshk141999 and others added 29 commits September 1, 2026 11:32
…lap-analytics

Fix: Edit Form pill overlaps form fields, add click analytics, and stop SureForms admin notices stacking
Conflict: tests/unit/admin/test-admin.php — both sides appended new test
methods at the end of Test_Admin. Kept both. Dev's test_add_removable_query_args
and test_maybe_track_edit_form_button_click come first, then the
missing-entries-table tests, so the private helpers (break_entries_table,
restore_entries_table, reset_table_cache, make_user) stay grouped at the end of
the class.

Dev's rename of print_thankyou_notice_styles to print_srfm_notice_styles applied
cleanly; no dangling references remain.
- High: the Instant Form live-preview exclusion was dead — should_track()
  read $_GET on the beacon request, which never carries the previewed
  page's live_mode. The client now forwards a live_preview signal derived
  from the previewed page's own URL, and should_track() reads it off the
  beacon request, so a non-privileged preview session is excluded too.
- Medium: make the non-object-cache rate-limit path atomic with a MySQL
  advisory lock (GET_LOCK, timeout 0) so a concurrent burst can't each
  read the same value and admit past the ceiling; deny on contention,
  fall back to best-effort where GET_LOCK is unsupported.
- Medium: return metric_sort_applied so the Forms table resets a stale
  sort arrow when the order silently fell back to date (feature off, or
  past the >500-form metric-sort ceiling).
- Low: expose the per-network and per-form view ceilings via
  srfm_form_views_rate_limit_max / _form_max filters.

Adds regression tests for the live-preview exclusion on an anonymous
(non-privileged) beacon session.
…ies-table

feat: detect a missing srfm_entries table and offer a one-click repair
…iews-conversion-nr

Feat: Views & Conversion Rate columns in the Forms table (#2973)
Replaces the single sidebar card with a SureRank-style Form Checks list.
Rows come from one server feed, so a new check needs no JavaScript
change, and an srfm_action_items filter lets pro contribute. Row markup
mirrors SureRank's CheckCard so the two plugins read as siblings. Red
triangle for a fault losing entries, amber for advice, green tick for a
passing check -- a healthy site sees the panel confirm it, rather than
the panel vanishing.

Adds detection for the twenty most-used caching and optimisation
plugins. A cached page serves the same HTML to everyone: the submit
token is embedded at render time and a JS-combining setup can reorder
the scripts a form depends on. Detection mirrors
is_any_smtp_plugin_active(), multisite network-active merge included,
and is tested path by path -- a typo in one of twenty means those users
silently never see the advice.

The classic notices now follow the admin across wp-admin rather than
sitting only on the dashboard, because someone whose forms are failing
may not open the WP dashboard or SureForms for days. They stand down on
SureForms' own dashboard, where the panel already lists them.

Contact Support opens the mail client with the diagnostics and the
recent log inline as a fenced block. mailto has no attachment field --
RFC 6068 omits it and the old attachment= parameter was removed as a
file-exfiltration hole -- so the log travels in the body, tail first and
whole lines only, within the length a mail client accepts.

Analytics for both notices: a shown event throttled to once per user per
day (the classic notice renders on every admin page, so counting renders
would measure browsing, not affected sites), a CTA event, and a dismiss
event recorded inside dismiss_action_item() where both the panel's cross
and the no-JS link land. caching_plugin was missing from the
handle_notice_response() allowlist entirely, so its clicks were being
rejected and dropped.

dismissible separates a fault from advice: a run of failures cannot be
waved away and clears when a submission succeeds, while the caching
advisory can be dismissed. The dismiss handler allowlists which ids
qualify so a crafted request cannot silence the fault.
dev now carries #3085 (missing entries table) and the edit-form pill
work, both of which touch admin/admin.php and its tests in the same
regions as this branch.

Resolved by reconstruction rather than by patching the conflict markers:
git split the hunks mid-statement, so taking either side left an
unterminated call. Started from dev's file and re-applied this branch's
nine methods, its import, hooks, localized keys and the two analytics
allowlist entries.

Both features are additive and independent -- the Form Checks panel and
the database repair notice each keep their own hooks and methods.

Test file resolved the same way. This branch's eight tests now use dev's
existing make_user() helper instead of the near-duplicate this branch had
added, since dev introduced one while the branches were apart.

Verified: method inventory is exactly dev's plus this branch's nine, with
no duplicates and nothing dropped; PHPCS clean; both files parse.
Verified each finding against the code rather than taking it at face
value; all five blocking ones were real.

field_keys capped the element count but not each element's length, so a
single unauthenticated POST with 100 keys of 10KB wrote 1,000,388 bytes
-- 95% of the cap in one request, and a second filled it. Because the log
stops rather than evicting, that silently disabled the feature until an
admin cleared it. Reproduced before fixing; the same request now writes
10,388 bytes. The existing test used short fixed strings and passed
throughout, so it never exercised length.

The digit-run rule only matched contiguous digits, so 555-123-4567 and
(555) 123-4567 -- how a phone number is actually written -- passed
straight through. Separators are matched now. The URL rule stripped query
strings but not fragments, so a token after # survived. Verified the
reCAPTCHA error codes and HTTP statuses still come through untouched:
the point is redaction, not losing the diagnosis.

Clear now takes two clicks and is disabled while in flight, rather than
destroying the only evidence in an active investigation on one stray
click.

On notice precedence I took the opposite resolution to the one suggested.
The existing chain is Thank You > rating > getting started, all
engagement prompts. A form that cannot accept submissions should not
stand down for a review request, so the action items sit above the chain
and the three promos check has_action_item_warnings() instead. That
helper re-derives its conditions rather than calling get_action_items(),
which records an impression and must not run from a show_if.

Also fixed the non-blocking question that turned out to matter most: a
throw inside field or captcha validation was captured nowhere, because
handleFormSubmission's outer catch had no log call. That is exactly the
intl-tel-input clobber this feature cites as its motivating example, so
the feature could not have caught the bug it was written for. It now
logs from that catch, before the request is ever attempted.
get_tail(), has_action_item_warnings(), handle_dismiss_action_item() and
handle_dismiss_action_item_link().

Real assertions rather than name-matching stubs: the tail keeps whole
JSON lines and the newest entries, the precedence guard flips with both
the fault streak and a dismissal, the AJAX dismissal refuses an id that
is not on the allowlist, and the no-JS link stops a subscriber before
anything is dismissed.
Adi is right, and it was mine to catch. The earlier "log by default"
change ran a script that hit an AssertionError partway through; I
re-applied only part of it and then verified by calling
Client_Logger::is_enabled() in isolation. That method was one of the two
places already correct, so the check passed while the settings paths
still said false.

Four places now agree that an absent key means on: the save-payload read
and the fresh-install default array in srfm_save_general_settings() /
srfm_get_general_settings(), and both in the Abilities getter.

The fresh-install array is what made this subtle. It sets the key, so the
later `! isset()` backfill never fires -- the site reported the toggle
OFF in the Settings UI and the Abilities API while logging was running,
and the next save of any unrelated General setting persisted false and
silently ended it.

Verified against the real paths this time rather than the runtime alone:
with the option deleted entirely, runtime, Settings UI and Abilities API
all report true, and saving srfm_ip_log leaves it true. The regression
test drives those same three paths and fails if either default is put
back.
feat: add an Enable Logs toggle that captures submission failures to a downloadable file
Conflicts were all in the General settings plumbing, between the
srfm_form_views_tracking key on this branch and srfm_enable_logs from
#3086. Both are additive and independent, so every hunk keeps both.

Three files, five hunks: the save-payload reads and the $settings array,
the two fresh-install default arrays, the two `! isset()` backfills, the
Abilities ALLOWED_KEYS list and its $boolean_keys map.

Kept from #3086 in the process: srfm_enable_logs defaults to on in every
path, matching Client_Logger::is_enabled(), and the srfm_log_file_size
the settings screen needs to render the log row.
Auto-generated by /i18n command on PR #3097
Three items from the review of #3097, all verified against the code first.

form-views.php: get_metadata_by_mid() returns false for a row that has
gone since the ids were read, and `?? 0` does not cover that -- reading a
property on false is a warning in its own right, logged under
WP_DEBUG_LOG on every run even though the total stayed correct. Guarded
explicitly and the row skipped.

General.js: the clear-log request never checked response.ok. fetch only
rejects on a network failure, so a 403 from a stale nonce or a 500
resolved normally and the catch never ran. Worse than the reported
"success toast on failure": it also zeroed logMeta.size, which hides the
whole row, so the log looked cleared while the file was still on disk.
Confirmed the endpoint answers HTTP 400 on a bad nonce, so response.ok is
the right signal.

admin.php: get_support_mailto_url() took a $count it immediately unset
and recomputed internally. Removed, along with its @PARAM. Its docblock
also still described downloading the log alongside the email, which
stopped being true when the log moved inline into the body -- corrected
rather than left to mislead the next reader.
fix: address the pre-merge findings on the 2.12.6 release PR
…e to 2nd September

Clicking Contact Support now records the report and hides the notice.
It comes back the moment something fails that has not been reported --
so a site owner who has already raised a ticket is not nagged, but a new
fault is never hidden behind an old one.

Recorded server-side in handle_notice_response() rather than in the
browser, so it holds for the classic wp-admin notice too, which is a
plain link with no JavaScript.

The record stores both a timestamp and the fault count, and the count is
what the comparison uses. A timestamp cannot decide this reliably: the
click and the fault are both written to the second, so a fault landing in
the same second would compare as not-newer and be suppressed -- hiding a
failure nobody reported. The counter is monotonic, so it is exact
whatever the timing. Verified by a test that reproduces the same-second
case and fails if the comparison is put back on the clock. The timestamp
is kept because it is what tells support when the report was made.

A successful submission clears the acknowledgement along with the streak,
so the next run of failures is judged on its own rather than against a
stale report.

Release date moved to 2nd September 2026 in README.md and readme.txt.
Also pins that a 'blocked' entry -- a stop the visitor can clear
themselves -- does not move it, since only faults should.
…-and-failure-ack

2.12.6: retire the submission-failure notice once reported, and correct the release date
…ailures

Adds distinct notices for email-notification and integration failures,
each naming the form it happened on, and makes the existing submission
notice name its form too. "A form is failing" is not actionable on a site
with twenty of them.

The three are tracked as separate categories because they read
completely differently to a site owner: submissions failing means
visitors cannot reach you, a notification failing means you are not
hearing about entries that did save, an integration failing means a third
party is not receiving them. Collapsing them into one warning would
describe none of those accurately. Reporting one leaves the other two
showing, and a submission getting through clears only its own category --
it says nothing about whether the email sent.

Also unifies the state, which is the part worth reviewing. Adding the
category store left two sources of truth for "are submissions failing":
get_action_items() read the new store while has_action_item_warnings()
still read the old streak option, so the sidebar panel and the
promotional-notice suppression could disagree. Caught by a test that went
red rather than by reading the diff. FAULT_STREAK_OPTION,
LAST_FAULT_OPTION and ACKNOWLEDGED_OPTION are gone and everything reads
the one store.

Integration failures need a pro-side change to be observable at all: pro
writes webhook and native-integration outcomes to the entry's own log and
fires no action. This adds the srfm_integration_failed hook and the free
side of the wiring; pro must fire it for that notice to appear.
get_failures(), get_open_failures() and record_integration_failure().

Real assertions rather than name-matching stubs. get_failures() pins that
a reported category stays on record while dropping out of
get_open_failures(), which is the distinction between "reported" and
"gone away". get_open_failures() reproduces the same-second case that
made a count comparison necessary in the first place.

record_integration_failure() goes through the srfm_integration_failed
action rather than calling the method, so the wiring pro depends on is
covered too, and asserts the failure lands in its own category rather
than counting as a submission failure.
…ogging

feat: detailed failure reasons, and separate notices for submission, notification and integration failures
@vanshk141999
vanshk141999 merged commit 295544f into master Sep 2, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants