PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / addons / StyleCustomizer / V2 / PreviewDraft.php
everest-forms / addons / StyleCustomizer / V2 Last commit date
assets 1 week ago BuilderPanel.php 1 week ago Compiler.php 1 week ago Engine.php 1 week ago FrontendEnqueue.php 1 week ago Migrator.php 1 week ago Palettes.php 1 week ago PreviewDraft.php 1 week ago RestController.php 1 week ago Sanitizer.php 1 week ago Schema.php 1 week ago Templates.php 1 week ago
PreviewDraft.php
371 lines
1 <?php
2 /**
3 * Style Customizer v2 — live builder-structure preview draft.
4 *
5 * Lets the Style panel's preview iframe show unsaved Fields-tab edits: the panel POSTs the
6 * builder's current serialized form data to {@see self::store()}, which caches it as a
7 * short-lived per-user transient; {@see self::filter_form_data()} swaps it in for the
8 * style-preview request only, leaving the real front end and normal preview untouched.
9 *
10 * @package EverestForms\Addons\StyleCustomizer\V2
11 * @since x.x.x
12 */
13
14 namespace EverestForms\Addons\StyleCustomizer\V2;
15
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * Per-user builder-structure draft for the live style preview.
20 */
21 final class PreviewDraft {
22
23 /**
24 * How long a draft stays warm (seconds). Short — it only needs to survive the round-trip
25 * from "panel POSTs the structure" to "iframe reloads and renders it".
26 */
27 const TTL = 1800;
28
29 /**
30 * The preview query flag that scopes the draft swap to the style customizer's own iframe.
31 */
32 const PREVIEW_FLAG = 'evf_style_preview';
33
34 /**
35 * The query arg carrying the per-page-load session token (see BuilderPanel). The draft is keyed
36 * by it, so a stale draft from an earlier builder load never applies to a freshly reloaded one.
37 */
38 const SESSION_ARG = 'evf_style_session';
39
40 /**
41 * The most recently parsed draft per form, valid only for the current request. Lets code
42 * that runs inside the SAME `save_preview_draft` request (e.g. {@see RestController::form_field_types()},
43 * the Multi-Part addon's section-visibility filter) react to the just-POSTed draft's
44 * fields/settings instead of the last-saved DB row, without a second lookup mechanism.
45 *
46 * @var array
47 */
48 protected static $current = array();
49
50 /**
51 * The current request's just-parsed draft for a form, if any.
52 *
53 * @param int $form_id Form id.
54 * @return array|null
55 */
56 public static function current( $form_id ) {
57 $form_id = absint( $form_id );
58 return isset( self::$current[ $form_id ] ) ? self::$current[ $form_id ] : null;
59 }
60
61 /**
62 * Wire the front-end filter. Called from {@see Engine::boot()} (runs on the front end too,
63 * where the preview iframe is rendered). Priority 5 so the swap happens before add-ons that
64 * read the form data on the default priority.
65 */
66 public static function register() {
67 add_filter( 'everest_forms_frontend_form_data', array( __CLASS__, 'filter_form_data' ), 5 );
68
69 // Server-side chrome hide for the style-preview iframe, set up before the template runs.
70 add_action( 'wp', array( __CLASS__, 'maybe_setup_embed' ) );
71 }
72
73 /**
74 * Is the current request the style customizer's own preview iframe? Tight gate: a front-end
75 * `?evf_preview` request carrying our flag, from a logged-in form manager. Public so addons
76 * can reuse the same gate (e.g. {@see self::preview_style_tokens()}'s callers).
77 *
78 * @return bool
79 */
80 public static function is_style_preview_request() {
81 if ( is_admin() ) {
82 return false;
83 }
84 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only gating on the public preview route; capability enforced below.
85 $is = isset( $_GET['evf_preview'] ) && isset( $_GET[ self::PREVIEW_FLAG ] );
86 // phpcs:enable WordPress.Security.NonceVerification.Recommended
87 return $is && is_user_logged_in() && current_user_can( 'manage_everest_forms' );
88 }
89
90 /**
91 * When the request is the style-preview iframe, hide all preview chrome (admin bar, preview
92 * toolbar, side panel) so only the form renders. Hooked on `wp` so it runs before the template.
93 */
94 public static function maybe_setup_embed() {
95 if ( ! self::is_style_preview_request() ) {
96 return;
97 }
98 add_filter( 'show_admin_bar', '__return_false', 100 );
99 add_action( 'wp_head', array( __CLASS__, 'print_embed_css' ), 100 );
100 }
101
102 /**
103 * Inline CSS that reduces the preview page to just the form. Mirrors the bridge's runtime
104 * chrome-hide, but server-rendered so it applies immediately in every browser.
105 */
106 public static function print_embed_css() {
107 echo '<style id="evf-style-preview-embed">'
108 . 'html{margin-top:0 !important;scrollbar-gutter:stable;}'
109 . '*,*::before,*::after{box-sizing:border-box;}'
110 . 'body.evf-multi-device-form-preview{margin:0 !important;padding:0 !important;background:#fff !important;}'
111 . '#wpadminbar,#nav-menu-header,.evf-form-side-panel,.evf-form-preview-sidepanel-toggler,.evf-form-preview-devices,.evf-form-preview-dropdown-container,.major-publishing-actions{display:none !important;}'
112 . '.evf-form-preview-main-content,.evf-form-preview-overlay{display:block !important;position:static !important;inset:auto !important;margin:0 !important;padding:16px !important;width:100% !important;max-width:100% !important;min-height:0 !important;height:auto !important;box-shadow:none !important;background:transparent !important;}'
113 // Hides evf-form-preview.scss's dark ::after scrim below a 992px container width.
114 . '.evf-form-preview-overlay::after{display:none !important;}'
115 . '.evf-form-preview-form{width:100% !important;max-width:100% !important;margin:0 !important;padding:0 !important;}'
116 . '.evf-preview-content{width:100% !important;max-width:100% !important;}'
117 . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static developer-controlled CSS.
118 }
119
120 /**
121 * Transient key for a given form + user + builder session. Scoped per user so two editors never
122 * clobber each other, and per session so a stale draft from an earlier builder load is ignored.
123 *
124 * @param int $form_id Form id.
125 * @param int $user_id User id.
126 * @param string $session Per-page-load session token.
127 * @return string
128 */
129 protected static function key( $form_id, $user_id, $session ) {
130 return 'evf_style_v2_draft_' . (int) $user_id . '_' . (int) $form_id . '_' . md5( (string) $session );
131 }
132
133 /**
134 * Store a draft from the serialized builder form data.
135 *
136 * @param int $form_id Form id.
137 * @param string $form_data_json JSON of the builder's serialized `[{name,value},…]` array
138 * (form inputs + layout structure), exactly as the save AJAX sends.
139 * @param string $session Per-page-load session token (from BuilderPanel).
140 * @param array $style_tokens Optional. Unsaved Style Customizer v2 token values with no
141 * CSS-variable fast path (e.g. `pagination.indicatorType`),
142 * keyed by token key — see {@see self::preview_style_tokens()}.
143 * @return bool True if a renderable draft was stored.
144 */
145 public static function store( $form_id, $form_data_json, $session, $style_tokens = null ) {
146 $form_id = absint( $form_id );
147 $user_id = get_current_user_id();
148 $session = sanitize_text_field( (string) $session );
149 if ( ! $form_id || ! $user_id || '' === $session ) {
150 return false;
151 }
152
153 // No wp_unslash() here: this string arrives via the REST route's JSON body (see
154 // RestController::save_preview_draft()), which WP never runs through wp_magic_quotes() the
155 // way $_POST is — unslashing it strips the backslashes that are JSON's OWN string escaping
156 // (e.g. a field value containing a literal `"`, like the reply-to merge tag default
157 // `{field_id="email"}`, is encoded as `\"` — stripping that corrupts the JSON and makes
158 // json_decode() fail), silently breaking the draft for any form where any field's value
159 // anywhere contains a double-quote character.
160 $decoded = json_decode( (string) $form_data_json );
161 if ( ! is_array( $decoded ) ) {
162 return false;
163 }
164
165 $data = self::parse( $decoded );
166
167 // Each push only carries the style tokens edited SINCE the last one (the client clears its
168 // pending set after every successful POST) — merge onto whatever's already staged instead
169 // of replacing it wholesale, or editing e.g. indicatorColor after indicatorType would drop
170 // the still-unsaved indicatorType override (a structure-only push, with no style_tokens at
171 // all, must also carry the existing overrides forward for the same reason).
172 $existing_draft = get_transient( self::key( $form_id, $user_id, $session ) );
173 $existing_tokens = ( is_array( $existing_draft ) && isset( $existing_draft['style_tokens'] ) && is_array( $existing_draft['style_tokens'] ) )
174 ? $existing_draft['style_tokens']
175 : array();
176
177 if ( is_array( $style_tokens ) ) {
178 $clean = array();
179 foreach ( $style_tokens as $key => $value ) {
180 if ( ! is_string( $key ) ) {
181 continue;
182 }
183 $sanitized = self::sanitize_style_token_value( $value );
184 if ( null !== $sanitized ) {
185 $clean[ $key ] = $sanitized;
186 }
187 }
188 $existing_tokens = array_merge( $existing_tokens, $clean );
189 }
190
191 if ( ! empty( $existing_tokens ) ) {
192 $data['style_tokens'] = $existing_tokens;
193 }
194
195 self::$current[ $form_id ] = $data;
196
197 // A draft with no fields isn't renderable — clear any stale draft instead.
198 if ( empty( $data['form_fields'] ) ) {
199 self::clear( $form_id, $session );
200 return false;
201 }
202
203 set_transient( self::key( $form_id, $user_id, $session ), $data, self::TTL );
204 return true;
205 }
206
207 /**
208 * Sanitize one staged style-token value — either a scalar (color/select value) or a box4
209 * shape `{top,right,bottom,left[,unit]}` (e.g. `pagination.margin`, sent as the resolved
210 * device value straight from the JS store — see PreviewBridge.ts's PAGINATION_STRUCTURAL_KEYS).
211 * Returns null for anything else (rejected, same as the old is_scalar()-only check did).
212 *
213 * @param mixed $value Raw staged value.
214 * @return string|array|null
215 */
216 protected static function sanitize_style_token_value( $value ) {
217 if ( is_scalar( $value ) ) {
218 return sanitize_text_field( (string) $value );
219 }
220 if ( is_array( $value ) ) {
221 $sides = array( 'top', 'right', 'bottom', 'left' );
222 if ( array_diff( $sides, array_keys( $value ) ) ) {
223 return null;
224 }
225 $box = array();
226 foreach ( $sides as $side ) {
227 if ( ! is_numeric( $value[ $side ] ) ) {
228 return null;
229 }
230 $box[ $side ] = (float) $value[ $side ];
231 }
232 if ( isset( $value['unit'] ) && is_string( $value['unit'] ) ) {
233 $box['unit'] = sanitize_text_field( $value['unit'] );
234 }
235 return $box;
236 }
237 return null;
238 }
239
240 /**
241 * Drop the current user's draft for a form + session.
242 *
243 * @param int $form_id Form id.
244 * @param string $session Session token.
245 */
246 public static function clear( $form_id, $session ) {
247 $user_id = get_current_user_id();
248 if ( $user_id ) {
249 delete_transient( self::key( absint( $form_id ), $user_id, $session ) );
250 }
251 }
252
253 /**
254 * Parse the serialized builder array into the nested form structure. Mirrors
255 * {@see \EVF_AJAX::save_form()}'s array-rebuilding so the draft renders identically to a saved
256 * form — but, unlike that method, does NOT `wp_slash()` the leaf values: `save_form()` needs
257 * that because it hands the result to `wp_update_post()`, which unslashes on the way into the
258 * DB; this draft is read straight back out of a transient with no such unslash step, so adding
259 * slashes here would leave a stray backslash in front of every quote/apostrophe a field's value
260 * happens to contain.
261 *
262 * @param array $form_post Array of `{name,value}` objects (already JSON-decoded).
263 * @return array Nested form data (`form_fields`, `structure`, `settings`, …).
264 */
265 protected static function parse( $form_post ) {
266 $form_post = function_exists( 'evf_sanitize_builder' ) ? evf_sanitize_builder( $form_post ) : $form_post;
267
268 $data = array();
269 if ( is_null( $form_post ) || ! $form_post ) {
270 return $data;
271 }
272
273 foreach ( $form_post as $post_index => $post_input_data ) {
274 if ( ! is_object( $post_input_data ) || ! isset( $post_input_data->name ) ) {
275 continue;
276 }
277 // For input names that are arrays (e.g. `form_fields[3][choices][1][label]`), derive
278 // the array path keys via regex and rebuild the value leaf-to-trunk.
279 preg_match( '#([^\[]*)(\[(.+)\])?#', $post_input_data->name, $matches );
280
281 $array_bits = array( $matches[1] );
282 if ( isset( $matches[3] ) ) {
283 $array_bits = array_merge( $array_bits, explode( '][', $matches[3] ) );
284 }
285
286 $new_post_data = array();
287 for ( $i = count( $array_bits ) - 1; $i >= 0; $i-- ) {
288 if ( count( $array_bits ) - 1 === $i ) {
289 if ( '' === $array_bits[ $i ] ) {
290 $new_post_data[ $post_index ] = $post_input_data->value;
291 } else {
292 $new_post_data[ $array_bits[ $i ] ] = $post_input_data->value;
293 }
294 } else {
295 $new_post_data = array(
296 $array_bits[ $i ] => $new_post_data,
297 );
298 }
299 }
300 $data = array_replace_recursive( $data, $new_post_data );
301 }
302
303 return $data;
304 }
305
306 /**
307 * Swap the draft structure into the front-end form data — style-preview iframe only.
308 *
309 * @param array $form_data Decoded saved form data.
310 * @return array
311 */
312 public static function filter_form_data( $form_data ) {
313 if ( is_admin() || ! is_array( $form_data ) ) {
314 return $form_data;
315 }
316
317 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only gating on the public preview route; capability is enforced below.
318 if ( ! isset( $_GET['evf_preview'] ) || ! isset( $_GET[ self::PREVIEW_FLAG ] ) ) {
319 return $form_data;
320 }
321 $req_form_id = isset( $_GET['form_id'] ) ? absint( wp_unslash( $_GET['form_id'] ) ) : 0;
322 $session = isset( $_GET[ self::SESSION_ARG ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::SESSION_ARG ] ) ) : '';
323 // phpcs:enable WordPress.Security.NonceVerification.Recommended
324
325 if ( ! $req_form_id || '' === $session || ! is_user_logged_in() || ! current_user_can( 'manage_everest_forms' ) ) {
326 return $form_data;
327 }
328
329 $data_form_id = isset( $form_data['id'] ) ? absint( $form_data['id'] ) : 0;
330 if ( $data_form_id && $data_form_id !== $req_form_id ) {
331 return $form_data;
332 }
333
334 $draft = get_transient( self::key( $req_form_id, get_current_user_id(), $session ) );
335 if ( ! is_array( $draft ) || empty( $draft['form_fields'] ) ) {
336 return $form_data;
337 }
338
339 return array_replace( $form_data, $draft );
340 }
341
342 /**
343 * The unsaved style-token overrides for the CURRENT style-preview iframe request, if any —
344 * same gating as {@see self::filter_form_data()}, but exposed for addons to consult while
345 * rendering (e.g. Multi-Part's `apply_v2_pagination_tokens()`) for tokens that change the
346 * front-end DOM structure and so can't be live-patched by the panel's CSS-variable fast path
347 * (see PreviewBridge.ts's `applyToken()`/`applyKeys()`).
348 *
349 * @param int $form_id Form id being rendered.
350 * @return array Token key => value, or empty if there's no matching draft.
351 */
352 public static function preview_style_tokens( $form_id ) {
353 if ( ! self::is_style_preview_request() ) {
354 return array();
355 }
356 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only gating, matches is_style_preview_request()/filter_form_data().
357 $req_form_id = isset( $_GET['form_id'] ) ? absint( wp_unslash( $_GET['form_id'] ) ) : 0;
358 $session = isset( $_GET[ self::SESSION_ARG ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::SESSION_ARG ] ) ) : '';
359 // phpcs:enable WordPress.Security.NonceVerification.Recommended
360
361 if ( ! $req_form_id || absint( $form_id ) !== $req_form_id || '' === $session ) {
362 return array();
363 }
364
365 $draft = get_transient( self::key( $req_form_id, get_current_user_id(), $session ) );
366 return is_array( $draft ) && isset( $draft['style_tokens'] ) && is_array( $draft['style_tokens'] )
367 ? $draft['style_tokens']
368 : array();
369 }
370 }
371