Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.53% covered (danger)
23.53%
32 / 136
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Form_Preview
23.53% covered (danger)
23.53%
32 / 136
40.00% covered (danger)
40.00%
4 / 10
582.80
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 filter_is_frontend
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 register_query_vars
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_preview_mode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generate_preview_url
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 verify_preview_access
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 maybe_render_preview
4.71% covered (danger)
4.71%
4 / 85
0.00% covered (danger)
0.00%
0 / 1
237.53
 render_form_preview_content
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_preview_styles
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 add_preview_mode_script
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Form_Preview class.
4 *
5 * Handles form preview functionality for Jetpack Forms.
6 *
7 * @package automattic/jetpack-forms
8 */
9
10namespace Automattic\Jetpack\Forms\ContactForm;
11
12use WP_Post;
13
14/**
15 * Handles form preview rendering with nonce-based authentication.
16 */
17class Form_Preview {
18
19    /**
20     * The nonce action prefix for form preview.
21     *
22     * @var string
23     */
24    const PREVIEW_NONCE_ACTION = 'jetpack_form_preview_';
25
26    /**
27     * The query variable for form preview.
28     *
29     * @var string
30     */
31    const PREVIEW_QUERY_VAR = 'jetpack_form_preview';
32
33    /**
34     * The query variable for preview nonce.
35     *
36     * @var string
37     */
38    const PREVIEW_NONCE_QUERY_VAR = 'preview_nonce';
39
40    /**
41     * Flag to track if we're in preview mode.
42     *
43     * @var bool
44     */
45    private static $is_preview_mode = false;
46
47    /**
48     * Initialize the form preview handler.
49     */
50    public static function init() {
51        add_filter( 'query_vars', array( __CLASS__, 'register_query_vars' ) );
52        add_filter( 'template_include', array( __CLASS__, 'maybe_render_preview' ), 99 );
53        add_filter( 'jetpack_is_frontend', array( __CLASS__, 'filter_is_frontend' ) );
54    }
55
56    /**
57     * Filter jetpack_is_frontend to return true during preview mode.
58     *
59     * This ensures the form block renders properly instead of showing a fallback.
60     *
61     * @param bool $is_frontend Whether the current request is for the frontend.
62     * @return bool True if in preview mode, otherwise the original value.
63     */
64    public static function filter_is_frontend( $is_frontend ) {
65        if ( self::$is_preview_mode ) {
66            return true;
67        }
68        return $is_frontend;
69    }
70
71    /**
72     * Register custom query variables.
73     *
74     * @param array $vars Existing query variables.
75     * @return array Modified query variables.
76     */
77    public static function register_query_vars( $vars ) {
78        $vars[] = self::PREVIEW_QUERY_VAR;
79        $vars[] = self::PREVIEW_NONCE_QUERY_VAR;
80        return $vars;
81    }
82
83    /**
84     * Check if we're currently in preview mode.
85     *
86     * @return bool True if in preview mode, false otherwise.
87     */
88    public static function is_preview_mode() {
89        return self::$is_preview_mode;
90    }
91
92    /**
93     * Generate a preview URL for a form.
94     *
95     * @param int $form_id The form post ID.
96     * @return string|null The preview URL, or null if generation fails.
97     */
98    public static function generate_preview_url( $form_id ) {
99        $form_id = absint( $form_id );
100
101        // Validate form exists.
102        $form = get_post( $form_id );
103        if ( ! $form || Contact_Form::POST_TYPE !== $form->post_type ) {
104            return null;
105        }
106
107        // Check user can edit this form.
108        if ( ! current_user_can( 'edit_post', $form_id ) ) {
109            return null;
110        }
111
112        // Generate nonce for this form.
113        $nonce = wp_create_nonce( self::PREVIEW_NONCE_ACTION . $form_id );
114
115        // Build preview URL.
116        $preview_url = add_query_arg(
117            array(
118                self::PREVIEW_QUERY_VAR       => $form_id,
119                self::PREVIEW_NONCE_QUERY_VAR => $nonce,
120            ),
121            home_url( '/' )
122        );
123
124        return $preview_url;
125    }
126
127    /**
128     * Verify preview access for a form.
129     *
130     * @param int    $form_id The form post ID.
131     * @param string $nonce   The preview nonce.
132     * @return bool True if access is verified, false otherwise.
133     */
134    public static function verify_preview_access( $form_id, $nonce ) {
135        // Must be logged in.
136        if ( ! is_user_logged_in() ) {
137            return false;
138        }
139
140        // Must have edit capability for this form.
141        if ( ! current_user_can( 'edit_post', $form_id ) ) {
142            return false;
143        }
144
145        // Verify nonce.
146        if ( ! wp_verify_nonce( $nonce, self::PREVIEW_NONCE_ACTION . $form_id ) ) {
147            return false;
148        }
149
150        return true;
151    }
152
153    /**
154     * Maybe render a form preview.
155     *
156     * @param string $template The template to include.
157     * @return string The template to include (potentially modified).
158     */
159    public static function maybe_render_preview( $template ) {
160        global $wp_query;
161
162        // Get form_id and nonce from query vars.
163        $form_id = get_query_var( self::PREVIEW_QUERY_VAR );
164        $nonce   = get_query_var( self::PREVIEW_NONCE_QUERY_VAR );
165
166        // If not a preview request, return template unchanged.
167        if ( empty( $form_id ) || empty( $nonce ) ) {
168            return $template;
169        }
170
171        $form_id = absint( $form_id );
172
173        // Verify access.
174        if ( ! self::verify_preview_access( $form_id, $nonce ) ) {
175            wp_die(
176                esc_html__( 'You do not have permission to preview this form.', 'jetpack-forms' ),
177                esc_html__( 'Access Denied', 'jetpack-forms' ),
178                array( 'response' => 403 )
179            );
180        }
181
182        // Get the form post.
183        $form = get_post( $form_id );
184        if ( ! $form || Contact_Form::POST_TYPE !== $form->post_type ) {
185            wp_die(
186                esc_html__( 'Form not found.', 'jetpack-forms' ),
187                esc_html__( 'Not Found', 'jetpack-forms' ),
188                array( 'response' => 404 )
189            );
190        }
191
192        // Check for autosave and use its content if available.
193        // This ensures the preview shows the latest unsaved changes.
194        $autosave = wp_get_post_autosave( $form_id, get_current_user_id() );
195        if ( $autosave && strtotime( $autosave->post_modified ) > strtotime( $form->post_modified ) ) {
196            $form = $autosave;
197        }
198
199        // Set preview mode flag.
200        self::$is_preview_mode = true;
201
202        // Set up fake page query context.
203        $wp_query->is_page     = true;
204        $wp_query->is_singular = true;
205        $wp_query->is_home     = false;
206        $wp_query->is_archive  = false;
207        $wp_query->is_category = false;
208        $wp_query->is_404      = false;
209        $wp_query->is_feed     = false;
210
211        // Create a fake post object for the page.
212        // Use the form's ID as the fake post ID to ensure proper form context.
213        // Note: Using 0 as the ID causes issues because '0' is falsy in PHP,
214        // which breaks the form ID validation in Contact_Form::parse().
215        $fake_post                = new \stdClass();
216        $fake_post->ID            = $form_id;
217        $fake_post->post_author   = get_current_user_id();
218        $fake_post->post_date     = current_time( 'mysql' );
219        $fake_post->post_date_gmt = current_time( 'mysql', true );
220        $fake_post->post_title    = sprintf(
221            /* translators: %s: Form title */
222            __( 'Preview: %s', 'jetpack-forms' ),
223            $form->post_title ? $form->post_title : __( 'Untitled Form', 'jetpack-forms' )
224        );
225        $fake_post->post_content   = '';
226        $fake_post->post_status    = 'publish';
227        $fake_post->comment_status = 'closed';
228        $fake_post->ping_status    = 'closed';
229        $fake_post->post_name      = 'form-preview';
230        $fake_post->post_type      = 'page';
231        $fake_post->filter         = 'raw';
232
233        // Set up query vars.
234        $wp_query->post              = new WP_Post( $fake_post );
235        $wp_query->posts             = array( $wp_query->post );
236        $wp_query->post_count        = 1;
237        $wp_query->found_posts       = 1;
238        $wp_query->max_num_pages     = 1;
239        $wp_query->queried_object    = $wp_query->post;
240        $wp_query->queried_object_id = $form_id;
241        $GLOBALS['post']             = $wp_query->post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Necessary to set up fake post context for preview.
242
243        // Enqueue preview styles.
244        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_preview_styles' ) );
245
246        // Add preview mode script variable.
247        add_action( 'wp_head', array( __CLASS__, 'add_preview_mode_script' ) );
248
249        // Hook the_content filter to render the form.
250        add_filter(
251            'the_content',
252            function ( $_content ) use ( $form ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- We replace the content entirely.
253                return self::render_form_preview_content( $form );
254            },
255            999
256        );
257
258        // Hook the_title filter to show preview title.
259        add_filter(
260            'the_title',
261            function ( $title, $id = null ) use ( $form, $form_id ) {
262                if ( $id === $form_id || ( isset( $GLOBALS['post'] ) && $GLOBALS['post']->ID === $form_id ) ) {
263                    return sprintf(
264                    /* translators: %s: Form title */
265                        __( 'Preview: %s', 'jetpack-forms' ),
266                        $form->post_title ? $form->post_title : __( 'Untitled Form', 'jetpack-forms' )
267                    );
268                }
269                return $title;
270            },
271            10,
272            2
273        );
274
275        // Use page template.
276        $page_template = get_page_template();
277        if ( $page_template ) {
278            return $page_template;
279        }
280
281        // Fallback to singular, index, or original template.
282        $singular_template = get_singular_template();
283        if ( $singular_template ) {
284            return $singular_template;
285        }
286
287        $index_template = get_index_template();
288        if ( $index_template ) {
289            return $index_template;
290        }
291
292        return $template;
293    }
294
295    /**
296     * Render the form preview content.
297     *
298     * @param WP_Post|null $form The form post.
299     * @return string The rendered content.
300     */
301    private static function render_form_preview_content( ?WP_Post $form ) {
302        if ( ! $form ) {
303            return '';
304        }
305
306        $output = '';
307
308        // Add preview banner.
309        $output .= '<div class="jetpack-form-preview-banner">';
310        $output .= esc_html__( 'This is a preview. Form submissions are disabled.', 'jetpack-forms' );
311        $output .= '</div>';
312
313        // Parse and render the form blocks.
314        $blocks = parse_blocks( $form->post_content );
315
316        foreach ( $blocks as $block ) {
317            $output .= render_block( $block );
318        }
319
320        return $output;
321    }
322
323    /**
324     * Enqueue preview styles.
325     */
326    public static function enqueue_preview_styles() {
327        $css_file = __DIR__ . '/css/form-preview.css';
328        $version  = file_exists( $css_file ) ? (string) filemtime( $css_file ) : \Automattic\Jetpack\Forms\Jetpack_Forms::PACKAGE_VERSION;
329
330        wp_enqueue_style(
331            'jetpack-form-preview',
332            plugins_url( 'css/form-preview.css', __FILE__ ),
333            array(),
334            $version
335        );
336    }
337
338    /**
339     * Add preview mode script variable.
340     */
341    public static function add_preview_mode_script() {
342        wp_print_inline_script_tag( 'window.jetpackFormsPreviewMode = true;' );
343    }
344}