| @@ -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 |
| @@ -282,8 +429,13 @@ | ||
| 282 | 429 | ] |
| 283 | 430 | ); |
| 284 | 431 | } |
| 285 | 432 | |
| 433 | + // Drop submitted keys this form does not define before anything consumes them. | |
| 434 | + // Runs on SUBMISSION only, so historical entries whose keys no longer match a | |
| 435 | + // rebuilt form (see #2665) stay fully readable on the read/export paths. | |
| 436 | + $form_data = Field_Validation::strip_unknown_field_keys( $form_data, $current_form_id ); | |
| 437 | + | |
| 286 | 438 | $validated_form_data = Field_Validation::validate_form_data( $form_data, $current_form_id ); |
| 287 | 439 | |
| 288 | 440 | if ( ! empty( $validated_form_data ) ) { |
| 289 | 441 | // Get the first error message to display as the main message. |
| @@ -600,16 +752,18 @@ | ||
| 600 | 752 | 'browser_name' => $browser_name, |
| 601 | 753 | 'device_name' => $device_name, |
| 602 | 754 | 'submission_url' => $submission_url, |
| 603 | 755 | ]; |
| 604 | - // Prefer the language the visitor saw at form-render time (captured in a | |
| 605 | - // hidden srfm-form-language input), since WPML's language detection on the | |
| 606 | - // REST submit endpoint frequently falls back to the default. The hidden | |
| 607 | - // input is client-supplied, so: | |
| 756 | + // Resolve the language the visitor saw at form-render time (captured in a | |
| 757 | + // hidden srfm-form-language input) so the confirmation message and email | |
| 758 | + // notifications below can be rendered in it — WPML's language detection on | |
| 759 | + // the REST submit endpoint frequently falls back to the default. This value | |
| 760 | + // is used only to switch_language() at submit time; it is not persisted. The | |
| 761 | + // hidden input is client-supplied, so: | |
| 608 | 762 | // 1. Validate shape with a BCP-47 regex. |
| 609 | 763 | // 2. Cross-check against the active multilingual provider's known |
| 610 | - // languages (active + default) so a crafted request can't pollute | |
| 611 | - // the column with codes the site doesn't support. | |
| 764 | + // languages (active + default) so a crafted request can't switch rendering | |
| 765 | + // to a code the site doesn't support. | |
| 612 | 766 | // 3. Fall back to the provider's current_language() on either failure. |
| 613 | 767 | $entry_language = Multilingual_Manager::get_instance()->provider()->current_language(); |
| 614 | 768 | $submitted_language = isset( $form_data['srfm-form-language'] ) ? sanitize_text_field( Helper::get_string_value( $form_data['srfm-form-language'] ) ) : ''; |
| 615 | 769 | if ( '' !== $submitted_language && preg_match( '/^[a-z]{2,3}([_-][A-Za-z0-9]{2,8})?$/', $submitted_language ) === 1 && $this->is_known_language( $submitted_language ) ) { |
| @@ -619,14 +773,17 @@ | ||
| 619 | 773 | $entries_data = [ |
| 620 | 774 | 'form_id' => $id, |
| 621 | 775 | 'form_data' => $submission_data, |
| 622 | 776 | 'submission_info' => $submission_info, |
| 623 | - 'language' => $entry_language, | |
| 624 | 777 | 'created_at' => current_time( 'mysql' ), |
| 625 | 778 | ]; |
| 626 | - if ( is_user_logged_in() ) { | |
| 627 | - // If user is logged in then save their user id. | |
| 628 | - $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; | |
| 629 | 786 | } |
| 630 | 787 | |
| 631 | 788 | $entries_data = apply_filters( |
| 632 | 789 | 'srfm_before_entry_data', |
| @@ -646,14 +803,21 @@ | ||
| 646 | 803 | // confirmation message, redirect URL, and email notifications render |
| 647 | 804 | // in the language the visitor saw at submit time. The REST submit |
| 648 | 805 | // endpoint doesn't carry the ?lang= URL parameter, so without this |
| 649 | 806 | // switch the provider would return strings in its default language |
| 650 | - // even though the entry itself is correctly tagged. | |
| 807 | + // even though the visitor filled the form in another language. | |
| 651 | 808 | $provider = Multilingual_Manager::get_instance()->provider(); |
| 652 | 809 | if ( $provider->is_active() && '' !== $entry_language ) { |
| 653 | 810 | $provider->switch_language( $entry_language ); |
| 654 | 811 | } |
| 655 | 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 | + | |
| 656 | 820 | // Send email after entry creation so {entry_id} is available when smart tags are processed. |
| 657 | 821 | $send_email = $this->send_email( $id, $submission_data, $form_data ); |
| 658 | 822 | if ( $send_email ) { |
| 659 | 823 | $emails = $send_email['emails']; |
| @@ -658,8 +822,15 @@ | ||
| 658 | 822 | if ( $send_email ) { |
| 659 | 823 | $emails = $send_email['emails']; |
| 660 | 824 | } |
| 661 | 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 | + | |
| 662 | 833 | $confirmation_message = Generate_Form_Markup::get_confirmation_markup( $form_data, $submission_data ); |
| 663 | 834 | $redirect_url = Generate_Form_Markup::get_redirect_url( $form_data, $submission_data ); |
| 664 | 835 | |
| 665 | 836 | if ( $provider->is_active() && '' !== $entry_language ) { |
| @@ -665,8 +836,10 @@ | ||
| 665 | 836 | if ( $provider->is_active() && '' !== $entry_language ) { |
| 666 | 837 | $provider->restore_language(); |
| 667 | 838 | } |
| 668 | 839 | |
| 840 | + $after_submit_nonce = wp_create_nonce( 'srfm_after_submission_' . Helper::get_string_value( $entry_id ) ); | |
| 841 | + | |
| 669 | 842 | $response = [ |
| 670 | 843 | 'success' => true, |
| 671 | 844 | 'message' => $confirmation_message, |
| 672 | 845 | 'data' => [ |
| @@ -672,9 +845,20 @@ | ||
| 672 | 845 | 'data' => [ |
| 673 | 846 | 'name' => $name, |
| 674 | 847 | 'submission_id' => $entry_id, |
| 675 | 848 | 'after_submit' => true, |
| 676 | - '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 | + ), | |
| 677 | 861 | ], |
| 678 | 862 | 'redirect_url' => $redirect_url, |
| 679 | 863 | ]; |
| 680 | 864 | |
| @@ -859,8 +1043,14 @@ | ||
| 859 | 1043 | */ |
| 860 | 1044 | public static function send_email( $id, $submission_data, $form_data = [] ) { |
| 861 | 1045 | $email_notification = get_post_meta( intval( $id ), '_srfm_email_notification' ); |
| 862 | 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; | |
| 863 | 1053 | $emails = []; |
| 864 | 1054 | |
| 865 | 1055 | // Filter to determine whether the email notification should be sent. |
| 866 | 1056 | $email_notification = apply_filters( 'srfm_email_notification_should_send', $email_notification, $submission_data, $form_data ); |
| @@ -929,11 +1119,22 @@ | ||
| 929 | 1119 | $sent = wp_mail( $parsed['to'], $parsed['subject'], $parsed['message'], $parsed['headers'] ); |
| 930 | 1120 | if ( ! $sent ) { |
| 931 | 1121 | // Fallback to default PHP mail if for some reasons wp_mail fails. |
| 932 | 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 | + } | |
| 933 | 1130 | } |
| 934 | 1131 | $email_report = ob_get_clean(); // Catch any printed notice/errors/message for reports. |
| 935 | 1132 | |
| 1133 | + if ( true !== $sent ) { | |
| 1134 | + $notification_failed = true; | |
| 1135 | + } | |
| 1136 | + | |
| 936 | 1137 | if ( is_int( $log_key ) ) { |
| 937 | 1138 | if ( true === $sent ) { |
| 938 | 1139 | $entries_db_instance->update_log( |
| 939 | 1140 | $log_key, |
| @@ -966,8 +1167,32 @@ | ||
| 966 | 1167 | ), |
| 967 | 1168 | ] |
| 968 | 1169 | ); |
| 969 | 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 | + ); | |
| 970 | 1195 | } |
| 971 | 1196 | } |
| 972 | 1197 | |
| 973 | 1198 | // Trigger an action after the email is sent, allowing additional processing or logging. |
| @@ -988,8 +1213,39 @@ | ||
| 988 | 1213 | if ( empty( $emails ) ) { |
| 989 | 1214 | $entries_db_instance->reset_logs(); |
| 990 | 1215 | $entries_db_instance->add_log( __( 'No emails were sent.', 'sureforms' ) ); |
| 991 | 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 | + } | |
| 992 | 1248 | } |
| 993 | 1249 | |
| 994 | 1250 | return [ |
| 995 | 1251 | 'success' => $is_mail_sent, |
| @@ -1028,8 +1284,15 @@ | ||
| 1028 | 1284 | if ( $this->is_unique_validation_rate_limited( $form_id ) ) { |
| 1029 | 1285 | wp_send_json_error( [ 'error' => __( 'Too many requests. Please try again shortly.', 'sureforms' ) ], 429 ); |
| 1030 | 1286 | } |
| 1031 | 1287 | |
| 1288 | + // SECURITY INVARIANT — only the fields the form itself marks unique may be | |
| 1289 | + // probed through this unauthenticated handler. The allowlist is what keeps the | |
| 1290 | + // lookup scoped to values a site owner opted into checking, rather than to | |
| 1291 | + // stored submission data generally. A form with no unique fields therefore | |
| 1292 | + // matches nothing and always answers with an empty set. | |
| 1293 | + $unique_block_ids = $this->get_unique_field_block_ids( $form_id ); | |
| 1294 | + | |
| 1032 | 1295 | // Extract and validate field values from POST data. |
| 1033 | 1296 | $skip_keys = [ 'action', 'token', 'id' ]; |
| 1034 | 1297 | $duplicates = []; |
| 1035 | 1298 | |
| @@ -1049,8 +1312,15 @@ | ||
| 1049 | 1312 | if ( '' === $value ) { |
| 1050 | 1313 | continue; |
| 1051 | 1314 | } |
| 1052 | 1315 | |
| 1316 | + // The key must resolve to a block this form configured as unique. | |
| 1317 | + $block_id = Helper::get_block_id_from_key( $field_key ); | |
| 1318 | + | |
| 1319 | + if ( '' === $block_id || ! isset( $unique_block_ids[ $block_id ] ) ) { | |
| 1320 | + continue; | |
| 1321 | + } | |
| 1322 | + | |
| 1053 | 1323 | // Single optimized query per field instead of loading all entries. |
| 1054 | 1324 | if ( Entries::has_duplicate_field_value( $form_id, $field_key, $value ) ) { |
| 1055 | 1325 | $duplicates[] = [ $field_key => 'not unique' ]; |
| 1056 | 1326 | } |
| @@ -1323,8 +1593,132 @@ | ||
| 1323 | 1593 | return $language === $provider->current_language(); |
| 1324 | 1594 | } |
| 1325 | 1595 | |
| 1326 | 1596 | /** |
| 1597 | + * Collect the block IDs of the fields a form configures as unique. | |
| 1598 | + * | |
| 1599 | + * Derived from the stored form, never from the request — the whole point is that | |
| 1600 | + * the client cannot nominate which fields are probeable. The frontend already | |
| 1601 | + * sends only inputs rendered with data-unique="true", which comes from the same | |
| 1602 | + * isUnique attribute, so this is the server-side mirror of what the client does. | |
| 1603 | + * | |
| 1604 | + * @param int $form_id Form ID. | |
| 1605 | + * | |
| 1606 | + * @since 2.12.3 | |
| 1607 | + * @return array<string,true> Unique field block IDs, keyed by block ID. | |
| 1608 | + */ | |
| 1609 | + private function get_unique_field_block_ids( $form_id ) { | |
| 1610 | + $form = get_post( $form_id ); | |
| 1611 | + | |
| 1612 | + if ( ! $form instanceof \WP_Post || '' === $form->post_content ) { | |
| 1613 | + return []; | |
| 1614 | + } | |
| 1615 | + | |
| 1616 | + $visited_refs = []; | |
| 1617 | + $block_ids = $this->collect_unique_field_block_ids( parse_blocks( $form->post_content ), $visited_refs ); | |
| 1618 | + | |
| 1619 | + /** | |
| 1620 | + * Filters the block IDs treated as unique fields for the AJAX uniqueness check. | |
| 1621 | + * | |
| 1622 | + * Lets add-ons whose fields a static parse of the form cannot see contribute | |
| 1623 | + * their own unique fields. | |
| 1624 | + * | |
| 1625 | + * @since 2.12.3 | |
| 1626 | + * | |
| 1627 | + * @param array<string,true> $block_ids Unique field block IDs, keyed by block ID. | |
| 1628 | + * A plain list of IDs is accepted too and is | |
| 1629 | + * normalised to this shape. | |
| 1630 | + * @param int $form_id Form ID. | |
| 1631 | + */ | |
| 1632 | + $filtered = apply_filters( 'srfm_unique_field_block_ids', $block_ids, $form_id ); | |
| 1633 | + | |
| 1634 | + // Normalise rather than trust: the lookup is isset( $set[ $block_id ] ), so an | |
| 1635 | + // add-on returning a plain list would silently disable uniqueness for the form | |
| 1636 | + // instead of adding to it. A non-array return keeps the derived set. | |
| 1637 | + return is_array( $filtered ) ? self::normalize_block_id_set( $filtered ) : $block_ids; | |
| 1638 | + } | |
| 1639 | + | |
| 1640 | + /** | |
| 1641 | + * Normalise a block-ID collection to a block ID => true map. | |
| 1642 | + * | |
| 1643 | + * Accepts both the documented map shape and a plain list of IDs. | |
| 1644 | + * | |
| 1645 | + * @param array<mixed> $block_ids Block IDs as a map or a list. | |
| 1646 | + * | |
| 1647 | + * @since 2.12.3 | |
| 1648 | + * @return array<string,true> Block IDs keyed by block ID. | |
| 1649 | + */ | |
| 1650 | + private static function normalize_block_id_set( $block_ids ) { | |
| 1651 | + $normalized = []; | |
| 1652 | + | |
| 1653 | + foreach ( $block_ids as $key => $value ) { | |
| 1654 | + // List entry: the ID is the value. Map entry: the ID is the key. | |
| 1655 | + $block_id = is_int( $key ) ? $value : $key; | |
| 1656 | + | |
| 1657 | + if ( is_string( $block_id ) && '' !== $block_id ) { | |
| 1658 | + $normalized[ $block_id ] = true; | |
| 1659 | + } | |
| 1660 | + } | |
| 1661 | + | |
| 1662 | + return $normalized; | |
| 1663 | + } | |
| 1664 | + | |
| 1665 | + /** | |
| 1666 | + * Recursively collect block IDs of blocks whose isUnique attribute is enabled. | |
| 1667 | + * | |
| 1668 | + * Recurses into innerBlocks (repeater/container children) and expands | |
| 1669 | + * reusable/synced patterns, mirroring Form_Styling::collect_form_block_ids(). | |
| 1670 | + * | |
| 1671 | + * Note: parse_blocks() does NOT apply block.json defaults, unlike the render path. | |
| 1672 | + * Every field block therefore has to keep isUnique defaulting to false — a block | |
| 1673 | + * that defaults it to true would be serialised without the attribute and would be | |
| 1674 | + * missed here while still rendering data-unique="true". | |
| 1675 | + * | |
| 1676 | + * @param array<mixed> $blocks Parsed blocks from parse_blocks(). | |
| 1677 | + * @param array<int, true> $visited_refs Reusable-block post IDs already expanded, | |
| 1678 | + * keyed by ID — guards against reference cycles. | |
| 1679 | + * | |
| 1680 | + * @since 2.12.3 | |
| 1681 | + * @return array<string,true> Unique field block IDs, keyed by block ID. | |
| 1682 | + */ | |
| 1683 | + private function collect_unique_field_block_ids( $blocks, &$visited_refs = [] ) { | |
| 1684 | + $block_ids = []; | |
| 1685 | + | |
| 1686 | + foreach ( $blocks as $block ) { | |
| 1687 | + if ( ! is_array( $block ) ) { | |
| 1688 | + continue; | |
| 1689 | + } | |
| 1690 | + | |
| 1691 | + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; | |
| 1692 | + | |
| 1693 | + if ( ! empty( $attrs['isUnique'] ) && ! empty( $attrs['block_id'] ) && is_scalar( $attrs['block_id'] ) ) { | |
| 1694 | + $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true; | |
| 1695 | + } | |
| 1696 | + | |
| 1697 | + // Reusable/synced pattern: expand the referenced wp_block post so a field | |
| 1698 | + // living inside a pattern is seen like an inline block. | |
| 1699 | + if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) { | |
| 1700 | + $ref = absint( $attrs['ref'] ); | |
| 1701 | + | |
| 1702 | + if ( $ref && ! isset( $visited_refs[ $ref ] ) ) { | |
| 1703 | + $visited_refs[ $ref ] = true; | |
| 1704 | + $ref_post = get_post( $ref ); | |
| 1705 | + | |
| 1706 | + if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && 'publish' === $ref_post->post_status && '' !== $ref_post->post_content ) { | |
| 1707 | + $block_ids += $this->collect_unique_field_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs ); | |
| 1708 | + } | |
| 1709 | + } | |
| 1710 | + } | |
| 1711 | + | |
| 1712 | + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { | |
| 1713 | + $block_ids += $this->collect_unique_field_block_ids( $block['innerBlocks'], $visited_refs ); | |
| 1714 | + } | |
| 1715 | + } | |
| 1716 | + | |
| 1717 | + return $block_ids; | |
| 1718 | + } | |
| 1719 | + | |
| 1720 | + /** | |
| 1327 | 1721 | * Check if the current request is rate-limited for unique validation. |
| 1328 | 1722 | * |
| 1329 | 1723 | * Uses transients keyed by IP + form ID to throttle requests. |
| 1330 | 1724 | * Allows 10 requests per 60-second window per IP per form. |
| @@ -1333,8 +1727,23 @@ | ||
| 1333 | 1727 | * @since 2.7.0 |
| 1334 | 1728 | * @return bool True if rate-limited (should block), false if allowed. |
| 1335 | 1729 | */ |
| 1336 | 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 ) { | |
| 1337 | 1746 | $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; |
| 1338 | 1747 | |
| 1339 | 1748 | if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { |
| 1340 | 1749 | return true; // Fail closed if IP cannot be determined. |
| @@ -1339,9 +1748,9 @@ | ||
| 1339 | 1748 | if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { |
| 1340 | 1749 | return true; // Fail closed if IP cannot be determined. |
| 1341 | 1750 | } |
| 1342 | 1751 | |
| 1343 | - $transient_key = 'srfm_uv_' . md5( $ip . '_' . $form_id ); | |
| 1752 | + $transient_key = $prefix . md5( $ip . '_' . $form_id ); | |
| 1344 | 1753 | $attempts = get_transient( $transient_key ); |
| 1345 | 1754 | |
| 1346 | 1755 | if ( false === $attempts ) { |
| 1347 | 1756 | set_transient( $transient_key, 1, MINUTE_IN_SECONDS ); |