10, 'send_request' => 10, 'update_profile' => 20, 'change_password' => 5, 'dismiss_notice' => 60, ); /** * Portal data. * * @var PortalData */ private $data; /** * Portal actions. * * @var PortalActions */ private $actions; /** * Access checks. * * @var Gate */ private $gate; /** * Customers. * * @var CustomerRepository */ private $customers; /** * Settings. * * @var Settings */ private $settings; /** * Rate limiter. * * @var RateLimiter */ private $limiter; /** * Constructor. * * @param PortalData $data Portal data. * @param PortalActions $actions Portal actions. * @param Gate $gate Access checks. * @param CustomerRepository $customers Customers. * @param Settings $settings Settings. * @param RateLimiter $limiter Rate limiter. */ public function __construct( PortalData $data, PortalActions $actions, Gate $gate, CustomerRepository $customers, Settings $settings, RateLimiter $limiter ) { $this->data = $data; $this->actions = $actions; $this->gate = $gate; $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_portal', array( $this, 'render' ) ); } /** * Handles a submitted portal form, then redirects. * * @since 0.1.0 * * @return void */ public function maybe_process(): void { if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || ! isset( $_POST['hostkhata_portal_nonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_POST['hostkhata_portal_nonce'] ) ); $user = get_current_user_id(); if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || $user < 1 ) { return; } $action = isset( $_POST['hk_action'] ) ? sanitize_key( wp_unslash( $_POST['hk_action'] ) ) : ''; $customer_id = $this->gate->current_customer_id(); $view = isset( $_POST['hk_view'] ) ? sanitize_key( wp_unslash( $_POST['hk_view'] ) ) : 'dashboard'; $id = isset( $_POST['hk_id'] ) ? absint( $_POST['hk_id'] ) : 0; if ( ! isset( self::ACTIONS[ $action ] ) || $customer_id < 1 || ! $this->may_use( $customer_id ) ) { return; } // The fields are read and cleaned by the action itself. $input = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Cleaned by PortalActions; passwords must be left as typed. $flash = $this->limiter->hit( 'portal_' . $action, self::ACTIONS[ $action ], HOUR_IN_SECONDS ) ? $this->run_action( $action, $customer_id, $user, $id, is_array( $input ) ? $input : array() ) : array( 'ok' => false, 'message' => __( 'Too many attempts. Please try again later.', 'hostkhata' ), 'errors' => array(), ); set_transient( 'hostkhata_flash_' . $user, $flash, 5 * MINUTE_IN_SECONDS ); // After a successful change of password the browser needs a fresh login cookie. if ( 'change_password' === $action && $flash['ok'] ) { wp_set_auth_cookie( $user ); } $back = $flash['ok'] && ! empty( $flash['view'] ) ? $flash['view'] : $view; $keep_id = in_array( $back, array( 'order', 'invoice' ), true ) && $back === $view ? $id : 0; wp_safe_redirect( $this->url( in_array( $back, self::VIEWS, true ) ? $back : 'dashboard', $keep_id, true ) ); exit; } /** * Renders the portal. * * @since 0.1.0 * * @return string */ public function render(): string { if ( ! is_user_logged_in() ) { return View::render( 'portal/portal-login', array( 'login_url' => wp_login_url( $this->url( 'dashboard' ) ) ) ); } $customer_id = $this->gate->current_customer_id(); $customer = $customer_id > 0 ? $this->customers->find( $customer_id ) : null; if ( null === $customer ) { return '

' . esc_html__( 'This account does not have a customer profile. Please contact us.', 'hostkhata' ) . '

'; } if ( ! $this->may_use( $customer_id ) ) { return '

' . esc_html__( 'Your account is not active. Please contact us.', 'hostkhata' ) . '

'; } wp_enqueue_style( 'hostkhata-portal', HOSTKHATA_URL . 'assets/public/portal.css', array(), HOSTKHATA_VERSION ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read only: picks which page to show. $view = isset( $_GET['hk'] ) ? sanitize_key( wp_unslash( $_GET['hk'] ) ) : 'dashboard'; $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; $page = isset( $_GET['hk_page'] ) ? max( 1, absint( $_GET['hk_page'] ) ) : 1; // phpcs:enable WordPress.Security.NonceVerification.Recommended $view = in_array( $view, self::VIEWS, true ) ? $view : 'dashboard'; $flash = get_transient( 'hostkhata_flash_' . get_current_user_id() ); if ( false !== $flash ) { delete_transient( 'hostkhata_flash_' . get_current_user_id() ); } $settings = $this->settings; return View::render( 'portal/portal', array( 'view' => $view, 'flash' => is_array( $flash ) ? $flash : array(), 'customer' => $customer, 'body' => $this->body( $view, $customer, $id, $page, is_array( $flash ) ? $flash : array() ), 'money' => static function ( int $minor ) use ( $settings ) { return $settings->format_money( $minor ); }, 'url' => function ( string $to, int $with_id = 0, int $with_page = 0 ) { return $this->url( $to, $with_id, false, $with_page ); }, 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'order_url' => $this->order_page_url(), ) ); } /** * Renders the content of one view. * * @param string $view View name. * @param array $customer Customer. * @param int $id Object id for detail views. * @param int $page Page number for lists. * @param array $flash Result of the last form. * @return string */ private function body( string $view, array $customer, int $id, int $page, array $flash ): string { $customer_id = (int) $customer['id']; $vars = array(); $template = 'portal/portal-' . $view; $settings = $this->settings; switch ( $view ) { case 'dashboard': $vars = array( 'summary' => $this->data->dashboard( $customer_id ) ); break; case 'orders': $vars = array( 'list' => $this->data->orders( $customer_id, $page ) ) + $this->paging( $page ); break; case 'order': $order = $this->data->order( $customer_id, $id ); $vars = array( 'order' => $order ); break; case 'services': $vars = array( 'list' => $this->data->services( $customer_id, $page ) ) + $this->paging( $page ); break; case 'domains': $vars = array( 'list' => $this->data->domains( $customer_id, $page ) ) + $this->paging( $page ); break; case 'invoices': $vars = array( 'list' => $this->data->invoices( $customer_id, $page ) ) + $this->paging( $page ); break; case 'invoice': $invoice = $this->data->invoice( $customer_id, $id ); $vars = array( 'invoice' => $invoice, 'print_url' => $invoice ? InvoiceDocument::customer_url( $invoice['id'] ) : '', 'balance_input' => $invoice ? Money::from_minor( max( 0, $invoice['balance'] ) )->to_decimal_string( (int) $settings->get( 'currency_decimals' ) ) : '', 'instructions' => (string) $settings->get( 'payment_instructions' ), ); break; case 'requests': $vars = array( 'types' => PortalActions::request_types(), 'services' => $this->data->services( $customer_id )['items'], 'domains' => $this->data->domains( $customer_id )['items'], ); break; } return View::render( $template, array_merge( $vars, array( 'customer' => $customer, 'money' => static function ( int $minor ) use ( $settings ) { return $settings->format_money( $minor ); }, 'url' => function ( string $to, int $with_id = 0, int $with_page = 0 ) { return $this->url( $to, $with_id, false, $with_page ); }, 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'view_name' => $view, 'order_url' => $this->order_page_url(), 'flash' => $flash, ) ) ); } /** * Paging values every list view needs. * * @param int $page Current page. * @return array */ private function paging( int $page ): array { return array( 'page' => $page, 'per_page' => PortalData::PER_PAGE, ); } /** * Runs one action and returns what to tell the customer. * * @param string $action Action name. * @param int $customer_id Customer id. * @param int $user_id User id. * @param int $id Object id from the form. * @param array $input Posted fields. * @return array */ private function run_action( string $action, int $customer_id, int $user_id, int $id, array $input ): array { $view = ''; switch ( $action ) { case 'claim_payment': $result = $this->actions->claim_payment( $customer_id, $id, $input ); $done = __( 'Thank you! We will check your payment and confirm it soon.', 'hostkhata' ); $view = 'invoice'; break; case 'send_request': $result = $this->actions->send_request( $customer_id, $input ); $done = __( 'Your request was sent. We will get back to you.', 'hostkhata' ); $view = 'requests'; break; case 'update_profile': $result = $this->actions->update_profile( $customer_id, $input ); $done = __( 'Your details were saved.', 'hostkhata' ); $view = 'profile'; break; case 'change_password': $result = $this->actions->change_password( $user_id, is_string( $input['current_password'] ?? null ) ? $input['current_password'] : '', is_string( $input['new_password'] ?? null ) ? $input['new_password'] : '', is_string( $input['confirm_password'] ?? null ) ? $input['confirm_password'] : '' ); $done = __( 'Your password was changed.', 'hostkhata' ); $view = 'profile'; break; default: $result = $this->actions->dismiss_notice( $customer_id, $id ); $done = ''; $view = 'dashboard'; break; } if ( $result instanceof WP_Error ) { $errors = (array) ( $result->get_error_data()['errors'] ?? array() ); return array( 'ok' => false, 'message' => $errors ? __( 'Please check the highlighted fields.', 'hostkhata' ) : $result->get_error_message(), 'errors' => $errors, 'values' => $this->safe_values( $input ), ); } return array( 'ok' => true, 'message' => $done, 'errors' => array(), 'view' => $view, ); } /** * Posted values that may be shown again after an error. Passwords are never kept. * * @param array $input Posted fields. * @return array */ private function safe_values( array $input ): array { $values = array(); foreach ( $input as $key => $value ) { if ( is_string( $value ) && ! preg_match( '/pass|nonce|hk_action/i', (string) $key ) ) { $values[ sanitize_key( (string) $key ) ] = sanitize_textarea_field( $value ); } } return $values; } /** * Tells whether a customer may use the portal: active customers only. * * @param int $customer_id Customer id. * @return bool */ private function may_use( int $customer_id ): bool { $customer = $this->customers->find( $customer_id ); return $customer && CustomerStatus::ACTIVE === $customer['status']; } /** * URL of a portal view. * * @since 0.1.0 * * @param string $view View name. * @param int $id Object id. * @param bool $flash Whether this is the redirect after a form. * @param int $page Page number. * @return string */ public function url( string $view, int $id = 0, bool $flash = false, int $page = 0 ): string { $page_id = (int) $this->settings->get( 'portal_page_id' ); $base = $page_id > 0 ? get_permalink( $page_id ) : ''; if ( ! $base ) { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $base = home_url( strtok( $uri, '?' ) ); } $args = array( 'hk' => $view ); if ( $id > 0 ) { $args['id'] = $id; } if ( $page > 1 ) { $args['hk_page'] = $page; } return add_query_arg( $args, $base ); } /** * URL of the order page, or an empty string when none is set. * * @return string */ private function order_page_url(): string { $page_id = (int) $this->settings->get( 'order_page_id' ); $url = $page_id > 0 ? get_permalink( $page_id ) : ''; return $url ? $url : ''; } }