Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/bit-form/bitforms.php
+++ b/bit-form/bitforms.php
@@ -4,7 +4,7 @@
* Plugin Name: Bit Form
* Plugin URI: https://www.bitapps.pro/bit-form
* Description: Contact Form Builder Plugin: Multi Step Contact Form, Payment Form, Custom Contact Form Plugin by Bit Form
- * Version: 3.2.0
+ * Version: 3.2.1
* Author: Contact Form Builder - Bit Form
* Author URI: https://www.bitapps.pro
* Text Domain: bit-form
@@ -22,9 +22,9 @@
}
// Define most essential constants.
-define('BITFORMS_VERSION', '3.2.0');
+define('BITFORMS_VERSION', '3.2.1');
define('BITFORMS_PLUGIN_MAIN_FILE', __FILE__);
-define('BITFORMS_REQUIRED_BITFORMPRO_VERSION', '3.2.0');
+define('BITFORMS_REQUIRED_BITFORMPRO_VERSION', '3.2.1');
global $bitforms_db_version;
$bitforms_db_version = '3.2';
@@ -105,7 +105,21 @@
return;
}
- $update_url = esc_url(admin_url('plugins.php?plugin_status=upgrade'));
+ // "Update now" triggers WordPress core's native plugin-update flow (Plugin_Upgrader:
+ // maintenance mode, filesystem creds, rollback UI) for users who can update plugins.
+ // Others fall back to the filtered Plugins screen. The update itself is staged into the
+ // update_plugins transient by Bit Form Pro's own Updater.
+ $pro_plugin_file = 'bitformpro/bitformpro.php';
+ if (current_user_can('update_plugins')) {
+ $update_url = wp_nonce_url(
+ self_admin_url('update.php?action=upgrade-plugin&plugin=' . $pro_plugin_file),
+ 'upgrade-plugin_' . $pro_plugin_file
+ );
+ } else {
+ // Users who cannot update plugins: send them to the Updates screen with a forced
+ // refresh so Bit Form Pro's Updater re-checks and stages the update for detection.
+ $update_url = self_admin_url('update-core.php?force-check=1');
+ }
$required = esc_html(BITFORMS_REQUIRED_BITFORMPRO_VERSION);
// Keep HTML out of translatable strings to prevent translator HTML injection
@@ -115,7 +129,7 @@
esc_html__('requires an update to version %s or higher for full compatibility.', 'bit-form'),
'<strong>' . $required . '</strong>'
)
- . ' <a href="' . $update_url . '">' . esc_html__('Update now', 'bit-form') . '</a>';
+ . ' <a href="' . esc_url($update_url) . '">' . esc_html__('Update now', 'bit-form') . '</a>';
wp_admin_notice(
$message,
--- a/bit-form/includes/Admin/AdminAjax.php
+++ b/bit-form/includes/Admin/AdminAjax.php
@@ -2007,7 +2007,7 @@
{
if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
$this->verifyAdminPermission();
- $data = get_option('bitform_app_config', (object) ['cache_plugin' => 0, 'delete_table' => 0]);
+ $data = get_option('bitform_app_config', (object) ['cache_plugin' => true, 'delete_table' => 0]);
if (is_wp_error($data)) {
wp_send_json_error($data->get_error_message(), 411);
--- a/bit-form/includes/Admin/Form/AdminFormHandler.php
+++ b/bit-form/includes/Admin/Form/AdminFormHandler.php
@@ -665,7 +665,12 @@
unset($templateDetail->clRef);
}
} else {
- $emailTemplateHandler->updateTemplate($templateDetail);
+ $updated = $emailTemplateHandler->updateTemplate($templateDetail);
+ if (is_wp_error($updated) && 'db_error' === $updated->get_error_code()) {
+ $newData['mailTemplate'] = 2;
+ } elseif (0 === $newData['mailTemplate']) {
+ $newData['mailTemplate'] = 1;
+ }
}
}
}
@@ -812,7 +817,11 @@
$fieldNames['__updated_at'] = __('Modified Time', 'bit-form');
$reportIsDefault = null;
- if (isset($form_content['report_id'])) {
+ // The client posts currentReport as {} whenever its report atom has not resolved yet. That
+ // is an empty stdClass, which empty() above treats as non-empty, so we reach here with
+ // nothing to save — and writing the validated result would replace the stored report's
+ // column order, hidden columns, page size and name with an empty list. Leave the row alone.
+ if (isset($form_content['report_id']) && ReportsModel::isValidatableReport($reports)) {
$validDateReport = $reportsModel->validateReportFields($reports, $fieldNames);
if (isset($reports->isDefault)) {
$reportIsDefault = $reports->isDefault;
@@ -1113,7 +1122,7 @@
'formInfo' => !empty($form_content->formInfo) ? $form_content->formInfo : (object) ['formName' => $formManager->getFormName()],
'fields' => $form_content->fields,
'form_name' => $formManager->getFormName(),
- 'workFlowExist' => $form_content->workFlowExist,
+ 'workFlowExist' => isset($form_content->workFlowExist) ? $form_content->workFlowExist : [],
'report_id' => isset($form_content->report_id) ? $form_content->report_id : null
];
$successMessageHandler
@@ -1411,7 +1420,7 @@
'layout' => $form_content->layout,
'fields' => $form_content->fields,
'form_name' => $formManager->getFormName(),
- 'workFlowExist' => $form_content->workFlowExist,
+ 'workFlowExist' => isset($form_content->workFlowExist) ? $form_content->workFlowExist : [],
'report_id' => isset($form_content->report_id) ? $form_content->report_id : null
];
$successMessageHandler
@@ -2020,6 +2029,63 @@
return $response;
}
+ /**
+ * Sanitize a request-supplied entry query condition before it reaches the Model
+ * layer. Only known entry-table columns are allowed as condition keys, operators
+ * are restricted to a safe allow-list ['form_id' => $id] when nothing valid remains.
+ *
+ * @param mixed $queryCondition
+ * @param int $id
+ * @return array
+ */
+ private function sanitizeEntryQueryCondition($queryCondition, $id)
+ {
+ $allowedColumns = [
+ 'id', 'form_id', 'status', 'created_at', 'updated_at',
+ 'user_id', 'user_ip', 'user_device', 'user_location', 'referer',
+ ];
+ $allowedOperators = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'IN'];
+
+ if (!is_array($queryCondition)) {
+ return ['form_id' => $id];
+ }
+
+ $safe = [];
+ foreach ($queryCondition as $key => $value) {
+ if (!in_array($key, $allowedColumns, true)) {
+ continue;
+ }
+
+ if (is_array($value)) {
+ // Never honour a request-supplied raw SQL fragment.
+ unset($value['raw']);
+
+ if (array_key_exists('operator', $value) || array_key_exists('value', $value)) {
+ $operator = isset($value['operator']) && is_string($value['operator'])
+ ? strtoupper(trim($value['operator']))
+ : '=';
+ if (!in_array($operator, $allowedOperators, true)) {
+ $operator = '=';
+ }
+ $condValue = isset($value['value']) ? $value['value'] : '';
+ if (is_scalar($condValue)) {
+ $safe[$key] = ['operator' => $operator, 'value' => $condValue];
+ }
+ } else {
+ // IN-style list: keep scalar members only (bound by the Model).
+ $list = array_values(array_filter($value, 'is_scalar'));
+ if (!empty($list)) {
+ $safe[$key] = $list;
+ }
+ }
+ } elseif (is_scalar($value)) {
+ $safe[$key] = $value;
+ }
+ }
+
+ return empty($safe) ? ['form_id' => $id] : $safe;
+ }
+
public function getFormEntry($Request, $post)
{
if (!empty($Request['id'])) {
@@ -2041,6 +2107,7 @@
wp_unslash($post->pageSize) : 10;
$queryCondition = isset($post->queryCondition) ? wp_unslash($post->queryCondition) : ['form_id' => $id];
}
+ $queryCondition = $this->sanitizeEntryQueryCondition($queryCondition, $id);
if (is_null($id)) {
return new WP_Error('empty_form', __('Form id is empty.', 'bit-form'));
}
--- a/bit-form/includes/Admin/Form/FrontEndScriptGenerator.php
+++ b/bit-form/includes/Admin/Form/FrontEndScriptGenerator.php
@@ -559,7 +559,13 @@
private function jsHiddenFieldScript()
{
$appConfig = get_option('bitform_app_config');
- if (Helpers::property_exists_nested($appConfig, 'cache_plugin', true)) {
+ $cacheTokenEnabled = true;
+ if (is_object($appConfig) && property_exists($appConfig, 'cache_plugin')) {
+ $cacheTokenEnabled = (bool) $appConfig->cache_plugin;
+ } elseif (is_array($appConfig) && array_key_exists('cache_plugin', $appConfig)) {
+ $cacheTokenEnabled = (bool) $appConfig['cache_plugin'];
+ }
+ if ($cacheTokenEnabled) {
$fileArr = ScriptFilePriorityManager::frontendScriptFile()['hidden-token-field'];
$this->addScriptInLoadedScriptsList($fileArr);
return;
--- a/bit-form/includes/Admin/Form/Template/TemplateProvider.php
+++ b/bit-form/includes/Admin/Form/Template/TemplateProvider.php
@@ -48,7 +48,7 @@
* Undocumented function
*
* @param String $name
- * @return void
+ * @return array|false
*/
protected function setTemplate($name = 'Contact Form', $newFormId)
{
@@ -71,7 +71,7 @@
/**
* This function helps to get TEMPLATE
*
- * @return bool setTemplate()
+ * @return array|false
*/
public function getTemplate($name, $newFormId)
{
--- a/bit-form/includes/Core/Api/BitFormPublicApi.php
+++ b/bit-form/includes/Core/Api/BitFormPublicApi.php
@@ -0,0 +1,225 @@
+<?php
+
+/**
+ * Public helper API for sibling Bit Apps plugins (currently consumed by Bit CRM).
+ *
+ * Exposed through the `bitform/api/*` filters registered in CoreHooksHooks -
+ * consumers should call apply_filters() with a null default instead of touching
+ * this class directly, so an absent or older Bit Form can never fatal them.
+ */
+
+namespace BitCodeBitFormCoreApi;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use BitCodeBitFormAdminFormAdminFormHandler;
+use BitCodeBitFormAdminFormAdminFormManager;
+use WP_Error;
+
+final class BitFormPublicApi
+{
+ public const CRM_INTEGRATION_TYPE = 'Bit CRM';
+
+ /**
+ * Forms that have a Bit CRM integration attached
+ *
+ * @return array[]|WP_Error items: {formId, formName, shortcode, createdAt, entriesCount,
+ * formStatus, integrationId, integrationStatus, urls}
+ */
+ public static function getCrmIntegratedForms()
+ {
+ $permission = self::guard();
+ if (is_wp_error($permission)) {
+ return $permission;
+ }
+
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT forms.id, forms.form_name, forms.status as form_status, forms.created_at,
+ integ.id as integration_id, integ.status as integration_status,
+ COUNT(entries.id) as entries_count
+ FROM `{$wpdb->prefix}bitforms_integration` as integ
+ INNER JOIN `{$wpdb->prefix}bitforms_form` as forms ON forms.id = integ.form_id
+ LEFT JOIN `{$wpdb->prefix}bitforms_form_entries` as entries ON forms.id = entries.form_id
+ WHERE integ.integration_type = %s AND integ.category = %s
+ GROUP BY forms.id, forms.form_name, forms.status, forms.created_at, integ.id, integ.status",
+ self::CRM_INTEGRATION_TYPE,
+ 'form'
+ )
+ );
+
+ if (is_null($rows)) {
+ return [];
+ }
+
+ $forms = [];
+ foreach ($rows as $row) {
+ $formId = (int) $row->id;
+ $forms[] = [
+ 'formId' => $formId,
+ 'formName' => $row->form_name,
+ 'shortcode' => '[bitform id="' . $formId . '"]',
+ 'createdAt' => $row->created_at,
+ 'entriesCount' => (int) $row->entries_count,
+ 'formStatus' => (int) $row->form_status,
+ 'integrationId' => (int) $row->integration_id,
+ 'integrationStatus' => (int) $row->integration_status,
+ 'urls' => self::buildUrls($formId),
+ ];
+ }
+ return $forms;
+ }
+
+ /**
+ * Publish/unpublish a form (controls the form list toggle in Bit CRM)
+ *
+ * @param int $formId form id
+ * @param int $status 1 published, 0 unpublished
+ *
+ * @return true|WP_Error
+ */
+ public static function toggleFormStatus($formId, $status)
+ {
+ $permission = self::guard();
+ if (is_wp_error($permission)) {
+ return $permission;
+ }
+
+ $formId = absint($formId);
+ $status = absint($status) ? 1 : 0;
+ if (empty($formId)) {
+ return new WP_Error('bitform_invalid_args', __('Form id is required', 'bit-form'));
+ }
+ if (!(new AdminFormManager($formId))->isExist()) {
+ return new WP_Error('bitform_not_found', __('Form not found', 'bit-form'));
+ }
+
+ // reuse the exact form-status handler the admin Form List uses (user tracking + validation)
+ $result = (new AdminFormHandler())->changeFormStatus(
+ ['id' => $formId, 'status' => $status ? 'true' : 'false'],
+ (object) []
+ );
+ if (is_wp_error($result)) {
+ // a 0-row update means the form already has the requested status - not a failure
+ if ('result_empty' === $result->get_error_code()) {
+ return true;
+ }
+ return $result;
+ }
+ if (!$result) {
+ return new WP_Error('bitform_status_failed', __('Failed to change form status', 'bit-form'));
+ }
+ return true;
+ }
+
+ /**
+ * Build the URL that opens the Bit Form builder to create a CRM-integrated form.
+ *
+ * A valid form cannot be built server-side: the builder's theme/style state
+ * (themeVars/themeColors/style) is JCOF-encoded output of the React themeProvider,
+ * generated in the browser from the template's JS data. So instead of inserting a
+ * half-built row (which opens blank), we return a URL that drives the exact same
+ * client flow as the template gallery's "Use Template" button. Bit CRM opens it in
+ * a new tab; the builder loads the template, sets the title, attaches the Bit CRM
+ * integration, and auto-saves. If a same-origin returnUrl is given, the tab then
+ * navigates back to Bit CRM; otherwise the user stays in the editor on the new form.
+ *
+ * The flow is integration-agnostic. To auto-attach an integration to the new form,
+ * pass an `integration` descriptor; Bit CRM sends {type: 'Bit CRM', config: {tagIds,
+ * newTagTitles}}. Any plugin can drive the same flow with its own integration type.
+ *
+ * $args:
+ * - title string required, max 50 chars
+ * - templateSlug string optional frontend template slug, default 'contact_form'
+ * - integration array optional {type: string, name?: string, config?: array}
+ * - returnUrl string optional same-origin URL to return to after creation
+ * - closeAfterCreate bool optional close the tab after save (needs a script-opened
+ * tab, i.e. window.open); falls back to returnUrl if blocked
+ *
+ * @return array|WP_Error {createUrl}
+ */
+ public static function getCreateFormUrl($args)
+ {
+ $permission = self::guard();
+ if (is_wp_error($permission)) {
+ return $permission;
+ }
+
+ $args = (array) $args;
+ $title = isset($args['title']) ? sanitize_text_field($args['title']) : '';
+ if ('' === $title) {
+ return new WP_Error('bitform_invalid_args', __('Form title is required', 'bit-form'));
+ }
+ if (strlen($title) > 50) {
+ $title = substr($title, 0, 50);
+ }
+ $slug = empty($args['templateSlug']) ? 'contact_form' : sanitize_text_field($args['templateSlug']);
+
+ // only allow a same-site returnUrl - never an open redirect to another host
+ $returnUrl = '';
+ if (!empty($args['returnUrl'])) {
+ $candidate = esc_url_raw($args['returnUrl']);
+ if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(site_url(), PHP_URL_HOST)) {
+ $returnUrl = $candidate;
+ }
+ }
+
+ $integration = '';
+ if (!empty($args['integration']) && !empty($args['integration']['type'])) {
+ $integration = wp_json_encode($args['integration']);
+ }
+
+ $query = array_filter(
+ [
+ 'title' => $title,
+ 'template' => $slug,
+ 'integration' => $integration,
+ 'returnUrl' => $returnUrl,
+ 'closeAfterCreate' => empty($args['closeAfterCreate']) ? '' : '1',
+ ],
+ static function ($value) {
+ return '' !== $value;
+ }
+ );
+
+ // hash route consumed by the React ExternalFormCreate entry (see frontend-dev App.jsx)
+ $createUrl = admin_url('admin.php?page=bitform') . '#/create-form?' . http_build_query($query);
+
+ return ['createUrl' => $createUrl];
+ }
+
+ /**
+ * Admin URLs of a form inside the Bit Form SPA
+ *
+ * @param int $formId
+ *
+ * @return array
+ */
+ public static function buildUrls($formId)
+ {
+ $formId = absint($formId);
+ return [
+ 'editForm' => admin_url('admin.php?page=bitform#/form/builder/edit/' . $formId . '/add-fields'),
+ // per-integration deep link is index-based in the SPA, so link to the list
+ 'editIntegration' => admin_url('admin.php?page=bitform#/form/settings/edit/' . $formId . '/integrations'),
+ 'viewEntries' => admin_url('admin.php?page=bitform#/form/responses/edit/' . $formId . '/'),
+ 'preview' => site_url('/?bitform-form-view=' . $formId),
+ ];
+ }
+
+ /**
+ * Capability gate: never trust the caller's (e.g. Bit CRM REST) context
+ *
+ * @return true|WP_Error
+ */
+ private static function guard()
+ {
+ if (!current_user_can('manage_bitform') && !current_user_can('manage_options')) {
+ return new WP_Error('bitform_forbidden', __('Insufficient permissions.', 'bit-form'));
+ }
+ return true;
+ }
+}
--- a/bit-form/includes/Core/Cryptography/SodiumCompat.php
+++ b/bit-form/includes/Core/Cryptography/SodiumCompat.php
@@ -9,10 +9,18 @@
{
public function __construct()
{
- if (!class_exists('ParagonIE_Sodium_Compat')) {
- require_once ABSPATH . WPINC . '/sodium_compat/autoload.php';
- Log::debug_log(class_exists('ParagonIE_Sodium_Compat') ? 'Found' : 'ParagonIESodiumCompat not found');
+ if (class_exists('ParagonIE_Sodium_Compat')) {
+ return;
}
+
+ $autoloader = ABSPATH . WPINC . '/sodium_compat/autoload.php';
+ if (!is_readable($autoloader)) {
+ Log::debug_log('WordPress sodium_compat autoloader not readable at ' . $autoloader);
+
+ return;
+ }
+
+ require_once $autoloader;
}
/**
--- a/bit-form/includes/Core/Database/Model.php
+++ b/bit-form/includes/Core/Database/Model.php
@@ -10,6 +10,7 @@
* Undocumented class
*/
+use BitCodeBitFormCoreUtilLog;
use WP_Error;
class Model
@@ -31,9 +32,9 @@
}
/**
- * Undocumented function
+ * Insert a row
*
- * @return void
+ * @return mixed insert id on success, WP_Error on failure
*/
public function insert($data = [])
{
@@ -80,7 +81,17 @@
$order = null;
if (!is_null($order_by)) {
$order_follow = is_null($order_follow) ? 'ASC' : $order_follow;
- $order .= " ORDER BY $order_by $order_follow";
+ $direction = is_string($order_follow) ? strtoupper(trim($order_follow)) : '';
+ if ($this->isSafeConditionIdentifier($order_by) && in_array($direction, ['ASC', 'DESC'], true)) {
+ $order .= ' ORDER BY ' . $this->quoteIdentifier($order_by) . ' ' . $direction;
+ } else {
+ Log::debug_log([
+ 'message' => 'Model::get() ignored an unsafe ORDER BY',
+ 'table' => $this->table_name,
+ 'order_by' => $order_by,
+ 'order_follow' => $order_follow,
+ ]);
+ }
}
$paginate = null;
if (!is_null($limit)) {
@@ -376,6 +387,86 @@
'%d' : (('double' === gettype($value)) ? '%f' : '%s');
}
+ /**
+ *
+ * @param mixed $identifier
+ *
+ * @return bool
+ */
+ protected function isSafeConditionIdentifier($identifier)
+ {
+ if (!is_string($identifier)) {
+ return false;
+ }
+ $identifier = trim($identifier);
+ if ('' === $identifier) {
+ return false;
+ }
+
+ // A condition column may be table-qualified and backtick-quoted — the multi-table JOIN DELETE
+ // in FormEntryModel::bulkDelete() *must* pass `wp_bitforms_form_entries`.`id`, because a bare
+ // `id` is ambiguous across the two joined tables. Validate each segment on its own.
+ $parts = explode('.', $identifier);
+ if (count($parts) > 2) {
+ return false;
+ }
+ foreach ($parts as $part) {
+ $part = trim($part);
+ if (strlen($part) > 1 && '`' === $part[0] && '`' === substr($part, -1)) {
+ $part = substr($part, 1, -1);
+ }
+ if (1 !== preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $part)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Backtick-quote a validated identifier, leaving an already-quoted or table-qualified one alone.
+ * Only ever call this on a value isSafeConditionIdentifier() has approved.
+ *
+ * @param string $identifier
+ *
+ * @return string
+ */
+ protected function quoteIdentifier($identifier)
+ {
+ $identifier = trim($identifier);
+ if (false !== strpos($identifier, '`') || false !== strpos($identifier, '.')) {
+ return $identifier;
+ }
+
+ return '`' . $identifier . '`';
+ }
+
+ /**
+ * @param mixed $operator
+ *
+ * @return bool
+ */
+ protected function isSafeConditionOperator($operator)
+ {
+ static $allowed = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS', 'IS NOT'];
+
+ return is_string($operator) && in_array(strtoupper(trim($operator)), $allowed, true);
+ }
+
+ /**
+ * A WHERE that matches nothing. Keeps a placeholder so the caller's
+ * $wpdb->prepare($sql, $values) still has something to bind.
+ *
+ * @return array
+ */
+ private function impossibleCondition()
+ {
+ return [
+ 'conditions' => ' WHERE 1=%d ',
+ 'values' => [0],
+ ];
+ }
+
protected function getFormatedCondition($condition, $check_operator = null, $join_operator = ' AND ')
{
if (is_null($condition)) {
@@ -386,11 +477,16 @@
$condition_to_check = ' WHERE ';
$all_values = [];
foreach ($condition as $key => $value) {
+ if (!$this->isSafeConditionIdentifier($key)) {
+ return $this->impossibleCondition();
+ }
$value_type = '';
if (is_array($value)) {
// Check for raw SQL values first
if (isset($value['raw'])) {
- // Handle raw SQL - don't format or add to prepared values
+ if (!is_string($value['raw'])) {
+ return $this->impossibleCondition();
+ }
$set_check_operator = isset($value['operator']) ? $value['operator'] : '=';
$value_type = $value['raw']; // Use raw SQL directly
// Don't add to $all_values since it's raw SQL
@@ -421,6 +517,9 @@
$value_type .= $this->getFieldFormat($value);
$all_values[] = $value;
}
+ if (!$this->isSafeConditionOperator($set_check_operator)) {
+ return $this->impossibleCondition();
+ }
$condition_to_check = $condition_to_check . $key . " $set_check_operator " . $value_type;
if ($index_checker < $no_condition - 1) {
$condition_to_check = $condition_to_check . " $join_operator ";
@@ -433,6 +532,22 @@
];
}
+ /**
+ * @param array $values values about to be bound by $wpdb->prepare()
+ *
+ * @return string|null the offending PHP type, or null when every value is bindable
+ */
+ private function findUnbindableValue(array $values)
+ {
+ foreach ($values as $value) {
+ if (!is_scalar($value) && !is_null($value)) {
+ return gettype($value);
+ }
+ }
+
+ return null;
+ }
+
protected function checkCondition(array $condition)
{
if (!is_null($condition) && array_keys($condition) === range(0, count($condition) - 1)) {
@@ -446,9 +561,24 @@
protected function execute($sql, $values = null)
{
+ // Clear the previous call's outcome before running a new query, so a failure can never be
+ // read back by whatever this instance is used for next.
+ $this->db_response = null;
if (is_null($values)) {
$preparedQuery = $sql;
} else {
+ $invalid = $this->findUnbindableValue((array) $values);
+ if (null !== $invalid) {
+ Log::debug_log([
+ 'message' => 'Model::execute() received an unbindable condition value',
+ 'table' => $this->table_name,
+ 'type' => $invalid,
+ 'sql' => $sql,
+ ]);
+ $this->db_response = new WP_Error('invalid_query_value', 'Query value must be scalar, ' . $invalid . ' given');
+
+ return $this;
+ }
$preparedQuery = $this->app_db->prepare($sql, $values);
}
// echo " Q S " . $preparedQuery . " Q EE";
@@ -464,7 +594,19 @@
protected function getResult($db_response = null)
{
- $db_response = !empty($this->db_response) ? $this->db_response : $db_response;
+ // The caller's own result wins. $db_response is an instance property that only execute()
+ // writes, and models are reused (AdminFormHandler keeps a static FormModel for the whole
+ // request), so letting the property override an explicitly passed result made insert() and
+ // update() report the outcome of some earlier, unrelated query on the same object.
+ // Without this fallback, execute()->getResult() (which passes no argument) never sees the
+ // query it just ran and every read returns 'result_empty'.
+ if (null === $db_response) {
+ $db_response = $this->db_response;
+ }
+
+ if (is_wp_error($db_response)) {
+ return $db_response;
+ }
if (!empty($this->app_db->last_error)) {
return new WP_Error('db_error', $this->app_db->last_error);
}
--- a/bit-form/includes/Core/Database/ReportsModel.php
+++ b/bit-form/includes/Core/Database/ReportsModel.php
@@ -15,9 +15,28 @@
{
protected static $table = 'bitforms_reports';
+ /**
+ * Whether a report payload carries enough to validate.
+ *
+ * The save path feeds this the request's `currentReport`, and the React `$reportSelector` atom
+ * yields `{}` whenever the report list has not resolved — an empty stdClass, which PHP's
+ * empty() reports as non-empty. Callers must therefore ask before acting on it.
+ *
+ * @param mixed $reportData
+ *
+ * @return bool
+ */
+ public static function isValidatableReport($reportData)
+ {
+ return is_object($reportData) && !empty($reportData->type) && isset($reportData->details);
+ }
+
public function validateReportFields($reportData, $fieldNames)
{
$reportDetails = [];
+ if (!self::isValidatableReport($reportData)) {
+ return $reportDetails;
+ }
switch ($reportData->type) {
case 'table':
$tableAction = 'table_ac';
--- a/bit-form/includes/Core/Form/FormManager.php
+++ b/bit-form/includes/Core/Form/FormManager.php
@@ -619,7 +619,7 @@
*
* @param array|object $layout single layout or array of steps ({layout} each)
* @param object|null $nestedLayout keyed by parent field key
- * @param object|array $fields raw form_content->fields
+ * @param mixed $fields raw form_content->fields (decoded JSON: shape is not guaranteed, hence the runtime guard)
*
* @return string[]|null orphan keys to drop, or null when the layout is
* unusable (fail closed: drop nothing)
@@ -1696,10 +1696,14 @@
$temp = $this->sanitize_text_recursive($_POST[$fldName]);
unset($_POST[$fldName]);
$_POST[$fieldKey] = $temp;
+ } elseif (array_key_exists($fieldKey, $_POST)) {
+ $_POST[$fieldKey] = $this->sanitize_text_recursive($_POST[$fieldKey]);
} elseif (array_key_exists($fldName, $_FILES)) {
$temp = $this->sanitize_text_recursive($_FILES[$fldName], false);
unset($_FILES[$fldName]);
$_FILES[$fieldKey] = $temp;
+ } elseif (array_key_exists($fieldKey, $_FILES)) {
+ $_FILES[$fieldKey] = $this->sanitize_text_recursive($_FILES[$fieldKey], false);
}
// Convert _session_id suffix (used by email-otp and similar fields)
if (array_key_exists($fldName . '_session_id', $_POST)) {
--- a/bit-form/includes/Core/Hooks/Hooks.php
+++ b/bit-form/includes/Core/Hooks/Hooks.php
@@ -9,6 +9,7 @@
use BitCodeBitFormAdminAdmin_Bar;
use BitCodeBitFormAPIRouteRoutes;
use BitCodeBitFormCoreAjaxAjaxService;
+use BitCodeBitFormCoreApiBitFormPublicApi;
use BitCodeBitFormCoreCapabilityRequest;
use BitCodeBitFormCoreDatabaseFormModel;
use BitCodeBitFormCoreFallbackFormFallback;
@@ -51,6 +52,11 @@
add_action('init', [RegisterBitformBricksWidget::class, 'register_widgets'], 11);
// Add Bit Form menu to admin bar by "manage_bitform" capability
add_filter('bitforms_form_access_capability', [Hooks::class, 'bitformMenuAccessCapability']);
+
+ // Public API bridge for sibling Bit Apps plugins (Bit CRM). Consumers call
+ add_filter('bitform/api/crm_integrated_forms', fn ($default = null) => BitFormPublicApi::getCrmIntegratedForms(), 10, 1);
+ add_filter('bitform/api/toggle_form_status', fn ($default = null, $formId = 0, $status = 0) => BitFormPublicApi::toggleFormStatus($formId, $status), 10, 3);
+ add_filter('bitform/api/create_form_url', fn ($default = null, $args = []) => BitFormPublicApi::getCreateFormUrl($args), 10, 2);
}
public static function updateBitFormVersion()
--- a/bit-form/includes/Core/Integration/BitCRM/BitCRMHandler.php
+++ b/bit-form/includes/Core/Integration/BitCRM/BitCRMHandler.php
@@ -0,0 +1,291 @@
+<?php
+
+/**
+ * Bit CRM Integration
+ *
+ */
+
+namespace BitCodeBitFormCoreIntegrationBitCRM;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use BitCodeBitFormCoreIntegrationIntegrationHandler;
+use BitCodeBitFormCoreUtilApiResponse as UtilApiResponse;
+use WP_Error;
+
+/**
+ * Provide functionality for Bit CRM integration
+ */
+class BitCRMHandler
+{
+ public const CRM_PLUGIN_BASENAME = 'bit-crm-sales-marketing-automation/bit-crm-sales-marketing-automation.php';
+
+ private $_formID;
+
+ private $_integrationID;
+
+ public function __construct($integrationID, $fromID)
+ {
+ $this->_formID = $fromID;
+ $this->_integrationID = $integrationID;
+ }
+
+ /**
+ * Helps to register ajax function's with wp
+ *
+ * @return void
+ */
+ public static function registerAjax()
+ {
+ add_action('wp_ajax_bitforms_bitcrm_authorize', [__CLASS__, 'bitcrmAuthorize']);
+ add_action('wp_ajax_bitforms_bitcrm_fields', [__CLASS__, 'bitcrmFields']);
+ add_action('wp_ajax_bitforms_bitcrm_tags', [__CLASS__, 'bitcrmTags']);
+ }
+
+ /**
+ * Check Bit CRM plugin is active and its lead service is loadable
+ */
+ public static function checkedExistsBitCRM()
+ {
+ if (!function_exists('is_plugin_active')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ return is_plugin_active(self::CRM_PLUGIN_BASENAME) && class_exists('BitAppsCrmServicesLeadService');
+ }
+
+ /**
+ * Capability gate for the BitCRM admin AJAX endpoints. Registration is already
+ * capability-gated in AjaxService, this is defense-in-depth matching AdminAjax.
+ *
+ * @return void
+ */
+ private static function verifyAdminPermission()
+ {
+ if (!current_user_can('manage_bitform') && !current_user_can('manage_options')) {
+ wp_send_json_error(__('Insufficient permissions.', 'bit-form'), 403);
+ }
+ }
+
+ /**
+ * Ajax: respond success if Bit CRM exists
+ *
+ * @return void
+ */
+ public static function bitcrmAuthorize()
+ {
+ if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
+ self::verifyAdminPermission();
+ if (self::checkedExistsBitCRM()) {
+ wp_send_json_success(true);
+ } else {
+ wp_send_json_error(
+ __(
+ 'Please! Install & activate Bit CRM',
+ 'bit-form'
+ ),
+ 400
+ );
+ }
+ } else {
+ wp_send_json_error(
+ __(
+ 'Token expired',
+ 'bit-form'
+ ),
+ 401
+ );
+ }
+ }
+
+ /**
+ * Ajax: respond with the lead fields (system + custom) of Bit CRM
+ *
+ * @return void
+ */
+ public static function bitcrmFields()
+ {
+ if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
+ self::verifyAdminPermission();
+ if (!self::checkedExistsBitCRM()) {
+ wp_send_json_error(
+ __(
+ 'Bit CRM plugin not found',
+ 'bit-form'
+ ),
+ 400
+ );
+ }
+ $response['bitcrmFields'] = self::getLeadFields();
+ wp_send_json_success($response, 200);
+ } else {
+ wp_send_json_error(
+ __(
+ 'Token expired',
+ 'bit-form'
+ ),
+ 401
+ );
+ }
+ }
+
+ /**
+ * Ajax: respond with the lead tags of Bit CRM
+ *
+ * @return void
+ */
+ public static function bitcrmTags()
+ {
+ if (isset($_REQUEST['_ajax_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_ajax_nonce'])), 'bitforms_save')) {
+ self::verifyAdminPermission();
+ if (!self::checkedExistsBitCRM()) {
+ wp_send_json_error(
+ __(
+ 'Bit CRM plugin not found',
+ 'bit-form'
+ ),
+ 400
+ );
+ }
+ $response['bitcrmTags'] = self::getLeadTags();
+ wp_send_json_success($response, 200);
+ } else {
+ wp_send_json_error(
+ __(
+ 'Token expired',
+ 'bit-form'
+ ),
+ 401
+ );
+ }
+ }
+
+ /**
+ * Fetch normalized lead field list from Bit CRM
+ *
+ * @return array [{key, label, required, isCustom, fieldId, fieldType}]
+ */
+ public static function getLeadFields()
+ {
+ // Bit CRM asks integrators to use the raw service classes (their CrmApi facade
+ // is reserved for a future public API), so we call LeadService directly.
+ $fields = (new BitAppsCrmServicesLeadService())->fields();
+ $fieldOptions = [];
+ foreach ($fields as $field) {
+ $field = (array) $field;
+ // section rows are UI group headers in CRM, not mappable fields
+ if (isset($field['type']) && 'section' === $field['type']) {
+ continue;
+ }
+ // Group fields (billing/shipping address) are not columns themselves; their
+ // mappable columns are the leaf sub-fields (billing_city, shipping_zip, ...).
+ if (!empty($field['group_fields']) && is_array($field['group_fields'])) {
+ foreach ($field['group_fields'] as $leaf) {
+ $option = self::normalizeLeadField((array) $leaf);
+ if ($option) {
+ $fieldOptions[] = $option;
+ }
+ }
+ continue;
+ }
+ $option = self::normalizeLeadField($field);
+ if ($option) {
+ $fieldOptions[] = $option;
+ }
+ }
+ return $fieldOptions;
+ }
+
+ /**
+ * Normalize one Bit CRM lead field (or address leaf) into a mappable option.
+ *
+ * @param array $field
+ *
+ * @return object|null null when the field has no key
+ */
+ private static function normalizeLeadField($field)
+ {
+ if (empty($field['field_key'])) {
+ return null;
+ }
+ $required = !empty($field['required']) || !empty($field['is_always_required']);
+ // Bit CRM defaults currency, but the form should not force it as a required mapping.
+ if ('currency' === $field['field_key']) {
+ $required = false;
+ }
+ return (object) [
+ 'key' => $field['field_key'],
+ 'label' => isset($field['label']) ? $field['label'] : $field['field_key'],
+ 'required' => $required,
+ 'isCustom' => !empty($field['is_custom']),
+ 'fieldId' => isset($field['id']) ? $field['id'] : null,
+ 'fieldType' => isset($field['type']) ? $field['type'] : 'text',
+ ];
+ }
+
+ /**
+ * Fetch lead tags from Bit CRM
+ *
+ * @return array [{id, title}]
+ */
+ public static function getLeadTags()
+ {
+ // Use the raw TagService (per Bit CRM's guidance). tagsByModule returns
+ // ['success' => bool, 'data' => <Tag collection>] where each row is a Model object.
+ $result = (new BitAppsCrmServicesTagService())->tagsByModule(['module' => BitAppsCrmModelLead::MODULE_NAME]);
+ $tags = (isset($result['success']) && $result['success'] && isset($result['data'])) ? $result['data'] : [];
+
+ $bitcrmTags = [];
+ foreach ($tags as $tag) {
+ // Tag rows are WPDatabase Model objects (data in a protected `attributes`
+ // bag reachable via magic __get); a plain (array) cast mangles the keys.
+ if (is_array($tag)) {
+ $id = isset($tag['id']) ? $tag['id'] : null;
+ $title = isset($tag['title']) ? $tag['title'] : null;
+ } else {
+ $id = $tag->id;
+ $title = $tag->title;
+ }
+ if (is_null($id)) {
+ continue;
+ }
+ $bitcrmTags[] = (object) [
+ 'id' => $id,
+ 'title' => $title,
+ ];
+ }
+ return $bitcrmTags;
+ }
+
+ public function execute(IntegrationHandler $integrationHandler, $integrationData, $fieldValues, $entryID, $logID)
+ {
+ $integrationDetails = is_string($integrationData->integration_details) ? json_decode($integrationData->integration_details) : $integrationData->integration_details;
+
+ if (!self::checkedExistsBitCRM()) {
+ $error = ['success' => false, 'messages' => 'Bit CRM plugin is not active'];
+ (new UtilApiResponse())->apiResponse(
+ $logID,
+ $this->_integrationID,
+ ['type' => 'record', 'type_name' => 'Lead-create'],
+ 'error',
+ $error,
+ ['formId' => $this->_formID, 'entryId' => $entryID, 'fieldValues' => $fieldValues]
+ );
+ return new WP_Error('PLUGIN_NOT_FOUND', __('Bit CRM plugin is not active', 'bit-form'));
+ }
+
+ $fieldMap = isset($integrationDetails->field_map) ? $integrationDetails->field_map : [];
+ if (empty($fieldMap)) {
+ return new WP_Error('REQ_FIELD_EMPTY', __('Field map is required for Bit CRM api', 'bit-form'));
+ }
+
+ $recordApiHelper = new RecordApiHelper($this->_integrationID, $logID, $entryID);
+
+ return $recordApiHelper->executeRecordApi(
+ $fieldValues,
+ $integrationDetails,
+ $this->_formID
+ );
+ }
+}
--- a/bit-form/includes/Core/Integration/BitCRM/RecordApiHelper.php
+++ b/bit-form/includes/Core/Integration/BitCRM/RecordApiHelper.php
@@ -0,0 +1,145 @@
+<?php
+
+/**
+ * Bit CRM Record Api
+ *
+ */
+
+namespace BitCodeBitFormCoreIntegrationBitCRM;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use BitCodeBitFormCoreUtilApiResponse as UtilApiResponse;
+
+/**
+ * Provide functionality for lead insert into Bit CRM
+ */
+class RecordApiHelper
+{
+ private $_integrationID;
+
+ private $_logID;
+
+ private $_logResponse;
+
+ private $_entryID;
+
+ public function __construct($integId, $logID, $entryID)
+ {
+ $this->_integrationID = $integId;
+ $this->_logID = $logID;
+ $this->_logResponse = new UtilApiResponse();
+ $this->_entryID = $entryID;
+ }
+
+ /**
+ * Build the LeadService payload and create the lead
+ *
+ * @param array $fieldValues submitted field values (smart tags already merged)
+ * @param object $integrationDetails decoded integration_details JSON
+ * @param int $formId current form id
+ *
+ * @return array LeadService result ['success' => bool, ...]
+ */
+ public function executeRecordApi($fieldValues, $integrationDetails, $formId)
+ {
+ $fieldMap = $integrationDetails->field_map;
+ $crmFieldMeta = $this->crmFieldMeta($integrationDetails);
+
+ $systemDefinedFieldsValues = [];
+ $customFieldsValues = [];
+ $skippedFields = [];
+
+ foreach ($fieldMap as $fieldPair) {
+ if (empty($fieldPair->crmFormField)) {
+ continue;
+ }
+ if ('custom' === $fieldPair->formField && isset($fieldPair->customValue)) {
+ $value = $fieldPair->customValue;
+ } else {
+ $value = isset($fieldValues[$fieldPair->formField]) ? $fieldValues[$fieldPair->formField] : null;
+ }
+ if (is_null($value) || '' === $value || (is_array($value) && 0 === count($value))) {
+ continue;
+ }
+
+ if (!isset($crmFieldMeta[$fieldPair->crmFormField])) {
+ // mapped field no longer exists in CRM (e.g. CRM Pro deactivated) - drop, don't fail the lead
+ $skippedFields[] = $fieldPair->crmFormField;
+ continue;
+ }
+
+ $meta = $crmFieldMeta[$fieldPair->crmFormField];
+ if (!empty($meta->isCustom)) {
+ // Multi-value custom fields (multi-select, checkbox) must be stored JSON-encoded:
+ // Bit CRM decodes field_value with JSON::is() on read, so an array round-trips as
+ // an array. Single values stay plain strings.
+ $customFieldsValues[$fieldPair->crmFormField] = [
+ 'field_id' => $meta->fieldId,
+ 'field_value' => is_array($value) ? wp_json_encode(array_values($value)) : (string) $value,
+ ];
+ } else {
+ // System columns are scalar; join a multi-value form field into a readable string.
+ $systemDefinedFieldsValues[$fieldPair->crmFormField] = is_array($value) ? implode(', ', $value) : (string) $value;
+ }
+ }
+
+ $payload = ['systemDefinedFieldsValues' => $systemDefinedFieldsValues];
+
+ if (!empty($customFieldsValues)) {
+ $payload['customFieldsValues'] = $customFieldsValues;
+ }
+ if (!empty($integrationDetails->tagIds)) {
+ $payload['tagIds'] = array_map('intval', (array) $integrationDetails->tagIds);
+ }
+ if (!empty($integrationDetails->newTagTitles)) {
+ $payload['newTagTitles'] = array_map('strval', (array) $integrationDetails->newTagTitles);
+ }
+
+ $recordApiResponse = (new BitAppsCrmServicesLeadService())->store($payload);
+
+ if (!empty($skippedFields)) {
+ $recordApiResponse['skipped_fields'] = 'Unknown Bit CRM field(s) skipped: ' . implode(', ', $skippedFields);
+ }
+
+ $entryDetails = [
+ 'formId' => $formId,
+ 'entryId' => $this->_entryID,
+ 'fieldValues' => $fieldValues
+ ];
+
+ if (!empty($recordApiResponse['success'])) {
+ $this->_logResponse->apiResponse($this->_logID, $this->_integrationID, ['type' => 'record', 'type_name' => 'Lead-create'], 'success', $recordApiResponse, $entryDetails);
+ } else {
+ $this->_logResponse->apiResponse($this->_logID, $this->_integrationID, ['type' => 'record', 'type_name' => 'Lead-create'], 'error', $recordApiResponse, $entryDetails);
+ }
+ return $recordApiResponse;
+ }
+
+ /**
+ * Authoritative CRM field metadata keyed by field key. Live fetch first
+ * (fresh isCustom/fieldId), saved crmFields snapshot as fallback.
+ *
+ * @return array<string, object>
+ */
+ private function crmFieldMeta($integrationDetails)
+ {
+ $fields = [];
+ try {
+ $fields = BitCRMHandler::getLeadFields();
+ } catch (Throwable $th) {
+ $fields = [];
+ }
+ if (empty($fields) && !empty($integrationDetails->crmFields)) {
+ $fields = $integrationDetails->crmFields;
+ }
+
+ $meta = [];
+ foreach ($fields as $field) {
+ $meta[$field->key] = $field;
+ }
+ return $meta;
+ }
+}
--- a/bit-form/includes/Core/Util/FieldValueHandler.php
+++ b/bit-form/includes/Core/Util/FieldValueHandler.php
@@ -212,7 +212,7 @@
if (in_array($fldData->typ, $file_upload_types)) {
continue;
}
- if (array_key_exists($fldKey, $fieldValues)) {
+ if (is_array($fieldValues) && array_key_exists($fldKey, $fieldValues)) {
$value = $fieldValues[$fldKey];
// if (is_array($value)) {
// $formattedFldValues[$fldKey] = htmlspecialchars(implode(', ', $value));
--- a/bit-form/includes/Core/Util/FileHandler.php
+++ b/bit-form/includes/Core/Util/FileHandler.php
@@ -235,35 +235,87 @@
$destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR;
self::createIndexFile($destinationDir);
+ $consumedFiles = [];
+
foreach ($submitted_data as $key => $data) {
if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) {
- $files = $data;
$fldData = $submitted_data[$key];
- $files = explode(',', $fldData);
- if (is_array($files) && count($files) > 0) {
- foreach ($files as $file) {
- self::fileCopy($tempDir, $destinationDir, trim($file));
+ // A repeater row (or a pre-split value) hands this field over as an array; explode()
+ // on an array is a TypeError on PHP 8, which would fatal mid-submission.
+ $files = is_array($fldData) ? $fldData : explode(',', is_scalar($fldData) ? (string) $fldData : '');
+ foreach ($files as $file) {
+ $safeFile = is_scalar($file) ? trim((string) $file) : '';
+ if ('' === $safeFile) {
+ continue;
}
- } else {
- self::fileCopy($tempDir, $destinationDir, trim($files));
+ self::fileCopy($tempDir, $destinationDir, $safeFile);
+ $consumedFiles[] = $safeFile;
}
if (!empty($files)) {
$submitted_data[$key] = $files;
}
}
}
+
+ self::cleanupTempUploads($tempDir, $consumedFiles);
+
+ return $submitted_data;
+ }
+
+ /**
+ * Clear the shared temp upload staging area after a submission has taken what it needs.
+ *
+ * @param string $tempDir shared staging directory
+ * @param array $consumedFiles file names copied into the entry directory by this submission
+ *
+ * @return void
+ */
+ private static function cleanupTempUploads($tempDir, array $consumedFiles)
+ {
$tempBase = realpath($tempDir);
- if (false !== $tempBase) {
- $tmpFiles = glob($tempBase . DIRECTORY_SEPARATOR . '*');
- foreach ((array) $tmpFiles as $tmpFile) {
- $resolved = realpath($tmpFile);
- if (false !== $resolved && 0 === strpos($resolved, $tempBase . DIRECTORY_SEPARATOR)) {
- wp_delete_file($resolved);
- }
+ if (false === $tempBase) {
+ return;
+ }
+ $boundary = $tempBase . DIRECTORY_SEPARATOR;
+
+ // Never remove the directory-hardening files the uploads dir relies on.
+ $protected = ['index.php', 'index.html', '.htaccess'];
+
+ foreach ($consumedFiles as $file) {
+ $resolved = realpath($boundary . $file);
+ if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
+ continue;
+ }
+ if (in_array(basename($resolved), $protected, true)) {
+ continue;
}
+ wp_delete_file($resolved);
}
- return $submitted_data;
+ // Sweep abandoned uploads. Anything still here after the retention window belongs to a form
+ // that was never submitted. Filterable so a site with very long multi-step forms can extend
+ // it; 0 or less disables the sweep entirely.
+ $retention = apply_filters('bitform_temp_upload_retention', DAY_IN_SECONDS);
+ $retention = is_numeric($retention) ? (int) $retention : DAY_IN_SECONDS;
+ if ($retention <= 0) {
+ return;
+ }
+
+ $cutoff = time() - $retention;
+ $tmpFiles = glob($boundary . '*');
+ foreach ((array) $tmpFiles as $tmpFile) {
+ $resolved = realpath($tmpFile);
+ if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
+ continue;
+ }
+ if (in_array(basename($resolved), $protected, true)) {
+ continue;
+ }
+ $modified = @filemtime($resolved);
+ if (false !== $modified && $modified < $cutoff) {
+ wp_delete_file($resolved);
+ }
+ }
}
private function getByteSizeByUnit($sizeString)
--- a/bit-form/includes/Core/Util/IpTool.php
+++ b/bit-form/includes/Core/Util/IpTool.php
@@ -320,7 +320,7 @@
/**
* Provide user details
*
- * @return _setUserDetail user details array
+ * @return array user details array
*/
public static function getUserDetail()
{
--- a/bit-form/includes/Core/Util/MailNotifier.php
+++ b/bit-form/includes/Core/Util/MailNotifier.php
@@ -21,7 +21,6 @@
$emailTemplateHandler = new EmailTemplateHandler($formID);
$attachments = [];
$tempPdfLinks = [];
-
if (is_string($notifyDetails->id)) {
$mailTemplateID = Utilities::jsonObj($notifyDetails->id)->id ?? null;
$mailTemplate = $emailTemplateHandler->getATemplate($mailTemplateID);
@@ -45,7 +44,7 @@
$from_mail = '';
if (!empty($notifyDetails->from)) {
$fromMail = FieldValueHandler::validateMailArry($notifyDetails->from, $fieldValue);
- $headerFromName = !empty($notifyDetails->fromName) ? $notifyDetails->fromName : explode('@', $fromMail[0])[0];
+ $headerFromName = !empty($notifyDetails->from_name) ? $notifyDetails->from_name : explode('@', $fromMail[0])[0];
$mailHeaders[] = "FROM: $headerFromName " . '<' . sanitize_email($fromMail[0]) . '>';
$from_mail = $fromMail[0];
}
@@ -198,6 +197,13 @@
add_action('phpmailer_init', $embedCb);
}
add_filter('wp_mail_content_type', [self::class, 'filterMailContentType']);
+ $fromNameCb = null;
+ if (!empty($from_name)) {
+ $fromNameCb = static function () use ($from_name) {
+ return $from_name;
+ };
+ add_filter('wp_mail_from_name', $fromNameCb);
+ }
$status = wp_mail($mailTo, $mailSubject, $mailBody, $mailHeaders, $attachments);
if (!$status) {
@@ -254,6 +260,9 @@
);
}
remove_filter('wp_mail_content_type', [self::class, 'filterMailContentType']);
+ if (null !== $fromNameCb) {
+ remove_filter('wp_mail_from_name', $fromNameCb);
+ }
if (!empty($cidMap)) {
remove_action('phpmailer_init', $embedCb);
}
--- a/bit-form/includes/Core/WorkFlow/Actions.php
+++ b/bit-form/includes/Core/WorkFlow/Actions.php
@@ -15,15 +15,43 @@
static::$_formID = $formId;
}
+ /**
+ * Resolve the field key a workflow action points at.
+ *
+ * Both arguments come straight out of stored workflow/form JSON, so neither shape is guaranteed
+ * — hence the runtime checks rather than type hints.
+ *
+ * @param mixed $actionDetail one action row decoded from the workflow JSON
+ * @param mixed $fieldData field map keyed by field name
+ *
+ * @return string|null null when the action points at a field the form no longer has
+ */
+ private static function resolveFieldKey($actionDetail, $fieldData)
+ {
+ if (!is_object($actionDetail) || !isset($actionDetail->field) || !is_array($fieldData)) {
+ return null;
+ }
+ $field = $actionDetail->field;
+ if (!is_string($field) && !is_int($field)) {
+ return null;
+ }
+ if (!isset($fieldData[$field]) || !is_array($fieldData[$field]) || !isset($fieldData[$field]['key'])) {
+ return null;
+ }
+ $key = $fieldData[$field]['key'];
+
+ return is_string($key) || is_int($key) ? (string) $key : null;
+ }
+
public function setValue($actionDetail, $fieldData, $fields)
{
- if (!empty($actionDetail->val)) {
- $actionValue = '';
- $fieldType = $fields->{$fieldData[$actionDetail->field]['key']}->typ;
- $evalMathExpr = preg_match('/month|date/', $fieldType);
- $fields->{$fieldData[$actionDetail->field]['key']}->val = '';
- $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool)$evalMathExpr);
- $fields->{$fieldData[$actionDetail->field]['key']}->val = $actionValue;
+ $fk = self::resolveFieldKey($actionDetail, $fieldData);
+ if (null !== $fk && isset($fields->{$fk}) && !empty($actionDetail->val)) {
+ $fieldType = isset($fields->{$fk}->typ) ? $fields->{$fk}->typ : '';
+ $evalMathExpr = preg_match('/month|date/', (string) $fieldType);
+ $fields->{$fk}->val = '';
+ $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool) $evalMathExpr);
+ $fields->{$fk}->val = $actionValue;
$fieldData[$actionDetail->field]['value'] = $actionValue;
}
return [$fields, $fieldData];
@@ -32,11 +60,12 @@
public function getActionValue($actionDetail, $fieldData, $fields)
{
$actionValue = '';
- if (!empty($actionDetail->val)) {
- $fieldType = $fields->{$fieldData[$actionDetail->field]['key']}->typ;
- $evalMathExpr = preg_match('/month|date/', $fieldType);
- $fields->{$fieldData[$actionDetail->field]['key']}->val = '';
- $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool)$evalMathExpr);
+ $fk = self::resolveFieldKey($actionDetail, $fieldData);
+ if (null !== $fk && isset($fields->{$fk}) && !empty($actionDetail->val)) {
+ $fieldType = isset($fields->{$fk}->typ) ? $fields->{$fk}->typ : '';
+ $evalMathExpr = preg_match('/month|date/', (string) $fieldType);
+ $fields->{$fk}->val = '';
+ $actionValue = Helper::replaceFieldWithValue($actionDetail->val, $fieldData, !(bool) $evalMathExpr);
}
return $actionValue;
}
@@ -44,10 +73,20 @@
public function getActiveListIndex($actionDetail, $fieldData, $fields)
{
$activeList = $this->getActionValue($actionDetail, $fieldData, $fields);
- $optionsList = $fields->{$fieldData[$actionDetail->field]['key']}->optionsList;
$activeListIndex = 0;
+ $fk = self::resolveFieldKey($actionDetail, $fieldData);
+ if (null === $fk || !isset($fields->{$fk}->optionsList)) {
+ return $activeListIndex;
+ }
+ $optionsList = $fields->{$fk}->optionsList;
+ if (!is_array($optionsList) && !is_object($optionsList)) {
+ return $activeListIndex;
+ }
foreach ($optionsList as $key => $optionObj) {
$valueArr = (array) $optionObj;
+ if (empty($valueArr)) {
+ continue;
+ }
$listName = array_keys($valueArr)[0];
if ($listName === $activeList) {
$activeListIndex = $key;
@@ -60,9 +99,13 @@
public function show($fields, $fieldData, $actionDetail)
{
- $fields->{$fieldData[$actionDetail->field]['key']}->valid->hide = false;
- if ('hidden' === $fields->{$fieldData[$actionDetail->field]['key']}->typ) {
- $fields->{$fieldData[$actionDetail->field]['key']}->typ = 'text';
+ $fk = self::resolveFieldKey($actionDetail, $fieldData);
+ if (null === $fk || !isset($fields->{$fk})) {
+ return;
+ }
+ Helper::setNestedProperty($fields, "{$fk}->valid->hide", false);
+ if (isset($fields->{$fk}->typ) && 'hidden' === $fields->{$fk}->typ) {
+ $fields->{$fk}->typ = 'text';
}
}
@@ -72,65 +115,71 @@
return [$fields, $fieldData];
}
foreach ($actions as $actionDetail) {
- if (!empty($actionDetail->action) && !empty($actionDetail->field) && isset($fields->{$fieldData[$actionDetail->field]['key']}) && !empty($fields->{$fieldData[$actionDetail->field]['key']})) {
- $fk = $fieldData[$actionDetail->field]['key'];
- switch ($actionDetail->action) {
- case 'value':
- $data = $this->setValue($actionDetail, $fieldData, $fields);
- $fields = $data[0];
- $fieldData = $data[1];
- break;
- case 'hide':
- // $fields->{$fk}->valid->hide = true;
- Helper::setNestedProperty($fields, "{$fk}->valid->hide", true);
- break;
- case 'disable':
- $fields->{$fk}->valid->disabled = true;
- break;
- case 'show':
- $this->show($fields, $fieldData, $actionDetail);
- break;
- case 'enable':
- $fields->{$fk}->valid->disabled = false;
- break;
- case 'readonly':
- $fields->{$fk}->valid->readonly = true;
- break;
- case 'writeable':
- $fields->{$fk}->valid->readonly = false;
- break;
- case 'required':
- $fields->{$fk}->valid->required = true;
- break;
- case 'limit':
- $fields->{$fk}->valid->limit = true;
- break;
- case 'min':
- $fields->{$fk}->valid->min = true;
- break;
- case 'max':
- $fields->{$fk}->valid->max = true;
- break;
- case 'activelist':
- $fields->{$fk}->config->activeList = $this->getActiveListIndex($actionDetail, $fieldData, $fields);
- break;
- case 'lbl':
- case 'ct':
- $fields->{$fk}->lbl = $this->getActionValue($actionDetail, $fieldData, $fields);
- break;
- case 'sub-titl':
- $fields->{$fk}->subtitle = $this->getActionValue($actionDetail, $fieldData, $fields);
- break;
- case 'hlp-txt':
- $fields->{$fk}->helperTxt = $this->getActionValue($actionDetail, $fieldData, $fields);
- break;
- case 'placeholder':
- $fields->{$fk}->ph = $this->getActionValue($actionDetail, $fieldData, $fields);
- break;
- case 'title':
- $fields->{$fk}->title = $this->getActionValue($actionDetail, $fieldData, $fields);
- break;
- }
+ // resolveFieldKey() must run *before* $fields is indexed: the old condition built the
+ // dynamic property name from $fieldData[...]['key'] inside its own isset(), so the missing
+ // key warned before isset() ever got a chance to short-circuit.
+ $fk = self::resolveFieldKey($actionDetail, $fieldData);
+ if (null === $fk || empty($actionDetail->action) || !isset($fields->{$fk}) || empty($fields->{$fk})) {
+ continue;
+ }
+ switch ($actionDetail->action) {
+ case 'value':
+ $data = $this->setValue($actionDetail, $fieldData, $fields);
+ $fields = $data[0];
+ $fieldData = $data[1];
+ break;
+ case 'hide':
+ Helper::setNestedProperty($fields, "{$fk}->valid->hide", true);
+ break;
+ case 'disable':
+ Helper::setNestedProperty($fields, "{$fk}->valid->disabled", true);
+ break;
+ case 'show':
+ $this->show($fields, $fieldData, $actionDetail);
+ break;
+ case 'enable':
+ Helper::setNestedProperty($fields, "{$fk}->valid->disabled", false);
+ break;
+ case 'readonly':
+ Helper::setNestedProperty($fields, "{$fk}->valid->readonly", true);
+ break;
+ case 'writeable':
+ Helper::setNestedProperty($fields, "{$fk}->valid->readonly", false);
+ break;
+ case 'required':
+ Helper::setNestedProperty($fields, "{$fk}->valid->required", true);
+ break;
+ case 'limit':
+ Helper::setNestedProperty($fields, "{$fk}->valid->limit", true);
+ break;
+ case 'min':
+ Helper::setNestedProperty($fields, "{$fk}->valid->min", true);
+ break;
+ case 'max':
+ Helper::setNestedProperty($fields, "{$fk}->valid->max", true);
+ break;
+ case 'activelist':
+ // setNestedProperty() creates the intermediate `valid`/`config` object when a legacy
+ // field JSON does not carry one; the direct writes here used to auto-vivify it and
+ // emit an undefined-property warning on every run.
+ Helper::setNestedProperty($fields, "{$fk}->config->activeList", $th