Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.37% covered (warning)
68.37%
134 / 196
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Form_Endpoint
68.37% covered (warning)
68.37%
134 / 196
60.00% covered (warning)
60.00%
9 / 15
141.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 get_status_counts
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 get_status_counts_for_author
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 get_preview_url
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 get_collection_params
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 get_items
28.57% covered (danger)
28.57%
8 / 28
0.00% covered (danger)
0.00%
0 / 1
64.48
 prepare_item_for_response
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 is_form_collecting_responses
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 find_contact_form_attributes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
9
 get_entries_count_by_form_id
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 filter_by_responses
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 get_items_permissions_check
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 create_item_permissions_check
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 check_read_permission
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Jetpack_Form_Endpoint class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use WP_REST_Request;
11
12/**
13 * REST endpoint for the jetpack_form custom post type.
14 */
15class Jetpack_Form_Endpoint extends \WP_REST_Posts_Controller {
16    /**
17     * Cached map of form_id => entries count for the current request.
18     *
19     * @var array<int,int>|null
20     */
21    private $entries_count_by_form_id = null;
22
23    /**
24     * Whether the current request filters by has_responses.
25     *
26     * @var bool
27     */
28    public $has_responses_filter = true;
29
30    /**
31     * Constructor.
32     */
33    public function __construct() {
34        parent::__construct( Contact_Form::POST_TYPE );
35    }
36
37    /**
38     * Registers the routes for the objects of the controller.
39     */
40    public function register_routes() {
41        parent::register_routes();
42
43        // Register custom preview-url route.
44        register_rest_route(
45            $this->namespace,
46            '/' . $this->rest_base . '/(?P<id>[\d]+)/preview-url',
47            array(
48                'methods'             => \WP_REST_Server::READABLE,
49                'callback'            => array( $this, 'get_preview_url' ),
50                'permission_callback' => array( $this, 'get_item_permissions_check' ),
51                'args'                => array(
52                    'id' => array(
53                        'description'       => __( 'Unique identifier for the form.', 'jetpack-forms' ),
54                        'type'              => 'integer',
55                        'required'          => true,
56                        'sanitize_callback' => 'absint',
57                    ),
58                ),
59            )
60        );
61
62        // Get form status counts.
63        register_rest_route(
64            $this->namespace,
65            '/' . $this->rest_base . '/status-counts',
66            array(
67                'methods'             => \WP_REST_Server::READABLE,
68                'permission_callback' => array( $this, 'get_items_permissions_check' ),
69                'callback'            => array( $this, 'get_status_counts' ),
70            )
71        );
72    }
73
74    /**
75     * Retrieves per-status counts for the jetpack_form post type.
76     *
77     * Users who can edit others' forms (e.g. admins and editors) receive
78     * site-wide counts via wp_count_posts(). Users who cannot (e.g. authors)
79     * receive counts scoped to the forms they authored, so aggregate counts of
80     * other users' forms are not leaked.
81     *
82     * @return \WP_REST_Response Response object with status counts.
83     */
84    public function get_status_counts() {
85        $post_type_object = get_post_type_object( $this->post_type );
86
87        if ( $post_type_object && current_user_can( $post_type_object->cap->edit_others_posts ) ) {
88            $counts = (array) wp_count_posts( Contact_Form::POST_TYPE );
89        } else {
90            $counts = $this->get_status_counts_for_author( get_current_user_id() );
91        }
92
93        $publish = (int) ( $counts['publish'] ?? 0 );
94        $draft   = (int) ( $counts['draft'] ?? 0 );
95        $pending = (int) ( $counts['pending'] ?? 0 );
96        $future  = (int) ( $counts['future'] ?? 0 );
97        $private = (int) ( $counts['private'] ?? 0 );
98        $trash   = (int) ( $counts['trash'] ?? 0 );
99
100        return rest_ensure_response(
101            array(
102                'all'     => $publish + $draft + $pending + $future + $private,
103                'publish' => $publish,
104                'draft'   => $draft,
105                'pending' => $pending,
106                'future'  => $future,
107                'private' => $private,
108                'trash'   => $trash,
109            )
110        );
111    }
112
113    /**
114     * Count forms authored by a specific user, grouped by post status.
115     *
116     * The wp_count_posts() function cannot be scoped by author (its second argument is a
117     * permission level, not query args), so a direct query is used to mirror its
118     * shape while restricting results to a single author. The result is
119     * user-scoped and computed by a single grouped aggregate run once per request
120     * (the dashboard preloads this endpoint), so it is intentionally not cached --
121     * unlike get_entries_count_by_form_id(), whose lookup is shared across forms
122     * and benefits from a short-lived cache.
123     *
124     * @param int $author_id User ID to scope the counts to.
125     * @return array<string,int> Map of post_status => count.
126     */
127    private function get_status_counts_for_author( int $author_id ): array {
128        global $wpdb;
129
130        // Intentionally uncached: the result is user-scoped and computed once per request.
131        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
132        $rows = $wpdb->get_results(
133            $wpdb->prepare(
134                "SELECT post_status, COUNT(1) AS num_posts
135                FROM {$wpdb->posts}
136                WHERE post_type = %s
137                  AND post_author = %d
138                GROUP BY post_status",
139                Contact_Form::POST_TYPE,
140                $author_id
141            )
142        );
143
144        $counts = array();
145        foreach ( (array) $rows as $row ) {
146            $counts[ $row->post_status ] = (int) $row->num_posts;
147        }
148
149        return $counts;
150    }
151
152    /**
153     * Get the preview URL for a form.
154     *
155     * @param WP_REST_Request $request Full details about the request.
156     * @return \WP_REST_Response|\WP_Error Response object or WP_Error.
157     */
158    public function get_preview_url( $request ) {
159        $form_id     = $request->get_param( 'id' );
160        $preview_url = Form_Preview::generate_preview_url( $form_id );
161
162        if ( ! $preview_url ) {
163            return new \WP_Error(
164                'rest_cannot_preview',
165                __( 'Unable to generate preview URL.', 'jetpack-forms' ),
166                array( 'status' => 403 )
167            );
168        }
169
170        return rest_ensure_response( array( 'preview_url' => $preview_url ) );
171    }
172
173    /**
174     * Add opt-in dashboard fields.
175     *
176     * @return array
177     */
178    public function get_collection_params() {
179        $params = parent::get_collection_params();
180
181        // Note: We do not use the built-in WP REST "context" param for this, because it's validated
182        // against core values (view/embed/edit). This param is for Jetpack Forms dashboard usage only.
183        $params['jetpack_forms_context'] = array(
184            'description'       => __( 'Request context for Jetpack Forms. Use "dashboard" to include dashboard-only fields.', 'jetpack-forms' ),
185            'type'              => 'string',
186            'default'           => '',
187            'enum'              => array( '', 'dashboard' ),
188            'sanitize_callback' => 'sanitize_key',
189        );
190
191        $params['has_responses'] = array(
192            'description'       => __( 'Filter forms by whether they have responses. "true" returns only forms with responses, "false" returns only forms without.', 'jetpack-forms' ),
193            'type'              => 'string',
194            'enum'              => array( '', 'true', 'false' ),
195            'default'           => '',
196            'sanitize_callback' => 'sanitize_key',
197        );
198
199        return $params;
200    }
201
202    /**
203     * Return a collection of forms.
204     *
205     * We override this to compute dashboard aggregate fields in a single pass.
206     *
207     * @param WP_REST_Request $request Full details about the request.
208     * @return \WP_REST_Response|\WP_Error
209     */
210    public function get_items( $request ) {
211        $has_responses = (string) $request->get_param( 'has_responses' );
212        if ( '' !== $has_responses ) {
213            $this->has_responses_filter = ( 'true' === $has_responses );
214            add_filter( 'posts_clauses', array( $this, 'filter_by_responses' ), 10, 2 );
215        }
216
217        $response = parent::get_items( $request );
218
219        if ( '' !== $has_responses ) {
220            remove_filter( 'posts_clauses', array( $this, 'filter_by_responses' ), 10 );
221        }
222
223        if ( is_wp_error( $response ) ) {
224            return $response;
225        }
226
227        $forms_context = (string) $request->get_param( 'jetpack_forms_context' );
228        if ( 'dashboard' !== $forms_context ) {
229            return $response;
230        }
231
232        $forms = $response->get_data();
233        if ( ! is_array( $forms ) || empty( $forms ) ) {
234            return $response;
235        }
236
237        $form_ids = array();
238        foreach ( $forms as $form ) {
239            if ( isset( $form['id'] ) ) {
240                $form_ids[] = (int) $form['id'];
241            }
242        }
243        $form_ids = array_values( array_unique( array_filter( $form_ids ) ) );
244
245        $this->entries_count_by_form_id = $this->get_entries_count_by_form_id( $form_ids );
246
247        foreach ( $forms as &$form ) {
248            $form_id               = isset( $form['id'] ) ? (int) $form['id'] : 0;
249            $form['entries_count'] = (int) ( $this->entries_count_by_form_id[ $form_id ] ?? 0 );
250            if ( $form_id ) {
251                $form['edit_url'] = get_edit_post_link( $form_id, 'raw' );
252            }
253        }
254
255        $response->set_data( $forms );
256        return $response;
257    }
258
259    /**
260     * Attach the `is_collecting_responses` flag to admin (edit-context) responses.
261     *
262     * Exposed on both the forms list and single-form fetches so the dashboard can
263     * warn about forms that drop their submissions. Only added for the `edit`
264     * context, which is permission-gated to users who can manage forms.
265     *
266     * @since 7.23.0
267     *
268     * @param \WP_Post         $item    Post object.
269     * @param \WP_REST_Request $request Request object.
270     * @return \WP_REST_Response
271     */
272    public function prepare_item_for_response( $item, $request ) {
273        $response = parent::prepare_item_for_response( $item, $request );
274
275        if ( 'edit' === $request->get_param( 'context' ) && isset( $item->ID ) ) {
276            $data                            = $response->get_data();
277            $data['is_collecting_responses'] = $this->is_form_collecting_responses( (int) $item->ID );
278            $response->set_data( $data );
279        }
280
281        return $response;
282    }
283
284    /**
285     * Whether a stored form is configured to collect its responses anywhere.
286     *
287     * Parses the form's block content and applies the shared detection rule.
288     * Returns true (no warning) when the form has no contact-form block to read.
289     *
290     * @since 7.23.0
291     *
292     * @param int $form_id Form (jetpack_form) post ID.
293     * @return bool
294     */
295    private function is_form_collecting_responses( int $form_id ): bool {
296        $post = get_post( $form_id );
297        if ( ! $post instanceof \WP_Post || '' === $post->post_content ) {
298            return true;
299        }
300
301        foreach ( parse_blocks( $post->post_content ) as $block ) {
302            $attributes = $this->find_contact_form_attributes( $block );
303            if ( null !== $attributes ) {
304                return Contact_Form::is_collecting_responses( $attributes );
305            }
306        }
307
308        return true;
309    }
310
311    /**
312     * Recursively locate the first jetpack/contact-form block's attributes.
313     *
314     * @since 7.23.0
315     *
316     * @param array $block A parsed block.
317     * @return array|null The block attributes, or null when not found.
318     */
319    private function find_contact_form_attributes( array $block ): ?array {
320        if ( isset( $block['blockName'] ) && 'jetpack/contact-form' === $block['blockName'] ) {
321            return isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
322        }
323
324        if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
325            foreach ( $block['innerBlocks'] as $inner_block ) {
326                $attributes = $this->find_contact_form_attributes( $inner_block );
327                if ( null !== $attributes ) {
328                    return $attributes;
329                }
330            }
331        }
332
333        return null;
334    }
335
336    /**
337     * Batch compute feedback counts for a list of form IDs.
338     *
339     * @param int[] $form_ids Form IDs to count entries for.
340     * @return array<int,int> Map of form_id => count
341     */
342    private function get_entries_count_by_form_id( array $form_ids ): array {
343        global $wpdb;
344
345        $form_ids = array_values( array_unique( array_map( 'absint', $form_ids ) ) );
346        if ( empty( $form_ids ) ) {
347            return array();
348        }
349
350        // Count only "inbox-visible" feedback statuses.
351        // Note: This is about feedback (response) statuses, not form post statuses (publish/draft/pending/future/private).
352        $statuses = array( 'publish', 'draft' );
353
354        // Cache the grouped counts briefly to avoid repeated DB hits (e.g. on reload / concurrent requests).
355        sort( $form_ids );
356        $cache_key   = 'feedback_counts_' . md5( implode( ',', $form_ids ) . '|' . implode( ',', $statuses ) );
357        $cache_group = 'jetpack_forms';
358        $cached      = wp_cache_get( $cache_key, $cache_group );
359        if ( false !== $cached && is_array( $cached ) ) {
360            return $cached;
361        }
362
363        $args = array_merge( array( Feedback::POST_TYPE ), $form_ids, $statuses );
364
365        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
366        $rows              = $wpdb->get_results(
367            $wpdb->prepare(
368                "SELECT post_parent, COUNT(1) AS entry_count
369                FROM {$wpdb->posts}
370                WHERE post_type = %s
371                  AND post_parent IN (" . implode( ',', array_fill( 0, count( $form_ids ), '%d' ) ) . ')
372                  AND post_status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')
373                GROUP BY post_parent',
374                $args
375            )
376        );
377        $counts_by_form_id = array();
378        foreach ( (array) $rows as $row ) {
379            $counts_by_form_id[ (int) $row->post_parent ] = (int) $row->entry_count;
380        }
381
382        wp_cache_set( $cache_key, $counts_by_form_id, $cache_group, 15 ); // 15 seconds.
383        return $counts_by_form_id;
384    }
385
386    /**
387     * Filter posts_clauses to include/exclude forms that have feedback responses.
388     *
389     * @param array     $clauses SQL clauses.
390     * @param \WP_Query $query   The current WP_Query instance.
391     * @return array Modified clauses.
392     */
393    public function filter_by_responses( $clauses, $query ) {
394        global $wpdb;
395
396        // Only modify the query for jetpack_form post type.
397        if ( $query->get( 'post_type' ) !== $this->post_type ) {
398            return $clauses;
399        }
400
401        $feedback_type = Feedback::POST_TYPE;
402        $operator      = $this->has_responses_filter ? 'EXISTS' : 'NOT EXISTS';
403
404        $subquery = $wpdb->prepare(
405            "SELECT 1 FROM {$wpdb->posts} AS feedback
406            WHERE feedback.post_parent = {$wpdb->posts}.ID
407            AND feedback.post_type = %s
408            AND feedback.post_status IN (%s, %s)",
409            $feedback_type,
410            'publish',
411            'draft'
412        );
413
414        $clauses['where'] .= " AND $operator ($subquery)";
415
416        return $clauses;
417    }
418
419    /**
420     * Checks if a given request has access to get items.
421     *
422     * @param \WP_REST_Request $request Full details about the request.
423     * @return true|\WP_Error True if the request has read access, WP_Error object otherwise.
424     */
425    public function get_items_permissions_check( $request ) {
426        $post_type = get_post_type_object( $this->post_type );
427
428        if ( ! current_user_can( $post_type->cap->edit_posts ) ) {
429            return new \WP_Error(
430                'rest_cannot_read',
431                __( 'Sorry, you are not allowed to view forms.', 'jetpack-forms' ),
432                array( 'status' => rest_authorization_required_code() )
433            );
434        }
435
436        return parent::get_items_permissions_check( $request );
437    }
438
439    /**
440     * Checks if a given request has access to create items.
441     *
442     * @param \WP_REST_Request $request Full details about the request.
443     * @return true|\WP_Error True if the request has access to create items, WP_Error object otherwise.
444     */
445    public function create_item_permissions_check( $request ) {
446        $post_type = get_post_type_object( $this->post_type );
447
448        if ( ! current_user_can( $post_type->cap->create_posts ) ) {
449            return new \WP_Error(
450                'rest_cannot_create',
451                __( 'Sorry, you are not allowed to create forms.', 'jetpack-forms' ),
452                array( 'status' => rest_authorization_required_code() )
453            );
454        }
455
456        return parent::create_item_permissions_check( $request );
457    }
458
459    /**
460     * Checks if a jetpack-form can be read.
461     *
462     * @param \WP_Post $post Post object that backs the block.
463     * @return bool Whether the pattern can be read.
464     */
465    public function check_read_permission( $post ) {
466        // By default the read_post capability is mapped to edit_posts.
467        if ( ! current_user_can( 'read_post', $post->ID ) ) {
468            return false;
469        }
470
471        return parent::check_read_permission( $post );
472    }
473}