admin = $admin; add_action( 'admin_menu', array( $this, 'menu' ), 20 ); add_action( 'admin_post_shapeoptions_export', array( $this, 'handle_export' ) ); add_action( 'admin_post_shapeoptions_import', array( $this, 'handle_import' ) ); } /** * Register the tools page. */ public function menu() { add_submenu_page( 'edit.php?post_type=shapeoptions_group', __( 'ShapeOptions Import / Export', 'shapeoptions' ), __( 'Import / Export', 'shapeoptions' ), 'manage_woocommerce', 'shapeoptions-import-export', array( $this, 'render_page' ) ); } /** * Render the tools page. */ public function render_page() { $notice = isset( $_GET['shapeoptions_notice'] ) ? sanitize_key( wp_unslash( $_GET['shapeoptions_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>


500 ) ); } nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=shapeoptions-export-' . gmdate( 'Ymd-His' ) . '.json' ); while ( ob_get_level() > 0 ) { ob_end_clean(); } echo "{\n"; echo "\t\"format\": \"shapeoptions/v1\",\n"; echo "\t\"version\": " . wp_json_encode( SHAPEOPTIONS_VERSION ) . ",\n"; echo "\t\"settings\": " . str_replace( "\n", "\n\t", wp_json_encode( Shapeoptions_Style_Settings::get(), JSON_PRETTY_PRINT ) ) . ",\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON body, not HTML. echo "\t\"groups\": ["; $batch = 100; $offset = 0; $first = true; do { $posts = get_posts( array( 'post_type' => 'shapeoptions_group', 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'posts_per_page' => $batch, 'offset' => $offset, 'orderby' => array( 'menu_order' => 'ASC', 'ID' => 'ASC', ), 'no_found_rows' => true, 'update_post_term_cache' => false, ) ); foreach ( $posts as $post ) { $group = array( 'title' => $post->post_title, 'status' => $post->post_status, 'menu_order' => $post->menu_order, 'fields' => get_post_meta( $post->ID, '_shapeoptions_fields', true ) ?: array(), 'conditions' => get_post_meta( $post->ID, '_shapeoptions_conditions', true ) ?: array( 'type' => 'all', 'ids' => array() ), 'placement' => get_post_meta( $post->ID, '_shapeoptions_placement', true ) ?: 'before', ); echo $first ? "\n" : ",\n"; $first = false; echo "\t\t" . str_replace( "\n", "\n\t\t", wp_json_encode( $group, JSON_PRETTY_PRINT ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON body, not HTML. } $offset += $batch; flush(); } while ( count( $posts ) === $batch ); echo "\n\t]\n}"; exit; } /** * Import groups from an uploaded JSON file. */ public function handle_import() { check_admin_referer( 'shapeoptions_import' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You are not allowed to import option groups.', 'shapeoptions' ) ); } $back = admin_url( 'edit.php?post_type=shapeoptions_group&page=shapeoptions-import-export' ); // No wp_unslash(): WordPress never slashes $_FILES, and unslashing // breaks Windows tmp_name paths. Validated by is_uploaded_file() below. $tmp = isset( $_FILES['shapeoptions_import_file']['tmp_name'] ) && is_string( $_FILES['shapeoptions_import_file']['tmp_name'] ) ? sanitize_text_field( $_FILES['shapeoptions_import_file']['tmp_name'] ) : ''; $data = null; if ( '' !== $tmp && is_uploaded_file( $tmp ) ) { $raw = file_get_contents( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- see above. if ( is_string( $raw ) && strlen( $raw ) <= self::max_file_bytes() ) { // Depth capped as well: json_decode recurses, and an export file // is only ever four levels deep. The decoded value is untrusted, // so it is sanitized before anything reads it. $data = $this->sanitize_import( json_decode( $raw, true, 32 ) ); } unset( $raw ); } if ( ! is_array( $data ) || 'shapeoptions/v1' !== ( $data['format'] ?? '' ) || ! is_array( $data['groups'] ?? null ) ) { wp_safe_redirect( add_query_arg( 'shapeoptions_notice', 'invalid', $back ) ); exit; } $result = $this->import_groups( $data['groups'] ); $settings_restored = false; if ( is_array( $data['settings'] ) ) { update_option( Shapeoptions_Style_Settings::OPTION_KEY, $data['settings'] ); $settings_restored = true; } wp_safe_redirect( add_query_arg( array( 'shapeoptions_notice' => 'imported', 'shapeoptions_imported' => $result['imported'], 'shapeoptions_skipped' => $result['skipped'], 'shapeoptions_settings' => $settings_restored ? 1 : 0, ), $back ) ); exit; } /** * Create draft groups from a decoded export's `groups` list. * * Split out from handle_import() so the import rules are reachable without * a real file upload — handle_import() is otherwise all request handling * ($_FILES, is_uploaded_file(), redirect, exit) wrapped around this. * * @param array $groups Raw group definitions, untrusted. * @return array{imported:int,skipped:int} */ public function import_groups( $groups ) { /** * Filter how many groups a single import may create. * * Each group is a wp_insert_post() plus three update_post_meta() calls, * so a file with thousands of them runs past max_execution_time and * dies mid-way — leaving a partial import with no record of where it * stopped. Importing a bounded number and saying plainly how many were * left is the honest outcome; the file can be split and re-imported. * * @param int $max Maximum groups per import. */ $max_groups = (int) apply_filters( 'shapeoptions/import_max_groups', 200 ); $imported = 0; $skipped = 0; foreach ( $groups as $group ) { if ( ! is_array( $group ) || '' === (string) ( $group['title'] ?? '' ) ) { continue; } if ( $max_groups > 0 && $imported >= $max_groups ) { $skipped++; continue; } $post_id = wp_insert_post( array( 'post_type' => 'shapeoptions_group', 'post_title' => sanitize_text_field( $group['title'] ), 'post_status' => 'draft', 'menu_order' => absint( $group['menu_order'] ?? 0 ), ) ); if ( ! $post_id || is_wp_error( $post_id ) ) { continue; } update_post_meta( $post_id, '_shapeoptions_fields', $this->sanitize_fields( (array) ( $group['fields'] ?? array() ) ) ); $conditions = (array) ( $group['conditions'] ?? array() ); update_post_meta( $post_id, '_shapeoptions_conditions', array( 'type' => sanitize_key( $conditions['type'] ?? 'all' ), 'ids' => array_map( 'absint', (array) ( $conditions['ids'] ?? array() ) ), ) ); update_post_meta( $post_id, '_shapeoptions_placement', 'after' === ( $group['placement'] ?? '' ) ? 'after' : 'before' ); $imported++; } return array( 'imported' => $imported, 'skipped' => $skipped, ); } /** * Largest import file that is read at all. * * json_decode() builds the entire structure in memory before anything can * be validated, so the guard has to come before the decode rather than * after it. A genuine export of a few hundred groups is well under this. * * @return int Bytes. */ private static function max_file_bytes() { /** * Filter the largest import file ShapeOptions will read. * * @param int $bytes Maximum size in bytes. */ return (int) apply_filters( 'shapeoptions/import_max_bytes', 8 * MB_IN_BYTES ); } /** * Sanitize imported field configs (imports are untrusted input) using * the builder's own sanitizer, so imports and saves stay in lockstep. * * @param array $fields Raw field list. * @return array[] */ private function sanitize_fields( $fields ) { return $this->admin->sanitize_fields( $fields ); } /** * Sanitize a decoded import file into the shape handle_import() reads. * * @param mixed $decoded Untrusted json_decode() result. * @return array|null [ format, groups, settings ], or null when it is not an object. */ private function sanitize_import( $decoded ) { if ( ! is_array( $decoded ) ) { return null; } $groups = null; if ( isset( $decoded['groups'] ) && is_array( $decoded['groups'] ) ) { $groups = array(); foreach ( $decoded['groups'] as $group ) { if ( ! is_array( $group ) ) { continue; } $conditions = isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array(); $groups[] = array( 'title' => is_scalar( $group['title'] ?? '' ) ? sanitize_text_field( (string) ( $group['title'] ?? '' ) ) : '', 'menu_order' => absint( $group['menu_order'] ?? 0 ), 'fields' => $this->sanitize_fields( $group['fields'] ?? array() ), 'conditions' => array( 'type' => sanitize_key( is_scalar( $conditions['type'] ?? 'all' ) ? (string) ( $conditions['type'] ?? 'all' ) : 'all' ), 'ids' => array_map( 'absint', (array) ( $conditions['ids'] ?? array() ) ), ), 'placement' => 'after' === ( $group['placement'] ?? '' ) ? 'after' : 'before', ); } } return array( 'format' => is_string( $decoded['format'] ?? null ) ? sanitize_text_field( $decoded['format'] ) : '', 'groups' => $groups, 'settings' => isset( $decoded['settings'] ) && is_array( $decoded['settings'] ) ? Shapeoptions_Style_Settings::sanitize_settings( $decoded['settings'] ) : null, ); } } endif;