limiter = $limiter; $this->terminator = $terminator ?? static function (): void { exit; }; $this->customers = $customers; $this->links = $links; } /** * Register WordPress hooks. * * @return void */ public function hooks(): void { add_action( 'template_redirect', array( $this, 'maybeHandle' ) ); } /** * Handle a registration POST, if this request is one. * * @return void */ public function maybeHandle(): void { if ( ! isset( $_POST[ self::ACTION_FIELD ] ) ) { return; } $nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : ''; if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { // Return a generic error for stale nonces. $this->redirect( 'failed' ); return; } $limited = $this->limiter->check( self::RATE_SCOPE, $this->clientIp(), $this->rateLimit(), $this->rateWindow() ); if ( null !== $limited ) { $this->redirect( 'throttled' ); return; } // Mimic success for bot submissions. $honeypot = isset( $_POST[ self::HONEYPOT_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::HONEYPOT_FIELD ] ) ) : ''; if ( '' !== $honeypot ) { $this->redirect( null ); return; } // Check registration on the server. if ( ! (bool) get_option( 'users_can_register' ) ) { $this->redirect( 'closed' ); return; } $email = isset( $_POST[ self::EMAIL_FIELD ] ) && is_string( $_POST[ self::EMAIL_FIELD ] ) ? sanitize_email( wp_unslash( $_POST[ self::EMAIL_FIELD ] ) ) : ''; if ( '' === $email || ! is_email( $email ) ) { $this->redirect( 'invalid' ); return; } $name = isset( $_POST[ self::NAME_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NAME_FIELD ] ) ) : ''; /** * Filters extra registration validation errors. * * @since 1.0.0 * * @param array $errors Accumulated error codes (empty to allow). * @param string $email The submitted email. * @param string $name The submitted name, or ''. */ $errors = (array) apply_filters( 'releasecart_registration_errors', array(), $email, $name ); if ( array() !== $errors ) { $this->redirect( 'invalid' ); return; } $login = $this->uniqueLogin( $email ); // Fire core's registration checks, as register_new_user() does, so CAPTCHA // and anti-spam plugins apply. They run for every address, before the // existing-email check. $coreErrors = new \WP_Error(); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook, fired as register_new_user() does. do_action( 'register_post', $login, $email, $coreErrors ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook, fired as register_new_user() does. $coreErrors = apply_filters( 'registration_errors', $coreErrors, $login, $email ); if ( ! $coreErrors instanceof \WP_Error || $coreErrors->has_errors() ) { $this->redirect( 'invalid' ); return; } // Do not reveal whether the address exists. if ( false !== email_exists( $email ) ) { $this->redirect( null ); return; } $this->redirect( $this->createUser( $email, $name, $login ) > 0 ? null : 'failed' ); } /** * Create the WordPress account and send its setup link. * * @param string $email The validated email. * @param string $name The submitted name, or ''. * @param string $login The login the core registration hooks saw. * @return int The new user id, or 0 on failure. */ private function createUser( string $email, string $name, string $login ): int { $args = array( 'user_login' => $login, 'user_email' => $email, 'user_pass' => wp_generate_password( 24 ), 'role' => AccountRole::resolve( get_option( 'default_role' ) ), ); if ( '' !== $name ) { $args['display_name'] = $name; } /** * Filters the arguments passed to wp_insert_user when the registration form * mints an account. * * The role cannot go beyond read. A role that can do more becomes subscriber. * * @since 1.0.0 * * @param array $args The wp_insert_user arguments. * @param string $email The submitted email. */ $args = apply_filters( 'releasecart_registration_user_args', $args, $email ); if ( ! is_array( $args ) ) { return 0; } $args['role'] = AccountRole::resolve( $args['role'] ?? '' ); $userId = wp_insert_user( $args ); if ( is_wp_error( $userId ) ) { return 0; } $userId = (int) $userId; // The 'user' notification sends only the set-password link; no plaintext // password is ever emailed. $send = static function () use ( $userId ): void { wp_send_new_user_notifications( $userId, 'user' ); }; if ( null !== $this->links ) { $this->links->withBrandedLinks( $send ); } else { $send(); } $this->syncSplitNames( $userId, $email ); return $userId; } /** * Copy saved customer names to the WordPress user. * * @param int $userId The new WordPress user id. * @param string $email The buyer email. * @return void */ private function syncSplitNames( int $userId, string $email ): void { if ( null === $this->customers ) { return; } $customer = $this->customers->getByEmail( $email ); if ( null === $customer ) { return; } if ( null !== $customer->firstName() ) { update_user_meta( $userId, 'first_name', $customer->firstName() ); } if ( null !== $customer->lastName() ) { update_user_meta( $userId, 'last_name', $customer->lastName() ); } } /** * Build a unique login from an email address. * * @param string $email The buyer email. * @return string A login not currently taken. */ private function uniqueLogin( string $email ): string { $parts = explode( '@', $email ); $base = sanitize_user( (string) ( $parts[0] ?? '' ), true ); if ( '' === $base ) { $base = 'user'; } $login = $base; $suffix = 2; while ( username_exists( $login ) ) { $login = $base . $suffix; ++$suffix; } return $login; } /** * The per-IP submission limit for the window. * * @return int */ private function rateLimit(): int { return max( 1, (int) ( $this->rateConfig()['limit'] ?? 10 ) ); } /** * The rate window length in seconds. * * @return int */ private function rateWindow(): int { return max( 1, (int) ( $this->rateConfig()['window'] ?? 3600 ) ); } /** * The filterable rate-limit configuration. * * @return array */ private function rateConfig(): array { /** * Filters the registration rate limit: `limit` submissions per `window` * seconds, per client IP. * * @since 1.0.0 * * @param array{limit: int, window: int} $config The default limit and window. */ $config = apply_filters( 'releasecart_registration_rate_limit', array( 'limit' => 10, 'window' => 3600, ) ); return is_array( $config ) ? $config : array(); } /** * Get the client IP for rate limiting. * * @return string The client IP, or 'unknown' when unavailable. */ private function clientIp(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : ''; return '' !== $ip ? $ip : 'unknown'; } /** * Redirect after a form submission. * * @param string|null $error A whitelisted error code, or null on success. * @return void */ private function redirect( ?string $error ): void { $target = esc_url_raw( RequestUrl::current() ); $target = remove_query_arg( array( self::ERROR_MARKER, self::SUCCESS_MARKER ), $target ); $target = null !== $error ? add_query_arg( self::ERROR_MARKER, $error, $target ) : add_query_arg( self::SUCCESS_MARKER, '1', $target ); wp_safe_redirect( $target ); ( $this->terminator )(); } }