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