obj( 'form' ) : null; if ( ! $handler || ! method_exists( $handler, 'add' ) || ! method_exists( $handler, 'update' ) ) { return new \WP_Error( 'pagecore_wpforms_missing', __( 'WPForms is not active on this site.', 'pagecore' ) ); } $source = isset( $item['content'] ) && is_string( $item['content'] ) ? json_decode( $item['content'], true ) : null; if ( ! is_array( $source ) || empty( $source['fields'] ) || ! is_array( $source['fields'] ) ) { return new \WP_Error( 'pagecore_wpforms_invalid', __( 'the demo did not send a form WPForms can read.', 'pagecore' ) ); } $title = isset( $item['title'] ) && is_scalar( $item['title'] ) ? sanitize_text_field( (string) $item['title'] ) : ''; $form = self::clean( $run, Demo_Wire::scrub_form( $source ), $title ); if ( ! $form['fields'] ) { return new \WP_Error( 'pagecore_wpforms_no_fields', __( 'none of its fields can be shown by WPForms Lite.', 'pagecore' ) ); } $title = '' !== $title ? $title : $form['settings']['form_title']; $title = '' !== $title ? $title : __( 'Contact form', 'pagecore' ); $local_id = $replaces && self::POST_TYPE === get_post_type( $replaces ) ? (int) $replaces : 0; $created = false; if ( ! $local_id ) { $local_id = $handler->add( $title ); if ( is_wp_error( $local_id ) ) { return $local_id; } $local_id = (int) $local_id; $created = true; if ( ! $local_id ) { return new \WP_Error( 'pagecore_wpforms_not_created', __( 'WPForms did not create the form. Your account may not be allowed to manage forms.', 'pagecore' ) ); } } $form['id'] = $local_id; // The form handler unslashes what it is given, as it does for the // builder's request, so it is given slashed data. $saved = $handler->update( $local_id, wp_slash( $form ), array( 'skip_revision' => true ) ); if ( ! $saved ) { // The form was created moments ago and has never been shown, so it // holds no submissions. It still goes through WPForms, and when // WPForms will not delete it the customer is told where it is. if ( $created && ! self::delete_form( $local_id ) ) { return new \WP_Error( 'pagecore_wpforms_not_saved', sprintf( /* translators: %s: form title. */ __( 'WPForms did not save the form, and the empty form "%s" it had started could not be removed. You can delete it under WPForms > All Forms.', 'pagecore' ), $title ) ); } return new \WP_Error( 'pagecore_wpforms_not_saved', __( 'WPForms did not save the form. Your account may not be allowed to manage forms.', 'pagecore' ) ); } return $local_id; } /** * Delete a form for good, through WPForms' own handler. * * WPForms checks that the account may delete it, removes the form's * entries along with it and tells other plugins the form is gone. When * WPForms is not active, or will not delete it, the form stays: deleting * the post behind WPForms' back would skip all of that. * * Removing a form a demo brought in goes through Provenance, which only * ever passes a form the import stamped as its own. * * @param int $form_id Local form id. * @return bool Whether WPForms deleted it. */ public static function delete_form( $form_id ) { $form_id = (int) $form_id; if ( $form_id <= 0 || self::POST_TYPE !== get_post_type( $form_id ) ) { return false; } $wpforms = self::wpforms(); $handler = $wpforms ? $wpforms->obj( 'form' ) : null; if ( ! is_object( $handler ) || ! method_exists( $handler, 'delete' ) ) { return false; } return (bool) $handler->delete( $form_id ); } /** * How many submissions a form has received, counted the way WPForms * counts them. * * WPForms Lite keeps no entries, only a count of submissions per form in * post meta. WPForms Pro keeps the entries themselves and counts them * through its entry handler. On Pro both are read and the larger wins, * because a site that moved up from Lite has its earlier submissions only * in the count. * * @param int $form_id Local form id. * @return int|null Null when it cannot be told, because WPForms is not active. */ public static function entry_count( $form_id ) { $wpforms = self::wpforms(); if ( ! $wpforms ) { return null; } $count = absint( get_post_meta( (int) $form_id, self::ENTRIES_COUNT_META, true ) ); if ( method_exists( $wpforms, 'is_pro' ) && $wpforms->is_pro() ) { $entries = $wpforms->obj( 'entry' ); if ( ! is_object( $entries ) || ! method_exists( $entries, 'get_entries' ) ) { return null; } $count = max( $count, absint( $entries->get_entries( array( 'form_id' => (int) $form_id ), true ) ) ); } return $count; } /** * Whether WPForms is active, and so able to delete a form. * * @return bool */ public static function is_active() { return null !== self::wpforms(); } /** * WPForms, when it is active and has the object registry used here. * * @return object|null */ private static function wpforms() { $wpforms = function_exists( 'wpforms' ) ? wpforms() : null; return is_object( $wpforms ) && method_exists( $wpforms, 'obj' ) ? $wpforms : null; } /** * The form rebuilt from the parts that are allowed to travel. * * @param Import_Run $run Import run. * @param array $form Form data, already scrubbed of the demo's accounts. * @param string $title Title the form arrived with. * @return array */ private static function clean( Import_Run $run, $form, $title ) { $fields = array(); $dropped = array(); $next_id = 0; foreach ( array_slice( $form['fields'], 0, self::MAX_FIELDS, true ) as $key => $field ) { $field = self::clean_field( $run, $key, $field ); if ( is_string( $field ) ) { $dropped[] = $field; continue; } $fields[ $field['id'] ] = $field; $next_id = max( $next_id, $field['id'] + 1 ); } if ( $dropped ) { $run->skip( $title, sprintf( /* translators: 1: form title, 2: comma separated field types. */ __( '%1$s came with fields this site cannot show, and they were left out: %2$s', 'pagecore' ), $title, implode( ', ', array_unique( $dropped ) ) ), false ); } $settings = isset( $form['settings'] ) && is_array( $form['settings'] ) ? $form['settings'] : array(); $clean = array( 'id' => 0, 'field_id' => max( $next_id, isset( $form['field_id'] ) ? absint( $form['field_id'] ) : 0 ), 'fields' => $fields, 'settings' => self::clean_settings( $run, $settings, $fields, $title ), ); if ( isset( $form['meta']['template'] ) && is_string( $form['meta']['template'] ) ) { $clean['meta'] = array( 'template' => sanitize_key( $form['meta']['template'] ) ); } return $clean; } /** * One field, rebuilt. * * @param Import_Run $run Import run. * @param mixed $key Key the field was stored under. * @param mixed $field Field as the demo sent it. * @return array|string The field, or the type that was refused. */ private static function clean_field( Import_Run $run, $key, $field ) { $type = is_array( $field ) && isset( $field['type'] ) && is_string( $field['type'] ) ? sanitize_key( $field['type'] ) : ''; if ( ! in_array( $type, self::FIELD_TYPES, true ) ) { return '' !== $type ? $type : __( 'unknown', 'pagecore' ); } // WPForms finds a field by the key it is stored under and by the id // it holds, so the two have to agree. if ( ! is_numeric( $key ) || (int) $key < 0 || ( isset( $field['id'] ) && (string) $field['id'] !== (string) $key ) ) { return $type; } $clean = array( 'id' => (int) $key, 'type' => $type, ); foreach ( self::FIELD_TEXT as $option ) { if ( isset( $field[ $option ] ) && is_scalar( $field[ $option ] ) ) { $clean[ $option ] = sanitize_text_field( (string) $field[ $option ] ); } } foreach ( self::FIELD_FLAGS as $option ) { if ( ! empty( $field[ $option ] ) ) { $clean[ $option ] = '1'; } } foreach ( self::FIELD_KEYS as $option ) { if ( isset( $field[ $option ] ) && is_string( $field[ $option ] ) ) { $clean[ $option ] = sanitize_key( $field[ $option ] ); } } foreach ( self::FIELD_NUMBERS as $option ) { if ( isset( $field[ $option ] ) && is_numeric( $field[ $option ] ) ) { $clean[ $option ] = (string) ( 0 + $field[ $option ] ); } } if ( isset( $field['size'] ) && in_array( $field['size'], array( 'small', 'medium', 'large' ), true ) ) { $clean['size'] = $field['size']; } if ( isset( $field['description'] ) && is_string( $field['description'] ) ) { $clean['description'] = self::clean_html( $run, $field['description'] ); } if ( isset( $field['css'] ) && is_string( $field['css'] ) ) { $clean['css'] = self::clean_classes( $field['css'] ); } if ( isset( $field['choices'] ) && is_array( $field['choices'] ) ) { $clean['choices'] = self::clean_choices( $run, $field['choices'] ); } return $clean; } /** * A field's choices. Pictures and icons stay behind: they are files on * the demo site. * * @param Import_Run $run Import run. * @param array $choices Choices as the demo sent them. * @return array */ private static function clean_choices( Import_Run $run, $choices ) { $clean = array(); foreach ( array_slice( $choices, 0, self::MAX_FIELDS, true ) as $key => $choice ) { if ( ! is_numeric( $key ) || ! is_array( $choice ) ) { continue; } $clean[ (int) $key ] = array( 'label' => isset( $choice['label'] ) && is_string( $choice['label'] ) ? self::clean_html( $run, $choice['label'] ) : '', 'value' => isset( $choice['value'] ) && is_scalar( $choice['value'] ) ? sanitize_text_field( (string) $choice['value'] ) : '', ); if ( ! empty( $choice['default'] ) ) { $clean[ (int) $key ]['default'] = '1'; } } return $clean; } /** * The form's settings. * * @param Import_Run $run Import run. * @param array $settings Settings as the demo sent them. * @param array $fields The form's fields, already cleaned. * @param string $title Title the form arrived with. * @return array */ private static function clean_settings( Import_Run $run, $settings, $fields, $title ) { $form_title = isset( $settings['form_title'] ) && is_scalar( $settings['form_title'] ) ? sanitize_text_field( (string) $settings['form_title'] ) : ''; $clean = array( 'form_title' => '' !== $form_title ? $form_title : $title, 'form_desc' => isset( $settings['form_desc'] ) && is_string( $settings['form_desc'] ) ? self::clean_html( $run, $settings['form_desc'] ) : '', ); foreach ( self::SETTING_TEXT as $key ) { if ( isset( $settings[ $key ] ) && is_scalar( $settings[ $key ] ) ) { $clean[ $key ] = sanitize_text_field( (string) $settings[ $key ] ); } } foreach ( self::SETTING_FLAGS as $key ) { if ( isset( $settings[ $key ] ) ) { $clean[ $key ] = empty( $settings[ $key ] ) ? '0' : '1'; } } foreach ( array( 'form_class', 'submit_class' ) as $key ) { if ( isset( $settings[ $key ] ) && is_string( $settings[ $key ] ) ) { $clean[ $key ] = self::clean_classes( $settings[ $key ] ); } } if ( isset( $settings['anti_spam'] ) && is_array( $settings['anti_spam'] ) ) { $clean['anti_spam'] = self::clean_plain( $settings['anti_spam'] ); } if ( isset( $settings['themes'] ) && is_array( $settings['themes'] ) ) { $clean['themes'] = self::clean_theme( $settings['themes'] ); } $clean['notifications'] = self::clean_notifications( $settings, $fields ); $clean['confirmations'] = self::clean_confirmations( $run, $settings ); return $clean; } /** * The form's notifications, addressed by this site. * * @param array $settings Settings as the demo sent them. * @param array $fields The form's fields, already cleaned. * @return array */ private static function clean_notifications( $settings, $fields ) { $notifications = isset( $settings['notifications'] ) && is_array( $settings['notifications'] ) ? $settings['notifications'] : array(); $clean = array(); foreach ( array_slice( $notifications, 0, 10, true ) as $key => $notification ) { if ( ! is_numeric( $key ) || ! is_array( $notification ) ) { continue; } // Scrubbed again here, whatever the demo did before sending it. $notification = Demo_Wire::scrub_form_notification( $notification ); // A reply goes to an address the visitor typed into the form, or // nowhere special. $replyto = ''; if ( preg_match( '/^\{field_id="(\d+)"\}$/', $notification['replyto'], $match ) && isset( $fields[ (int) $match[1] ] ) && 'email' === $fields[ (int) $match[1] ]['type'] ) { $replyto = $notification['replyto']; } $clean[ (int) $key ] = array( 'notification_name' => isset( $notification['notification_name'] ) && is_scalar( $notification['notification_name'] ) ? sanitize_text_field( (string) $notification['notification_name'] ) : '', 'email' => $notification['email'], 'subject' => isset( $notification['subject'] ) && is_scalar( $notification['subject'] ) ? sanitize_text_field( (string) $notification['subject'] ) : '', 'sender_name' => sanitize_text_field( $notification['sender_name'] ), 'sender_address' => $notification['sender_address'], 'replyto' => $replyto, 'message' => isset( $notification['message'] ) && is_string( $notification['message'] ) ? sanitize_textarea_field( $notification['message'] ) : '{all_fields}', ); if ( isset( $notification['enable'] ) ) { $clean[ (int) $key ]['enable'] = empty( $notification['enable'] ) ? '0' : '1'; } } return $clean; } /** * What the visitor sees after sending the form. * * Only a message travels. A confirmation that opens a page or an address * points at the demo site, where the id or the link means nothing, so it * shows its message instead, or WPForms' usual thanks. * * @param Import_Run $run Import run. * @param array $settings Settings as the demo sent them. * @return array */ private static function clean_confirmations( Import_Run $run, $settings ) { $confirmations = isset( $settings['confirmations'] ) && is_array( $settings['confirmations'] ) ? $settings['confirmations'] : array(); $clean = array(); foreach ( array_slice( $confirmations, 0, 10, true ) as $key => $confirmation ) { if ( ! is_numeric( $key ) || ! is_array( $confirmation ) ) { continue; } $message = isset( $confirmation['message'] ) && is_string( $confirmation['message'] ) ? self::clean_html( $run, $confirmation['message'] ) : ''; $clean[ (int) $key ] = array( 'type' => 'message', 'message' => '' !== trim( wp_strip_all_tags( $message ) ) ? $message : self::default_thanks(), 'message_scroll' => empty( $confirmation['message_scroll'] ) ? '0' : '1', ); if ( isset( $confirmation['name'] ) && is_scalar( $confirmation['name'] ) ) { $clean[ (int) $key ]['name'] = sanitize_text_field( (string) $confirmation['name'] ); } } if ( ! $clean ) { $clean[1] = array( 'type' => 'message', 'message' => self::default_thanks(), 'message_scroll' => '1', ); } return $clean; } /** * The thanks WPForms gives a form of its own. * * @return string */ private static function default_thanks() { return __( 'Thanks for contacting us! We will be in touch with you shortly.', 'pagecore' ); } /** * Theme settings: colours, sizes and border styles. Pictures and custom * CSS stay behind - a picture is a file on the demo site, and custom CSS * can load anything from anywhere. * * @param array $theme Theme settings as the demo sent them. * @return array */ private static function clean_theme( $theme ) { $clean = array(); foreach ( $theme as $key => $value ) { if ( ! self::is_setting_name( $key ) || ! is_scalar( $value ) || preg_match( '/url|image|css|json/i', $key ) ) { continue; } $value = trim( (string) $value ); if ( preg_match( '/^[#a-z0-9.,%()\s-]{0,64}$/i', $value ) && ! preg_match( '/url|expression/i', $value ) ) { $clean[ $key ] = $value; } } return $clean; } /** * A nested group of plain settings, such as the anti-spam options. * * @param array $values Values as the demo sent them. * @param int $depth How far down this is. * @return array */ private static function clean_plain( $values, $depth = 0 ) { $clean = array(); foreach ( $values as $key => $value ) { if ( ! is_int( $key ) && ! self::is_setting_name( $key ) ) { continue; } if ( is_array( $value ) && $depth < 2 ) { $clean[ $key ] = self::clean_plain( $value, $depth + 1 ); } elseif ( is_scalar( $value ) ) { $clean[ $key ] = sanitize_text_field( (string) $value ); } } return $clean; } /** * Whether a key can be a WPForms setting name. Names such as * fieldBorderColor are matched exactly, so the case has to survive. * * @param mixed $key Key. * @return bool */ private static function is_setting_name( $key ) { return is_string( $key ) && (bool) preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $key ); } /** * Text that may hold simple HTML, such as a field's description. * * Links to the demo site are moved onto this one, the same way the * demo's pages are. * * @param Import_Run $run Import run. * @param string $html HTML. * @return string */ private static function clean_html( Import_Run $run, $html ) { $html = wp_kses_post( $html ); $home = untrailingslashit( (string) $run->get( 'home' ) ); $local = untrailingslashit( home_url() ); if ( '' !== $home && false !== strpos( $html, $home ) ) { $html = str_replace( $home, $local, $html ); } return $html; } /** * A list of CSS class names. * * @param string $classes Space separated class names. * @return string */ private static function clean_classes( $classes ) { $clean = array_filter( array_map( 'sanitize_html_class', preg_split( '/\s+/', $classes ) ) ); return implode( ' ', array_unique( $clean ) ); } }