PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 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 All 98 releases
← All changes | inc/form-submit.php +270 -5 2.12.4 → 2.12.8 View file →
@@ -53,8 +53,25 @@
53 53 * @since 0.0.1
54 54 */
55 55 public function __construct() {
56 56 add_action( 'rest_api_init', [ $this, 'register_custom_endpoint' ] );
57 + // One submission getting through retires the failure notice. srfm_form_submit
58 + // fires only on the success path.
59 + add_action( 'srfm_form_submit', [ Client_Logger::class, 'reset_fault_streak' ] );
60 +
61 + /**
62 + * Fired when an integration fails to receive a submission.
63 + *
64 + * Pro's webhooks and native integrations write their outcome to the entry's
65 + * own log, which nobody reads until a ticket is already open. Firing this
66 + * as well surfaces it on the dashboard.
67 + *
68 + * @since 2.12.6
69 + *
70 + * @param int $form_id Form the submission belongs to.
71 + * @param string $reason Short description of what failed.
72 + */
73 + add_action( 'srfm_integration_failed', [ $this, 'record_integration_failure' ], 10, 2 );
57 74 add_action( 'wp_ajax_validation_ajax_action', [ $this, 'field_unique_validation' ] );
58 75 add_action( 'wp_ajax_nopriv_validation_ajax_action', [ $this, 'field_unique_validation' ] );
59 76 // for quick action bar.
60 77 add_action( 'wp_ajax_srfm_global_update_allowed_block', [ $this, 'srfm_global_update_allowed_block' ] );
@@ -76,11 +93,141 @@
76 93 'callback' => [ $this, 'handle_form_submission' ],
77 94 'permission_callback' => [ $this, 'submit_form_permissions_check' ],
78 95 ]
79 96 );
97 +
98 + register_rest_route(
99 + $this->namespace,
100 + '/log-client-error',
101 + [
102 + 'methods' => WP_REST_Server::CREATABLE,
103 + 'callback' => [ $this, 'handle_client_error_log' ],
104 + 'permission_callback' => [ $this, 'client_error_log_permissions_check' ],
105 + ]
106 + );
80 107 }
81 108
82 109 /**
110 + * Record an integration failure against the form it happened on.
111 + *
112 + * Hooked - srfm_integration_failed.
113 + *
114 + * @param int $form_id Form the submission belongs to.
115 + * @param string $reason Short description of what failed.
116 + * @since 2.12.6
117 + * @return void
118 + */
119 + public function record_integration_failure( $form_id = 0, $reason = '' ) {
120 + $form_id = absint( $form_id );
121 +
122 + Client_Logger::append(
123 + Client_Logger::sanitize_entry(
124 + [
125 + 'type' => 'message',
126 + 'form_id' => $form_id,
127 + 'form_title' => $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '',
128 + 'message' => 'Integration failed. ' . Helper::get_string_value( $reason ),
129 + ]
130 + )
131 + );
132 +
133 + Client_Logger::record_failure(
134 + 'integration',
135 + $form_id,
136 + $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : ''
137 + );
138 + }
139 +
140 + /**
141 + * Gate the client error log route.
142 + *
143 + * Order matters. The enabled check runs first and returns 404 rather than 403,
144 + * because it is the only thing that actually stops logging: the frontend flag
145 + * is baked into cached HTML and can be a full cache TTL out of date, so
146 + * switching the setting off does not stop already-cached pages from posting.
147 + *
148 + * The submit token is then required for consistency with /submit-form, but be
149 + * clear about what it buys. It is per-form, not per-visitor, valid for up to
150 + * 48 hours, and readable from one GET of any public page carrying the form. It
151 + * filters undirected scanners and costs nothing; it is not visitor
152 + * authentication. The controls that carry real weight here are the fixed
153 + * payload schema in Client_Logger::sanitize_entry() and the rate limit below.
154 + *
155 + * @param \WP_REST_Request $request Incoming REST request.
156 + * @since 2.12.6
157 + * @return WP_Error|bool
158 + */
159 + public function client_error_log_permissions_check( $request ) {
160 + if ( ! Client_Logger::is_enabled() ) {
161 + return new WP_Error(
162 + 'srfm_rest_no_route',
163 + __( 'Not found.', 'sureforms' ),
164 + [ 'status' => 404 ]
165 + );
166 + }
167 +
168 + $token = Helper::get_string_value( $request->get_header( 'X-WP-Submit-Token' ) );
169 + $form_id = absint( $request->get_param( 'form_id' ) );
170 +
171 + if ( ! Submit_Token::verify( $token, $form_id ) ) {
172 + return new WP_Error(
173 + 'srfm_token_invalid',
174 + __( 'Security verification failed.', 'sureforms' ),
175 + [ 'status' => 403 ]
176 + );
177 + }
178 +
179 + return true;
180 + }
181 +
182 + /**
183 + * Record one client-reported form submission failure.
184 + *
185 + * Always answers 204, whether or not a line was written. The browser has
186 + * nothing useful to do with a failure here, and a response that distinguishes
187 + * "written" from "dropped" would report back whether logging is on, whether
188 + * the log is full, and whether the caller is being throttled.
189 + *
190 + * @param \WP_REST_Request $request Incoming REST request.
191 + * @since 2.12.6
192 + * @return \WP_REST_Response
193 + */
194 + public function handle_client_error_log( $request ) {
195 + $response = new \WP_REST_Response( null, 204 );
196 +
197 + $form_id = absint( $request->get_param( 'form_id' ) );
198 +
199 + if ( $this->is_rate_limited( 'srfm_cl_', $form_id ) ) {
200 + return $response;
201 + }
202 +
203 + $entries = $request->get_param( 'entries' );
204 +
205 + if ( ! is_array( $entries ) ) {
206 + return $response;
207 + }
208 +
209 + // Cap the batch as well as each entry: a single request must not be able to
210 + // consume the whole file and evict the failure someone is trying to capture.
211 + foreach ( array_slice( $entries, 0, 10 ) as $raw ) {
212 + if ( ! is_array( $raw ) ) {
213 + continue;
214 + }
215 +
216 + $raw['form_id'] = $form_id;
217 +
218 + // Resolved here rather than sent by the browser: the title is what makes
219 + // a log line identifiable at a glance, and taking it from the request
220 + // would let a caller label an entry as any form it liked.
221 + $raw['form_title'] = $form_id ? Helper::get_string_value( get_the_title( $form_id ) ) : '';
222 +
223 + Client_Logger::append( Client_Logger::sanitize_entry( $raw ) );
224 + }
225 +
226 + return $response;
227 + }
228 +
229 + /**
83 230 * Check whether a given request has permission to submit the form.
84 231 *
85 232 * Validates the HMAC-based submission token embedded in the page at render
86 233 * time. Tokens remain valid for up to 48 hours (four 12-hour windows), so
@@ -628,11 +775,15 @@
628 775 'form_data' => $submission_data,
629 776 'submission_info' => $submission_info,
630 777 'created_at' => current_time( 'mysql' ),
631 778 ];
632 - if ( is_user_logged_in() ) {
633 - // If user is logged in then save their user id.
634 - $entries_data['user_id'] = get_current_user_id();
779 + // Resolved via Helper rather than get_current_user_id() directly: this runs on
780 + // a REST request that carries no nonce, which core de-authenticates before
781 + // dispatch, so the plain call returns 0 even for a signed-in submitter and the
782 + // entry would lose its attribution. Returns 0 when genuinely anonymous.
783 + $submitting_user_id = Helper::get_submitting_user_id();
784 + if ( $submitting_user_id ) {
785 + $entries_data['user_id'] = $submitting_user_id;
635 786 }
636 787
637 788 $entries_data = apply_filters(
638 789 'srfm_before_entry_data',
@@ -658,8 +809,15 @@
658 809 if ( $provider->is_active() && '' !== $entry_language ) {
659 810 $provider->switch_language( $entry_language );
660 811 }
661 812
813 + // Entries::add() has stored the logs collected so far. Start the instance
814 + // empty so the update below writes only what send_email() records --
815 + // Entries::update() merges with the stored logs, so anything left here
816 + // would be written twice.
817 + $entries_db_instance = Entries::get_instance();
818 + $entries_db_instance->reset_logs();
819 +
662 820 // Send email after entry creation so {entry_id} is available when smart tags are processed.
663 821 $send_email = $this->send_email( $id, $submission_data, $form_data );
664 822 if ( $send_email ) {
665 823 $emails = $send_email['emails'];
@@ -664,8 +822,15 @@
664 822 if ( $send_email ) {
665 823 $emails = $send_email['emails'];
666 824 }
667 825
826 + // send_email() logs to the in-memory instance; the entry already exists,
827 + // so the log only reaches it through an update.
828 + $notification_logs = $entries_db_instance->get_logs();
829 + if ( ! empty( $notification_logs ) ) {
830 + Entries::update( Helper::get_integer_value( $entry_id ), [ 'logs' => $notification_logs ] );
831 + }
832 +
668 833 $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data );
669 834 $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data );
670 835
671 836 if ( $provider->is_active() && '' !== $entry_language ) {
@@ -671,8 +836,10 @@
671 836 if ( $provider->is_active() && '' !== $entry_language ) {
672 837 $provider->restore_language();
673 838 }
674 839
840 + $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) );
841 +
675 842 $response = [
676 843 'success' => true,
677 844 'message' => $confirmation_message,
678 845 'data' => [
@@ -678,9 +845,20 @@
678 845 'data' => [
679 846 'name' => $name,
680 847 'submission_id' => $entry_id,
681 848 'after_submit' => true,
682 - 'after_submit_nonce' => wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ),
849 + 'after_submit_nonce' => $after_submit_nonce,
850 + // Built here rather than assembled in JS. rest_url() already knows
851 + // whether the route is a path or a `?rest_route=` query arg, and
852 + // add_query_arg() knows whether the nonce needs `?` or `&` — the
853 + // client has no way to get either right without reimplementing
854 + // both, and concatenating produced a URL that did not route at all
855 + // on plain-permalink sites.
856 + 'after_submit_url' => add_query_arg(
857 + 'after_submit_nonce',
858 + $after_submit_nonce,
859 + rest_url( 'sureforms/v1/after-submission/' . Helper::get_integer_value( $entry_id ) )
860 + ),
683 861 ],
684 862 'redirect_url' => $redirect_url,
685 863 ];
686 864
@@ -865,8 +1043,14 @@
865 1043 */
866 1044 public static function send_email( $id, $submission_data, $form_data = [] ) {
867 1045 $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' );
868 1046 $is_mail_sent = false;
1047 + // Any recipient failing counts as a failure for the whole submission, so
1048 + // these are set inside the loop and only read after it.
1049 + $notification_failed = false;
1050 + // Whether any recipient's "success" came from the mail() fallback, which
1051 + // reports true for a message the local MTA accepted and will bounce.
1052 + $used_mail_fallback = false;
869 1053 $emails = [];
870 1054
871 1055 // Filter to determine whether the email notification should be sent.
872 1056 $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data );
@@ -935,11 +1119,22 @@
935 1119 $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
936 1120 if ( ! $sent ) {
937 1121 // Fallback to default PHP mail if for some reasons wp_mail fails.
938 1122 $sent = mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] );
1123 +
1124 + if ( $sent ) {
1125 + // Accepted by the local MTA, not delivered. Good
1126 + // enough to avoid recording a fault, not good
1127 + // enough to retire one.
1128 + $used_mail_fallback = true;
1129 + }
939 1130 }
940 1131 $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports.
941 1132
1133 + if ( true !== $sent ) {
1134 + $notification_failed = true;
1135 + }
1136 +
942 1137 if ( is_int( $log_key ) ) {
943 1138 if ( true === $sent ) {
944 1139 $entries_db_instance->update_log(
945 1140 $log_key,
@@ -972,8 +1167,32 @@
972 1167 ),
973 1168 ]
974 1169 );
975 1170
1171 + // Also record it in the debug log. The submission itself
1172 + // succeeded, so the visitor saw nothing wrong and nobody
1173 + // looks at the entry's own log until a ticket is already
1174 + // open. The recipient address is not included -- the log
1175 + // is downloadable and must not carry personal data.
1176 + Client_Logger::append(
1177 + Client_Logger::sanitize_entry(
1178 + [
1179 + 'type' => 'message',
1180 + 'form_id' => intval( $id ),
1181 + 'form_title' => Helper::get_string_value( get_the_title( intval( $id ) ) ),
1182 + 'message' => 'Email notification failed to send. ' . $reason,
1183 + ]
1184 + )
1185 + );
1186 +
1187 + // Its own category: the entry saved, so this is not a
1188 + // submission failure. The site owner is simply not being
1189 + // told about entries they did receive.
1190 + Client_Logger::record_failure(
1191 + 'notification',
1192 + intval( $id ),
1193 + Helper::get_string_value( get_the_title( intval( $id ) ) )
1194 + );
976 1195 }
977 1196 }
978 1197
979 1198 // Trigger an action after the email is sent, allowing additional processing or logging.
@@ -994,8 +1213,39 @@
994 1213 if ( empty( $emails ) ) {
995 1214 $entries_db_instance->reset_logs();
996 1215 $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) );
997 1216 }
1217 +
1218 + // The notification fault clears when notifications work again. Nothing
1219 + // else retired it: Client_Logger::clear_category() had a single caller
1220 + // hardcoded to 'submission', and the notice is deliberately not
1221 + // dismissible, so a site that had fixed its SMTP kept an undismissable
1222 + // banner on every admin page until somebody opened a support ticket.
1223 + // Held until the loop is done because one recipient succeeding while
1224 + // another fails is still a failure.
1225 + //
1226 + // Scoped to the form the fault was recorded against. send_email() runs
1227 + // on the public submit path and the counter is per category, not per
1228 + // form, so without this an anonymous submission of a working form
1229 + // wipes a different form's standing fault -- once per admin page load,
1230 + // by anyone. The notice names a form, so the granularity is visible
1231 + // now that this clears as well as records.
1232 + //
1233 + // wp_mail() only. The mail() fallback above returns true when the local
1234 + // MTA merely accepts a message it will later bounce, which is the
1235 + // broken configuration rather than the fixed one.
1236 + //
1237 + // is_int( $log_key ) mirrors the recording guard: record_failure() sits
1238 + // inside it, so without it an install where add_log() returns a
1239 + // non-int would never record a notification fault but would still
1240 + // clear one.
1241 + $open_failures = Client_Logger::get_failures();
1242 +
1243 + if ( ! empty( $emails ) && ! $notification_failed && is_int( $log_key )
1244 + && ! $used_mail_fallback
1245 + && intval( $id ) === Helper::get_integer_value( $open_failures['notification']['form_id'] ?? 0 ) ) {
1246 + Client_Logger::clear_category( 'notification' );
1247 + }
998 1248 }
999 1249
1000 1250 return [
1001 1251 'success' => $is_mail_sent,
@@ -1477,8 +1727,23 @@
1477 1727 * @since 2.7.0
1478 1728 * @return bool True if rate-limited (should block), false if allowed.
1479 1729 */
1480 1730 private function is_unique_validation_rate_limited( $form_id ) {
1731 + return $this->is_rate_limited( 'srfm_uv_', $form_id );
1732 + }
1733 +
1734 + /**
1735 + * Throttle a public endpoint to 10 requests per minute per IP per form.
1736 + *
1737 + * Shared by the uniqueness check and the client log route rather than
1738 + * duplicated, so a change to the window applies to both.
1739 + *
1740 + * @param string $prefix Transient key prefix, unique per endpoint.
1741 + * @param int $form_id The form ID the request relates to.
1742 + * @since 2.12.6
1743 + * @return bool True if rate-limited (should block), false if allowed.
1744 + */
1745 + private function is_rate_limited( $prefix, $form_id ) {
1481 1746 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
1482 1747
1483 1748 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1484 1749 return true; // Fail closed if IP cannot be determined.
@@ -1483,9 +1748,9 @@
1483 1748 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1484 1749 return true; // Fail closed if IP cannot be determined.
1485 1750 }
1486 1751
1487 - $transient_key = 'srfm_uv_' . md5( $ip . '_' . $form_id );
1752 + $transient_key = $prefix . md5( $ip . '_' . $form_id );
1488 1753 $attempts = get_transient( $transient_key );
1489 1754
1490 1755 if ( false === $attempts ) {
1491 1756 set_transient( $transient_key, 1, MINUTE_IN_SECONDS );