PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
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 0.0.2 All 97 releases
← All changes | inc/database/base.php +342 -11 2.7.12.12.7 View file →
@@ -88,9 +88,9 @@
88 88 *
89 89 * @var array<string>
90 90 * @since 1.8.0
91 91 */
92 - private $allowed_where_operators = [ 'LIKE', 'IN', '=', '!=', '>', '<', '>=', '<=' ];
92 + private $allowed_where_operators = [ 'LIKE', 'IN', 'NOT IN', '=', '!=', '>', '<', '>=', '<=' ];
93 93
94 94 /**
95 95 * Init class.
96 96 *
@@ -228,8 +228,194 @@
228 228 return $this->table_name;
229 229 }
230 230
231 231 /**
232 + * Whether this table currently exists in the database.
233 + *
234 + * Deliberately `SHOW TABLES LIKE` rather than the existing get_columns():
235 + * `SHOW COLUMNS FROM <missing table>` is a MySQL error, so it pollutes
236 + * $wpdb->last_error, prints under WP_DEBUG_DISPLAY, and cannot tell "the table
237 + * is gone" apart from "SHOW is denied". This returns a clean empty set instead.
238 + *
239 + * esc_like() matters because $wpdb->prefix contains `_`, which is a LIKE
240 + * wildcard — without it `wp_srfm_entries` would also match `wpXsrfm_entries`.
241 + * The comparison is against the real, unescaped name so the match stays exact.
242 + *
243 + * Fails safe: any DB-level error reports the table as present. A false "your
244 + * database needs updating" on a transient connection blip is worse than a
245 + * missed one, because the notice it drives asks the user to alter their schema.
246 + *
247 + * @since 2.12.6
248 + * @return bool True when the table exists, or when existence cannot be determined.
249 + */
250 + public function table_exists() {
251 + $wpdb = $this->wpdb;
252 + $table = $this->get_tablename();
253 +
254 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; the caller owns caching, and a cached answer here would defeat the check.
255 + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) );
256 +
257 + if ( ! empty( $wpdb->last_error ) ) {
258 + return true;
259 + }
260 +
261 + return $found === $table;
262 + }
263 +
264 + /**
265 + * A table holding this table's data under a different prefix, if there is one.
266 + *
267 + * Changing `$table_prefix` — a manual edit, a restored dump from a site with a
268 + * different prefix, or a security plugin that renames tables and misses the ones
269 + * it does not know about — leaves our data behind under the old name while the
270 + * plugin looks for the new one. Creating a fresh empty table there would strand
271 + * every stored entry, so look for the old one first and adopt it instead.
272 + *
273 + * Refuses to guess. Returns '' unless exactly one credible candidate exists, and
274 + * only when that candidate carries every column this table's schema declares —
275 + * an unrelated table that merely ends in the same words is never touched.
276 + *
277 + * On multisite, other blogs' tables are legitimate and belong to those blogs.
278 + * Anything matching the `{base_prefix}{digits}_` pattern, or the base prefix
279 + * itself, is excluded so a subsite can never adopt another subsite's data.
280 + *
281 + * @since 2.12.6
282 + * @return string Full table name to adopt, or '' when there is nothing safe to adopt.
283 + */
284 + public function find_adoptable_table() {
285 + $wpdb = $this->wpdb;
286 + $correct = $this->get_tablename();
287 + $needle = 'srfm_' . $this->table_suffix;
288 +
289 + // Wildcard on the left only: the name must *end* at the suffix, so a
290 + // deliberate copy such as `wp_srfm_entries_backup` is never a candidate.
291 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; a cached answer would defeat the check.
292 + $found = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', '%' . $wpdb->esc_like( $needle ) ) );
293 +
294 + if ( ! empty( $wpdb->last_error ) || ! is_array( $found ) ) {
295 + return '';
296 + }
297 +
298 + $base = $wpdb->base_prefix;
299 + $blog_table = '/^' . preg_quote( $base, '/' ) . '\d+_' . preg_quote( $needle, '/' ) . '$/';
300 + $candidates = [];
301 +
302 + foreach ( $found as $table ) {
303 + $table = (string) $table;
304 +
305 + // The table we are looking for, another blog's table, or the network's
306 + // main-site table — none of these are ours to rename.
307 + if ( $table === $correct || $base . $needle === $table || preg_match( $blog_table, $table ) ) {
308 + continue;
309 + }
310 +
311 + $candidates[] = $table;
312 + }
313 +
314 + // More than one and we cannot tell which holds the real data. Refuse rather
315 + // than pick, and let the caller fall back to creating an empty table.
316 + if ( 1 !== count( $candidates ) ) {
317 + return '';
318 + }
319 +
320 + return $this->has_expected_columns( $candidates[0] ) ? $candidates[0] : '';
321 + }
322 +
323 + /**
324 + * Rename a differently-prefixed table into this table's expected name.
325 + *
326 + * RENAME rather than create-and-copy: it is atomic, needs no second copy of the
327 + * data, and cannot half-succeed and leave rows in two places.
328 + *
329 + * @param string $from Full name of the table to adopt.
330 + * @since 2.12.6
331 + * @return bool True when the table is in place afterwards.
332 + */
333 + public function adopt_table( $from ) {
334 + $wpdb = $this->wpdb;
335 + $to = $this->get_tablename();
336 +
337 + if ( empty( $from ) || $from === $to ) {
338 + return false;
339 + }
340 +
341 + // Never rename over an existing table; the one already in place wins.
342 + if ( $this->table_exists() ) {
343 + return true;
344 + }
345 +
346 + $query = $wpdb->prepare( 'RENAME TABLE %1s TO %2s', str_replace( '`', '', $from ), str_replace( '`', '', $to ) ); // phpcs:ignore -- Same complex-placeholder pattern as create(): identifiers must not be quoted, and both names come from SHOW TABLES / $wpdb->prefix.
347 +
348 + if ( ! $query ) {
349 + // prepare() returned nothing usable; do not fall through to a raw query.
350 + return false;
351 + }
352 +
353 + $wpdb->query( $query ); // phpcs:ignore -- We are already using prepare above, and one-off DDL has nothing to cache.
354 +
355 + if ( ! empty( $wpdb->last_error ) ) {
356 + /** This action is documented in inc/database/base.php */
357 + do_action( 'srfm_db_upgrade_query_failed', $wpdb->last_error, 'RENAME TABLE', $to );
358 + }
359 +
360 + return $this->table_exists();
361 + }
362 +
363 + /**
364 + * Stamp this site's owner signature onto a table's MySQL comment.
365 + *
366 + * Best-effort: a host that refuses ALTER simply leaves the table unstamped,
367 + * which later reads as "ownership unproven" — the safe direction.
368 + *
369 + * @param string $table Full table name; defaults to this table's own name.
370 + * @since 2.12.6
371 + * @return void
372 + */
373 + public function stamp_owner_signature( $table = '' ) {
374 + $wpdb = $this->wpdb;
375 + $table = '' === $table ? $this->get_tablename() : $table;
376 +
377 + $query = $wpdb->prepare( 'ALTER TABLE %1s COMMENT = %s', str_replace( '`', '', $table ), $this->get_owner_signature() ); // phpcs:ignore -- Identifier must not be quoted; the comment value is a bound, quoted string.
378 +
379 + if ( ! $query ) {
380 + return;
381 + }
382 +
383 + $wpdb->query( $query ); // phpcs:ignore -- Prepared above; one-off DDL with nothing to cache.
384 + }
385 +
386 + /**
387 + * Whether a table carries this site's owner signature.
388 + *
389 + * Gates adoption: on shared hosting a different install's identically-named,
390 + * same-schema table can be the only candidate, and renaming it in would destroy
391 + * that site's data. Deny by default — anything but an exact signature match
392 + * (including a read error, an empty comment, or a legacy table stamped before
393 + * this plugin wrote signatures) returns false.
394 + *
395 + * @param string $table Full table name to inspect.
396 + * @since 2.12.6
397 + * @return bool
398 + */
399 + public function table_belongs_to_site( $table ) {
400 + $wpdb = $this->wpdb;
401 + $bare = str_replace( '`', '', (string) $table );
402 +
403 + if ( '' === $bare ) {
404 + return false;
405 + }
406 +
407 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; a cached answer would defeat the check.
408 + $comment = $wpdb->get_var( $wpdb->prepare( 'SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $bare ) );
409 +
410 + if ( ! empty( $wpdb->last_error ) || ! is_string( $comment ) || '' === $comment ) {
411 + return false;
412 + }
413 +
414 + return hash_equals( $this->get_owner_signature(), $comment );
415 + }
416 +
417 + /**
232 418 * Conditionally returns current database charset or collate.
233 419 *
234 420 * @since 0.0.10
235 421 * @return string
@@ -286,10 +472,31 @@
286 472
287 473 if ( false === $result ) {
288 474 // Stop DB alteration if we have any error.
289 475 $this->db_upgradable = false;
476 +
477 + /**
478 + * Fires when a table could not be created.
479 + *
480 + * Column changes have announced their failures since 2.11.0 but table
481 + * creation never did — so the one failure that leaves a site with no
482 + * table at all, a host denying CREATE TABLE, was the only silent one.
483 + * Same signature as the ALTER case so one listener can handle both.
484 + *
485 + * @param string $last_error The database error.
486 + * @param string $query The query that failed.
487 + * @param string $table_name The table it was for.
488 + * @since 2.12.6
489 + */
490 + do_action( 'srfm_db_upgrade_query_failed', $wpdb->last_error, $query, $this->get_tablename() );
290 491 }
291 492
493 + if ( false !== $result ) {
494 + // Stamp our own table so a future adoption can prove it belongs to this
495 + // site before renaming it in. See stamp_owner_signature().
496 + $this->stamp_owner_signature();
497 + }
498 +
292 499 return $result;
293 500 }
294 501
295 502 /**
@@ -420,10 +627,22 @@
420 627 // Execute the query.
421 628 $result = $wpdb->query( $query ); // phpcs:ignore -- It is okay. We are already using prepare above and we need to do DB query directly here.
422 629
423 630 if ( false === $result ) {
424 - // Stop DB alteration if we have any error.
631 + // Stop DB alteration if we have any error. A failed ALTER leaves the table
632 + // version un-bumped, so it retries on every request — expose the underlying
633 + // error so persistent failures are diagnosable (hook for logging/monitoring).
425 634 $this->db_upgradable = false;
635 +
636 + /**
637 + * Fires when a SureForms DB schema-upgrade query fails.
638 + *
639 + * @since 2.11.0
640 + * @param string $last_error The DB error message ( $wpdb->last_error ).
641 + * @param string $query The ALTER query that failed.
642 + * @param string $table The table being altered.
643 + */
644 + do_action( 'srfm_db_upgrade_query_failed', $this->wpdb->last_error, $query, $this->get_tablename() );
426 645 }
427 646
428 647 return $result;
429 648 }
@@ -510,8 +729,12 @@
510 729 $format = $prepared_data['format'];
511 730 }
512 731
513 732 $result = $this->wpdb->insert( $this->get_tablename(), $prepared_data['data'], $format );
733 +
734 + // Reset cache so subsequent queries in the same request include the new row.
735 + $this->cache_reset();
736 +
514 737 return $result ? $this->wpdb->insert_id : false;
515 738 }
516 739
517 740 /**
@@ -605,10 +828,12 @@
605 828 // Add a semicolon at the end of the query.
606 829 $query = rtrim( trim( $query ), ';' ) . ';';
607 830
608 831 $cached_results = $this->cache_get( $query );
609 - if ( $cached_results ) {
610 - // Return the cached data if exists.
832 + if ( null !== $cached_results ) {
833 + // Return the cached data if exists. Tested against null rather than
834 + // truthiness: an empty result set is a real answer, and re-running the
835 + // query for it means every no-match lookup runs once per caller.
611 836 return Helper::get_array_value( $cached_results );
612 837 }
613 838
614 839 // phpcs:ignore
@@ -694,10 +919,12 @@
694 919 // Add a semicolon at the end of the query.
695 920 $query = rtrim( trim( $query ), ';' ) . ';';
696 921
697 922 $cached_results = $this->cache_get( $query );
698 - if ( $cached_results ) {
699 - // Return the cached data if exists.
923 + if ( null !== $cached_results ) {
924 + // Return the cached data if exists. Tested against null rather than
925 + // truthiness: a count of zero is a real answer, and the editor exclusion
926 + // makes zero the common case rather than the exception.
700 927 return Helper::get_integer_value( $cached_results );
701 928 }
702 929
703 930 // phpcs:ignore
@@ -707,8 +934,59 @@
707 934 return Helper::get_integer_value( $this->cache_set( $query, $results ) );
708 935 }
709 936
710 937 /**
938 + * The signature this plugin stamps on tables it owns on this site.
939 + *
940 + * A random per-site token, generated once and stored in options. Embedded in
941 + * the table's MySQL comment at creation time; the comment survives RENAME, so a
942 + * table that moved under a different prefix still carries it, while an unrelated
943 + * install sharing the same database carries a different one.
944 + *
945 + * @since 2.12.6
946 + * @return string
947 + */
948 + protected function get_owner_signature() {
949 + $token = get_option( 'srfm_db_owner_token' );
950 +
951 + if ( ! is_string( $token ) || '' === $token ) {
952 + $token = wp_generate_password( 20, false );
953 + update_option( 'srfm_db_owner_token', $token, false );
954 + }
955 +
956 + return 'srfm-owner:' . $token;
957 + }
958 +
959 + /**
960 + * Whether a table carries every column this table's schema declares.
961 + *
962 + * Guards adoption: a same-named table from an unrelated source should never be
963 + * renamed into place just because its name matches.
964 + *
965 + * @param string $table Full table name to inspect.
966 + * @since 2.12.6
967 + * @return bool
968 + */
969 + protected function has_expected_columns( $table ) {
970 + $wpdb = $this->wpdb;
971 +
972 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema lookup; a cached answer would defeat the check.
973 + $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %1s', str_replace( '`', '', $table ) ) ); // phpcs:ignore -- Same complex-placeholder pattern as create(): an identifier must not be quoted, and the name comes from SHOW TABLES on this connection.
974 +
975 + if ( ! empty( $wpdb->last_error ) || ! is_array( $columns ) ) {
976 + return false;
977 + }
978 +
979 + foreach ( array_keys( $this->get_schema() ) as $column ) {
980 + if ( ! in_array( $column, $columns, true ) ) {
981 + return false;
982 + }
983 + }
984 +
985 + return true;
986 + }
987 +
988 + /**
711 989 * Get the allowed column names for ORDER BY clauses.
712 990 * Child classes may override this method to restrict orderable columns further.
713 991 *
714 992 * @since 2.6.0
@@ -777,8 +1055,9 @@
777 1055 * @type string $RELATION Optional. The logical relation ('AND' or 'OR').
778 1056 * }
779 1057 * }
780 1058 *
1059 + * @since 2.12.7 -- Added support for "NOT IN" compare.
781 1060 * @since 1.1.1 -- Added support for "IN" compare.
782 1061 * @since 0.0.13
783 1062 * @return string The prepared SQL WHERE clause with placeholders, or an empty string if no clauses were provided.
784 1063 */
@@ -803,10 +1082,19 @@
803 1082 if ( is_int( $key ) ) {
804 1083 $clause_parts = [];
805 1084 foreach ( $value as $_key => $_value ) {
806 1085 if ( is_int( $_key ) ) {
1086 + // Normalised before the allowlist test. Payments'
1087 + // builder upper-cases and trims, this one compared
1088 + // strictly -- so a caller writing 'not in' was honoured
1089 + // by one and silently dropped by the other. A dropped
1090 + // condition used to be harmless; now that NOT IN is the
1091 + // exclusion primitive, dropping it disables the
1092 + // exclusion without a word.
1093 + $compare = strtoupper( trim( Helper::get_string_value( $_value['compare'] ) ) );
1094 +
807 1095 // Check if the operator is allowed.
808 - if ( ! in_array( $_value['compare'], $this->allowed_where_operators, true ) ) {
1096 + if ( ! in_array( $compare, $this->allowed_where_operators, true ) ) {
809 1097 continue;
810 1098 }
811 1099
812 1100 // Skip if key is not in schema.
@@ -813,23 +1101,58 @@
813 1101 if ( ! isset( $schema[ $_value['key'] ] ) ) {
814 1102 continue;
815 1103 }
816 1104
817 - switch ( $_value['compare'] ) {
1105 + switch ( $compare ) {
818 1106 case 'LIKE':
819 - $clause_parts[] = $_value['key'] . ' ' . $_value['compare'] . ' "%%' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) ) . '%%"';
1107 + // Single quotes to match WP core. Under a MySQL session with
1108 + // ANSI_QUOTES set (not in WP's incompatible_modes list, which
1109 + // only names the compound ANSI mode) a double-quoted pattern
1110 + // parses as an identifier and the query hard-fails, taking out
1111 + // both the listing and its COUNT(*).
1112 + $clause_parts[] = $_value['key'] . ' ' . $compare . " '%%" . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) ) . "%%'";
820 1113 $values[] = $_value['value'];
821 1114 break;
822 1115
823 1116 case 'IN':
1117 + case 'NOT IN':
1118 + // A scalar is a caller bug, not an empty set, and it must
1119 + // surface. 'NOT IN' with value 5 -- a plausible typo for
1120 + // [ 5 ] -- would otherwise drop the condition and exclude
1121 + // nobody, with no error and a green test suite, while the
1122 + // same typo on 'IN' fails closed. On a primitive whose only
1123 + // job is scoping data, that asymmetry is a hazard.
1124 + if ( ! is_array( $_value['value'] ) ) {
1125 + _doing_it_wrong(
1126 + __METHOD__,
1127 + esc_html( "{$compare} requires an array value, received " . gettype( $_value['value'] ) . '.' ),
1128 + '2.12.7'
1129 + );
1130 + break;
1131 + }
1132 +
1133 + // An empty list cannot be interpolated: "col IN ()" is a syntax
1134 + // error that fails the whole query, listing and COUNT alike.
1135 + // An empty IN matches nothing, so '1 = 0' says that in any
1136 + // relation. An empty NOT IN excludes nothing, but a literal
1137 + // would be '1 = 1', and that makes an enclosing OR group
1138 + // unconditionally true. Dropping the condition means the same
1139 + // thing under AND and stays fail-closed under OR.
1140 + if ( [] === $_value['value'] ) {
1141 + if ( 'IN' === $compare ) {
1142 + $clause_parts[] = '1 = 0';
1143 + }
1144 + break;
1145 + }
1146 +
824 1147 // Based on the number of values and datatype, it will create WHERE clause for $wpdb::prepare method. Eg: for ID with three values column: ID IN (%d, %d, %d).
825 1148 $datatype = $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) );
826 - $clause_parts[] = $_value['key'] . ' ' . $_value['compare'] . ' (' . implode( ', ', array_fill( 0, count( $_value['value'] ), $datatype ) ) . ')';
1149 + $clause_parts[] = $_value['key'] . ' ' . $compare . ' (' . implode( ', ', array_fill( 0, count( $_value['value'] ), $datatype ) ) . ')';
827 1150 $values = array_merge( $values, $_value['value'] );
828 1151 break;
829 1152
830 1153 default:
831 - $clause_parts[] = $_value['key'] . ' ' . $_value['compare'] . ' ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) );
1154 + $clause_parts[] = $_value['key'] . ' ' . $compare . ' ' . $this->get_format_by_datatype( Helper::get_string_value( $schema[ $_value['key'] ]['type'] ) );
832 1155 $values[] = $_value['value'];
833 1156 break;
834 1157 }
835 1158 }
@@ -854,8 +1177,16 @@
854 1177 return '';
855 1178 }
856 1179
857 1180 $where = ' WHERE ' . implode( ' AND ', $groups );
1181 +
1182 + if ( [] === $values ) {
1183 + // Every branch that builds a placeholder also pushes a value, so an
1184 + // empty list here means the only conditions were constant ones. There
1185 + // is nothing for prepare() to fill, and calling it with no placeholder
1186 + // trips _doing_it_wrong.
1187 + return $where;
1188 + }
858 1189
859 1190 // Prepare the query with placeholders.
860 1191 // @phpstan-ignore-next-line -- We are already assigning non-literal string above using "get_format_by_datatype" methods.
861 1192 return $wpdb->prepare( $where, ...$values ); // phpcs:ignore -- We are returning prepared sql query here. We are already using necessary placeholders in $where variable.