, values: array} */ private $result = array( 'errors' => array(), 'values' => array(), ); /** * Constructor. * * @param OrderService $orders Order service. * @param ProductRepository $products Products. * @param CustomerRepository $customers Customers. * @param Settings $settings Settings. * @param RateLimiter $limiter Rate limiter. */ public function __construct( OrderService $orders, ProductRepository $products, CustomerRepository $customers, Settings $settings, RateLimiter $limiter ) { $this->orders = $orders; $this->products = $products; $this->customers = $customers; $this->settings = $settings; $this->limiter = $limiter; } /** * {@inheritdoc} */ public function register(): void { add_action( 'init', array( $this, 'maybe_process' ), 20 ); add_shortcode( 'hostkhata_order', array( $this, 'render' ) ); } /** * Processes a submitted form. * * @since 0.1.0 * * @return void */ public function maybe_process(): void { if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || ! isset( $_POST['hostkhata_order_nonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_POST['hostkhata_order_nonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'hostkhata_order' ) ) { $this->fail( __( 'Your session expired. Please try again.', 'hostkhata' ) ); return; } $customer = is_user_logged_in() ? $this->customers->find_by_user_id( get_current_user_id() ) : null; if ( null === $customer ) { $this->fail( __( 'Please log in to place an order.', 'hostkhata' ) ); return; } // A real visitor never fills the hidden field. if ( ! empty( $_POST['hk_website'] ) ) { $this->fail( __( 'We could not place your order.', 'hostkhata' ) ); return; } $key = isset( $_POST['request_key'] ) ? preg_replace( '/[^a-f0-9]/', '', sanitize_text_field( wp_unslash( $_POST['request_key'] ) ) ) : ''; // The same form sent twice (double click, back button) shows the first order again. if ( '' !== $key && get_transient( 'hostkhata_ordkey_' . $key ) ) { $this->redirect_done(); return; } if ( ! $this->limiter->hit( 'order', self::ATTEMPTS_PER_HOUR, HOUR_IN_SECONDS ) ) { $this->fail( __( 'Too many attempts. Please try again later.', 'hostkhata' ) ); return; } $input = $this->read_input(); $this->result['values'] = $input['form']; $order = $this->orders->place( $input['order'], (int) $customer['id'], array( 'customer_notes' => $input['form']['notes'] ) ); if ( $order instanceof WP_Error ) { $errors = (array) ( $order->get_error_data()['errors'] ?? array() ); $this->result['errors'] = $errors ? $this->flatten( $errors ) : array( 'form' => $order->get_error_message() ); return; } if ( '' !== $key ) { set_transient( 'hostkhata_ordkey_' . $key, $order['order_number'], HOUR_IN_SECONDS ); } set_transient( 'hostkhata_ordered_' . get_current_user_id(), $order['order_number'], 10 * MINUTE_IN_SECONDS ); $this->redirect_done(); } /** * Renders the shortcode. * * @since 0.1.0 * * @param array|string $atts Shortcode attributes: `product` (slug). * @return string */ public function render( $atts = array() ): string { $atts = shortcode_atts( array( 'product' => '' ), is_array( $atts ) ? $atts : array(), 'hostkhata_order' ); if ( ! is_user_logged_in() ) { return View::render( 'portal/order-login', array( 'login_url' => wp_login_url( $this->current_url() ), 'register_url' => $this->settings->get( 'registration_enabled' ) ? wp_registration_url() : '', ) ); } $customer = $this->customers->find_by_user_id( get_current_user_id() ); if ( null === $customer ) { return '

' . esc_html__( 'Your account is not set up for ordering. Please contact us.', 'hostkhata' ) . '

'; } wp_enqueue_style( 'hostkhata-order', HOSTKHATA_URL . 'assets/public/order.css', array(), HOSTKHATA_VERSION ); $number = get_transient( 'hostkhata_ordered_' . get_current_user_id() ); if ( isset( $_GET['hk_ordered'] ) && $number ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only reads a flag; the order number comes from a transient. delete_transient( 'hostkhata_ordered_' . get_current_user_id() ); return View::render( 'portal/order-done', array( 'order_number' => (string) $number ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read only: picks which plan to show. $slug = '' !== $atts['product'] ? sanitize_title( $atts['product'] ) : ( isset( $_GET['product'] ) ? sanitize_title( wp_unslash( $_GET['product'] ) ) : '' ); $product = '' !== $slug ? $this->products->find_by_slug( $slug ) : null; if ( null === $product || 'active' !== $product['status'] || ! in_array( $product['type'], array( ProductType::HOSTING, ProductType::ADDON, ProductType::SSL, ProductType::OTHER ), true ) ) { return View::render( 'portal/order-choose', array( 'plans' => $this->plans(), 'url' => $this->current_url( false ), ) ); } return View::render( 'portal/order-form', array( 'product' => $product, 'cycles' => $this->cycles( $product ), 'addons' => $this->addons( $product ), 'errors' => $this->result['errors'], 'values' => $this->result['values'], 'request_id' => bin2hex( random_bytes( 8 ) ), ) ); } /** * Reads and cleans what the visitor submitted. * * @return array{order: array, form: array} */ private function read_input(): array { // The nonce was verified by the caller. // phpcs:disable WordPress.Security.NonceVerification.Missing $slug = isset( $_POST['product'] ) ? sanitize_title( wp_unslash( $_POST['product'] ) ) : ''; $cycle = isset( $_POST['cycle'] ) ? sanitize_key( wp_unslash( $_POST['cycle'] ) ) : ''; $domain = isset( $_POST['domain_name'] ) ? sanitize_text_field( wp_unslash( $_POST['domain_name'] ) ) : ''; $coupon = isset( $_POST['coupon_code'] ) ? sanitize_text_field( wp_unslash( $_POST['coupon_code'] ) ) : ''; $notes = isset( $_POST['notes'] ) ? sanitize_textarea_field( wp_unslash( $_POST['notes'] ) ) : ''; $answers = array(); if ( isset( $_POST['answers'] ) && is_array( $_POST['answers'] ) ) { foreach ( wp_unslash( $_POST['answers'] ) as $name => $value ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised below. $answers[ sanitize_key( (string) $name ) ] = is_array( $value ) ? array_map( 'sanitize_text_field', $value ) : sanitize_text_field( $value ); } } $addon_ids = isset( $_POST['addon_ids'] ) && is_array( $_POST['addon_ids'] ) ? array_map( 'absint', wp_unslash( $_POST['addon_ids'] ) ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Cast to integers. // phpcs:enable WordPress.Security.NonceVerification.Missing $product = '' !== $slug ? $this->products->find_by_slug( $slug ) : null; return array( 'order' => array( 'lines' => array( array( 'product_id' => $product ? $product['id'] : 0, 'cycle' => $cycle, 'domain_name' => $domain, 'answers' => $answers, 'addon_ids' => $addon_ids, ), ), 'coupon_code' => $coupon, ), 'form' => array( 'product' => $slug, 'cycle' => $cycle, 'domain_name' => $domain, 'coupon_code' => $coupon, 'notes' => $notes, 'answers' => $answers, 'addon_ids' => $addon_ids, ), ); } /** * Sets a general error. * * @param string $message Message. * @return void */ private function fail( string $message ): void { $this->result['errors'] = array( 'form' => $message ); } /** * Turns planner errors (`lines.0`, `coupon_code`, …) into messages the form can show. * * @param array $errors Errors keyed by field. * @return array */ private function flatten( array $errors ): array { $flat = array(); foreach ( $errors as $field => $message ) { $target = 0 === strpos( $field, 'lines' ) ? 'form' : $field; $flat[ $target ] = $message; } return $flat; } /** * Redirects to the thank-you view. * * @return void */ private function redirect_done(): void { wp_safe_redirect( add_query_arg( 'hk_ordered', '1', remove_query_arg( array( 'hk_ordered' ), $this->current_url() ) ) ); exit; } /** * URL of the current page. * * @param bool $with_query Keep the query string. * @return string */ private function current_url( bool $with_query = true ): string { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = $with_query ? $uri : strtok( $uri, '?' ); return home_url( $path ); } /** * Recurring prices of a product as options for the cycle select. * * @param array $product Product with prices. * @return array> */ private function cycles( array $product ): array { $cycles = array(); foreach ( $product['prices'] as $price ) { if ( 'recurring' !== $price['price_type'] ) { continue; } $cycles[] = array( 'value' => $price['billing_cycle'], 'label' => BillingCycle::label( $price['billing_cycle'] ), 'price' => $this->settings->format_money( $price['price'] ), 'setup' => $price['setup_fee'] > 0 ? $this->settings->format_money( $price['setup_fee'] ) : '', ); } return $cycles; } /** * Add-ons a product accepts, with their prices. * * @param array $product Product. * @return array> */ private function addons( array $product ): array { $addons = array(); foreach ( (array) $product['addon_ids'] as $addon_id ) { $addon = $this->products->find( (int) $addon_id ); if ( $addon && 'active' === $addon['status'] ) { $addon['cycles'] = $this->cycles( $addon ); $addons[] = $addon; } } return $addons; } /** * Active products a visitor can order, for the "choose a plan" view. * * @return array> */ private function plans(): array { return $this->products->public_listing( '', ProductType::HOSTING ); } }