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 ); } } } }