WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_import_rows' ),
'permission_callback' => $permission,
'args' => array(
'rows' => array(
'type' => 'array',
'required' => true,
'items' => array( 'type' => 'string' ),
'maxItems' => self::CHUNK_SIZE,
),
'first_row' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'product_name' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'batch' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
register_rest_route(
'novoverify/v1',
'/import/upload',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_upload' ),
'permission_callback' => $permission,
)
);
register_rest_route(
'novoverify/v1',
'/import/process',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_process' ),
'permission_callback' => $permission,
'args' => array(
'token' => array(
'type' => 'string',
'required' => true,
'pattern' => '^[A-Za-z0-9]{32}$',
),
'product_name' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
'batch' => array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
register_rest_route(
'novoverify/v1',
'/import/cancel',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_cancel' ),
'permission_callback' => $permission,
'args' => array(
'token' => array(
'type' => 'string',
'required' => true,
'pattern' => '^[A-Za-z0-9]{32}$',
),
),
)
);
}
/**
* Permission callback for all import routes.
*
* @return bool
*/
public function can_import() {
return current_user_can( NovoVerify_Config::CAPABILITY );
}
/**
* Imports a chunk of manually entered codes (one code per row).
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function rest_import_rows( WP_REST_Request $request ) {
$records = array();
foreach ( (array) $request['rows'] as $row ) {
$records[] = array(
'code' => (string) $row,
'product_name' => '',
'batch' => '',
);
}
$stats = self::import_records(
$records,
array(
'product_name' => $request['product_name'],
'batch' => $request['batch'],
),
(int) $request['first_row']
);
return rest_ensure_response( $stats );
}
/**
* Receives a CSV file and returns a token for processing it.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function rest_upload( WP_REST_Request $request ) {
$files = $request->get_file_params();
if ( empty( $files['file'] ) || ! is_array( $files['file'] ) ) {
return new WP_Error( 'novoverify_no_file', __( 'Please choose a CSV file.', 'novoverify' ), array( 'status' => 400 ) );
}
$file = $files['file'];
$name = isset( $file['name'] ) ? sanitize_file_name( (string) $file['name'] ) : '';
$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, array( 'csv', 'txt' ), true ) ) {
return new WP_Error( 'novoverify_bad_type', __( 'Only .csv and .txt files can be imported.', 'novoverify' ), array( 'status' => 400 ) );
}
self::delete_stale_files();
if ( ! function_exists( 'wp_handle_upload' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$token = wp_generate_password( 32, false, false );
$set_dir = static function ( $dirs ) {
$dirs['subdir'] = '/' . self::UPLOAD_FOLDER;
$dirs['path'] = $dirs['basedir'] . $dirs['subdir'];
$dirs['url'] = $dirs['baseurl'] . $dirs['subdir'];
return $dirs;
};
add_filter( 'upload_dir', $set_dir );
self::protect_folder();
// wp_handle_upload() checks the real file type and extension against the allowed MIME types.
$result = wp_handle_upload(
$file,
array(
'test_form' => false,
'mimes' => array(
'csv' => 'text/csv',
'txt' => 'text/plain',
),
'unique_filename_callback' => static function ( $dir, $filename, $extension ) use ( $token ) {
return $token . $extension;
},
)
);
remove_filter( 'upload_dir', $set_dir );
if ( isset( $result['error'] ) ) {
return new WP_Error( 'novoverify_upload_failed', (string) $result['error'], array( 'status' => 400 ) );
}
$path = (string) $result['file'];
$size = (int) filesize( $path );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a local temporary upload line by line.
$handle = fopen( $path, 'rb' );
if ( ! $handle ) {
wp_delete_file( $path );
return new WP_Error( 'novoverify_upload_failed', __( 'The file could not be read.', 'novoverify' ), array( 'status' => 400 ) );
}
$first_line = (string) fgets( $handle );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- See fopen() above.
fclose( $handle );
set_transient(
self::transient_key( $token ),
array(
'path' => $path,
'user_id' => get_current_user_id(),
'delimiter' => NovoVerify_Csv::detect_delimiter( NovoVerify_Csv::strip_bom( $first_line ) ),
'map' => null,
'offset' => 0,
'row' => 0,
),
self::STALE_AFTER
);
return rest_ensure_response(
array(
'token' => $token,
'size' => $size,
)
);
}
/**
* Processes the next chunk of an uploaded CSV file.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function rest_process( WP_REST_Request $request ) {
$token = (string) $request['token'];
$state = self::get_state( $token );
if ( is_wp_error( $state ) ) {
return $state;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading a local temporary upload in chunks.
$handle = fopen( $state['path'], 'rb' );
if ( ! $handle ) {
self::finish( $token, $state );
return new WP_Error( 'novoverify_upload_failed', __( 'The file could not be read.', 'novoverify' ), array( 'status' => 400 ) );
}
fseek( $handle, (int) $state['offset'] );
$records = array();
$taken = 0;
while ( $taken < self::CHUNK_SIZE ) {
$row = fgetcsv( $handle, 0, $state['delimiter'], '"', '' );
if ( false === $row ) {
break;
}
if ( null === $row || NovoVerify_Csv::is_blank_row( $row ) ) {
++$state['row'];
continue;
}
if ( null === $state['map'] ) {
$row[0] = NovoVerify_Csv::strip_bom( (string) $row[0] );
$map = NovoVerify_Csv::header_map( $row );
++$state['row'];
if ( null !== $map ) {
$state['map'] = $map;
continue;
}
$state['map'] = NovoVerify_Csv::default_map();
$records[] = NovoVerify_Csv::row_to_record( $row, $state['map'] ) + array( 'row' => (int) $state['row'] );
++$taken;
continue;
}
++$state['row'];
$records[] = NovoVerify_Csv::row_to_record( $row, (array) $state['map'] ) + array( 'row' => (int) $state['row'] );
++$taken;
}
$state['offset'] = (int) ftell( $handle );
$done = feof( $handle );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- See fopen() above.
fclose( $handle );
$stats = self::import_records(
$records,
array(
'product_name' => $request['product_name'],
'batch' => $request['batch'],
)
);
$size = (int) filesize( $state['path'] );
if ( $done ) {
self::finish( $token, $state );
} else {
set_transient( self::transient_key( $token ), $state, self::STALE_AFTER );
}
return rest_ensure_response(
array_merge(
$stats,
array(
'done' => $done,
'progress' => $size > 0 ? min( 100, (int) floor( $state['offset'] * 100 / $size ) ) : 100,
)
)
);
}
/**
* Cancels an import and deletes its file.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function rest_cancel( WP_REST_Request $request ) {
$token = (string) $request['token'];
$state = self::get_state( $token );
if ( is_wp_error( $state ) ) {
return $state;
}
self::finish( $token, $state );
return rest_ensure_response( array( 'cancelled' => true ) );
}
/**
* Imports records: validates, skips duplicates and inserts the rest.
*
* @param array[] $records Records with code, product_name, batch and optionally row (row number for reports).
* @param array $defaults Default product_name and batch for records without one.
* @param int $first_row Row number of the first record when records have no row number.
* @return array{imported: int, duplicates: int, invalid: int, duplicate_items: array, invalid_items: array, rows: int}
*/
public static function import_records( array $records, array $defaults = array(), $first_row = 1 ) {
$default_product = isset( $defaults['product_name'] ) ? self::clean_text( $defaults['product_name'], 191 ) : '';
$default_batch = isset( $defaults['batch'] ) ? self::clean_text( $defaults['batch'], 100 ) : '';
$stats = array(
'imported' => 0,
'duplicates' => 0,
'invalid' => 0,
'duplicate_items' => array(),
'invalid_items' => array(),
'rows' => count( $records ),
);
$candidates = array();
foreach ( array_values( $records ) as $index => $record ) {
$row_number = isset( $record['row'] ) ? (int) $record['row'] : (int) $first_row + $index;
$raw = isset( $record['code'] ) ? trim( sanitize_text_field( (string) $record['code'] ) ) : '';
$code = NovoVerify_Codes::normalize_with_settings( $raw );
if ( '' === $raw ) {
continue;
}
if ( ! NovoVerify_Codes::is_valid( $code ) ) {
++$stats['invalid'];
self::report( $stats['invalid_items'], $row_number, $raw );
} elseif ( isset( $candidates[ $code ] ) ) {
++$stats['duplicates'];
self::report( $stats['duplicate_items'], $row_number, $raw );
} else {
$product = self::clean_text( isset( $record['product_name'] ) ? $record['product_name'] : '', 191 );
$batch = self::clean_text( isset( $record['batch'] ) ? $record['batch'] : '', 100 );
$candidates[ $code ] = array(
'code' => $code,
'display_code' => mb_substr( $raw, 0, 100 ),
'product_name' => '' !== $product ? $product : $default_product,
'batch' => '' !== $batch ? $batch : $default_batch,
'row' => $row_number,
);
}
}
if ( ! empty( $candidates ) ) {
$existing = array_flip( NovoVerify_DB::get_existing_codes( array_keys( $candidates ) ) );
foreach ( $candidates as $code => $candidate ) {
if ( isset( $existing[ $code ] ) ) {
++$stats['duplicates'];
self::report( $stats['duplicate_items'], $candidate['row'], $candidate['display_code'] );
unset( $candidates[ $code ] );
}
}
}
if ( ! empty( $candidates ) ) {
$stats['imported'] = NovoVerify_DB::insert_codes_bulk( array_values( $candidates ) );
/**
* Fires after a chunk of codes was imported.
*
* @param int $imported Number of imported codes.
*/
do_action( 'novoverify_codes_imported', $stats['imported'] );
}
return $stats;
}
/**
* Adds an entry to a capped report list.
*
* @param array $items Report list (by reference).
* @param int $row Row number.
* @param string $value Entered value.
*/
private static function report( array &$items, $row, $value ) {
if ( count( $items ) < self::REPORT_LIMIT ) {
$items[] = array(
'row' => (int) $row,
'value' => mb_substr( (string) $value, 0, 100 ),
);
}
}
/**
* Sanitizes a free-text value and limits its length.
*
* @param mixed $value Value.
* @param int $length Maximum length in characters.
* @return string
*/
private static function clean_text( $value, $length ) {
return mb_substr( trim( sanitize_text_field( (string) $value ) ), 0, $length );
}
/**
* Transient key for an import token.
*
* @param string $token Token.
* @return string
*/
private static function transient_key( $token ) {
return 'novoverify_import_' . $token;
}
/**
* Loads the state of an import and checks that it belongs to the current user.
*
* @param string $token Token.
* @return array|WP_Error
*/
private static function get_state( $token ) {
$state = get_transient( self::transient_key( $token ) );
if ( ! is_array( $state ) || get_current_user_id() !== (int) $state['user_id'] || ! is_readable( $state['path'] ) ) {
return new WP_Error( 'novoverify_import_expired', __( 'This import has expired. Please upload the file again.', 'novoverify' ), array( 'status' => 404 ) );
}
return $state;
}
/**
* Deletes the file and state of an import.
*
* @param string $token Token.
* @param array $state Import state.
*/
private static function finish( $token, array $state ) {
if ( ! empty( $state['path'] ) && 0 === strpos( wp_normalize_path( $state['path'] ), wp_normalize_path( self::folder() ) ) ) {
wp_delete_file( $state['path'] );
}
delete_transient( self::transient_key( $token ) );
}
/**
* Absolute path of the temporary upload folder.
*
* @return string
*/
public static function folder() {
$uploads = wp_upload_dir( null, false );
return trailingslashit( $uploads['basedir'] ) . self::UPLOAD_FOLDER;
}
/**
* Creates the upload folder and blocks direct web access to it.
*/
private static function protect_folder() {
$folder = self::folder();
if ( ! wp_mkdir_p( $folder ) ) {
return;
}
global $wp_filesystem;
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
if ( ! WP_Filesystem() || ! $wp_filesystem ) {
return;
}
$files = array(
'index.php' => " "\n\tRequire all denied\n\n\n\tDeny from all\n\n",
);
foreach ( $files as $name => $content ) {
$path = trailingslashit( $folder ) . $name;
if ( ! $wp_filesystem->exists( $path ) ) {
$wp_filesystem->put_contents( $path, $content, FS_CHMOD_FILE );
}
}
}
/**
* Deletes unfinished uploads older than STALE_AFTER.
*/
public static function delete_stale_files() {
$files = glob( trailingslashit( self::folder() ) . '*' );
if ( ! $files ) {
return;
}
$limit = time() - self::STALE_AFTER;
foreach ( $files as $file ) {
$ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
if ( in_array( $ext, array( 'csv', 'txt' ), true ) && filemtime( $file ) < $limit ) {
wp_delete_file( $file );
}
}
}
}