Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.23% covered (danger)
44.23%
46 / 104
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Form_Endpoint
44.23% covered (danger)
44.23%
46 / 104
44.44% covered (danger)
44.44%
4 / 9
143.25
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%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_items
22.73% covered (danger)
22.73%
5 / 22
0.00% covered (danger)
0.00%
0 / 1
56.14
 get_entries_count_by_form_id
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 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     * Constructor.
25     */
26    public function __construct() {
27        parent::__construct( Contact_Form::POST_TYPE );
28    }
29
30    /**
31     * Registers the routes for the objects of the controller.
32     */
33    public function register_routes() {
34        parent::register_routes();
35
36        // Register custom preview-url route.
37        register_rest_route(
38            $this->namespace,
39            '/' . $this->rest_base . '/(?P<id>[\d]+)/preview-url',
40            array(
41                'methods'             => \WP_REST_Server::READABLE,
42                'callback'            => array( $this, 'get_preview_url' ),
43                'permission_callback' => array( $this, 'get_item_permissions_check' ),
44                'args'                => array(
45                    'id' => array(
46                        'description'       => __( 'Unique identifier for the form.', 'jetpack-forms' ),
47                        'type'              => 'integer',
48                        'required'          => true,
49                        'sanitize_callback' => 'absint',
50                    ),
51                ),
52            )
53        );
54    }
55
56    /**
57     * Get the preview URL for a form.
58     *
59     * @param WP_REST_Request $request Full details about the request.
60     * @return \WP_REST_Response|\WP_Error Response object or WP_Error.
61     */
62    public function get_preview_url( $request ) {
63        $form_id     = $request->get_param( 'id' );
64        $preview_url = Form_Preview::generate_preview_url( $form_id );
65
66        if ( ! $preview_url ) {
67            return new \WP_Error(
68                'rest_cannot_preview',
69                __( 'Unable to generate preview URL.', 'jetpack-forms' ),
70                array( 'status' => 403 )
71            );
72        }
73
74        return rest_ensure_response( array( 'preview_url' => $preview_url ) );
75    }
76
77    /**
78     * Add opt-in dashboard fields.
79     *
80     * @return array
81     */
82    public function get_collection_params() {
83        $params = parent::get_collection_params();
84
85        // Note: We do not use the built-in WP REST "context" param for this, because it's validated
86        // against core values (view/embed/edit). This param is for Jetpack Forms dashboard usage only.
87        $params['jetpack_forms_context'] = array(
88            'description'       => __( 'Request context for Jetpack Forms. Use "dashboard" to include dashboard-only fields.', 'jetpack-forms' ),
89            'type'              => 'string',
90            'default'           => '',
91            'enum'              => array( '', 'dashboard' ),
92            'sanitize_callback' => 'sanitize_key',
93        );
94
95        return $params;
96    }
97
98    /**
99     * Return a collection of forms.
100     *
101     * We override this to compute dashboard aggregate fields in a single pass.
102     *
103     * @param WP_REST_Request $request Full details about the request.
104     * @return \WP_REST_Response|\WP_Error
105     */
106    public function get_items( $request ) {
107        $response = parent::get_items( $request );
108
109        if ( is_wp_error( $response ) ) {
110            return $response;
111        }
112
113        $forms_context = (string) $request->get_param( 'jetpack_forms_context' );
114        if ( 'dashboard' !== $forms_context ) {
115            return $response;
116        }
117
118        $forms = $response->get_data();
119        if ( ! is_array( $forms ) || empty( $forms ) ) {
120            return $response;
121        }
122
123        $form_ids = array();
124        foreach ( $forms as $form ) {
125            if ( isset( $form['id'] ) ) {
126                $form_ids[] = (int) $form['id'];
127            }
128        }
129        $form_ids = array_values( array_unique( array_filter( $form_ids ) ) );
130
131        $this->entries_count_by_form_id = $this->get_entries_count_by_form_id( $form_ids );
132
133        foreach ( $forms as &$form ) {
134            $form_id               = isset( $form['id'] ) ? (int) $form['id'] : 0;
135            $form['entries_count'] = (int) ( $this->entries_count_by_form_id[ $form_id ] ?? 0 );
136            if ( $form_id ) {
137                $form['edit_url'] = get_edit_post_link( $form_id, 'raw' );
138            }
139        }
140
141        $response->set_data( $forms );
142        return $response;
143    }
144
145    /**
146     * Batch compute feedback counts for a list of form IDs.
147     *
148     * @param int[] $form_ids Form IDs to count entries for.
149     * @return array<int,int> Map of form_id => count
150     */
151    private function get_entries_count_by_form_id( array $form_ids ): array {
152        global $wpdb;
153
154        $form_ids = array_values( array_unique( array_map( 'absint', $form_ids ) ) );
155        if ( empty( $form_ids ) ) {
156            return array();
157        }
158
159        // Count only "inbox-visible" feedback statuses.
160        // Note: This is about feedback (response) statuses, not form post statuses (publish/draft/pending/future/private).
161        $statuses = array( 'publish', 'draft' );
162
163        // Cache the grouped counts briefly to avoid repeated DB hits (e.g. on reload / concurrent requests).
164        sort( $form_ids );
165        $cache_key   = 'feedback_counts_' . md5( implode( ',', $form_ids ) . '|' . implode( ',', $statuses ) );
166        $cache_group = 'jetpack_forms';
167        $cached      = wp_cache_get( $cache_key, $cache_group );
168        if ( false !== $cached && is_array( $cached ) ) {
169            return $cached;
170        }
171
172        $args = array_merge( array( Feedback::POST_TYPE ), $form_ids, $statuses );
173
174        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
175        $rows              = $wpdb->get_results(
176            $wpdb->prepare(
177                "SELECT post_parent, COUNT(1) AS entry_count
178                FROM {$wpdb->posts}
179                WHERE post_type = %s
180                  AND post_parent IN (" . implode( ',', array_fill( 0, count( $form_ids ), '%d' ) ) . ')
181                  AND post_status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')
182                GROUP BY post_parent',
183                $args
184            )
185        );
186        $counts_by_form_id = array();
187        foreach ( (array) $rows as $row ) {
188            $counts_by_form_id[ (int) $row->post_parent ] = (int) $row->entry_count;
189        }
190
191        wp_cache_set( $cache_key, $counts_by_form_id, $cache_group, 15 ); // 15 seconds.
192        return $counts_by_form_id;
193    }
194
195    /**
196     * Checks if a given request has access to get items.
197     *
198     * @param \WP_REST_Request $request Full details about the request.
199     * @return true|\WP_Error True if the request has read access, WP_Error object otherwise.
200     */
201    public function get_items_permissions_check( $request ) {
202        $post_type = get_post_type_object( $this->post_type );
203
204        if ( ! current_user_can( $post_type->cap->edit_posts ) ) {
205            return new \WP_Error(
206                'rest_cannot_read',
207                __( 'Sorry, you are not allowed to view forms.', 'jetpack-forms' ),
208                array( 'status' => rest_authorization_required_code() )
209            );
210        }
211
212        return parent::get_items_permissions_check( $request );
213    }
214
215    /**
216     * Checks if a given request has access to create items.
217     *
218     * @param \WP_REST_Request $request Full details about the request.
219     * @return true|\WP_Error True if the request has access to create items, WP_Error object otherwise.
220     */
221    public function create_item_permissions_check( $request ) {
222        $post_type = get_post_type_object( $this->post_type );
223
224        if ( ! current_user_can( $post_type->cap->create_posts ) ) {
225            return new \WP_Error(
226                'rest_cannot_create',
227                __( 'Sorry, you are not allowed to create forms.', 'jetpack-forms' ),
228                array( 'status' => rest_authorization_required_code() )
229            );
230        }
231
232        return parent::create_item_permissions_check( $request );
233    }
234
235    /**
236     * Checks if a jetpack-form can be read.
237     *
238     * @param \WP_Post $post Post object that backs the block.
239     * @return bool Whether the pattern can be read.
240     */
241    public function check_read_permission( $post ) {
242        // By default the read_post capability is mapped to edit_posts.
243        if ( ! current_user_can( 'read_post', $post->ID ) ) {
244            return false;
245        }
246
247        return parent::check_read_permission( $post );
248    }
249}