Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/fluentform/app/Helpers/Helper.php
+++ b/fluentform/app/Helpers/Helper.php
@@ -244,7 +244,9 @@
$statuses = apply_filters('fluentform/entry_statuses_core', $statuses, $form_id);
- $statuses['trashed'] = 'Trashed';
+ $statuses['spam'] = __('Spam', 'fluentform');
+
+ $statuses['trashed'] = __('Trashed', 'fluentform');
return $statuses;
}
@@ -987,7 +989,7 @@
public static function sanitizeForCSV($content)
{
- $formulas = ['=', '-', '+', '@', "t", "r"];
+ $formulas = ['=', '-', '+', '@', "t", "r", "n"];
$formulas = apply_filters('fluentform/csv_sanitize_formulas', $formulas);
@@ -1349,7 +1351,7 @@
$fieldData = ArrayHelper::get($field, 'raw');
$data = (new SelectCountry())->loadCountries($fieldData);
$validCountries = ArrayHelper::get($fieldData, 'settings.country_list.priority_based', []);
- $validCountries = array_merge($validCountries, array_keys(ArrayHelper::get($data, 'options')));
+ $validCountries = array_merge($validCountries, array_keys((array) ArrayHelper::get($data, 'options', [])));
$isValid = in_array($inputValue, $validCountries);
break;
case 'repeater_field':
@@ -1608,8 +1610,15 @@
return home_url($args);
}
- public static function getCountryCodeFromHeaders()
+ public static function getCountryCodeFromHeaders($forRestriction = false)
{
+ // SECURITY (FINDING-26): CDN country headers are client-spoofable. Trust them for analytics
+ // storage (spoof is cosmetic) but not for restriction enforcement (spoof = bypass). Filterable.
+ $trustHeaders = apply_filters('fluentform/trust_geo_headers', !$forRestriction);
+ if (!$trustHeaders) {
+ return null;
+ }
+
$headers = [
// Cloudflare (most common)
'HTTP_CF_IPCOUNTRY',
--- a/fluentform/app/Hooks/Ajax.php
+++ b/fluentform/app/Hooks/Ajax.php
@@ -35,17 +35,17 @@
$data['form_id'] = $formId;
$isValidJson = (!empty($data['formFields'])) && json_decode($data['formFields'], true);
- if(!$isValidJson) {
+ if (!$isValidJson) {
wp_send_json([
'message' => 'Looks like the provided JSON is invalid. Please try again or contact support',
- 'reason' => 'formFields JSON validation failed'
+ 'reason' => 'formFields JSON validation failed',
], 422);
}
$formService = new FluentFormAppServicesFormFormService();
$form = $formService->update($data);
wp_send_json([
- 'message' => __('The form is successfully updated.', 'fluentform')
+ 'message' => __('The form is successfully updated.', 'fluentform'),
], 200);
} catch (Exception $exception) {
wp_send_json([
@@ -144,8 +144,14 @@
}
});
-$app->addAction('wp_ajax_fluentform-get-users', function () use ($app) {
- Acl::verify('fluentform_entries_viewer');
+$app->addAction('wp_ajax_fluentform-get-users', function () use ($app, $resolveSubmissionFormId) {
+ $submissionId = absint($app->request->get('submission_id'));
+ $formId = Acl::verifyFormId(
+ $resolveSubmissionFormId($submissionId),
+ 'Invalid submission id.'
+ );
+
+ Acl::verify('fluentform_manage_entries', $formId);
$search = sanitize_text_field($app->request->get('search'));
$users = get_users([
'search' => "*{$search}*",
@@ -241,7 +247,7 @@
'error' => 'You do not have permission to do this',
], 403);
}
-
+
wp_send_json([
'nonce' => wp_create_nonce('wp_rest'),
], 200);
--- a/fluentform/app/Hooks/actions.php
+++ b/fluentform/app/Hooks/actions.php
@@ -163,6 +163,7 @@
FluentFormAppModulesRegistererReviewQuery::register();
FluentFormAppModulesRegistererMigrationNotice::register();
FluentFormAppModulesRegistererStripeKeyNotice::register();
+ FluentFormAppModulesRegistererCaptchaKeyNotice::register();
}
});
@@ -969,6 +970,18 @@
$tokenBasedSpamProtection->verify($insertData, $requestData, $form->id);
}, 9, 3);
+// The token-based spam check (FINDING-25) enforces on conversational forms too, but its ~1h TTL
+// token cannot be refreshed by the conversational JS app — it bakes hidden inputs statically at
+// render, so behind a full-page cache the token expires and rejects every legitimate submission.
+// Disable ONLY the token for conversational forms, resolved from server-side form meta (never the
+// client-supplied isFFConversational flag, which was the original bypass). The honeypot still applies.
+$app->addFilter('fluentform/token_based_spam_protection_status', function ($status, $formId) {
+ if ($status && FluentFormAppHelpersHelper::isConversionForm($formId)) {
+ return false;
+ }
+ return $status;
+}, 10, 2);
+
// Maybe update current user allowed form ids,
// if current user has specific form permission and capable to create form
$app->addAction('fluentform/inserted_new_form', function ($formId) {
@@ -1039,6 +1052,10 @@
$note = $status;
}
+ $note = is_scalar($note)
+ ? sanitize_text_field(wp_unslash((string) $note))
+ : sanitize_text_field((string) wp_json_encode($note));
+
if (strlen($note) > 255) {
if (function_exists('mb_substr')) {
$note = mb_substr($note, 0, 251) . '...';
@@ -1070,6 +1087,10 @@
$note = $status;
}
+ $note = is_scalar($note)
+ ? sanitize_text_field(wp_unslash((string) $note))
+ : sanitize_text_field((string) wp_json_encode($note));
+
if (strlen($note) > 255) {
if (function_exists('mb_substr')) {
$note = mb_substr($note, 0, 251) . '...';
--- a/fluentform/app/Hooks/filters.php
+++ b/fluentform/app/Hooks/filters.php
@@ -133,22 +133,25 @@
if (!isset($captcha)) {
return $form;
}
- // place recaptcha below custom submit button
+ // place captcha below custom submit button
+ $formFields = $form->fields;
$hasCustomSubmit = false;
- foreach ($form->fields['fields'] as $index => $field) {
+ foreach ($formFields['fields'] as $index => $field) {
if (in_array($field['element'], ['recaptcha', 'hcaptcha', 'turnstile'])) {
- FluentFormFrameworkHelpersArrayHelper::forget($form->fields['fields'], $index);
+ FluentFormFrameworkHelpersArrayHelper::forget($formFields['fields'], $index);
}
if ('custom_submit_button' == $field['element']) {
$hasCustomSubmit = true;
- array_splice($form->fields['fields'], $index, 0, [$captcha]);
+ array_splice($formFields['fields'], $index, 0, [$captcha]);
break;
}
}
if (!$hasCustomSubmit) {
- $form->fields['fields'][] = $captcha;
+ $formFields['fields'][] = $captcha;
}
+ $form->fields = $formFields;
+
return $form;
}, 10, 1);
@@ -382,12 +385,12 @@
'status' => false,
'values' => [],
'message' => __('Sorry! You can't submit a form the country you are residing.', 'fluentform'),
- 'validation_type' => 'fail_on_condition_met'
+ 'validation_type' => 'fail_on_condition_met',
],
'keywords' => [
'status' => false,
'values' => '',
- 'message' => __('Sorry! Your submission contains some restricted keywords.', 'fluentform')
+ 'message' => __('Sorry! Your submission contains some restricted keywords.', 'fluentform'),
],
]
];
--- a/fluentform/app/Http/Controllers/FormIntegrationController.php
+++ b/fluentform/app/Http/Controllers/FormIntegrationController.php
@@ -2,6 +2,7 @@
namespace FluentFormAppHttpControllers;
+use FluentFormAppModulesAclAcl;
use FluentFormAppServicesIntegrationsFormIntegrationService;
class FormIntegrationController extends Controller
@@ -10,6 +11,14 @@
{
try {
$formId = (int) $formId;
+ // SECURITY (FINDING-09): returns full integration feed configurations (webhook
+ // URLs, headers, credential-like fields). The shared `index` method name resolved
+ // to FormPolicy@index (dashboard_access); require forms-manager on this form.
+ if (!Acl::hasPermission('fluentform_forms_manager', $formId)) {
+ return $this->sendError([
+ 'message' => __('You do not have permission to view these integrations.', 'fluentform'),
+ ], 403);
+ }
return $this->sendSuccess(
$integrationService->get($formId)
);
--- a/fluentform/app/Http/Controllers/FormSettingsController.php
+++ b/fluentform/app/Http/Controllers/FormSettingsController.php
@@ -3,6 +3,7 @@
namespace FluentFormAppHttpControllers;
use Exception;
+use FluentFormAppModulesAclAcl;
use FluentFormAppServicesSettingsCustomizer;
use FluentFormAppServicesSettingsSettingsService;
use FluentFormFrameworkValidatorValidationException;
@@ -12,8 +13,21 @@
{
public function index(SettingsService $settingsService, $formId)
{
+ $formId = (int) $formId;
+
+ // SECURITY (FINDING-09): this endpoint returns arbitrary form_meta by meta_key —
+ // including integration feeds that hold webhook Authorization headers/credentials.
+ // Because the method name collides with the forms-list controller, it resolved to
+ // FormPolicy@index (fluentform_dashboard_access, the lowest tier). Require the
+ // forms-manager capability, scoped to this form, to read its settings/meta.
+ if (!Acl::hasPermission('fluentform_forms_manager', $formId)) {
+ return $this->sendError([
+ 'message' => __('You do not have permission to view these settings.', 'fluentform'),
+ ], 403);
+ }
+
$attributes = $this->request->all();
- $attributes['form_id'] = (int) $formId;
+ $attributes['form_id'] = $formId;
$result = $settingsService->get($attributes);
--- a/fluentform/app/Http/Controllers/GlobalIntegrationController.php
+++ b/fluentform/app/Http/Controllers/GlobalIntegrationController.php
@@ -39,6 +39,11 @@
$settingsKey = sanitize_text_field($this->request->get('settings_key'));
$integration = wp_unslash($this->request->get('integration'));
+ // SECURITY (FINDING-16): connected credentials are redacted on read; restore any field
+ // the browser posted back still masked so a re-save (e.g. "Verify Connection Again")
+ // cannot overwrite a live credential with the '********' mask.
+ $integration = (new GlobalIntegrationService())->unmaskCredentials($settingsKey, $integration);
+
do_action_deprecated(
'fluentform_save_global_integration_settings_' . $settingsKey,
[
--- a/fluentform/app/Http/Controllers/McpSettingsController.php
+++ b/fluentform/app/Http/Controllers/McpSettingsController.php
@@ -0,0 +1,301 @@
+<?php
+
+namespace FluentFormAppHttpControllers;
+
+use FluentFormAppModulesMCPAbilitiesRegistrar;
+use FluentFormAppModulesMCPMCPInit;
+use FluentFormAppModulesMCPSupportPermissionGate;
+
+/**
+ * Backend for the FluentForm → Settings → MCP card.
+ *
+ * The MCP feature stores its on/off state in the dedicated, autoloaded
+ * _fluentform_mcp_settings option (PermissionGate::isEnabled/setEnabled). This
+ * controller owns the status / toggle / connection-snippet endpoints; the toggle
+ * is instant rather than riding the generic global-settings save.
+ */
+class McpSettingsController extends Controller
+{
+ const TOOLKIT_PLUGIN_FILE = 'fluent-toolkit/fluent-toolkit.php';
+
+ const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
+
+ const TOOLKIT_DOWNLOAD_URL = 'https://github.com/WPManageNinja/fluent-toolkit';
+
+ public function status()
+ {
+ // The route policy only asks for fluentform_settings_manager, but every
+ // control this payload drives is manage_options-only — and it discloses
+ // the endpoint URL and the full tool catalogue. Match the toggle's bar.
+ if (!current_user_can('manage_options')) {
+ return $this->sendError([
+ 'message' => __('Sorry, you do not have permission to view the MCP settings.', 'fluentform'),
+ ]);
+ }
+
+ $user = wp_get_current_user();
+
+ // Count the same catalogue the card lists; MCPInit::toolsCount() applies
+ // the Pro ability-names filter and would drift from the visible list.
+ // Count only actually-available tools — the greyed Pro teasers are not.
+ $tools = AbilitiesRegistrar::catalogue();
+ $availableCount = count(array_filter($tools, function ($tool) {
+ return !isset($tool['available']) || $tool['available'];
+ }));
+
+ return $this->sendSuccess([
+ 'mcp_enabled' => PermissionGate::isEnabled(),
+ 'adapter_available' => MCPInit::adapterAvailable(),
+ 'adapter_installed' => $this->isToolkitInstalled() || $this->isPluginInstalled(self::ADAPTER_PLUGIN_FILE),
+ 'toolkit_installed' => $this->isToolkitInstalled(),
+ 'can_auto_install' => (bool) apply_filters('fluent_toolkit/can_auto_install', false),
+ 'toolkit_download_url' => self::TOOLKIT_DOWNLOAD_URL,
+ 'endpoint_url' => MCPInit::getEndpointUrl(),
+ 'tools_count' => $availableCount,
+ 'tools' => $tools,
+ 'app_passwords_url' => admin_url('profile.php#application-passwords-section'),
+ 'plugins_url' => admin_url('plugins.php'),
+ 'current_user_login' => ($user && $user->exists()) ? $user->user_login : '',
+ 'is_local_dev' => $this->isLocalDev(),
+ ]);
+ }
+
+ public function toggle()
+ {
+ if (!current_user_can('manage_options')) {
+ return $this->sendError([
+ 'message' => __('Sorry, you do not have permission to change the MCP setting.', 'fluentform'),
+ ]);
+ }
+
+ $value = $this->request->get('mcp_enabled');
+ $enabled = is_string($value) ? in_array(strtolower($value), ['yes', 'true', '1', 'on'], true) : (bool) $value;
+
+ PermissionGate::setEnabled($enabled);
+
+ $stored = PermissionGate::isEnabled();
+
+ return $this->sendSuccess([
+ 'mcp_enabled' => $stored,
+ 'message' => $stored
+ ? __('MCP enabled. AI agents with a valid application password can now reach the FluentForm tools.', 'fluentform')
+ : __('MCP disabled. The endpoint will reject requests until re-enabled.', 'fluentform'),
+ ]);
+ }
+
+ public function installAdapter()
+ {
+ // Match the toggle's bar: enabling MCP and installing its adapter are
+ // both admin-only, so require manage_options in addition to the
+ // plugin-install capability.
+ if (!current_user_can('manage_options') || !current_user_can('install_plugins')) {
+ return $this->sendError([
+ 'message' => __('Sorry, you do not have permission to install plugins.', 'fluentform'),
+ ]);
+ }
+
+ $canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
+ if (!$canAutoInstall) {
+ return $this->sendError([
+ 'message' => __('Automatic install needs a Fluent Pro plugin. Install FluentHub / Fluent Toolkit manually, then reload this page to connect FluentForm with AI agents.', 'fluentform'),
+ 'toolkit_download_url' => self::TOOLKIT_DOWNLOAD_URL,
+ ]);
+ }
+
+ do_action('fluent_toolkit/do_auto_install');
+
+ wp_clean_plugins_cache();
+
+ $available = MCPInit::adapterAvailable();
+
+ return $this->sendSuccess([
+ 'adapter_available' => $available,
+ 'toolkit_installed' => $this->isToolkitInstalled(),
+ 'message' => $available
+ ? __('Adapter installed and activated. The MCP endpoint is ready.', 'fluentform')
+ : __('Adapter installed. Please reload this page to finish connecting the MCP endpoint.', 'fluentform'),
+ ]);
+ }
+
+ /**
+ * Connection snippets for every supported client. Credentials are never sent:
+ * each snippet carries placeholders the browser fills in, so an application
+ * password never round-trips through the server.
+ */
+ public function getConfigSnippets()
+ {
+ if (!current_user_can('manage_options')) {
+ return $this->sendError([
+ 'message' => __('Sorry, you do not have permission to view the MCP connection details.', 'fluentform'),
+ ]);
+ }
+
+ $endpoint = MCPInit::getEndpointUrl();
+
+ // Determined server-side only. This flag decides whether the Claude
+ // Desktop snippet disables TLS certificate validation, so a caller must
+ // not be able to ask for it — a request parameter here meant a snippet
+ // that skips certificate checks could be produced on a production host.
+ $isLocalDev = $this->isLocalDev();
+
+ $clients = ['claude-code', 'claude-desktop', 'cursor', 'codex', 'generic'];
+ $snippets = [];
+ foreach ($clients as $client) {
+ $snippets[$client] = $this->buildSnippet($client, $endpoint, $isLocalDev);
+ }
+
+ return $this->sendSuccess([
+ 'snippets' => $snippets,
+ 'endpoint' => $endpoint,
+ 'app_passwords_url' => admin_url('profile.php#application-passwords-section'),
+ 'is_local_dev' => $isLocalDev,
+ ]);
+ }
+
+ private function isToolkitInstalled()
+ {
+ if (defined('FLUENT_TOOLKIT_VERSION')) {
+ return true;
+ }
+
+ return $this->isPluginInstalled(self::TOOLKIT_PLUGIN_FILE);
+ }
+
+ private function isPluginInstalled($pluginFile)
+ {
+ if (!function_exists('get_plugins')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ $plugins = get_plugins();
+
+ return isset($plugins[$pluginFile]);
+ }
+
+ private function buildSnippet($client, $endpoint, $isLocalDev)
+ {
+ $basic = '<base64(your-username:application-password)>';
+ $user = '<your-username>';
+ $pass = '<your-application-password>';
+
+ switch ($client) {
+ case 'claude-desktop':
+ $env = [
+ 'WP_API_URL' => $endpoint,
+ 'WP_API_USERNAME' => $user,
+ 'WP_API_PASSWORD' => $pass,
+ 'OAUTH_ENABLED' => 'false',
+ ];
+ // Local installs typically run behind a self-signed certificate
+ // that Node rejects outright. Only ever emitted for a host this
+ // server itself recognises as local, and always with the warning
+ // below attached so nobody copies it onto a live site.
+ if ($isLocalDev) {
+ $env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
+ }
+ $snippet = wp_json_encode([
+ 'mcpServers' => [
+ 'fluentform' => [
+ 'command' => 'npx',
+ 'args' => ['-y', '@automattic/mcp-wordpress-remote@latest'],
+ 'env' => $env,
+ ],
+ ],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ $instructions = __('Add this to your Claude Desktop config (Settings → Developer → Edit Config), fill in your username + application password, then restart Claude Desktop.', 'fluentform');
+ if ($isLocalDev) {
+ $instructions .= ' ' . __('This site looks like a local development install, so the snippet sets NODE_TLS_REJECT_UNAUTHORIZED=0 to accept its self-signed certificate. That disables TLS verification for the client — remove that line before using this config against any site reachable over the internet.', 'fluentform');
+ }
+ break;
+
+ case 'cursor':
+ $snippet = wp_json_encode([
+ 'mcpServers' => [
+ 'fluentform' => [
+ 'url' => $endpoint,
+ 'type' => 'http',
+ 'headers' => ['Authorization' => 'Basic ' . $basic],
+ ],
+ ],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ $instructions = __('Fill your username and application password above — the base64 Authorization header is generated for you — then add this to Cursor’s mcp.json.', 'fluentform');
+ break;
+
+ case 'codex':
+ $snippet = "Settings → Connect to a custom MCPnn"
+ . "Name: fluentformn"
+ . "Transport: Streamable HTTPn"
+ . "URL: {$endpoint}nn"
+ . "Header:n Key: Authorizationn Value: Basic {$basic}";
+ $instructions = __('In Codex, add a custom MCP server with Streamable HTTP transport and the Authorization header above.', 'fluentform');
+ break;
+
+ case 'generic':
+ $snippet = "URL: {$endpoint}n"
+ . "Auth: Authorization: Basic {$basic}nn"
+ . "# Quick test (curl base64-encodes for you):n"
+ . "curl -s -u '{$user}:{$pass}' \n"
+ . " -X POST {$endpoint} \n"
+ . " -H 'Content-Type: application/json' \n"
+ . " -H 'Accept: application/json, text/event-stream' \n"
+ . ' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1.0"}}}'';
+ $instructions = __('Any MCP client that speaks Streamable HTTP can connect using this URL and a Basic auth header.', 'fluentform');
+ break;
+
+ case 'claude-code':
+ default:
+ $client = 'claude-code';
+ $snippet = "claude mcp add \n"
+ . " --transport http \n"
+ . " fluentform {$endpoint} \n"
+ . " --header "Authorization: Basic {$basic}"";
+ $instructions = __('Fill your username and application password above — the base64 Authorization header is generated for you — then run this in the terminal where Claude Code is installed.', 'fluentform');
+ break;
+ }
+
+ // The browser fills credential placeholders into this template; it must
+ // escape them for the snippet's syntax (JSON string vs single-quoted shell
+ // arg). Declaring the format here keeps that contract on one side.
+ $jsonClients = ['claude-desktop', 'cursor'];
+ $shellClients = ['generic', 'claude-code'];
+ if (in_array($client, $jsonClients, true)) {
+ $format = 'json';
+ } elseif (in_array($client, $shellClients, true)) {
+ $format = 'shell';
+ } else {
+ $format = 'text';
+ }
+
+ return [
+ 'client' => $client,
+ 'snippet' => $snippet,
+ 'instructions' => $instructions,
+ 'format' => $format,
+ ];
+ }
+
+ private function isLocalDev()
+ {
+ $host = '';
+ $home = home_url();
+ if ($home) {
+ $parsed = wp_parse_url($home, PHP_URL_HOST);
+ $host = $parsed ? strtolower($parsed) : '';
+ }
+
+ $isLocal = false;
+ if ($host) {
+ foreach (['.test', '.local', '.localhost', '.lab'] as $tld) {
+ if (substr($host, -strlen($tld)) === $tld) {
+ $isLocal = true;
+ break;
+ }
+ }
+ if (!$isLocal && in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
+ $isLocal = true;
+ }
+ }
+
+ return (bool) apply_filters('fluentform/mcp_is_local_dev', $isLocal, $host);
+ }
+}
--- a/fluentform/app/Http/Controllers/SubmissionController.php
+++ b/fluentform/app/Http/Controllers/SubmissionController.php
@@ -4,6 +4,7 @@
use Exception;
use FluentFormAppModelsSubmission;
+use FluentFormAppModulesAclAcl;
use FluentFormAppServicesSubmissionSubmissionService;
use FluentFormFrameworkSupportArr;
@@ -41,12 +42,24 @@
{
try {
$attributes = $this->request->all();
-
+
$sanitizeMap = [
'form_id' => 'intval',
];
$attributes = fluentform_backend_sanitizer($attributes, $sanitizeMap);
-
+
+ // SECURITY (FINDING-02): this route has no {entry_id} placeholder, so SubmissionPolicy
+ // authorizes the form owning the *request* entry_id, while resources() then reads a
+ // separate form_id — letting a form-scoped user read another form's counts/labels/
+ // fields and (via next/previous) submission rows. Re-verify the caller may view
+ // entries of the form actually being queried.
+ $formId = (int) Arr::get($attributes, 'form_id');
+ if (!$formId || !Acl::hasPermission('fluentform_entries_viewer', $formId)) {
+ return $this->sendError([
+ 'message' => __('You do not have permission to view this form's entries.', 'fluentform'),
+ ], 403);
+ }
+
return $this->sendSuccess(
$submissionService->resources($attributes)
);
@@ -57,10 +70,12 @@
}
}
- public function updateStatus(SubmissionService $submissionService)
+ public function updateStatus(SubmissionService $submissionService, $submissionId)
{
try {
- $status = $submissionService->updateStatus($this->request->all());
+ $attributes = $this->request->all();
+ $attributes['entry_id'] = intval($submissionId);
+ $status = $submissionService->updateStatus($attributes);
/* translators: %s is the submission status */
$message = sprintf(__('The submission has been marked as %s', 'fluentform'), $status);
@@ -76,11 +91,11 @@
}
}
- public function toggleIsFavorite(SubmissionService $submissionService)
+ public function toggleIsFavorite(SubmissionService $submissionService, $submissionId)
{
try {
[$message, $isFavourite] = $submissionService->toggleIsFavorite(
- $this->request->get('entry_id')
+ intval($submissionId)
);
return $this->sendSuccess([
@@ -106,7 +121,7 @@
]);
}
}
-
+
public function remove(SubmissionService $submissionService, $submissionId)
{
try {
@@ -124,19 +139,27 @@
]);
}
}
-
+
/**
* Get user list for submission page
+ *
* @return WP_REST_Response
*/
public function submissionUsers()
{
+ // SECURITY (FINDING-21): don't let a lower-tier user enumerate the whole WP roster here.
+ // Require WP's list_users OR the FF entries-manager permission this feature is built for —
+ // a delegated non-admin manager holds fluentform_manage_entries (and the assign-user UI is
+ // shown only to them) but NOT core list_users, so gating on list_users alone broke them.
+ if (!current_user_can('list_users') && !current_user_can('fluentform_manage_entries')) {
+ return $this->sendError(['message' => __('You do not have permission to list users.', 'fluentform')], 403);
+ }
$search = sanitize_text_field($this->request->get('search'));
$users = get_users([
'search' => "*{$search}*",
'number' => 50,
]);
-
+
$formattedUsers = [];
foreach ($users as $user) {
$formattedUsers[] = [
@@ -144,7 +167,7 @@
'label' => $user->display_name . ' - ' . $user->user_email,
];
}
-
+
return $this->sendSuccess([
'users' => $formattedUsers,
]);
@@ -152,16 +175,16 @@
/**
* Update User of a submission
+ *
* @param SubmissionService $submissionService
+ * @param int $submissionId
* @return WP_REST_Response
*/
- public function updateSubmissionUser(SubmissionService $submissionService)
+ public function updateSubmissionUser(SubmissionService $submissionService, $submissionId)
{
try {
$userId = intval($this->request->get('user_id'));
- // Use entry_id from route parameter — not submission_id from body —
- // to ensure authorization target matches the mutation target.
- $submissionId = intval($this->request->get('entry_id'));
+ $submissionId = intval($submissionId);
$response = $submissionService->updateSubmissionUser($userId, $submissionId);
return $this->sendSuccess($response);
} catch (Exception $e) {
@@ -170,9 +193,10 @@
]);
}
}
-
+
/**
* Get All Submissions
+ *
* @param Submission $submission
* @return WP_REST_Response
*/
@@ -192,6 +216,7 @@
}
/**
* Get printable content
+ *
* @param SubmissionService $submissionService
* @return WP_REST_Response
*/
@@ -199,9 +224,9 @@
{
try {
$attributes = $this->request->all();
-
+
$sanitizeMap = [
- 'submission_ids' => function($value) {
+ 'submission_ids' => function ($value) {
if (is_array($value)) {
return array_map('intval', $value);
}
@@ -219,13 +244,13 @@
? array_map('intval', $entryIds)
: [];
}
-
+
return $this->sendSuccess(
$submissionService->getPrintContent($attributes)
);
} catch (Exception $e) {
return $this->sendError([
- 'message' => $e->getMessage()
+ 'message' => $e->getMessage(),
]);
}
}
--- a/fluentform/app/Http/Policies/ReportPolicy.php
+++ b/fluentform/app/Http/Policies/ReportPolicy.php
@@ -56,6 +56,29 @@
return !$userId || !FormManagerService::hasSpecificFormsPermission($userId);
}
+ private function canAccessPaymentReport(Request $request)
+ {
+ // Payment/revenue/subscription data requires the dedicated, form-scoped
+ // payment-view capability — the entry-view capability must not authorize
+ // financial aggregates.
+ $formId = $this->resolveFormId($request);
+
+ if ($formId) {
+ return Acl::hasPermission('fluentform_view_payments', $formId);
+ }
+
+ if (!Acl::hasPermission('fluentform_view_payments')) {
+ return false;
+ }
+
+ // A specific-forms manager must NOT read the all-forms (form_id 0)
+ // payment view — otherwise the per-form scope is bypassed by loading the
+ // aggregate revenue/subscription report. Mirrors canAccessOptionalFormScopedReport.
+ $userId = get_current_user_id();
+
+ return !$userId || !FormManagerService::hasSpecificFormsPermission($userId);
+ }
+
/**
* Check permission for any method
*
@@ -84,7 +107,7 @@
public function getRevenueChart(Request $request)
{
- return $this->canAccessRequestedForm($request);
+ return $this->canAccessPaymentReport($request);
}
public function getFormStats(Request $request)
@@ -99,7 +122,7 @@
public function getPaymentTypes(Request $request)
{
- return $this->canAccessRequestedForm($request);
+ return $this->canAccessPaymentReport($request);
}
public function getCompletionRate(Request $request)
@@ -124,7 +147,7 @@
public function getSubscriptions(Request $request)
{
- return $this->canAccessOptionalFormScopedReport($request);
+ return $this->canAccessPaymentReport($request);
}
public function getFormsDropdown(Request $request)
@@ -134,7 +157,7 @@
public function netRevenue(Request $request)
{
- return $this->canAccessOptionalFormScopedReport($request);
+ return $this->canAccessPaymentReport($request);
}
public function submissionsAnalysis(Request $request)
--- a/fluentform/app/Http/Policies/SubmissionPolicy.php
+++ b/fluentform/app/Http/Policies/SubmissionPolicy.php
@@ -61,6 +61,11 @@
return Acl::hasPermission('fluentform_manage_entries', $formId);
}
+ public function submissionUsers(Request $request)
+ {
+ return $this->updateSubmissionUser($request);
+ }
+
/**
* Resolve the form_id for authorization.
*
--- a/fluentform/app/Http/Routes/api.php
+++ b/fluentform/app/Http/Routes/api.php
@@ -1,8 +1,10 @@
<?php
-defined('ABSPATH') or die;
+defined('ABSPATH') || exit;
/**
+ * REST API route definitions.
+ *
* @var $router FluentFormFrameworkHttpRouter
*/
@@ -67,7 +69,7 @@
$router->prefix('{entry_id}')->group(function ($router) {
$router->get('/', 'SubmissionController@find');
-
+
$router->post('status', 'SubmissionController@updateStatus');
$router->post('is-favorite', 'SubmissionController@toggleIsFavorite');
@@ -76,9 +78,9 @@
$router->get('notes', 'SubmissionNoteController@get');
$router->post('notes', 'SubmissionNoteController@store');
-
- $router->get('submission-users','SubmissionController@submissionUsers');
- $router->post('update-submission-user','SubmissionController@updateSubmissionUser');
+
+ $router->get('submission-users', 'SubmissionController@submissionUsers');
+ $router->post('update-submission-user', 'SubmissionController@updateSubmissionUser');
});
});
@@ -97,7 +99,7 @@
$router->get('/', 'GlobalIntegrationController@index')->withPolicy('GlobalIntegrationPolicy');
$router->post('/', 'GlobalIntegrationController@updateIntegration')->withPolicy('GlobalIntegrationPolicy');
$router->post('update-status', 'GlobalIntegrationController@updateModuleStatus')->withPolicy('GlobalIntegrationPolicy');
-
+
/*
* Form Integrations
*/
@@ -106,7 +108,7 @@
$router->get('/', 'FormIntegrationController@find');
$router->post('/', 'FormIntegrationController@update');
$router->delete('/', 'FormIntegrationController@delete');
-
+
$router->get('/integration-list-id', 'FormIntegrationController@integrationListComponent');
});
});
@@ -118,6 +120,15 @@
$router->post('/', 'GlobalSettingsController@store');
});
/*
+* MCP (Model Context Protocol) Settings
+*/
+$router->prefix('mcp')->withPolicy('GlobalSettingsPolicy')->group(function ($router) {
+ $router->get('status', 'McpSettingsController@status');
+ $router->post('toggle', 'McpSettingsController@toggle');
+ $router->post('install-adapter', 'McpSettingsController@installAdapter');
+ $router->get('config-snippets', 'McpSettingsController@getConfigSnippets');
+});
+/*
* Permission Roles
*/
$router->prefix('roles')->withPolicy('RoleManagerPolicy')->group(function ($router) {
--- a/fluentform/app/Models/Form.php
+++ b/fluentform/app/Models/Form.php
@@ -6,6 +6,23 @@
use FluentFormFrameworkSupportArr;
use FluentFormAppModelsTraitsPredefinedForms;
+/**
+ * Column properties resolved at runtime via the ORM's magic __get; declared
+ * here so static analysis can verify attribute access against the real schema
+ * (database/Migrations/Forms.php).
+ *
+ * @property int $id
+ * @property string $title
+ * @property string|null $status
+ * @property string|null $appearance_settings
+ * @property string|null $form_fields
+ * @property int $has_payment
+ * @property string|null $type
+ * @property string|null $conditions
+ * @property int|null $created_by
+ * @property string|null $created_at
+ * @property string|null $updated_at
+ */
class Form extends Model
{
use PredefinedForms;
--- a/fluentform/app/Models/Submission.php
+++ b/fluentform/app/Models/Submission.php
@@ -7,6 +7,33 @@
use FluentFormAppServicesManagerFormManagerService;
use FluentFormFrameworkSupportArr;
+/**
+ * Column properties resolved at runtime via the ORM's magic __get; declared
+ * here so static analysis can verify attribute access against the real schema
+ * (database/Migrations/Submissions.php).
+ *
+ * @property int $id
+ * @property int|null $form_id
+ * @property int|null $serial_number
+ * @property string|null $response
+ * @property string|null $source_url
+ * @property int|null $user_id
+ * @property string|null $status
+ * @property int $is_favourite
+ * @property string|null $browser
+ * @property string|null $device
+ * @property string|null $ip
+ * @property string|null $city
+ * @property string|null $country
+ * @property string|null $payment_status
+ * @property string|null $payment_method
+ * @property string|null $payment_type
+ * @property string|null $currency
+ * @property float|null $payment_total
+ * @property float|null $total_paid
+ * @property string|null $created_at
+ * @property string|null $updated_at
+ */
class Submission extends Model
{
/**
--- a/fluentform/app/Models/Subscription.php
+++ b/fluentform/app/Models/Subscription.php
@@ -2,6 +2,8 @@
namespace FluentFormAppModels;
+use FluentFormAppHelpersHelper;
+
class Subscription extends Model
{
/**
@@ -43,12 +45,12 @@
public function getOriginalPlanAttribute($value)
{
- return maybe_unserialize($value);
+ return Helper::safeUnserialize($value);
}
public function getVendorResponseAttribute($value)
{
- return maybe_unserialize($value);
+ return Helper::safeUnserialize($value);
}
public function scopeBySubmission($query, $submissionId)
--- a/fluentform/app/Models/Traits/PredefinedForms.php
+++ b/fluentform/app/Models/Traits/PredefinedForms.php
@@ -9,6 +9,21 @@
{
public static function resolvePredefinedForm($attributes = [])
{
+ // A create request must name a template. Without this, Arr::get($map, null)
+ // inside findPredefinedForm() returns the WHOLE template map — a non-empty
+ // array that sails past both guards below and yields a form persisted with
+ // NULL form_fields. findPredefinedForm() itself keeps that whole-map
+ // behaviour on purpose: FormService::templates() depends on it.
+ $hasTemplateId = Arr::get($attributes, 'predefined')
+ || 'blank_conversational' === Arr::get($attributes, 'type');
+
+ if (!$hasTemplateId) {
+ throw new Exception(
+ // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message, not output
+ __("The selected template couldn't be found.", 'fluentform')
+ );
+ }
+
$predefinedForm = static::findPredefinedForm($attributes);
if (!$predefinedForm) {
--- a/fluentform/app/Modules/Acl/Acl.php
+++ b/fluentform/app/Modules/Acl/Acl.php
@@ -436,6 +436,21 @@
$user->add_cap($permission);
}
+ /**
+ * Fires after per-user FluentForm permissions are attached.
+ *
+ * Role-level changes already announce themselves via
+ * fluentform/after_permission_set_assignment; this is the per-user
+ * equivalent, so caches keyed on a user's effective permissions can be
+ * invalidated when an individual manager is granted or revoked.
+ *
+ * @since 6.2.5
+ *
+ * @param WP_User $user The user whose permissions changed.
+ * @param array $permissions The permissions now attached.
+ */
+ do_action('fluentform/after_user_permissions_attached', $user, $permissions);
+
return $user;
}
}
--- a/fluentform/app/Modules/Ai/AiFormBuilder.php
+++ b/fluentform/app/Modules/Ai/AiFormBuilder.php
@@ -2,7 +2,7 @@
namespace FluentFormAppModulesAi;
-defined('ABSPATH') or die;
+defined('ABSPATH') || die;
use Exception;
use FluentFormAppHelpersHelper;
@@ -47,6 +47,8 @@
}
/**
+ * Map the AI-generated field list into a persisted form.
+ *
* @param array $form
* @return Form|FluentFormFrameworkDatabaseQueryBuilder
* @throws Exception
@@ -88,6 +90,8 @@
}
/**
+ * Send the prompt to the AI service and return the decoded form fields.
+ *
* @param array $args
* @return array response form fields
* @throws Exception
@@ -108,7 +112,7 @@
'site_title' => get_bloginfo('name'),
'site_locale' => determine_locale(),
'has_pro' => Helper::hasPro(),
- 'has_payment' => $paymentSetting['status'] == 'yes',
+ 'has_payment' => 'yes' == $paymentSetting['status'],
'request_id' => uniqid('ff_ai_'),
'save_usage' => apply_filters('fluentform/ai_save_usage', true),
];
@@ -118,9 +122,12 @@
if (is_wp_error($result)) {
throw new Exception(esc_html($result->get_error_message()));
}
-
+
$response = trim(Arr::get($result, 'response', ''), '"');
- if (false !== preg_match('/```json(.*?)```/s', $response, $matches)) {
+ // preg_match() returns 0 when there is no fence and false only on error,
+ // so this must test for an actual match — otherwise an unfenced (and
+ // perfectly valid) JSON reply is replaced with an empty string.
+ if (1 === preg_match('/```json(.*?)```/s', $response, $matches)) {
$response = trim($matches[1]);
}
@@ -130,20 +137,22 @@
}
return $this->applyPromptHints($decoded, $args);
}
-
+
protected function getDefaultFields()
{
if ($this->allDefaultFields) {
return $this->allDefaultFields;
}
- /**
- * @var FluentFormAppServicesFormBuilderComponents
- */
$components = $this->app->make('components');
$this->app->doAction('fluentform/editor_init', $components);
$editorComponents = $components->toArray();
- $general = Arr::get($editorComponents, 'general', []);
- $advanced = Arr::get($editorComponents, 'advanced', []);
+ // Re-key by element name. The palette groups are keyed by element in
+ // DefaultElements.php, but Components::sort() renumbers them 0..n for
+ // the editor's JSON contract - so whether these arrive keyed or as a
+ // list depends on whether anything rendered the palette earlier in the
+ // request. resolveInput() matches on the key, so normalise here.
+ $general = array_column(Arr::get($editorComponents, 'general', []), null, 'element');
+ $advanced = array_column(Arr::get($editorComponents, 'advanced', []), null, 'element');
$container = Arr::get($editorComponents, 'container', []);
// Apply filter to get additional components
@@ -164,7 +173,7 @@
$this->allDefaultFields = array_merge($general, $payments, $advanced, ['container' => $container]);
return $this->allDefaultFields;
}
-
+
protected function processField($element, $field, $allFields)
{
if ('container' == $element) {
@@ -189,7 +198,7 @@
$formatField['attributes'] = wp_parse_args($attributes, $matchedField['attributes']);
}
- $formatField['uniqElKey'] = "el_" . uniqid();
+ $formatField['uniqElKey'] = 'el_' . uniqid();
if ('form_step' === $element) {
return $formatField;
@@ -227,10 +236,10 @@
}
}
}
-
+
return $formatField;
}
-
+
protected function resolveInput($field)
{
if (!is_array($field)) {
@@ -264,7 +273,7 @@
}
return false;
}
-
+
protected function getOptions($options = [])
{
$formattedOptions = [];
@@ -292,10 +301,10 @@
'value' => $value,
];
}
-
+
return $formattedOptions;
}
-
+
protected function getBlankFormConfig()
{
$attributes = ['type' => 'form', 'predefined' => 'blank_form'];
@@ -305,16 +314,16 @@
$customForm['form_fields'] = json_encode($customForm['form_fields']);
return $customForm;
}
-
+
protected function saveForm($formattedInputs, $title, $isStepForm = false, $isConversational = false, $customCss = '')
{
$customForm = $this->prepareCustomForm($formattedInputs, $isStepForm);
$data = Form::prepare($customForm);
$form = $this->model->create($data);
- $form->title = $title ?: $form->title . ' (ChatGPT#' . $form->id . ')';
+ $form->title = $title ? $title : $form->title . ' (ChatGPT#' . $form->id . ')';
- $formData = (object)$form->toArray();
+ $formData = (object) $form->toArray();
if (FormFieldsParser::hasPaymentFields($formData)) {
$form->has_payment = 1;
}
@@ -334,7 +343,7 @@
if ($customCss = fluentformSanitizeCSS($customCss)) {
Helper::setFormMeta($form->id, '_custom_form_css', $customCss);
}
-
+
do_action('fluentform/inserted_new_form', $form->id, $data);
return $form;
}
@@ -389,6 +398,8 @@
}
/**
+ * Build the step-wrapper skeleton used to wrap a multi-step form.
+ *
* @return array
*/
protected function getStepWrapper()
@@ -409,7 +420,7 @@
'enable_step_page_resume' => 'no',
],
'editor_options' => [
- 'title' => 'Start Paging'
+ 'title' => 'Start Paging',
],
],
'stepEnd' => [
@@ -422,38 +433,48 @@
'prev_btn' => [
'type' => 'default',
'text' => 'Previous',
- 'img_url' => ''
- ]
+ 'img_url' => '',
+ ],
],
'editor_options' => [
- 'title' => 'End Paging'
+ 'title' => 'End Paging',
],
- ]
+ ],
];
}
-
+
private function getUserPrompt($args)
{
- $startingQuery = "Create a form for ";
+ $startingQuery = 'Create a form for ';
$query = Sanitizer::sanitizeTextField(Arr::get($args, 'query'));
if (empty($query)) {
throw new Exception(esc_html__('Query is empty!', 'fluentform'));
}
-
- // Validate query length to prevent abuse (max 2000 characters)
- if (strlen($query) > 2000) {
- throw new Exception(esc_html__('Query is too long. Please limit your prompt to 2000 characters.', 'fluentform'));
+
+ // Validate query length to prevent abuse (filterable; default 12000 characters)
+ $maxQueryLength = (int) apply_filters('fluentform/ai_query_max_length', 12000);
+ if (mb_strlen($query) > $maxQueryLength) {
+ throw new Exception(esc_html(sprintf(
+ /* translators: %d is the maximum allowed number of characters */
+ __('Query is too long. Please limit your prompt to %d characters.', 'fluentform'),
+ $maxQueryLength
+ )));
}
-
+
$additionalQuery = Sanitizer::sanitizeTextField(Arr::get($args, 'additional_query'));
-
- // Validate additional query length (max 1000 characters)
- if ($additionalQuery && strlen($additionalQuery) > 1000) {
- throw new Exception(esc_html__('Additional query is too long. Please limit to 1000 characters.', 'fluentform'));
+
+ // Validate additional query length (filterable; default 6000 characters)
+ $maxAdditionalQueryLength = (int) apply_filters('fluentform/ai_additional_query_max_length', 6000);
+ if ($additionalQuery && mb_strlen($additionalQuery) > $maxAdditionalQueryLength) {
+ throw new Exception(esc_html(sprintf(
+ /* translators: %d is the maximum allowed number of characters */
+ __('Additional query is too long. Please limit to %d characters.', 'fluentform'),
+ $maxAdditionalQueryLength
+ )));
}
-
+
if ($additionalQuery) {
- $query .= "n including questions for information like " . $additionalQuery . ".";
+ $query .= "n including questions for information like " . $additionalQuery . '.';
}
return $startingQuery . $query . $this->getPromptContractInstructions();
}
@@ -462,9 +483,9 @@
{
return "nnReturn strict JSON only. The user's instructions may be written in any language. "
. "Preserve labels and help text in the user's original language, but always return machine-readable field settings. "
- . "For every field, explicitly set whether it is required in settings.validation_rules.required.value when the prompt marks it as required or optional. "
- . "If the prompt lists allowed upload extensions, map them into settings.validation_rules.allowed_file_types.value. "
- . "If the prompt asks for a multi-step form with sections, include form_step elements between sections.";
+ . 'For every field, explicitly set whether it is required in settings.validation_rules.required.value when the prompt marks it as required or optional. '
+ . 'If the prompt lists allowed upload extensions, map them into settings.validation_rules.allowed_file_types.value. '
+ . 'If the prompt asks for a multi-step form with sections, include form_step elements between sections.';
}
private function applyPromptHints(array $form, array $args)
@@ -510,7 +531,7 @@
}
$hint = Arr::get($hints, $hintIndex);
- $hintIndex++;
+ ++$hintIndex;
if (!$hint) {
continue;
@@ -676,7 +697,7 @@
'wymagane',
'zorunlu',
'必填',
- '必須'
+ '必須',
];
}
@@ -691,7 +712,7 @@
'opcional',
'opcionales',
'选填',
- '任意'
+ '任意',
];
}
@@ -711,7 +732,7 @@
'caricamento file',
'bestandsupload',
'yükleme',
- '上传'
+ '上传',
];
}
@@ -747,7 +768,7 @@
'ficheiro',
'bestand',
'yükleme',
- '上传'
+ '上传',
];
}
}
--- a/fluentform/app/Modules/Entries/Report.php
+++ b/fluentform/app/Modules/Entries/Report.php
@@ -1,352 +0,0 @@
-<?php
-
-namespace FluentFormAppModulesEntries;
-
-use FluentFormAppHelpersHelper;
-use FluentFormAppModulesFormFormFieldsParser;
-use FluentFormAppServicesSubmissionSubmissionService;
-use FluentFormFrameworkFoundationApplication;
-use FluentFormFrameworkHelpersArrayHelper;
-
-/**
- *
- * @deprecated use FluentFormAppServicesReportReportHelper
- * all used method reference updated with new ReportHelper, except Fluentformpro
- *
- */
-class Report
-{
- private $app;
- private $formModel;
-
- public function __construct(Application $app)
- {
- $this->app = $app;
- $this->formModel = wpFluent()->table('fluentform_forms');
- }
-
- /**
- * Get report
- *
- * @param bool $formId
- */
- public function getReport($formId = false)
- {
- if (!$formId) {
- $formId = intval($this->app->request->get('form_id'));
- }
-
- $this->maybeMigrateData($formId);
-
- $statuses = $this->app->request->get('statuses');
-
- $form = $this->formModel->find($formId);
-
- $report = $this->generateReport($form, $statuses);
-
- wp_send_json_success($report);
- }
-
- public function generateReport($form, $statuses = [])
- {
- $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'element', 'options']);
-
- $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
-
- $elements = [];
-
- foreach ($formInputs as $inputName => $input) {
- $elements[$inputName] = $input['element'];
- if ('select_country' == $input['element']) {
- $formInputs[$inputName]['options'] = getFluentFormCountryList();
- }
- }
-
- $reportableInputs = Helper::getReportableInputs();
-
- $formReportableInputs = array_intersect($reportableInputs, array_values($elements));
-
- $reportableInputs = Helper::getSubFieldReportableInputs();
- $formSubFieldInputs = array_intersect($reportableInputs, array_values($elements));
-
- if (!$formReportableInputs && !$formSubFieldInputs) {
- return [
- 'report_items' => (object) [],
- 'total_entries' => 0,
- ];
- }
-
- $inputs = [];
- $subfieldInputs = [];
- foreach ($elements as $elementKey => $element) {
- if (in_array($element, $formReportableInputs)) {
- $inputs[$elementKey] = $element;
- }
- if (in_array($element, $formSubFieldInputs)) {
- $subfieldInputs[$elementKey] = $element;
- }
- }
-
- $whereClasuses = [];
-
- if ($statuses) {
- $whereClasuses['fluentform_submissions.status'] = [
- 'method' => 'whereIn',
- 'values' => $statuses,
- ];
- }
-
- $reports = $this->getInputReport($form->id, array_keys($inputs), $whereClasuses);
-
- $subFieldReports = $this->getSubFieldInputReport($form->id, array_keys($subfieldInputs), $whereClasuses);
-
- $reports = array_merge($reports, $subFieldReports);
-
- foreach ($reports as $reportKey => $report) {
- $reports[$reportKey]['label'] = $inputLabels[$reportKey];
- $reports[$reportKey]['element'] = ArrayHelper::get($inputs, $reportKey, []);
- $reports[$reportKey]['options'] = $formInputs[$reportKey]['options'];
- }
-
- return [
- 'report_items' => $reports,
- 'total_entries' => $this->getEntryCounts($form->id, $statuses),
- 'browsers' => $this->getbrowserCounts($form->id, $statuses),
- 'devices' => $this->getDeviceCounts($form->id, $statuses),
- ];
- }
-
- public function getInputReport($formId, $fieldNames, $whereClasuses)
- {
- if (!$fieldNames) {
- return [];
- }
- global $wpdb;
- $reportQuery = wpFluent()->table('fluentform_entry_details')
- ->select([
- 'fluentform_entry_details.field_name',
- 'fluentform_entry_details.sub_field_name',
- 'fluentform_entry_details.field_value',
- wpFluent()->raw('count(' . $wpdb->prefix . 'fluentform_entry_details.field_name) as total_count')
- ])
- ->where('fluentform_entry_details.form_id', $formId)
- ->whereIn('fluentform_entry_details.field_name', $fieldNames)
- ->rightJoin('fluentform_submissions', 'fluentform_submissions.id', '=', 'fluentform_entry_details.submission_id');
-
- if ($whereClasuses) {
- foreach ($whereClasuses as $clauseColumn => $clasus) {
- $reportQuery = $reportQuery->{$clasus['method']}($clauseColumn, $clasus['values']);
- }
- }
-
- $reports = $reportQuery->groupBy(['fluentform_entry_details.field_name', 'fluentform_entry_details.field_value'])
- ->get();
-
- $formattedReports = [];
- foreach ($reports as $report) {
- $formattedReports[$report->field_name]['reports'][] = [
- 'value' => Helper::safeUnserialize($report->field_value),
- 'count' => $report->total_count,
- 'sub_field' => $report->sub_field_name,
- ];
- $formattedReports[$report->field_name]['total_entry'] = $this->getEntryTotal($report->field_name, $formId, $whereClasuses);
- }
-
- return $formattedReports;
- }
-
- public function getSubFieldInputReport($formId, $fieldNames, $whereClasuses)
- {
- if (!$fieldNames) {
- return [];
- }
-
- global $wpdb;
- $reportQuery = wpFluent()->table('fluentform_entry_details')
- ->select([
- 'fluentform_entry_details.field_name',
- 'fluentform_entry_details.sub_field_name',
- 'fluentform_entry_details.field_value',
- wpFluent()->raw('count(' . $wpdb->prefix . 'fluentform_entry_details.field_name) as total_count')
- ])
- ->where('fluentform_entry_details.form_id', $formId)
- ->whereIn('fluentform_entry_details.field_name', $fieldNames)
- ->leftJoin('fluentform_submissions', 'fluentform_submissions.id', '=', 'fluentform_entry_details.submission_id');
-
- if ($whereClasuses) {
- foreach ($whereClasuses as $clauseColumn => $clasus) {
- $reportQuery = $reportQuery->{$clasus['method']}($clauseColumn, $clasus['values']);
- }
- }
-
- $reports = $reportQuery->groupBy(['fluentform_entry_details.field_name', 'fluentform_entry_details.field_value', 'fluentform_entry_details.sub_field_name'])
- ->get();
-
- return $this->getFormattedReportsForSubInputs($reports, $formId, $whereClasuses);
- }
-
- protected function getFormattedReportsForSubInputs($reports, $formId, $whereClasuses)
- {
- if (!count($reports)) {
- return [];
- }
-
- $formattedReports = [];
-
- foreach ($reports as $report) {
- $this->setReportForSubInput((array) $report, $formattedReports);
- }
-
- foreach ($formattedReports as $fieldName => $val) {
- $formattedReports[$fieldName]['total_entry'] = $this->getEntryTotal(
- $report->field_name,
- $formId,
- $whereClasuses
- );
-
- $formattedReports[$fieldName]['reports'] = array_values(
- $formattedReports[$fieldName]['reports']
- );
- }
-
- return $formattedReports;
- }
-
- protected function setReportForSubInput($report, &$formattedReports)
- {
- $filedValue = Helper::safeUnserialize($report['field_value']);
-
- if (is_array($file