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/client-logger.php +245 -26 2.12.6 → 2.12.8 View file →
@@ -82,9 +82,9 @@
82 82 /**
83 83 * Option holding per-category failure state, keyed by category.
84 84 *
85 85 * Shape: [ category => [ 'count' => int, 'form_id' => int, 'form_title' => string,
86 - * 'at' => int, 'acked' => int ] ].
86 + * 'at' => int, 'acked' => int, 'acked_at' => int ] ].
87 87 *
88 88 * Kept per category because the three read completely differently to a site
89 89 * owner: submissions failing means visitors cannot reach you, a notification
90 90 * failing means you are not hearing about entries that did save, and an
@@ -102,8 +102,20 @@
102 102 */
103 103 public const CATEGORIES = [ 'submission', 'notification', 'integration' ];
104 104
105 105 /**
106 + * Memoised get_tail() results for this request, keyed by character budget.
107 + *
108 + * A class property rather than a static inside the method so append() and
109 + * clear() can invalidate it: a request that writes to the log and then reads a
110 + * tail must not be handed the tail from before the write.
111 + *
112 + * @var array<string,array{text:string,shown:int,total:int}>
113 + * @since 2.12.7
114 + */
115 + private static $tail_memo = [];
116 +
117 + /**
106 118 * Whether client error logging is currently switched on.
107 119 *
108 120 * On by default, including on installs whose stored settings predate the
109 121 * option. The point of the log is that the evidence already exists when a
@@ -117,18 +129,24 @@
117 129 * baked into cached HTML and can be a full cache TTL out of date, so every
118 130 * write path re-checks here.
119 131 *
120 132 * @since 2.12.6
133 + * @since 2.12.8 Filterable through `srfm_enable_logs`.
121 134 * @return bool
122 135 */
123 136 public static function is_enabled() {
124 137 $general = get_option( 'srfm_general_settings_options', [] );
138 + $enabled = ! is_array( $general ) || ! isset( $general['srfm_enable_logs'] ) || (bool) $general['srfm_enable_logs'];
125 139
126 - if ( ! is_array( $general ) || ! isset( $general['srfm_enable_logs'] ) ) {
127 - return true;
128 - }
129 -
130 - return (bool) $general['srfm_enable_logs'];
140 + /**
141 + * Filter whether client logging is on, without changing the stored
142 + * setting. SureForms Pro's Distraction Free mode turns it off here.
143 + *
144 + * @since 2.12.8
145 + *
146 + * @param bool $enabled The stored setting (true when never saved).
147 + */
148 + return (bool) apply_filters( 'srfm_enable_logs', $enabled );
131 149 }
132 150
133 151 /**
134 152 * Whether an entry means the site is broken, rather than the visitor.
@@ -150,15 +168,22 @@
150 168 public static function is_fault( array $entry ) {
151 169 $type = $entry['type'] ?? '';
152 170
153 171 // Allowlist, so an unrecognised or new category is not a fault by default.
154 - // 'blocked' is deliberately absent: it is the label the browser puts on a
155 - // stop the visitor can clear themselves.
172 + // A visitor-correctable stop never reaches here: sanitize_entry() drops the
173 + // browser's 'blocked' type before anything is written.
156 174 if ( in_array( $type, [ 'error', 'response', 'message' ], true ) ) {
157 175 return true;
158 176 }
159 177
160 - if ( 'network' !== $type ) {
178 + // 'after_submission' runs after the entry is already saved, so it is only a
179 + // fault when the server said so. A browser-side failure there -- an aborted
180 + // fetch as the page unloads, which is what a redirect confirmation does and
181 + // what `keepalive` exists to survive -- tells us nothing about whether the
182 + // work ran: the endpoint is guarded by is_after_submission_process_triggered
183 + // and usually has. Safari spells that abort "TypeError: Load failed", and
184 + // alarming on it reported healthy sites as broken.
185 + if ( ! in_array( $type, [ 'network', 'after_submission' ], true ) ) {
161 186 return false;
162 187 }
163 188
164 189 $status = isset( $entry['status'] ) ? Helper::get_integer_value( $entry['status'] ) : 0;
@@ -180,8 +205,27 @@
180 205 * @since 2.12.6
181 206 * @return void
182 207 */
183 208 public static function record_failure( $category, $form_id = 0, $form_title = '' ) {
209 + // Logging off means nothing is recorded, not merely nothing displayed.
210 + //
211 + // The display side was already gated -- get_action_items() skips the
212 + // first-party items and has_action_item_warnings() returns false -- but the
213 + // counter kept being written, from the two call sites in form-submit.php
214 + // that sit beside an append() the enabled check does stop. So a site with
215 + // logging switched off still accumulated failure state, and switching
216 + // logging on surfaced every fault recorded while it was off, behind a View
217 + // details report whose debug log is empty because nothing was written.
218 + //
219 + // Gated here rather than at the call sites, for the reason append() states:
220 + // the guard belongs on the function that writes, not on today's callers.
221 + // clear_category() and the acknowledge helpers are deliberately left
222 + // ungated -- they only remove state, and must keep working so nothing is
223 + // stranded by the toggle.
224 + if ( ! self::is_enabled() ) {
225 + return;
226 + }
227 +
184 228 if ( ! in_array( $category, self::CATEGORIES, true ) ) {
185 229 return;
186 230 }
187 231
@@ -195,8 +239,13 @@
195 239 'at' => time(),
196 240 // Preserved: a report already made still stands until this new count
197 241 // overtakes it, which is what get_open_failures() compares.
198 242 'acked' => Helper::get_integer_value( $existing['acked'] ?? 0 ),
243 + // Carried forward too. This array is rebuilt from a fixed set of keys, so
244 + // anything not named here is dropped -- and "when did I last report
245 + // this" quietly disappearing on the next failure is exactly the kind of
246 + // loss nobody notices until support asks.
247 + 'acked_at' => Helper::get_integer_value( $existing['acked_at'] ?? 0 ),
199 248 ];
200 249
201 250 update_option( self::FAILURES_OPTION, $failures, false );
202 251 }
@@ -253,8 +302,18 @@
253 302 }
254 303
255 304 $failures[ $category ]['acked'] = Helper::get_integer_value( $failures[ $category ]['count'] ?? 0 );
256 305
306 + // Recorded for the report -- "you told us at 14:12" is worth having when
307 + // support reads the ticket -- but deliberately not what decides whether the
308 + // notice comes back. The count does that.
309 + //
310 + // A timestamp cannot: it is written to the second, so a failure recorded in
311 + // the same second as the acknowledgement compares equal and gets swallowed.
312 + // That is the one moment it matters most, because a fault arriving as
313 + // someone reports the last one is a fault still happening.
314 + $failures[ $category ]['acked_at'] = time();
315 +
257 316 update_option( self::FAILURES_OPTION, $failures, false );
258 317 }
259 318
260 319 /**
@@ -313,10 +372,16 @@
313 372 return [];
314 373 }
315 374
316 375 return [
317 - 'at' => Helper::get_integer_value( $failures['submission']['at'] ?? 0 ),
318 - 'streak' => $acked,
376 + // The last fault time, not the acknowledgement time. Kept under this
377 + // key because callers already read it as "when the thing happened".
378 + 'at' => Helper::get_integer_value( $failures['submission']['at'] ?? 0 ),
379 + // When the owner reported it. Separate, because the two answer
380 + // different questions and are usually seconds apart on a fresh fault
381 + // and days apart on an old one.
382 + 'acked_at' => Helper::get_integer_value( $failures['submission']['acked_at'] ?? 0 ),
383 + 'streak' => $acked,
319 384 ];
320 385 }
321 386
322 387 /**
@@ -346,10 +411,15 @@
346 411 *
347 412 * Hooked - srfm_form_submit, which fires only on the success path.
348 413 *
349 414 * Only the submission category is cleared. A submission getting through says
350 - * nothing about whether its notification email sent or its integrations ran,
351 - * so those clear when they next succeed or when the owner reports them.
415 + * nothing about whether its notification email sent or its integrations ran.
416 + * Notification clears on its own path, in Form_Submit::send_email(), once every
417 + * recipient for a submission has sent. Integration has no success signal to
418 + * clear on yet -- the failures are recorded by pro through
419 + * Form_Submit::log_integration_failure() and there is no matching
420 + * "it worked" call -- so that category still clears only when the owner
421 + * reports it.
352 422 *
353 423 * @since 2.12.6
354 424 * @return void
355 425 */
@@ -391,8 +461,11 @@
391 461 * @since 2.12.6
392 462 * @return bool True when the line was written.
393 463 */
394 464 public static function append( array $entry ) {
465 + // Any write invalidates a memoised tail, whether or not this one lands.
466 + self::$tail_memo = [];
467 +
395 468 // Checked here as well as at the route, so the guard sits on the function
396 469 // that writes rather than only on today's single caller. Without it any
397 470 // future caller writes to disk on a site that never switched logging on.
398 471 if ( ! self::is_enabled() ) {
@@ -407,11 +480,20 @@
407 480 // directory must not stop the site owner being told the form is failing --
408 481 // on a badly broken site those are exactly the conditions that occur.
409 482 // A notification or integration failure records its own category at the call
410 483 // site; everything else reaching here is the submission itself.
411 - if ( self::is_fault( $entry ) && 'message' !== ( $entry['type'] ?? '' ) ) {
484 + $type = Helper::get_string_value( $entry['type'] ?? '' );
485 +
486 + if ( self::is_fault( $entry ) && 'message' !== $type ) {
487 + // The after-submission step runs on an entry that is already saved and
488 + // fires srfm_after_submission_process, which is where integrations and
489 + // webhooks hook in. Calling that a submission failure told the site owner
490 + // "their entries were not saved" about entries that were -- the wrong
491 + // message on the one notice that cannot be dismissed. The category is
492 + // derived here rather than taken from the entry: the client names what
493 + // happened, the server decides what it means.
412 494 self::record_failure(
413 - 'submission',
495 + 'after_submission' === $type ? 'integration' : 'submission',
414 496 Helper::get_integer_value( $entry['form_id'] ?? 0 ),
415 497 Helper::get_string_value( $entry['form_title'] ?? '' )
416 498 );
417 499 }
@@ -442,14 +524,17 @@
442 524
443 525 /**
444 526 * The most recent whole log lines, up to a character budget.
445 527 *
446 - * For pasting into a support email, where the transport imposes the limit: a
447 - * mailto URL has to survive percent-encoding and every mail client's own
448 - * length cap, so only a tail fits. Newest entries are the ones that describe
449 - * the failure being reported, so the tail is the useful end.
528 + * An excerpt for a support email. The budget is the caller's, and it is a
529 + * ceiling: the excerpt goes into a mailto: URL, which a mail client drops
530 + * whole when it runs past its length limit. Newest entries are the ones that
531 + * describe the failure being reported, so the tail is the useful end and the
532 + * oldest are what a smaller budget drops.
450 533 *
451 - * Whole lines only -- half a JSON object helps nobody.
534 + * Whole lines only -- half a JSON object helps nobody -- except when the
535 + * newest line alone is over the budget. That one is cut and marked, because
536 + * an empty excerpt helps nobody either.
452 537 *
453 538 * @param int $max_chars Character budget for the returned text.
454 539 * @since 2.12.6
455 540 * @return array{text:string,shown:int,total:int}
@@ -454,8 +539,28 @@
454 539 * @since 2.12.6
455 540 * @return array{text:string,shown:int,total:int}
456 541 */
457 542 public static function get_tail( $max_chars = 1200 ) {
543 + // Memoised per request and per budget. get_action_items() asks once per
544 + // open failure category and runs twice per admin request -- building the
545 + // localisation payload and again in the classic renderer -- so a site with
546 + // three open failures was reading a file capped at 1 MB six times to render
547 + // one page.
548 + $max_chars = (int) $max_chars;
549 +
550 + // Keyed by blog as well as budget: get_log_path() hashes the blog id into
551 + // the filename, so after a switch_to_blog() the same budget is a different
552 + // file. Unreachable today; nothing switches blogs on this path.
553 + //
554 + // Its own variable, not $max_chars reused -- that key is a string, and the
555 + // byte-budget comparison below coerces "1:1200" to 1, which silently
556 + // reduces every excerpt to a single line.
557 + $memo_key = get_current_blog_id() . ':' . $max_chars;
558 +
559 + if ( isset( self::$tail_memo[ $memo_key ] ) ) {
560 + return self::$tail_memo[ $memo_key ];
561 + }
562 +
458 563 $empty = [
459 564 'text' => '',
460 565 'shown' => 0,
461 566 'total' => 0,
@@ -463,8 +568,10 @@
463 568
464 569 $path = self::get_log_path( false );
465 570
466 571 if ( '' === $path || ! file_exists( $path ) ) {
572 + self::$tail_memo[ $memo_key ] = $empty;
573 +
467 574 return $empty;
468 575 }
469 576
470 577 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file, WordPress.WP.AlternativeFunctions.file_system_read_file -- Reading a file this class owns; WP_Filesystem would prompt for credentials and is unavailable here.
@@ -470,8 +577,10 @@
470 577 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file, WordPress.WP.AlternativeFunctions.file_system_read_file -- Reading a file this class owns; WP_Filesystem would prompt for credentials and is unavailable here.
471 578 $lines = file( $path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
472 579
473 580 if ( ! is_array( $lines ) || empty( $lines ) ) {
581 + self::$tail_memo[ $memo_key ] = $empty;
582 +
474 583 return $empty;
475 584 }
476 585
477 586 $total = count( $lines );
@@ -480,23 +589,32 @@
480 589
481 590 foreach ( array_reverse( $lines ) as $line ) {
482 591 $length = strlen( $line ) + 1;
483 592
484 - // Always keep one line, even if it alone exceeds the budget: an empty
485 - // excerpt is worse than a long one.
486 593 if ( $used + $length > $max_chars && ! empty( $kept ) ) {
487 594 break;
488 595 }
489 596
597 + // Always keep one line, since an empty excerpt is worse than a long one --
598 + // but never past the budget, which is a ceiling the caller relies on. The
599 + // start is kept because it names what failed. Lines are wp_json_encode()d,
600 + // so they are ASCII and a byte cut cannot split a character.
601 + if ( $length > $max_chars ) {
602 + $marker = ' [truncated]';
603 + $line = substr( $line, 0, max( 0, $max_chars - strlen( $marker ) ) ) . $marker;
604 + }
605 +
490 606 array_unshift( $kept, $line );
491 607 $used += $length;
492 608 }
493 609
494 - return [
610 + self::$tail_memo[ $memo_key ] = [
495 611 'text' => implode( "\n", $kept ),
496 612 'shown' => count( $kept ),
497 613 'total' => $total,
498 614 ];
615 +
616 + return self::$tail_memo[ $memo_key ];
499 617 }
500 618
501 619 /**
502 620 * Whether the log has reached its size cap.
@@ -532,8 +650,10 @@
532 650 * @since 2.12.6
533 651 * @return bool
534 652 */
535 653 public static function clear() {
654 + self::$tail_memo = [];
655 +
536 656 $path = self::get_log_path( false );
537 657
538 658 if ( '' === $path || ! file_exists( $path ) ) {
539 659 return true;
@@ -554,9 +674,16 @@
554 674 * @since 2.12.6
555 675 * @return array<string,mixed> Empty when nothing usable survived.
556 676 */
557 677 public static function sanitize_entry( array $raw ) {
558 - $allowed_types = [ 'network', 'response', 'error', 'message', 'blocked' ];
678 + // 'blocked' is deliberately absent. It is the browser's label for a stop the
679 + // visitor can clear themselves -- a required field left empty, an expired
680 + // captcha, a declined card, a rejection naming the field to fix -- and those
681 + // were the most common lines in a real log. Every one of them was pasted into
682 + // support reports about some other failure and consumed budget the log does
683 + // not give back, because it stops at its cap rather than rotating. Dropped at
684 + // the shape gate so no caller, present or future, can write one.
685 + $allowed_types = [ 'network', 'response', 'error', 'message', 'after_submission' ];
559 686 $type = isset( $raw['type'] ) ? sanitize_key( Helper::get_string_value( $raw['type'] ) ) : '';
560 687
561 688 if ( ! in_array( $type, $allowed_types, true ) ) {
562 689 return [];
@@ -593,9 +720,23 @@
593 720 // call -- and because the log stops rather than evicting, that silently
594 721 // disabled the feature until an admin cleared it. A real key is
595 722 // `srfm-input-lbl-<base64>`, far inside this bound.
596 723 foreach ( array_slice( $raw['field_keys'], 0, 100 ) as $field_key ) {
597 - $keys[] = mb_substr( sanitize_text_field( Helper::get_string_value( $field_key ) ), 0, self::MAX_KEY_LENGTH );
724 + // Through scrub_text() like every other free-text value. A key is
725 + // supposed to be `srfm-input-lbl-<base64>`, but the array arrives
726 + // from the browser and nothing server-side guarantees that, so a
727 + // caller is free to put an address or a token in one.
728 + //
729 + // wp_check_invalid_utf8() is kept because scrub_text() is not a
730 + // drop-in for sanitize_text_field(): invalid UTF-8 reaching
731 + // wp_json_encode() in append() makes it return false and drop the
732 + // whole line -- after record_failure() has already incremented the
733 + // counter, leaving a banner with no log line behind it.
734 + $keys[] = mb_substr(
735 + self::scrub_text( wp_check_invalid_utf8( Helper::get_string_value( $field_key ) ) ),
736 + 0,
737 + self::MAX_KEY_LENGTH
738 + );
598 739 }
599 740
600 741 $entry['field_keys'] = $keys;
601 742 }
@@ -614,8 +755,21 @@
614 755 * and a page URL routinely carries an address or a reset key in its query
615 756 * string. Whitespace is collapsed as a log-injection guard, matching
616 757 * inc/ai-form-builder/ai-helper.php.
617 758 *
759 + * Removes, in order: JSON slash-escaping, so the rules below can see URLs at
760 + * all; credentials in a URL's userinfo; query strings and fragments; a foreign
761 + * URL's path past its first segment, keeping same-origin paths intact because
762 + * those are stack frames and the path is the diagnosis; the value following a
763 + * name that identifies a credential; email addresses; and long digit runs.
764 + *
765 + * A single-segment foreign path is truncated whole rather than kept, which is
766 + * the safe direction.
767 + *
768 + * What it cannot remove is a name, a street address or a free-text message
769 + * body -- those have no shape to match, so the log excerpt this produces
770 + * should still be treated as personal data.
771 + *
618 772 * @param string $text Raw text.
619 773 * @since 2.12.6
620 774 * @return string
621 775 */
@@ -623,11 +777,76 @@
623 777 if ( '' === $text ) {
624 778 return '';
625 779 }
626 780
781 + // Clamped before the rules run, not after. Without it every pattern below
782 + // is applied to whatever the caller sent, however long that is.
783 + $text = mb_substr( $text, 0, self::MAX_TEXT_LENGTH * 4 );
784 +
785 + // A WP REST error body arrives slash-escaped -- wp_json_encode() escapes
786 + // "/" and WP_REST_Server::serve_request() does not pass
787 + // JSON_UNESCAPED_SLASHES -- and two of the four body sinks log the raw
788 + // response text rather than the decoded object. Without this every URL
789 + // rule below misses every URL in the largest sink, including the webhook
790 + // tokens they exist for.
791 + $text = str_replace( '\\/', '/', $text );
792 +
793 + // Credentials in the userinfo position, before the host rules see them.
794 + $text = (string) preg_replace( '#(https?://)[^\s/@]+@#i', '$1[credentials]@', $text );
795 +
627 796 // Drop query strings and fragments wholesale rather than allowlisting
628 797 // parameters. A token after # is just as sensitive as one after ?.
629 - $text = (string) preg_replace( '#(https?://[^\s?\#]+)[?\#]\S*#i', '$1', $text );
798 + $text = (string) preg_replace( '#(https?://[^\s?\#]+)[?\#][^\s"\'<>,;)\]}]*#i', '$1', $text );
799 +
800 + $site_host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) );
801 +
802 + // Keep the origin and the first path segment of a foreign URL, drop the
803 + // rest: a webhook credential sits in the path as often as in the query,
804 + // and Slack and Discord both put theirs there.
805 + //
806 + // Same-origin URLs are exempt. `source` is a stack frame, not a page
807 + // address -- assets/js/unminified/form-submit.js takes
808 + // error.stack.split( "\n" )[1] -- so truncating our own paths deletes the
809 + // filename, the line and column, and which plugin threw, which is the
810 + // whole diagnosis. A third-party credential is never same-origin.
811 + //
812 + // The port is stripped before comparing. The pattern captures the whole
813 + // authority, so a site served on a non-default port produced frames reading
814 + // `example.test:8443`, while $site_host is PHP_URL_HOST and never carries a
815 + // port -- every own frame failed the check and was truncated to /[path],
816 + // which is the case this exemption exists for. Same host, different port is
817 + // treated as ours: on a WordPress install that is the same site behind a dev
818 + // server or a proxy, and the alternative is deleting the diagnosis.
819 + $text = (string) preg_replace_callback(
820 + '#(https?://)([^\s/]+)((?:/[^\s/]*)?)/[^\s"\'<>,;)\]}]+#i',
821 + static function ( $matches ) use ( $site_host ) {
822 + // Trailing :digits only, so an IPv6 literal keeps its brackets and
823 + // its own colons -- [::1]:8080 becomes [::1], which is the form
824 + // wp_parse_url() returns for one.
825 + $host = (string) preg_replace( '/:\d+$/', '', $matches[2] );
826 +
827 + if ( '' !== $site_host && 0 === strcasecmp( $host, $site_host ) ) {
828 + return $matches[0];
829 + }
830 +
831 + return $matches[1] . $matches[2] . $matches[3] . '/[path]';
832 + },
833 + $text
834 + );
835 +
836 + // Credentials named in the text itself. The name is matched as a whole
837 + // identifier, so a keyword with a prefix or suffix is still caught --
838 + // AWS_SECRET_ACCESS_KEY, stripe_secret_key, X-Hub-Signature. And a
839 + // separator is required, so ordinary prose survives: "Invalid token
840 + // provided" and "password protected" are the most common things support
841 + // reads out of this log, and an earlier version redacted both. `bearer`
842 + // and `basic` are the exception, because those carry the value after a
843 + // space with no separator at all.
844 + $text = (string) preg_replace(
845 + '/(\b[\w.-]*(?:api[_-]?key|key|secret|token|password|passwd|pwd|auth|credential|signature)[\w.-]*["\']?\s*[:=]\s*["\']?|\b(?:bearer|basic)\s+)[^\s"\',;&]{8,}/i',
846 + '$1[redacted]',
847 + $text
848 + );
630 849
631 850 // Email addresses.
632 851 $text = (string) preg_replace( '/[\w.+-]+@[\w-]+\.[\w.-]+/', '[email]', $text );
633 852