rest_base = 'forms'; } /** * Registers the routes. */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_item_permissions_check' ), 'args' => $this->writable_args( true ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), 'args' => array_merge( $this->id_arg( __( 'Unique identifier of the form.', 'devplugins-custom-form-builder' ) ), array( 'with_fields' => array( 'description' => __( 'Include the fields of the form in the response.', 'devplugins-custom-form-builder' ), 'type' => 'boolean', 'default' => true, ), ) ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), 'args' => array_merge( $this->id_arg( __( 'Unique identifier of the form.', 'devplugins-custom-form-builder' ) ), $this->writable_args( false ) ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'delete_item_permissions_check' ), 'args' => $this->id_arg( __( 'Unique identifier of the form.', 'devplugins-custom-form-builder' ) ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Checks whether the caller may list forms. * * @param WP_REST_Request $request Current request. * * @return true|WP_Error True when allowed. */ public function get_items_permissions_check( $request ): bool|WP_Error { return $this->authorize( Capabilities::MANAGE_FORMS ); } /** * Checks whether the caller may read a form. * * @param WP_REST_Request $request Current request. * * @return true|WP_Error True when allowed. */ public function get_item_permissions_check( $request ): bool|WP_Error { return $this->authorize( Capabilities::MANAGE_FORMS ); } /** * Checks whether the caller may create a form. * * @param WP_REST_Request $request Current request. * * @return true|WP_Error True when allowed. */ public function create_item_permissions_check( $request ): bool|WP_Error { return $this->authorize( Capabilities::MANAGE_FORMS ); } /** * Checks whether the caller may update a form. * * @param WP_REST_Request $request Current request. * * @return true|WP_Error True when allowed. */ public function update_item_permissions_check( $request ): bool|WP_Error { return $this->authorize( Capabilities::MANAGE_FORMS ); } /** * Checks whether the caller may delete a form. * * @param WP_REST_Request $request Current request. * * @return true|WP_Error True when allowed. */ public function delete_item_permissions_check( $request ): bool|WP_Error { return $this->authorize( Capabilities::MANAGE_FORMS ); } /** * Returns a page of forms. * * @param WP_REST_Request $request Current request. */ public function get_items( $request ): WP_REST_Response { $args = array( 'search' => (string) $request->get_param( 'search' ), 'status' => (string) $request->get_param( 'status' ), 'orderby' => (string) $request->get_param( 'orderby' ), 'order' => (string) $request->get_param( 'order' ), 'per_page' => $this->per_page( $request ), 'page' => (int) $request->get_param( 'page' ), ); $items = array(); foreach ( $this->forms->query( $args ) as $form ) { $items[] = $this->prepare_form( $form, false ); } return $this->collection( $items, $this->forms->count( $args ), (int) $args['per_page'] ); } /** * Returns a single form. * * @param WP_REST_Request $request Current request. * * @return WP_REST_Response|WP_Error The response. */ public function get_item( $request ): WP_REST_Response|WP_Error { $form = $this->forms->find( (int) $request->get_param( 'id' ) ); if ( ! $form instanceof Form ) { return $this->not_found( __( 'No form was found with that ID.', 'devplugins-custom-form-builder' ) ); } return rest_ensure_response( $this->prepare_form( $form, (bool) $request->get_param( 'with_fields' ) ) ); } /** * Creates a form. * * @param WP_REST_Request $request Current request. * * @return WP_REST_Response|WP_Error The response. */ public function create_item( $request ): WP_REST_Response|WP_Error { $form_id = $this->forms->insert( $this->writable_data( $request ) ); if ( 0 === $form_id ) { return new WP_Error( 'formpilot_rest_create_failed', __( 'The form could not be created.', 'devplugins-custom-form-builder' ), array( 'status' => 500 ) ); } $form = $this->forms->find( $form_id ); $response = rest_ensure_response( $form instanceof Form ? $this->prepare_form( $form, true ) : array() ); $response->set_status( 201 ); $response->header( 'Location', rest_url( $this->namespace . '/' . $this->rest_base . '/' . $form_id ) ); return $response; } /** * Updates a form. * * @param WP_REST_Request $request Current request. * * @return WP_REST_Response|WP_Error The response. */ public function update_item( $request ): WP_REST_Response|WP_Error { $form_id = (int) $request->get_param( 'id' ); if ( ! $this->forms->exists( $form_id ) ) { return $this->not_found( __( 'No form was found with that ID.', 'devplugins-custom-form-builder' ) ); } $data = $this->writable_data( $request, true, $this->forms->find( $form_id ) ); if ( array() !== $data ) { $this->forms->update( $form_id, $data ); } $form = $this->forms->find( $form_id ); return rest_ensure_response( $form instanceof Form ? $this->prepare_form( $form, true ) : array() ); } /** * Deletes a form and everything attached to it. * * @param WP_REST_Request $request Current request. * * @return WP_REST_Response|WP_Error The response. */ public function delete_item( $request ): WP_REST_Response|WP_Error { $form_id = (int) $request->get_param( 'id' ); $form = $this->forms->find( $form_id ); if ( ! $form instanceof Form ) { return $this->not_found( __( 'No form was found with that ID.', 'devplugins-custom-form-builder' ) ); } $previous = $this->prepare_form( $form, false ); if ( ! $this->forms->delete( $form_id ) ) { return new WP_Error( 'formpilot_rest_delete_failed', __( 'The form could not be deleted.', 'devplugins-custom-form-builder' ), array( 'status' => 500 ) ); } return rest_ensure_response( array( 'deleted' => true, 'previous' => $previous, ) ); } /** * Returns the arguments a collection request accepts. * * @return array> Argument definitions. */ public function get_collection_params(): array { return array_merge( $this->pagination_args(), array( 'status' => array( 'description' => __( 'Limit results to forms with a given status.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'default' => '', 'enum' => array_merge( array( '' ), array_keys( Form::statuses() ) ), ), 'orderby' => array( 'description' => __( 'Column to sort the collection by.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'default' => 'created_at', 'enum' => array( 'id', 'title', 'slug', 'status', 'submission_count', 'created_at', 'updated_at' ), ), ) ); } /** * Returns the schema of a form. * * @return array The schema. */ public function get_item_schema(): array { if ( $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $this->schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'formpilot-form', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'Unique identifier of the form.', 'devplugins-custom-form-builder' ), 'type' => 'integer', 'readonly' => true, ), 'title' => array( 'description' => __( 'Title of the form.', 'devplugins-custom-form-builder' ), 'type' => 'string', ), 'slug' => array( 'description' => __( 'Unique slug of the form.', 'devplugins-custom-form-builder' ), 'type' => 'string', ), 'status' => array( 'description' => __( 'Whether the form accepts submissions.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'enum' => array_keys( Form::statuses() ), ), 'settings' => array( 'description' => __( 'Form settings, including its notifications.', 'devplugins-custom-form-builder' ), 'type' => 'object', ), 'author_id' => array( 'description' => __( 'User who created the form.', 'devplugins-custom-form-builder' ), 'type' => 'integer', 'readonly' => true, ), 'submission_count' => array( 'description' => __( 'Number of stored submissions.', 'devplugins-custom-form-builder' ), 'type' => 'integer', 'readonly' => true, ), 'shortcode' => array( 'description' => __( 'Shortcode embedding the form.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'readonly' => true, ), 'created_at' => array( 'description' => __( 'Date the form was created.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'readonly' => true, ), 'updated_at' => array( 'description' => __( 'Date the form was last changed.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'readonly' => true, ), 'fields' => array( 'description' => __( 'Fields of the form.', 'devplugins-custom-form-builder' ), 'type' => 'array', 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $this->schema ); } /** * Returns the arguments a write request accepts. * * @param bool $creating Whether the arguments describe a creation. * * @return array> Argument definitions. */ private function writable_args( bool $creating ): array { return array( 'title' => array( 'description' => __( 'Title of the form.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'required' => $creating, 'sanitize_callback' => 'sanitize_text_field', ), 'slug' => array( 'description' => __( 'Unique slug of the form. Generated from the title when empty.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_title', ), 'status' => array( 'description' => __( 'Whether the form accepts submissions.', 'devplugins-custom-form-builder' ), 'type' => 'string', 'enum' => array_keys( Form::statuses() ), ), 'settings' => array( 'description' => __( 'Form settings, including its notifications.', 'devplugins-custom-form-builder' ), 'type' => 'object', ), ); } /** * Extracts the writable values from a request. * * @param WP_REST_Request $request Current request. * @param bool $partial Optional. Skip values the request omitted. Default false. * * @return array Column data for the repository. */ private function writable_data( WP_REST_Request $request, bool $partial = false, ?Form $existing = null ): array { $data = array(); foreach ( array( 'title', 'slug', 'status', 'settings' ) as $key ) { if ( $partial && null === $request->get_param( $key ) ) { continue; } $value = $request->get_param( $key ); if ( 'settings' === $key ) { $stored = $existing instanceof Form ? $existing->settings() : array(); /* * Merged over what is stored, not substituted for it. The repository * JSON-encodes this column as a whole, so * `PUT /forms/5 {"settings":{"success_message":"hi"}}` used to erase * that form's notifications, webhooks and every integration * credential — and passing a non-array erased them without so much as * an error. The admin screen has always merged; this route did not. * * `restore_masked()` then puts back anything the client is echoing * from a redacted GET, so an ordinary read-modify-write does not * overwrite a credential with a row of asterisks. */ if ( ! is_array( $value ) ) { if ( ! $partial ) { $data[ $key ] = $stored; } continue; } $data[ $key ] = self::restore_masked( array_replace_recursive( $stored, $this->sanitize_settings( $value ) ), $stored ); continue; } $data[ $key ] = is_scalar( $value ) ? (string) $value : ''; } return $data; } /** * Puts stored credentials back where the request echoed a mask. * * @param array $settings Settings about to be written. * @param array $stored Settings the form already has. * * @return array The settings to write. */ private static function restore_masked( array $settings, array $stored ): array { foreach ( $settings as $key => $value ) { if ( ! array_key_exists( $key, $stored ) ) { continue; } if ( is_array( $value ) && is_array( $stored[ $key ] ) ) { $settings[ $key ] = self::restore_masked( $value, $stored[ $key ] ); continue; } // A masked value is the client repeating what it was shown, not a new one. if ( Redact::is_mask( $value ) || ( is_string( $value ) && str_contains( $value, '/#' ) && $value === Redact::endpoint( (string) $stored[ $key ] ) ) ) { $settings[ $key ] = $stored[ $key ]; } } return $settings; } /** * Recursively sanitises a settings payload. * * @param array $settings Raw settings. * * @return array The sanitised settings. */ private function sanitize_settings( array $settings ): array { $clean = array(); foreach ( $settings as $key => $value ) { $key = is_string( $key ) ? sanitize_key( $key ) : (int) $key; /* * Webhook headers get the admin screen's cleaning, not the generic one. * `sanitize_textarea_field()` keeps line breaks, so a value such as * `x\r\nX-Other: y` was stored as-is and became a second header on the * outbound request. Breaks are flattened per pair first, so a value can * never turn into a header of its own, then names are held to the * RFC token set by WebhookHeaders. */ if ( 'headers' === $key && is_array( $value ) ) { $lines = array(); foreach ( $value as $name => $header ) { if ( is_scalar( $header ) ) { $lines[] = preg_replace( '/[\x00-\x1F\x7F]+/', '', (string) $name ) . ': ' . preg_replace( '/[\x00-\x1F\x7F]+/', ' ', (string) $header ); } } $clean[ $key ] = WebhookHeaders::parse( implode( "\n", $lines ) ); continue; } if ( is_array( $value ) ) { $clean[ $key ] = $this->sanitize_settings( $value ); continue; } if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { $clean[ $key ] = $value; continue; } /* * Notification bodies are the one setting that legitimately carries * markup. Stripping it here would silently mangle a template the * admin screen accepts, so the same kses policy applies on both * routes; everything else is plain text. */ $clean[ $key ] = is_string( $key ) && in_array( $key, self::RICH_TEXT_KEYS, true ) ? Escape::rich_text( (string) $value ) : sanitize_textarea_field( (string) $value ); } return $clean; } /** * Turns a form into its response shape. * * @param Form $form Form to prepare. * @param bool $with_fields Whether to include the fields. * * @return array The prepared item. */ private function prepare_form( Form $form, bool $with_fields ): array { $data = array( 'id' => $form->id(), 'title' => $form->title(), 'slug' => $form->slug(), 'status' => $form->status(), /* * Redacted. `settings` was emitted wholesale, which meant every * `GET /forms` response carried each form's webhook signing secrets and * URLs, its Mailchimp API key, its HubSpot token and its Sheets shared * secret. The route requires `manage_options`, so this was never a * privilege break — it is that one cached response, one logged request * or one admin-side XSS handed over every integration the site has ever * configured, all at once. A client that needs to know whether a key is * set can see the mask; nothing legitimately needs to read the key back. */ 'settings' => self::redact_settings( $form->settings() ), 'author_id' => $form->author_id(), 'submission_count' => $form->submission_count(), 'shortcode' => $form->shortcode(), 'created_at' => $form->created_at(), 'updated_at' => $form->updated_at(), ); if ( $with_fields ) { $data['fields'] = array(); foreach ( $this->fields->for_form( $form->id() ) as $field ) { $data['fields'][] = $field->to_array(); } } /** * Filters a form prepared for the REST API. * * @since 1.0.0 * * @param array $data Prepared item. * @param Form $form Form that was prepared. */ return (array) apply_filters( 'formpilot_rest_prepare_form', $data, $form ); } /** * Masks credentials anywhere in a settings payload. * * Matched on the key name at any depth, which covers the three shapes the * plugin stores credentials in — `webhooks[*].secret`, `integrations[*]. * api_key` and the Sheets `secret` — without this method having to know about * any of them, and covers a service added later for free. * * A webhook URL is masked down to its host for the same reason it is in the * delivery log: for Slack, Discord, Zapier and Make the URL *is* the * credential. * * @param array $settings Stored settings. * * @return array The settings, safe to return. */ private static function redact_settings( array $settings ): array { foreach ( $settings as $key => $value ) { /* * Every value of a webhook's custom headers. They are where an endpoint's * credentials go (`Authorization: Bearer …`, `X-Api-Key: …`), and a header * name rarely contains one of the secret words below, so they were * returned in clear. A client echoing the mask back keeps the stored * value, through restore_masked(). */ if ( 'headers' === $key && is_array( $value ) ) { $settings[ $key ] = array_map( static fn( $header ) => is_scalar( $header ) ? Redact::secret( (string) $header ) : $header, $value ); continue; } if ( is_array( $value ) ) { $settings[ $key ] = self::redact_settings( $value ); continue; } if ( ! is_string( $key ) || ! is_scalar( $value ) ) { continue; } if ( str_ends_with( $key, self::ENDPOINT_KEY_SUFFIX ) && '' !== (string) $value ) { $settings[ $key ] = Redact::endpoint( (string) $value ); continue; } foreach ( self::SECRET_KEYS as $needle ) { if ( str_contains( strtolower( $key ), $needle ) ) { $settings[ $key ] = Redact::secret( $value ); break; } } } return $settings; } }