PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
← All changes | includes/Core/Database/Model.php +234 -40 2.03.3.1 View file →
@@ -9,11 +9,13 @@
9 9 /**
10 10 * Undocumented class
11 11 */
12 12
13 +use BitCode\BitForm\Core\Util\Log;
13 14 use WP_Error;
14 15
15 -class Model {
16 +class Model
17 +{
16 18 protected static $table;
17 19 protected static $primary_key;
18 20 protected $app_db;
19 21 protected $table_name;
@@ -21,9 +23,10 @@
21 23
22 24 /**
23 25 * Undocumented function
24 26 */
25 - public function __construct() {
27 + public function __construct()
28 + {
26 29 global $wpdb;
27 30 $this->app_db = $wpdb;
28 31 $this->table_name = $wpdb->prefix . static::$table;
29 32 }
@@ -28,15 +31,16 @@
28 31 $this->table_name = $wpdb->prefix . static::$table;
29 32 }
30 33
31 34 /**
32 - * Undocumented function
35 + * Insert a row
33 36 *
34 - * @return void
37 + * @return mixed insert id on success, WP_Error on failure
35 38 */
36 - public function insert($data = []) {
39 + public function insert($data = [])
40 + {
37 41 if (is_null($data)) {
38 - return new WP_Error('empty_data', __('Form data is empty', 'bit-form'));
42 + return new WP_Error('empty_data', 'Form data is empty');
39 43 }
40 44 $result = $this->app_db->insert(
41 45 $this->table_name,
42 46 $data
@@ -46,14 +50,26 @@
46 50
47 51 /**
48 52 * Undocumented function
49 53 *
50 - * @param string $item
51 - * @param array $condition
54 + * @param string|string[] $item
55 + * @param array $condition
52 56 *
53 57 * @return mixed
54 58 */
55 - public function get($item = '*', $condition = [], $limit = null, $offset = null, $order_by = null, $order_follow = null) {
59 + public function get($item = '*', $condition = [], $limit = null, $offset = null, $order_by = null, $order_follow = null)
60 + {
61 + static $tableExistsCache = [];
62 + if (!isset($tableExistsCache[$this->table_name])) {
63 + $tableExistsCache[$this->table_name] = !is_null(
64 + $this->app_db->get_var(
65 + $this->app_db->prepare('SHOW TABLES LIKE %s', $this->table_name)
66 + )
67 + );
68 + }
69 + if (!$tableExistsCache[$this->table_name]) {
70 + return [];
71 + }
56 72 if (\is_array($item)) {
57 73 $column_to_select = implode(',', $item);
58 74 } else {
59 75 $column_to_select = $item;
@@ -64,9 +80,19 @@
64 80 }
65 81 $order = null;
66 82 if (!\is_null($order_by)) {
67 83 $order_follow = \is_null($order_follow) ? 'ASC' : $order_follow;
68 - $order .= " ORDER BY $order_by $order_follow";
84 + $direction = \is_string($order_follow) ? strtoupper(trim($order_follow)) : '';
85 + if ($this->isSafeConditionIdentifier($order_by) && \in_array($direction, ['ASC', 'DESC'], true)) {
86 + $order .= ' ORDER BY ' . $this->quoteIdentifier($order_by) . ' ' . $direction;
87 + } else {
88 + Log::debug_log([
89 + 'message' => 'Model::get() ignored an unsafe ORDER BY',
90 + 'table' => $this->table_name,
91 + 'order_by' => $order_by,
92 + 'order_follow' => $order_follow,
93 + ]);
94 + }
69 95 }
70 96 $paginate = null;
71 97 if (!\is_null($limit)) {
72 98 $limit = \intval($limit);
@@ -101,9 +127,10 @@
101 127 * @param array $condition
102 128 *
103 129 * @return void
104 130 */
105 - public function count($condition = null) {
131 + public function count($condition = null)
132 + {
106 133 $checkCondition = $this->checkCondition($condition);
107 134 if (is_wp_error($checkCondition)) {
108 135 return $checkCondition;
109 136 }
@@ -121,9 +148,9 @@
121 148 $all_values = null;
122 149 }
123 150 $result = $this->app_db->query(
124 151 $this->app_db->prepare(
125 - "SELECT COUNT(*) as count FROM `$this->table_name`"
152 + "SELECT COUNT(*) as count FROM `{$this->table_name}`"
126 153 . $condition_to_check,
127 154 $all_values
128 155 )
129 156 );
@@ -131,9 +158,9 @@
131 158 if (!$result) {
132 159 if ($this->app_db->last_error) {
133 160 return new WP_Error('db_error', $this->app_db->last_error);
134 161 }
135 - return new WP_Error('db_error', __('Result is empty', 'bit-form'));
162 + return new WP_Error('db_error', 'Result is empty');
136 163 } else {
137 164 return $this->app_db->last_result;
138 165 }
139 166 }
@@ -143,11 +170,12 @@
143 170 *
144 171 * @param array $data_to_update
145 172 * @param array $condition
146 173 *
147 - * @return void
174 + * @return mixed affected-row count on success, WP_Error on failure or when no row matched
148 175 */
149 - public function update(array $data, array $condition) {
176 + public function update(array $data, array $condition)
177 + {
150 178 if (
151 179 !\is_null($data)
152 180 && \is_array($data)
153 181 && array_keys($data) !== range(0, count($data) - 1)
@@ -155,9 +183,9 @@
155 183 $data_to_update = $data;
156 184 } else {
157 185 return new WP_Error(
158 186 'update_error',
159 - __('Nothing to update', 'bit-form')
187 + 'Nothing to update'
160 188 );
161 189 }
162 190 $update_condition = (!\is_null($condition) &&
163 191 array_keys($condition) !== range(0, count($condition) - 1)) ? $condition : null;
@@ -176,9 +204,10 @@
176 204 * @param array $condition
177 205 *
178 206 * @return void
179 207 */
180 - public function bulkUpdate(array $data = null, array $condition = null) {
208 + public function bulkUpdate(array $data = null, array $condition = null)
209 + {
181 210 if (
182 211 !\is_null($data)
183 212 && \is_array($data)
184 213 && array_keys($data) !== range(0, count($data) - 1)
@@ -186,9 +215,9 @@
186 215 $data_to_update = $data;
187 216 } else {
188 217 return new WP_Error(
189 218 'update_error',
190 - __('Nothing to update', 'bit-form')
219 + 'Nothing to update'
191 220 );
192 221 }
193 222
194 223 $update_fields = '';
@@ -213,9 +242,9 @@
213 242 $condition_to_check = null;
214 243 }
215 244 $result = $this->app_db->query(
216 245 $this->app_db->prepare(
217 - "UPDATE $this->table_name SET $update_fields $condition_to_check",
246 + "UPDATE `{$this->table_name}` SET $update_fields $condition_to_check",
218 247 $all_values
219 248 )
220 249 );
221 250 return $this->getResult($result);
@@ -228,9 +257,10 @@
228 257 * @param array $condition
229 258 *
230 259 * @return void
231 260 */
232 - public function duplicate(array $columns, array $duplicate, array $condition) {
261 + public function duplicate(array $columns, array $duplicate, array $condition)
262 + {
233 263 if (!(!\is_null($columns)
234 264 && \is_array($columns)
235 265 && array_keys($columns) === range(0, count($columns) - 1)
236 266 && !\is_null($duplicate)
@@ -237,9 +267,9 @@
237 267 && \is_array($duplicate)
238 268 && array_keys($duplicate) === range(0, count($duplicate) - 1))) {
239 269 return new WP_Error(
240 270 'duplicate_error',
241 - __('Nothing to duplicate', 'bit-form')
271 + 'Nothing to duplicate'
242 272 );
243 273 }
244 274
245 275 $dupCol = '';
@@ -264,15 +294,16 @@
264 294 if ($formatted_conditions) {
265 295 $condition_to_check = $formatted_conditions['conditions'];
266 296 $all_values = array_merge($all_values, $formatted_conditions['values']);
267 297 }
268 - $query = "INSERT INTO $this->table_name ($insCol)
269 - SELECT $dupCol FROM $this->table_name $condition_to_check";
298 + $query = "INSERT INTO `{$this->table_name}` ($insCol)
299 + SELECT $dupCol FROM `{$this->table_name}` $condition_to_check";
270 300 $this->execute($query, $all_values);
271 301 return $this->getResult();
272 302 }
273 303
274 - public function trash(array $condition = null) {
304 + public function trash(array $condition = null)
305 + {
275 306 if (
276 307 !\is_null($condition)
277 308 && \is_array($condition)
278 309 && array_keys($condition) !== range(0, count($condition) - 1)
@@ -280,9 +311,9 @@
280 311 $delete_condition = $condition;
281 312 } else {
282 313 return new WP_Error(
283 314 'deletion_error',
284 - __('At least 1 condition needed', 'bit-form')
315 + 'At least 1 condition needed'
285 316 );
286 317 }
287 318 $update_condition = (!\is_null($condition) &&
288 319 array_keys($condition) !== range(0, count($condition) - 1)) ? $condition : null;
@@ -293,9 +324,10 @@
293 324 );
294 325 return $this->getResult($result);
295 326 }
296 327
297 - public function delete(array $condition = null) {
328 + public function delete(array $condition = null)
329 + {
298 330 if (
299 331 !\is_null($condition)
300 332 && \is_array($condition)
301 333 && array_keys($condition) !== range(0, count($condition) - 1)
@@ -303,9 +335,9 @@
303 335 $delete_condition = $condition;
304 336 } else {
305 337 return new WP_Error(
306 338 'deletion_error',
307 - __('At least 1 condition needed', 'bit-form')
339 + 'At least 1 condition needed'
308 340 );
309 341 }
310 342 $result = $this->app_db->delete(
311 343 $this->table_name,
@@ -313,9 +345,10 @@
313 345 );
314 346 return $this->getResult($result);
315 347 }
316 348
317 - public function bulkDelete(array $condition = null) {
349 + public function bulkDelete(array $condition = null)
350 + {
318 351 if (
319 352 !\is_null($condition)
320 353 && \is_array($condition)
321 354 && array_keys($condition) !== range(0, count($condition) - 1)
@@ -323,9 +356,9 @@
323 356 $delete_condition = $condition;
324 357 } else {
325 358 return new WP_Error(
326 359 'deletion_error',
327 - __('At least 1 condition needed', 'bit-form')
360 + 'At least 1 condition needed'
328 361 );
329 362 }
330 363 // $formatted_conditions = $this->getFormatedCondition($delete_condition, $check_operator);
331 364 $formatted_conditions = $this->getFormatedCondition($delete_condition);
@@ -335,14 +368,14 @@
335 368 } else {
336 369 $condition_to_check = null;
337 370 return new WP_Error(
338 371 'deletion_error',
339 - __('At least 1 condition needed', 'bit-form')
372 + 'At least 1 condition needed'
340 373 );
341 374 }
342 375 $result = $this->app_db->query(
343 376 $this->app_db->prepare(
344 - "DELETE FROM $this->table_name $condition_to_check",
377 + "DELETE FROM `{$this->table_name}` $condition_to_check",
345 378 $all_values
346 379 )
347 380 );
348 381 return $this->getResult($result);
@@ -347,14 +380,96 @@
347 380 );
348 381 return $this->getResult($result);
349 382 }
350 383
351 - protected function getFieldFormat($value) {
384 + protected function getFieldFormat($value)
385 + {
352 386 return ('integer' === gettype($value)) ?
353 387 '%d' : (('double' === gettype($value)) ? '%f' : '%s');
354 388 }
355 389
356 - protected function getFormatedCondition($condition, $check_operator = null, $join_operator = ' AND ') {
390 + /**
391 + *
392 + * @param mixed $identifier
393 + *
394 + * @return bool
395 + */
396 + protected function isSafeConditionIdentifier($identifier)
397 + {
398 + if (!\is_string($identifier)) {
399 + return false;
400 + }
401 + $identifier = trim($identifier);
402 + if ('' === $identifier) {
403 + return false;
404 + }
405 +
406 + // A condition column may be table-qualified and backtick-quoted — the multi-table JOIN DELETE
407 + // in FormEntryModel::bulkDelete() *must* pass `wp_bitforms_form_entries`.`id`, because a bare
408 + // `id` is ambiguous across the two joined tables. Validate each segment on its own.
409 + $parts = explode('.', $identifier);
410 + if (count($parts) > 2) {
411 + return false;
412 + }
413 + foreach ($parts as $part) {
414 + $part = trim($part);
415 + if (\strlen($part) > 1 && '`' === $part[0] && '`' === substr($part, -1)) {
416 + $part = substr($part, 1, -1);
417 + }
418 + if (1 !== preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $part)) {
419 + return false;
420 + }
421 + }
422 +
423 + return true;
424 + }
425 +
426 + /**
427 + * Backtick-quote a validated identifier, leaving an already-quoted or table-qualified one alone.
428 + * Only ever call this on a value isSafeConditionIdentifier() has approved.
429 + *
430 + * @param string $identifier
431 + *
432 + * @return string
433 + */
434 + protected function quoteIdentifier($identifier)
435 + {
436 + $identifier = trim($identifier);
437 + if (false !== strpos($identifier, '`') || false !== strpos($identifier, '.')) {
438 + return $identifier;
439 + }
440 +
441 + return '`' . $identifier . '`';
442 + }
443 +
444 + /**
445 + * @param mixed $operator
446 + *
447 + * @return bool
448 + */
449 + protected function isSafeConditionOperator($operator)
450 + {
451 + static $allowed = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS', 'IS NOT'];
452 +
453 + return \is_string($operator) && \in_array(strtoupper(trim($operator)), $allowed, true);
454 + }
455 +
456 + /**
457 + * A WHERE that matches nothing. Keeps a placeholder so the caller's
458 + * $wpdb->prepare($sql, $values) still has something to bind.
459 + *
460 + * @return array
461 + */
462 + private function impossibleCondition()
463 + {
464 + return [
465 + 'conditions' => ' WHERE 1=%d ',
466 + 'values' => [0],
467 + ];
468 + }
469 +
470 + protected function getFormatedCondition($condition, $check_operator = null, $join_operator = ' AND ')
471 + {
357 472 if (\is_null($condition)) {
358 473 return false;
359 474 }
360 475 $no_condition = count($condition);
@@ -361,15 +476,28 @@
361 476 $index_checker = 0;
362 477 $condition_to_check = ' WHERE ';
363 478 $all_values = [];
364 479 foreach ($condition as $key => $value) {
480 + if (!$this->isSafeConditionIdentifier($key)) {
481 + return $this->impossibleCondition();
482 + }
365 483 $value_type = '';
366 484 if (is_array($value)) {
367 - if (isset($value['operator'])) {
485 + // Check for raw SQL values first
486 + if (isset($value['raw'])) {
487 + if (!\is_string($value['raw'])) {
488 + return $this->impossibleCondition();
489 + }
490 + $set_check_operator = isset($value['operator']) ? $value['operator'] : '=';
491 + $value_type = $value['raw']; // Use raw SQL directly
492 + // Don't add to $all_values since it's raw SQL
493 + } elseif (isset($value['operator'])) {
494 + // logic for operator arrays
368 495 $set_check_operator = $value['operator'];
369 496 $value_type .= $this->getFieldFormat($value['value']);
370 497 $all_values[] = $value['value'];
371 498 } else {
499 + // logic for IN conditions
372 500 $set_check_operator = \is_null($check_operator) ? 'in' : $check_operator;
373 501 $value_type .= ' ( ';
374 502 $value_index_checker = 0;
375 503 $value_count = count($value) - 1;
@@ -383,12 +511,16 @@
383 511 }
384 512 $value_type .= ' )';
385 513 }
386 514 } else {
515 + // logic for simple values
387 516 $set_check_operator = \is_null($check_operator) ? '=' : $check_operator;
388 517 $value_type .= $this->getFieldFormat($value);
389 518 $all_values[] = $value;
390 519 }
520 + if (!$this->isSafeConditionOperator($set_check_operator)) {
521 + return $this->impossibleCondition();
522 + }
391 523 $condition_to_check = $condition_to_check . $key . " $set_check_operator " . $value_type;
392 524 if ($index_checker < $no_condition - 1) {
393 525 $condition_to_check = $condition_to_check . " $join_operator ";
394 526 }
@@ -399,9 +531,26 @@
399 531 'values' => $all_values
400 532 ];
401 533 }
402 534
403 - protected function checkCondition(array $condition) {
535 + /**
536 + * @param array $values values about to be bound by $wpdb->prepare()
537 + *
538 + * @return string|null the offending PHP type, or null when every value is bindable
539 + */
540 + private function findUnbindableValue(array $values)
541 + {
542 + foreach ($values as $value) {
543 + if (!is_scalar($value) && !is_null($value)) {
544 + return \gettype($value);
545 + }
546 + }
547 +
548 + return null;
549 + }
550 +
551 + protected function checkCondition(array $condition)
552 + {
404 553 if (!is_null($condition) && array_keys($condition) === range(0, count($condition) - 1)) {
405 554 return new WP_Error(
406 555 'get_condition',
407 556 'Require ASSOC_ARRAY but found N_ARRAY'
@@ -409,17 +558,33 @@
409 558 }
410 559 return true;
411 560 }
412 561
413 - protected function execute($sql, $values = null) {
562 + protected function execute($sql, $values = null)
563 + {
564 + // Clear the previous call's outcome before running a new query, so a failure can never be
565 + // read back by whatever this instance is used for next.
566 + $this->db_response = null;
414 567 if (is_null($values)) {
415 568 $preparedQuery = $sql;
416 569 } else {
570 + $invalid = $this->findUnbindableValue((array) $values);
571 + if (null !== $invalid) {
572 + Log::debug_log([
573 + 'message' => 'Model::execute() received an unbindable condition value',
574 + 'table' => $this->table_name,
575 + 'type' => $invalid,
576 + 'sql' => $sql,
577 + ]);
578 + $this->db_response = new WP_Error('invalid_query_value', 'Query value must be scalar, ' . $invalid . ' given');
579 +
580 + return $this;
581 + }
417 582 $preparedQuery = $this->app_db->prepare($sql, $values);
418 583 }
419 584 // echo " Q S " . $preparedQuery . " Q EE";
420 585 if (empty($preparedQuery)) {
421 - $this->db_response = new WP_Error('null_query', __('prepared query is empty', 'bit-form'));
586 + $this->db_response = new WP_Error('null_query', 'prepared query is empty');
422 587 } else {
423 588 $this->db_response = false !== stripos($preparedQuery, 'DELETE') ? $this->app_db->query($preparedQuery)
424 589 : $this->app_db->get_results($preparedQuery, OBJECT_K);
425 590 }
@@ -426,10 +591,23 @@
426 591 // print_r($this->app_db->last_query);
427 592 return $this;
428 593 }
429 594
430 - protected function getResult($db_response = null) {
431 - $db_response = !empty($this->db_response) ? $this->db_response : $db_response;
595 + protected function getResult($db_response = null)
596 + {
597 + // The caller's own result wins. $db_response is an instance property that only execute()
598 + // writes, and models are reused (AdminFormHandler keeps a static FormModel for the whole
599 + // request), so letting the property override an explicitly passed result made insert() and
600 + // update() report the outcome of some earlier, unrelated query on the same object.
601 + // Without this fallback, execute()->getResult() (which passes no argument) never sees the
602 + // query it just ran and every read returns 'result_empty'.
603 + if (null === $db_response) {
604 + $db_response = $this->db_response;
605 + }
606 +
607 + if (is_wp_error($db_response)) {
608 + return $db_response;
609 + }
432 610 if (!empty($this->app_db->last_error)) {
433 611 return new WP_Error('db_error', $this->app_db->last_error);
434 612 }
435 613 if (!$db_response) {
@@ -438,9 +616,9 @@
438 616 }
439 617 if (is_wp_error($db_response)) {
440 618 $response = $db_response;
441 619 }
442 - $response = new WP_Error('result_empty', __('Result is empty', 'bit-form'));
620 + $response = new WP_Error('result_empty', 'Result is empty');
443 621 } elseif (is_array($this->app_db->last_result) && !empty($this->app_db->last_result)) {
444 622 $response = $this->app_db->last_result;
445 623 } elseif ($this->app_db->insert_id) {
446 624 $response = $this->app_db->insert_id;
@@ -448,6 +626,22 @@
448 626 $response = $db_response;
449 627 }
450 628 $this->app_db->flush();
451 629 return $response;
630 + }
631 +
632 + /**
633 + * Get last inserted id
634 + *
635 + * @return int
636 + */
637 + public function lastId()
638 + {
639 + $sql = "SELECT id FROM `{$this->table_name}`
640 + ORDER BY id DESC LIMIT 1";
641 + $result = $this->execute($sql)->getResult();
642 + if (is_wp_error($result)) {
643 + return 0;
644 + }
645 + return $result[0]->id;
452 646 }
453 647 }