repo = $repo; $this->settings = $settings; } /** * Hooks registration form handling and shortcode. * * @since 1.0.0 */ public function register() { add_shortcode( 'ppl_afw_registration', array( $this, 'registration_shortcode' ) ); add_action( 'admin_post_ppl_afw_register', array( $this, 'handle_registration' ) ); add_filter( 'woocommerce_login_redirect', array( $this, 'login_redirect' ), 10, 1 ); } /** * Sends a customer back to the page they logged in from (the affiliate * page) when a safe redirect_to was passed on the My Account URL. * * @since 1.0.0 * @param string $redirect Default WooCommerce redirect. * @return string */ public function login_redirect( $redirect ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read only redirect target validated below. $target = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; if ( $target ) { $safe = wp_validate_redirect( $target, '' ); if ( $safe ) { return $safe; } } return $redirect; } /** * Returns the repository. * * @since 1.0.0 * @return Affiliate_Repository */ public function repository() { return $this->repo; } /** * Renders the registration form shortcode. * * @since 1.0.0 * @return string */ public function registration_shortcode() { $this->enqueue_styles(); if ( ! is_user_logged_in() ) { $login_url = $this->login_url(); $register_url = $this->register_url(); $intro = '

' . esc_html__( 'Earn commission by referring customers to our store. Log in to apply for the affiliate program.', 'affireo-affiliates-for-woocommerce' ) . '

'; $intro .= '

' . esc_html__( 'Log in to apply', 'affireo-affiliates-for-woocommerce' ) . ''; if ( $register_url ) { $intro .= ' ' . esc_html__( 'Create an account', 'affireo-affiliates-for-woocommerce' ) . ''; } $intro .= '

'; /** * Filters what a visitor who is not logged in sees in place of * the signup form, for example a form that also creates the * account. * * @since 1.3.0 * @param string $intro Escaped HTML: the login and create account buttons. * @param string $login_url Login URL. * @param string $register_url Account creation URL, empty when registration is off. */ $intro = (string) apply_filters( 'ppl_afw_registration_logged_out', $intro, $login_url, $register_url ); return '
' . $intro . '
'; } $existing = $this->repo->find_by_user( get_current_user_id() ); if ( $existing ) { $messages = array( 'pending' => __( 'Thanks for applying! Your application is under review and we will email you once it is approved.', 'affireo-affiliates-for-woocommerce' ), 'active' => __( 'You are an active affiliate.', 'affireo-affiliates-for-woocommerce' ), 'rejected' => __( 'Your application was not approved. Contact us if you believe this is a mistake.', 'affireo-affiliates-for-woocommerce' ), 'suspended' => __( 'Your affiliate account is currently suspended.', 'affireo-affiliates-for-woocommerce' ), ); $link = ''; $page = (int) $this->settings->get( 'portal_page_id' ); if ( 'active' === $existing->status && $page && (int) get_queried_object_id() !== $page ) { $link = '

' . esc_html__( 'Open your affiliate dashboard', 'affireo-affiliates-for-woocommerce' ) . '

'; } return '

' . esc_html( $messages[ $existing->status ] ?? '' ) . '

' . $link . '
'; } return '
' . $this->registration_form_html() . '
'; } /** * The registration form markup, from the overridable template. * * @since 1.3.0 * @return string */ public function registration_form_html() { $this->enqueue_styles(); ob_start(); $template = $this->locate_template( 'registration-form.php' ); include $template; return ob_get_clean(); } /** * The login URL, preferring the WooCommerce My Account page so customers * use the same account they shop with, and returning to the affiliate * page after signing in. * * @since 1.0.0 * @return string */ private function login_url() { $url = ''; if ( function_exists( 'wc_get_page_permalink' ) ) { $account = wc_get_page_permalink( 'myaccount' ); if ( $account && ! is_wp_error( $account ) ) { $url = add_query_arg( 'redirect_to', rawurlencode( get_permalink() ?: home_url( '/' ) ), $account ); } } if ( ! $url ) { $url = wp_login_url( get_permalink() ); } /** * Filters the affiliate signup login URL. * * @since 1.0.0 * @param string $url Login URL. */ return apply_filters( 'ppl_afw_login_url', $url ); } /** * The account creation URL. Prefers the WooCommerce My Account page when * WooCommerce registration is on, otherwise the WordPress register page. * * @since 1.0.0 * @return string Empty when registration is disabled everywhere. */ private function register_url() { $url = ''; if ( function_exists( 'wc_get_page_permalink' ) && 'yes' === get_option( 'woocommerce_enable_myaccount_registration' ) ) { $account = wc_get_page_permalink( 'myaccount' ); if ( $account && ! is_wp_error( $account ) ) { $url = $account; } } if ( ! $url && get_option( 'users_can_register' ) ) { $url = wp_registration_url(); } /** * Filters the affiliate signup account creation URL. * * @since 1.0.0 * @param string $url Registration URL, empty when disabled. */ return apply_filters( 'ppl_afw_register_url', $url ); } /** * Enqueues the signup styles for markup printed outside the shortcode. * * @since 1.3.0 */ public function registration_styles() { $this->enqueue_styles(); } /** * Enqueues the shared portal stylesheet so the registration form and * gate messages are styled with the store's accent color. * * @since 1.0.0 */ private function enqueue_styles() { $css = PPL_AFW_DIR . 'assets/css/portal.css'; wp_enqueue_style( 'ppl-afw-portal', PPL_AFW_URL . 'assets/css/portal.css', array(), file_exists( $css ) ? md5_file( $css ) : PPL_AFW_VERSION ); $primary = sanitize_hex_color( (string) $this->settings->get( 'portal_primary_color' ) ) ?: '#4f46e5'; wp_add_inline_style( 'ppl-afw-portal', ':root{--ppl-afw-primary:' . $primary . ';}' ); } /** * Handles the registration form POST. * * @since 1.0.0 */ public function handle_registration() { if ( ! is_user_logged_in() || ! isset( $_POST['ppl_afw_register_nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['ppl_afw_register_nonce'] ) ), 'ppl_afw_register' ) ) { wp_die( esc_html__( 'Security check failed.', 'affireo-affiliates-for-woocommerce' ) ); } $user_id = get_current_user_id(); if ( $this->rate_limited( 'register' ) ) { wp_die( esc_html__( 'Too many attempts. Please try again later.', 'affireo-affiliates-for-woocommerce' ) ); } if ( $this->repo->find_by_user( $user_id ) ) { wp_safe_redirect( wp_get_referer() ?: home_url() ); exit; } $redirect = wp_get_referer() ?: home_url(); $fields = $this->collect_registration_fields(); if ( is_wp_error( $fields ) ) { wp_safe_redirect( add_query_arg( 'ppl_afw_reg_error', rawurlencode( $fields->get_error_message() ), $redirect ) ); exit; } $this->register_application( $user_id, $fields ); wp_safe_redirect( add_query_arg( 'ppl_afw_registered', '1', $redirect ) ); exit; } /** * Reads and validates the posted signup answers and terms. The caller * verifies the nonce. * * @since 1.3.0 * @return array|\WP_Error Answers by field key, or the first error. */ public function collect_registration_fields() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified by the callers. if ( empty( $_POST['ppl_afw_terms'] ) ) { return new \WP_Error( 'terms', __( 'You must accept the program terms.', 'affireo-affiliates-for-woocommerce' ) ); } $fields = array(); foreach ( (array) $this->settings->get( 'registration_fields' ) as $field ) { $field = is_array( $field ) ? $field : array( 'key' => $field ); $key = sanitize_key( $field['key'] ?? '' ); if ( '' === $key ) { continue; } $raw = isset( $_POST[ 'ppl_afw_' . $key ] ) ? sanitize_textarea_field( wp_unslash( $_POST[ 'ppl_afw_' . $key ] ) ) : ''; $value = 'textarea' === ( $field['type'] ?? '' ) ? $raw : sanitize_text_field( $raw ); if ( ! empty( $field['required'] ) && '' === trim( $value ) ) { return new \WP_Error( 'required', sprintf( /* translators: %s: field label */ __( 'Please fill in the %s field.', 'affireo-affiliates-for-woocommerce' ), \PPL_AFW\Core\Settings::field_label( $field ) ) ); } $fields[ $key ] = $value; } // phpcs:enable WordPress.Security.NonceVerification.Missing $fields['terms_version'] = (int) $this->settings->get( 'terms_version' ); return $fields; } /** * Creates the application of a user and announces it. * * @since 1.3.0 * @param int $user_id User id. * @param array $fields Signup answers. * @return int Affiliate id. */ public function register_application( $user_id, array $fields ) { $id = $this->create_affiliate( $user_id, $fields ); /** * Fires after a new affiliate application is submitted. * * @since 1.0.0 * @param int $id Affiliate id. * @param array $fields Submitted registration fields. */ do_action( 'ppl_afw_affiliate_registered', $id, $fields ); return $id; } /** * Creates an affiliate record for a user. * * @since 1.0.0 * @param int $user_id User id. * @param array $fields Registration data. * @return int Affiliate id. */ public function create_affiliate( $user_id, array $fields = array() ) { $auto = (bool) $this->settings->get( 'auto_approve_affiliates' ); $status = $auto ? 'active' : 'pending'; $id = $this->repo->insert( array( 'user_id' => absint( $user_id ), 'status' => $status, 'slug' => $this->generate_slug( $user_id ), 'registration_data' => wp_json_encode( $fields ), 'updated_at' => current_time( 'mysql', true ), ) ); $this->repo->log_activity( $id, 'registered', $auto ? 'Auto approved on registration' : 'Application submitted' ); if ( $auto ) { /** This action is documented in includes/affiliates/class-ppl-afw-affiliates-service.php */ do_action( 'ppl_afw_affiliate_approved', $id ); } return $id; } /** * Changes an affiliate status with audit logging. * * @since 1.0.0 * @param int $id Affiliate id. * @param string $status New status. * @param string $note Reason note. * @return bool */ public function set_status( $id, $status, $note = '' ) { if ( ! in_array( $status, array( 'pending', 'active', 'rejected', 'suspended' ), true ) ) { return false; } $this->repo->update( $id, array( 'status' => $status ) ); $this->repo->log_activity( $id, 'status_' . $status, $note ); if ( 'active' === $status ) { /** * Fires when an affiliate is approved or reactivated. * * @since 1.0.0 * @param int $id Affiliate id. */ do_action( 'ppl_afw_affiliate_approved', $id ); } elseif ( 'rejected' === $status ) { /** * Fires when an affiliate application is rejected. * * @since 1.0.0 * @param int $id Affiliate id. * @param string $note Rejection reason. */ do_action( 'ppl_afw_affiliate_rejected', $id, $note ); } return true; } /** * Validates and updates an affiliate's vanity slug. * * @since 1.0.0 * @param int $id Affiliate id. * @param string $slug Requested slug. * @return true|\WP_Error */ public function set_slug( $id, $slug ) { $slug = strtolower( trim( $slug ) ); if ( ! preg_match( '/^[a-z0-9-]{3,32}$/', $slug ) ) { return new \WP_Error( 'invalid_slug', __( 'Slugs must be 3 to 32 characters: lowercase letters, numbers, and hyphens only.', 'affireo-affiliates-for-woocommerce' ) ); } /** * Filters the reserved slug blacklist. * * @since 1.0.0 * @param string[] $reserved Reserved slugs. */ $reserved = apply_filters( 'ppl_afw_reserved_slugs', self::RESERVED_SLUGS ); if ( in_array( $slug, $reserved, true ) ) { return new \WP_Error( 'reserved_slug', __( 'That slug is reserved. Please choose another.', 'affireo-affiliates-for-woocommerce' ) ); } if ( $this->repo->slug_exists( $slug, $id ) ) { return new \WP_Error( 'taken_slug', __( 'That slug is already taken.', 'affireo-affiliates-for-woocommerce' ) ); } $this->repo->update( $id, array( 'slug' => $slug ) ); $this->repo->log_activity( $id, 'slug_changed', $slug ); return true; } /** * Generates a unique default slug from the user login. * * @since 1.0.0 * @param int $user_id User id. * @return string */ private function generate_slug( $user_id ) { $user = get_userdata( $user_id ); $base = $user ? preg_replace( '/[^a-z0-9-]/', '', strtolower( $user->user_login ) ) : 'aff'; $base = substr( str_pad( $base, 3, '0' ), 0, 26 ); $slug = $base; $i = 1; while ( $this->repo->slug_exists( $slug ) || in_array( $slug, self::RESERVED_SLUGS, true ) ) { $slug = $base . '-' . $i++; } return $slug; } /** * Transient based rate limiting keyed on hashed IP. * * @since 1.0.0 * @param string $action Action bucket. * @param int $limit Allowed hits per window. * @param int $window Window seconds. * @return bool True when the caller is over the limit. */ public function rate_limited( $action, $limit = 5, $window = 300 ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $key = 'ppl_afw_rl_' . $action . '_' . substr( \PPL_AFW\Core\Crypto::hash( $ip ), 0, 24 ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return true; } set_transient( $key, $count + 1, $window ); return false; } /** * Locates a template, preferring theme overrides in yourtheme/ppl-afw/. * * @since 1.0.0 * @param string $name Template file name. * @return string Absolute path. */ public function locate_template( $name ) { $theme = locate_template( array( 'ppl-afw/' . $name ) ); return $theme ? $theme : PPL_AFW_DIR . 'templates/' . $name; } }