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 : '';
}
}