:
". */ public const META_ORIGIN = '_tflexforms_import_origin'; /** * Key names that mark a credential. A file with such a key anywhere is * refused, and export leaves such keys out. */ private const SECRET_KEY = '/secret|passw|api[_-]?key|private[_-]?key|access[_-]?key|token|credential|licen[cs]e[_-]?key/i'; /** * A short, stable identifier of this site (not its address). */ public static function site_id(): string { return substr( hash( 'sha256', untrailingslashit( home_url() ) ), 0, 16 ); } // ---------------------------------------------------------------- Export. /** * The export document for some forms. * * @param int[] $ids Form IDs; ones that are not forms are skipped. * @return array */ public static function export( array $ids ): array { $forms = array(); foreach ( array_unique( array_map( 'intval', $ids ) ) as $id ) { $post = get_post( $id ); if ( $post instanceof \WP_Post && Form_Post_Type::POST_TYPE === $post->post_type ) { $forms[] = self::export_form( $post ); } } return array( 'format' => self::FORMAT, 'formatVersion' => self::FORMAT_VERSION, 'pluginVersion' => VERSION, 'exportedAt' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'site' => self::site_id(), 'forms' => $forms, ); } /** * The export document as pretty-printed JSON. * * @param int[] $ids Form IDs. */ public static function export_json( array $ids ): string { return (string) wp_json_encode( self::export( $ids ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * One form as stored, ready for the file. * * @param \WP_Post $post Form post. * @return array */ private static function export_form( \WP_Post $post ): array { $meta = static function ( string $key ) use ( $post ) { return get_post_meta( $post->ID, $key, true ); }; $json = $meta( Form_Post_Type::META_SCHEMA ); $schema = is_string( $json ) && '' !== $json ? Form_Schema::from_json( $json ) : null; $item = array( 'id' => $post->ID, 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'schema' => null !== $schema ? $schema->to_array() : null, 'template' => is_string( $meta( Form_Post_Type::META_TEMPLATE ) ) ? $meta( Form_Post_Type::META_TEMPLATE ) : '', 'mail' => is_array( $meta( Form_Post_Type::META_MAIL ) ) ? $meta( Form_Post_Type::META_MAIL ) : array(), 'messages' => is_array( $meta( Form_Post_Type::META_MESSAGES ) ) ? $meta( Form_Post_Type::META_MESSAGES ) : array(), 'settings' => is_array( $meta( Form_Post_Type::META_SETTINGS ) ) ? $meta( Form_Post_Type::META_SETTINGS ) : array(), 'panels' => is_array( $meta( Extensions::META_PANELS ) ) ? $meta( Extensions::META_PANELS ) : array(), 'style' => Style::get( $post->ID ), ); /** * Filters one form's data before it is written to an export file. * Credentials are removed after this filter, whatever it returns. * * @param array $item Form data. * @param \WP_Post $post Form post. */ $item = (array) apply_filters( 'tflexforms_export_form', $item, $post ); return self::without_secrets( $item ); } /** * Removes every key that names a credential, at any depth. * * @param array $data Data. * @return array */ private static function without_secrets( array $data ): array { $out = array(); foreach ( $data as $key => $value ) { if ( is_string( $key ) && preg_match( self::SECRET_KEY, $key ) ) { continue; } $out[ $key ] = is_array( $value ) ? self::without_secrets( $value ) : $value; } return $out; } /** * Paths of keys that name a credential, e.g. "forms[0].panels.crm.api_key". * * @param array $data Data. * @param string $path Path so far. * @return string[] */ private static function secret_paths( array $data, string $path = '' ): array { $found = array(); foreach ( $data as $key => $value ) { $here = is_int( $key ) ? $path . '[' . $key . ']' : ( '' === $path ? (string) $key : $path . '.' . $key ); if ( is_string( $key ) && preg_match( self::SECRET_KEY, $key ) ) { $found[] = $here; } elseif ( is_array( $value ) ) { $found = array_merge( $found, self::secret_paths( $value, $here ) ); } } return $found; } // ---------------------------------------------------------------- Import. /** * Checks an export file and plans the import. Nothing is changed. * * The whole file is refused (errors set, no items) when it is not valid * JSON, not a Flexsolutions Form Builder export, of an unknown format or schema * version, too large, carries credentials, or lists the same form twice. * Otherwise every form becomes an item; one that already exists on this * site, or has no readable definition, is refused with its reason. * * @param string $json File contents. * @return array{errors:string[],items:array>} */ public static function read( string $json ): array { $refuse = static fn( string $message ): array => array( 'errors' => array( $message ), 'items' => array(), ); if ( strlen( $json ) > self::MAX_BYTES ) { /* translators: %s: maximum file size, e.g. "5 MB". */ return $refuse( sprintf( __( 'The file is larger than %s.', 'flexsolutions-form-builder' ), size_format( self::MAX_BYTES ) ) ); } $data = json_decode( (string) preg_replace( '/^\xEF\xBB\xBF/', '', $json ), true, 64 ); if ( ! is_array( $data ) ) { return $refuse( __( 'The file is not valid JSON. Choose a file exported from Flexsolutions Form Builder.', 'flexsolutions-form-builder' ) ); } if ( self::FORMAT !== ( $data['format'] ?? null ) || ! is_array( $data['forms'] ?? null ) || ! array_is_list( $data['forms'] ) ) { return $refuse( __( 'This is not a Flexsolutions Form Builder export file.', 'flexsolutions-form-builder' ) ); } $version = $data['formatVersion'] ?? null; if ( ! is_int( $version ) || $version < 1 || $version > self::FORMAT_VERSION ) { /* translators: %s: file format version found in the file. */ return $refuse( sprintf( __( 'The file uses export format version %s, which this version of Flexsolutions Form Builder cannot read. Update the plugin and try again.', 'flexsolutions-form-builder' ), is_scalar( $version ) ? (string) $version : '?' ) ); } if ( array() === $data['forms'] ) { return $refuse( __( 'The file contains no forms.', 'flexsolutions-form-builder' ) ); } if ( count( $data['forms'] ) > self::MAX_FORMS ) { /* translators: %d: maximum number of forms. */ return $refuse( sprintf( __( 'The file contains more than %d forms. Export fewer forms at a time.', 'flexsolutions-form-builder' ), self::MAX_FORMS ) ); } $secrets = self::secret_paths( $data ); if ( array() !== $secrets ) { return array( 'errors' => array_merge( array( __( 'The file contains what look like passwords, keys or other credentials, so it was not imported. Flexsolutions Form Builder never exports these; remove them from the file and try again:', 'flexsolutions-form-builder' ) ), array_slice( $secrets, 0, 20 ) ), 'items' => array(), ); } $site = is_string( $data['site'] ?? null ) ? $data['site'] : ''; $errors = array(); $seen = array(); foreach ( $data['forms'] as $index => $form ) { if ( ! is_array( $form ) ) { /* translators: %d: position of the form in the file. */ $errors[] = sprintf( __( 'Form %d is not a form object.', 'flexsolutions-form-builder' ), $index + 1 ); continue; } $id = $form['id'] ?? null; if ( is_int( $id ) && $id > 0 ) { if ( isset( $seen[ $id ] ) ) { /* translators: 1: position of the form in the file, 2: position of the earlier copy. */ $errors[] = sprintf( __( 'Form %1$d is a duplicate of form %2$d in the same file.', 'flexsolutions-form-builder' ), $index + 1, $seen[ $id ] + 1 ); } $seen[ $id ] = $index; } if ( isset( $form['schema'] ) && null !== $form['schema'] ) { $schema_version = is_array( $form['schema'] ) ? ( $form['schema']['schemaVersion'] ?? null ) : null; if ( ! is_array( $form['schema'] ) || null === Form_Schema::upgrade( $form['schema'] ) ) { $errors[] = sprintf( /* translators: 1: position of the form in the file, 2: schema version found. */ __( 'Form %1$d uses form schema version %2$s, which this version of Flexsolutions Form Builder cannot read. Update the plugin and try again.', 'flexsolutions-form-builder' ), $index + 1, is_scalar( $schema_version ) ? (string) $schema_version : '?' ); } } } if ( array() !== $errors ) { return array( 'errors' => $errors, 'items' => array(), ); } $items = array(); foreach ( $data['forms'] as $form ) { $items[] = self::plan_item( $form, $site ); } return array( 'errors' => array(), 'items' => $items, ); } /** * One form of a checked file, sanitized, with why it cannot be imported * ('' when it can). * * @param array $form Form from the file. * @param string $site Site ID from the file. * @return array */ private static function plan_item( array $form, string $site ): array { $schema = is_array( $form['schema'] ?? null ) ? Form_Schema::from_array( $form['schema'] ) : null; $template = is_string( $form['template'] ?? null ) ? Form::sanitize_template( $form['template'] ) : ''; $title = sanitize_text_field( is_string( $form['title'] ?? null ) ? $form['title'] : '' ); $id = is_int( $form['id'] ?? null ) ? (int) $form['id'] : 0; $origin = '' !== $site && $id > 0 ? $site . ':' . $id : ''; $item = array( 'title' => '' !== $title ? $title : __( 'Imported form', 'flexsolutions-form-builder' ), 'origin' => $origin, 'schema' => null !== $schema ? $schema->to_json() : '', 'template' => $template, 'mail' => Form::sanitize_mail_settings( $form['mail'] ?? array() ), 'messages' => Form::sanitize_messages( $form['messages'] ?? array() ), 'settings' => Form::sanitize_settings( $form['settings'] ?? array() ), 'style' => Style::sanitize( $form['style'] ?? array() ), // Each panel is sanitized by its own add-on when the form is created. 'panels' => is_array( $form['panels'] ?? null ) ? $form['panels'] : array(), 'fields' => null !== $schema ? count( $schema->to_array()['fields'] ) : count( ( new Tag_Parser() )->parse( $template ) ), 'refused' => '', ); // Add-on settings whose add-on is not active here are left out. $item['skipped_panels'] = array_values( array_map( 'strval', array_diff( array_keys( $item['panels'] ), array_keys( Extensions::settings_panels() ) ) ) ); if ( null === $schema && '' === trim( $template ) ) { $item['refused'] = __( 'It has no fields or template to import.', 'flexsolutions-form-builder' ); } elseif ( '' !== $origin ) { $existing = self::existing( $site, $id, $origin ); if ( null !== $existing ) { $item['refused'] = sprintf( /* translators: 1: form title, 2: form ID. */ __( 'It is already on this site as "%1$s" (#%2$d). Use Duplicate on the forms list to make a copy.', 'flexsolutions-form-builder' ), get_the_title( $existing ), $existing ); } } return $item; } /** * The form on this site that a file's form is, or was imported as. * * @param string $site Site ID from the file. * @param int $id Form ID on that site. * @param string $origin ":". * @return int|null Form ID. */ private static function existing( string $site, int $id, string $origin ): ?int { if ( self::site_id() === $site ) { $post = get_post( $id ); if ( $post instanceof \WP_Post && Form_Post_Type::POST_TYPE === $post->post_type && 'trash' !== $post->post_status ) { return $id; } } $found = get_posts( array( 'post_type' => Form_Post_Type::POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'numberposts' => 1, 'fields' => 'ids', 'meta_key' => self::META_ORIGIN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Admin import only. 'meta_value' => $origin, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); return array() !== $found ? (int) $found[0] : null; } /** * Creates a new draft form from a planned item. Never touches existing forms. * * @param array $item Item from read(). * @return int|\WP_Error New form ID. */ public static function create( array $item ): int|\WP_Error { if ( '' !== (string) ( $item['refused'] ?? '' ) ) { return new \WP_Error( 'tflexforms_import_refused', (string) $item['refused'] ); } $id = wp_insert_post( array( 'post_type' => Form_Post_Type::POST_TYPE, 'post_status' => 'draft', 'post_title' => (string) $item['title'], ), true ); if ( is_wp_error( $id ) ) { return $id; } // The registered meta sanitizers run again on save. update_post_meta( $id, Form_Post_Type::META_TEMPLATE, wp_slash( (string) $item['template'] ) ); update_post_meta( $id, Form_Post_Type::META_MAIL, wp_slash( (array) $item['mail'] ) ); update_post_meta( $id, Form_Post_Type::META_MESSAGES, wp_slash( (array) $item['messages'] ) ); update_post_meta( $id, Form_Post_Type::META_SETTINGS, wp_slash( (array) $item['settings'] ) ); if ( isset( $item['style'] ) ) { update_post_meta( $id, Style::META, wp_slash( Style::sanitize( $item['style'] ) ) ); } if ( '' !== (string) $item['schema'] ) { update_post_meta( $id, Form_Post_Type::META_SCHEMA, wp_slash( (string) $item['schema'] ) ); update_post_meta( $id, Schema_Migration::META_SOURCE, 'import' ); } else { // A template-only form (one that could not be converted where it came from). Schema_Migration::convert_form( $id ); } if ( '' !== (string) $item['origin'] ) { update_post_meta( $id, self::META_ORIGIN, (string) $item['origin'] ); } self::import_panels( $id, (array) $item['panels'] ); return $id; } /** * Stores add-on panel values through each active panel's own sanitizer. * Values for panels whose add-on is not active are not imported. * * @param int $id New form ID. * @param array $panels Panel ID => values. */ private static function import_panels( int $id, array $panels ): void { $active = Extensions::settings_panels(); $post = get_post( $id ); $stored = array(); foreach ( $panels as $panel => $values ) { if ( ! isset( $active[ $panel ] ) || ! is_array( $values ) || ! $post instanceof \WP_Post ) { continue; } $clean = call_user_func( $active[ $panel ]['sanitize'], $values, Form::from_post( $post ) ); $stored[ $panel ] = is_array( $clean ) ? $clean : array(); } if ( array() !== $stored ) { update_post_meta( $id, Extensions::META_PANELS, wp_slash( $stored ) ); } } }