$cursor Cursor with the last product id seen. * @return array|null */ public function step( int $scan_id, array $cursor ): ?array { global $wpdb; if ( ! post_type_exists( 'product' ) ) { return null; } $last = (int) ( $cursor['last'] ?? 0 ); if ( 0 === $last ) { $this->check_settings( $scan_id ); } $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads products in batches. $wpdb->prepare( "SELECT ID, post_title, post_content, post_excerpt FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type = 'product' AND ID > %d ORDER BY ID ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core table name. $last, self::PER_STEP ) ); foreach ( $rows as $row ) { $this->inspect( $scan_id, $row ); $last = (int) $row->ID; } return count( $rows ) < self::PER_STEP ? null : array( 'last' => $last ); } /** * Checks one product. * * @param int $scan_id Scan id. * @param \stdClass $row Product row. */ private function inspect( int $scan_id, \stdClass $row ): void { $id = (int) $row->ID; $variable = has_term( 'variable', 'product_type', $id ); $problems = ProductRules::problems( array( 'title' => (string) $row->post_title, 'content' => (string) $row->post_content, 'excerpt' => (string) $row->post_excerpt, 'image' => (int) get_post_thumbnail_id( $id ), 'price' => (string) get_post_meta( $id, '_price', true ), 'sku' => (string) get_post_meta( $id, '_sku', true ), 'variable' => (bool) $variable, 'gallery' => count( array_filter( explode( ',', (string) get_post_meta( $id, '_product_image_gallery', true ) ) ) ), 'external' => has_term( 'external', 'product_type', $id ), 'url' => (string) get_post_meta( $id, '_product_url', true ), ) ); $source = new Source( Source::POST_CONTENT, (string) $id ); $label = ( new SourceLabels() )->label( $source ); $link = get_permalink( $id ); $edit = (string) get_edit_post_link( $id, 'raw' ); $titles = array( 'no_description' => array( Priority::MEDIUM, Priority::ERROR, __( 'This product has no description', 'forensix-digital' ) ), 'no_image' => array( Priority::MEDIUM, Priority::ERROR, __( 'This product has no image', 'forensix-digital' ) ), 'no_price' => array( Priority::HIGH, Priority::ERROR, __( 'This product has no price', 'forensix-digital' ) ), 'no_sku' => array( Priority::LOW, Priority::SUGGESTION, __( 'This product has no SKU', 'forensix-digital' ) ), 'no_gallery' => array( Priority::LOW, Priority::SUGGESTION, __( 'This product has only one image', 'forensix-digital' ) ), 'no_buy_url' => array( Priority::HIGH, Priority::ERROR, __( 'This external product has no buy address', 'forensix-digital' ) ), 'placeholder' => array( Priority::MEDIUM, Priority::ERROR, __( 'This product contains placeholder text', 'forensix-digital' ) ), ); foreach ( $problems as $name ) { $this->store->record( $scan_id, $this->app, new Observation( 'commerce.product.' . $name, $titles[ $name ][0], $titles[ $name ][1], $titles[ $name ][2], false !== $link ? $link : home_url( '/' ), $source->type, $source->ref, $label, array() ), $edit, (string) $row->post_title ); } } /** * Checks the shop settings once per scan. * * @param int $scan_id Scan id. */ private function check_settings( int $scan_id ): void { $notes = ShopRules::judge( self::facts() ); $titles = array( 'no_terms' => array( __( 'The shop has no terms and conditions page', 'forensix-digital' ), 'woocommerce_terms_page_id', 'admin.php?page=wc-settings&tab=advanced' ), 'no_privacy' => array( __( 'No privacy policy page is set', 'forensix-digital' ), 'wp_page_for_privacy_policy', 'options-privacy.php' ), 'no_https' => array( __( 'The shop is not served over HTTPS', 'forensix-digital' ), 'home', 'options-general.php' ), 'test_mode' => array( __( 'A payment method is still in test mode', 'forensix-digital' ), 'woocommerce_gateways', 'admin.php?page=wc-settings&tab=checkout' ), 'no_taxes' => array( __( 'Tax calculation is switched off', 'forensix-digital' ), 'woocommerce_calc_taxes', 'admin.php?page=wc-settings&tab=tax' ), 'no_country' => array( __( 'The shop has no base country', 'forensix-digital' ), 'woocommerce_default_country', 'admin.php?page=wc-settings' ), ); foreach ( $notes as $note ) { $info = $titles[ $note['name'] ]; $source = new Source( Source::OPTION, $info[1] ); $this->store->record( $scan_id, $this->app, new Observation( 'commerce.shop.' . $note['name'], $note['level'], 'low' === $note['level'] ? Priority::SUGGESTION : Priority::ERROR, $info[0], home_url( '/' ), $source->type, $source->ref, __( 'Shop settings', 'forensix-digital' ), array() ), admin_url( $info[2] ), $note['context'] ); } } /** * The facts ShopRules judges, read from the settings. * * @return array{terms_page: int, privacy_page: int, https: bool, test_gateways: array, taxes: bool, country: string, development: bool} */ public static function facts(): array { $test = array(); foreach ( self::test_mode_options() as $label => $option ) { $settings = get_option( $option[0], array() ); if ( is_array( $settings ) && ( $settings[ $option[1] ] ?? 'no' ) === 'yes' ) { $test[] = $label; } } return array( 'terms_page' => (int) get_option( 'woocommerce_terms_page_id', 0 ), 'privacy_page' => (int) get_option( 'wp_page_for_privacy_policy', 0 ), 'https' => 0 === strpos( home_url(), 'https://' ), 'test_gateways' => $test, 'taxes' => 'yes' === get_option( 'woocommerce_calc_taxes', 'no' ), 'country' => (string) get_option( 'woocommerce_default_country', '' ), 'development' => in_array( wp_get_environment_type(), array( 'local', 'development', 'staging' ), true ), ); } /** * Payment methods whose settings have a test or sandbox switch. * * @return array Label => option name and key. */ private static function test_mode_options(): array { /** * Filters the payment methods checked for test mode. Each entry is a label and the option * name and key that hold "yes" when the method is in test mode. * * @param array $options Methods. */ $options = apply_filters( 'forensix_shop_test_mode_options', array( 'Stripe' => array( 'woocommerce_stripe_settings', 'testmode' ), 'PayPal' => array( 'woocommerce_paypal_settings', 'testmode' ), ) ); return (array) $options; } }