PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Tools / StylingTools.php

StylingTools.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/MCP/Tools/StylingTools.php

321 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Tools;
4
5 defined('ABSPATH') || exit;
6
7 use FluentForm\App\Helpers\Helper;
8 use FluentForm\App\Modules\MCP\Support\ErrorCodes;
9 use FluentForm\App\Modules\MCP\Support\FormAccess;
10 use FluentForm\App\Modules\MCP\Support\MCPHelper;
11 use FluentForm\App\Modules\MCP\Support\Mutation;
12 use FluentForm\App\Modules\MCP\Support\WriteGuard;
13 use FluentForm\App\Services\Settings\Customizer;
14 use FluentForm\Framework\Support\Arr;
15
16 /**
17 * Form styling tools.
18 *
19 * Read/write a form's theme preset and custom CSS. Theme presets are always
20 * writable; custom CSS writes only when the user holds unfiltered_html — the same
21 * gate the admin styler enforces. Custom JS is intentionally not writable here
22 * (that surface stays in the form styler UI).
23 */
24 class StylingTools
25 {
26 const STYLE_META = ['_ff_selected_style', '_ff_form_styles', '_custom_form_css', '_custom_form_js'];
27
28 public static function definitions()
29 {
30 return [
31 'fluentform/get-form-styling' => [
32 'label' => __('Get Form Styling', 'fluentform'),
33 'group' => __('Design', 'fluentform'),
34 'description' => __('Read a form\'s styling: theme preset (styler_theme), structured styles (styler_styles), and any custom CSS. Requires form_id.', 'fluentform'),
35 'input_schema' => [
36 'type' => 'object',
37 'properties' => [
38 'form_id' => ['type' => 'integer', 'description' => 'Required. The form to read styling for.'],
39 ],
40 'required' => ['form_id'],
41 ],
42 'execute_callback' => [self::class, 'getStyling'],
43 'capability' => 'fluentform_forms_manager',
44 'annotations' => ['readonly' => true],
45 ],
46
47 'fluentform/update-form-styling' => [
48 'label' => __('Update Form Styling', 'fluentform'),
49 'group' => __('Design', 'fluentform'),
50 'description' => __('Update a form\'s styling. styler_theme (preset id) is always writable. Custom css is written only if you hold the unfiltered_html capability; otherwise the call returns unfiltered_html_required and changes nothing. Pass only the keys you want to change. Call once with dry_run:true to preview the before/after and get a confirm_token, then call again with the same values plus confirm_token to execute. Requires form_id. (Structured styler_styles are read-only via get-form-styling; edit them in the form styler UI. Custom JS is not editable through MCP.)', 'fluentform'),
51 'input_schema' => [
52 'type' => 'object',
53 'properties' => array_merge([
54 'form_id' => ['type' => 'integer', 'description' => 'Required. The form to restyle.'],
55 'styler_theme' => ['type' => 'string', 'description' => 'Theme preset id (e.g. ffs_default).'],
56 'css' => ['type' => 'string', 'description' => 'Custom CSS (requires unfiltered_html).'],
57 ], WriteGuard::schemaProps()),
58 'required' => ['form_id'],
59 // Unknown keys (e.g. styler_styles, js) must fail loudly, not be
60 // silently dropped — agents need the signal.
61 'additionalProperties' => false,
62 ],
63 'execute_callback' => [self::class, 'updateStyling'],
64 'capability' => 'fluentform_forms_manager',
65 ],
66 ];
67 }
68
69 /**
70 * Every preset id this install can apply, from the Pro styler when available
71 * else the free fallbacks — the same source DefaultStyleApplicator uses.
72 *
73 * @return array<string, array>
74 */
75 public static function presets()
76 {
77 if (class_exists('\FluentFormPro\classes\FormStyler')) {
78 $presets = (new \FluentFormPro\classes\FormStyler())->getPresets();
79
80 return is_array($presets) ? $presets : [];
81 }
82
83 return [
84 'ffs_default' => ['style' => '[]'],
85 'ffs_inherit_theme' => ['style' => '{}'],
86 ];
87 }
88
89 /** The preset ids valid on this install, for validation + error hints. */
90 public static function presetIds()
91 {
92 return array_keys(self::presets());
93 }
94
95 /**
96 * The structured styles a preset id maps to. Null when the preset is unknown
97 * or carries unparseable styles.
98 */
99 private static function presetStyles($theme)
100 {
101 $presets = self::presets();
102
103 if (!isset($presets[$theme]['style'])) {
104 return null;
105 }
106
107 $styles = json_decode($presets[$theme]['style'], true);
108
109 return is_array($styles) ? $styles : null;
110 }
111
112 /**
113 * The form's live style state, read fresh from the DB. The forced read busts
114 * Helper's per-request meta cache, so a re-read inside the write transaction
115 * reflects a concurrent styler save rather than the value read on entry.
116 */
117 private static function freshStyleState($formId)
118 {
119 Helper::getFormMeta($formId, '_ff_selected_style', '', true);
120
121 return (new Customizer())->get($formId, self::STYLE_META);
122 }
123
124 /**
125 * Fingerprint of the style state the confirm_token is bound to. Both sides of
126 * every comparison must read through freshStyleState so identical data always
127 * yields the same string (Customizer::get drops empty metas; a raw read does not).
128 */
129 private static function styleFingerprint($theme, $css)
130 {
131 return 'styling:' . md5(wp_json_encode([$theme, $css]));
132 }
133
134 public static function getStyling($params = [])
135 {
136 $form = FormAccess::resolveForm($params);
137 if (is_wp_error($form)) {
138 return $form;
139 }
140 $formId = (int) $form->id;
141
142 $styling = (new Customizer())->get($formId, self::STYLE_META);
143
144 return MCPHelper::envelope(
145 sprintf(
146 /* translators: %s: form title */
147 __('Styling for "%s" loaded.', 'fluentform'),
148 $form->title
149 ),
150 [
151 'form_id' => $formId,
152 'styler_theme' => Arr::get($styling, 'styler_theme'),
153 'styler_styles' => Arr::get($styling, 'styler_styles'),
154 'css' => Arr::get($styling, 'css'),
155 ]
156 );
157 }
158
159 public static function updateStyling($params = [])
160 {
161 $form = FormAccess::resolveForm($params);
162 if (is_wp_error($form)) {
163 return $form;
164 }
165 $formId = (int) $form->id;
166
167 $hasTheme = array_key_exists('styler_theme', $params);
168 $hasCss = array_key_exists('css', $params);
169
170 if (!$hasTheme && !$hasCss) {
171 return MCPHelper::error(ErrorCodes::MISSING_PARAM, __('Provide at least one of: styler_theme, css.', 'fluentform'), ['fields' => ['styler_theme', 'css']]);
172 }
173
174 // An unknown preset id used to be stored verbatim and reported as a
175 // success, leaving the form pointed at a theme that does not exist.
176 // Which ids are valid depends on whether Pro is active, so the error
177 // carries the list this install actually accepts.
178 if ($hasTheme) {
179 $requestedTheme = sanitize_text_field($params['styler_theme']);
180 if (null === self::presetStyles($requestedTheme)) {
181 return MCPHelper::error(
182 ErrorCodes::INVALID_PARAM,
183 sprintf(
184 /* translators: %s: comma-separated list of valid theme preset ids */
185 __('Unknown styler_theme. Valid preset ids on this site: %s.', 'fluentform'),
186 implode(', ', self::presetIds())
187 ),
188 ['fields' => ['styler_theme'], 'valid_values' => self::presetIds()]
189 );
190 }
191 }
192
193 // CSS writes require unfiltered_html — the WP-standard gate the styler
194 // enforces. Pre-check so the agent gets a structured error instead of the
195 // raw exception Customizer::store() throws, and nothing is persisted.
196 if ($hasCss && !fluentformCanUnfilteredHTML()) {
197 return MCPHelper::error(ErrorCodes::UNFILTERED_HTML_REQUIRED, __('Saving custom CSS requires the unfiltered_html capability.', 'fluentform'), ['fields' => ['css']]);
198 }
199
200 // Sanitize the CSS even for unfiltered_html users — an agent is a more
201 // easily-manipulated input channel than a human at the styler, so it gets
202 // a stricter bar. fluentformSanitizeCSS blanks the whole value if it holds
203 // any tag (a </style><script> breakout); reject loudly instead of silently
204 // storing empty. Same sanitizer create-form runs on custom CSS.
205 $safeCss = '';
206 if ($hasCss) {
207 $safeCss = fluentformSanitizeCSS((string) $params['css']);
208 if ('' === $safeCss && '' !== trim((string) $params['css'])) {
209 return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('The css contains markup (an HTML tag) and was rejected. Provide plain CSS only — no <style>, <script>, or other tags.', 'fluentform'), ['fields' => ['css']]);
210 }
211 }
212
213 $before = self::freshStyleState($formId);
214 $fingerprint = self::styleFingerprint(Arr::get($before, 'styler_theme'), Arr::get($before, 'css'));
215
216 return Mutation::runGuarded(
217 'fluentform/update-form-styling',
218 $params,
219 // The requested values are part of the key, so a token minted for a
220 // harmless theme swap cannot be replayed to inject a CSS payload.
221 'styling:' . $formId . ':' . md5(($hasTheme ? (string) $params['styler_theme'] : '~') . '|' . $safeCss),
222 $fingerprint,
223 function () use ($formId, $before, $hasTheme, $hasCss, $params, $safeCss) {
224 $preview = ['form_id' => $formId, 'changes' => []];
225 if ($hasTheme) {
226 $preview['changes']['styler_theme'] = [
227 'from' => Arr::get($before, 'styler_theme'),
228 'to' => sanitize_text_field($params['styler_theme']),
229 ];
230 }
231 if ($hasCss) {
232 $preview['changes']['css'] = [
233 'from' => MCPHelper::preview((string) Arr::get($before, 'css', '')),
234 'to' => MCPHelper::preview($safeCss),
235 ];
236 }
237
238 return $preview;
239 },
240 function () use ($formId, $form, $hasTheme, $hasCss, $params, $safeCss, $fingerprint) {
241 global $wpdb;
242
243 $changed = [];
244
245 // Persist the related style meta in one transaction with each write
246 // verified (setFormMeta returns null on a failed persist), so a
247 // half-write can't leave _ff_selected_style pointing at a theme whose
248 // _ff_form_styles is stale. START/COMMIT go through raw $wpdb, so
249 // their return values are checked too; roll back on any failure.
250 if (false === $wpdb->query('START TRANSACTION')) {
251 return MCPHelper::error(ErrorCodes::TOOL_FAILED, __('Could not start a database transaction for the styling update.', 'fluentform'), ['retryable' => true]);
252 }
253 try {
254 // Serialize against a concurrent styler save (admin UI, another
255 // agent) and close the window between the on-entry read the
256 // confirm_token was validated against and this write: lock the
257 // form row, then re-read the live style state and re-check it
258 // against what was previewed. A change that landed in that window
259 // would otherwise be silently clobbered (lost update); refuse and
260 // force a fresh dry_run instead.
261 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, id is %d-prepared
262 $wpdb->query($wpdb->prepare("SELECT id FROM {$wpdb->prefix}fluentform_forms WHERE id = %d FOR UPDATE", $formId));
263
264 $current = self::freshStyleState($formId);
265 if (self::styleFingerprint(Arr::get($current, 'styler_theme'), Arr::get($current, 'css')) !== $fingerprint) {
266 $wpdb->query('ROLLBACK');
267 return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form styling changed while this update was in flight. Run a fresh dry_run to re-preview, then execute.', 'fluentform'), ['next_step' => 'set dry_run:true']);
268 }
269
270 if ($hasTheme) {
271 $theme = sanitize_text_field($params['styler_theme']);
272 $presetStyles = self::presetStyles($theme);
273 // Mirror DefaultStyleApplicator: the selected style and its
274 // structured styles move together (preset validated non-null
275 // up front), so a reader never sees one theme's id with
276 // another theme's styles.
277 if (
278 null === Helper::setFormMeta($formId, '_ff_selected_style', $theme)
279 || null === Helper::setFormMeta($formId, '_ff_form_styles', $presetStyles)
280 ) {
281 throw new \Exception(__('The theme styles could not be saved.', 'fluentform'));
282 }
283 $changed[] = 'styler_theme';
284 }
285
286 if ($hasCss) {
287 // Write only the css meta directly — no js read-and-rewrite — so a
288 // concurrent js edit in the form styler UI is never clobbered. css is
289 // already sanitized (fluentformSanitizeCSS) and unfiltered_html-gated
290 // above, matching what Customizer::store would apply.
291 if (null === Helper::setFormMeta($formId, '_custom_form_css', $safeCss)) {
292 throw new \Exception(__('The custom CSS could not be saved.', 'fluentform'));
293 }
294 $changed[] = 'css';
295 }
296 } catch (\Throwable $e) {
297 $wpdb->query('ROLLBACK');
298
299 return MCPHelper::error(ErrorCodes::TOOL_FAILED, $e->getMessage(), ['retryable' => true]);
300 }
301
302 if (false === $wpdb->query('COMMIT')) {
303 $wpdb->query('ROLLBACK');
304
305 return MCPHelper::error(ErrorCodes::TOOL_FAILED, __('The styling update could not be committed; no changes were saved.', 'fluentform'), ['retryable' => true]);
306 }
307
308 return MCPHelper::envelope(
309 sprintf(
310 /* translators: %s: form title */
311 __('Styling for "%s" updated.', 'fluentform'),
312 $form->title
313 ),
314 ['form_id' => $formId, 'updated' => $changed]
315 );
316 },
317 ['form_id' => $formId]
318 );
319 }
320 }
321