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;