*/ public static function types() { return array( 'text' => __( 'Short text', 'aslansheed-login' ), 'textarea' => __( 'Long text', 'aslansheed-login' ), 'number' => __( 'Number', 'aslansheed-login' ), 'url' => __( 'Web address', 'aslansheed-login' ), 'tel' => __( 'Phone number', 'aslansheed-login' ), 'date' => __( 'Date', 'aslansheed-login' ), 'select' => __( 'Dropdown', 'aslansheed-login' ), 'radio' => __( 'Radio buttons', 'aslansheed-login' ), 'checkbox' => __( 'Single checkbox', 'aslansheed-login' ), 'checkboxes' => __( 'Checkbox group', 'aslansheed-login' ), ); } /** * Returns every saved definition. * * @return array> */ public static function all() { $list = Options::get( 'forms.custom_fields' ); return is_array( $list ) ? array_values( $list ) : array(); } /** * Returns the fields a new user is asked for. * * @param string $type Identifier type: "phone" or "email". * @return array> */ public static function for_type( $type ) { $out = array(); foreach ( self::all() as $field ) { if ( 'hidden' === $field['mode'] ) { continue; } if ( 'both' !== $field['applies'] && $field['applies'] !== $type ) { continue; } $out[] = $field; } return $out; } /** * Returns the part of each field the form needs. * * @param string $type Identifier type. * @return array> */ public static function for_client( $type ) { $out = array(); foreach ( self::for_type( $type ) as $field ) { $out[] = array( 'key' => $field['key'], 'label' => $field['label'], 'type' => $field['type'], 'required' => 'required' === $field['mode'], 'placeholder' => $field['placeholder'], 'help' => $field['help'], 'options' => $field['options'], 'min' => $field['min'], 'max' => $field['max'], 'maxlength' => self::max_length( $field ), ); } return $out; } /** * Cleans submitted definitions. Accepts the JSON the builder posts or an array. * * @param mixed $value JSON string or array of definitions. * @return array> */ public static function sanitize_definitions( $value ) { if ( is_string( $value ) ) { $value = json_decode( $value, true ); } if ( ! is_array( $value ) ) { return array(); } $types = self::types(); $out = array(); $keys = array(); foreach ( $value as $raw ) { if ( ! is_array( $raw ) || count( $out ) >= self::MAX_FIELDS ) { continue; } $label = isset( $raw['label'] ) ? substr( sanitize_text_field( (string) $raw['label'] ), 0, 100 ) : ''; $type = isset( $raw['type'] ) && isset( $types[ (string) $raw['type'] ] ) ? (string) $raw['type'] : 'text'; if ( '' === $label ) { continue; } $key = isset( $raw['key'] ) ? strtolower( preg_replace( '/[^a-z0-9_]/i', '', (string) $raw['key'] ) ) : ''; if ( ! preg_match( '/^[a-z][a-z0-9_]{0,39}$/', $key ) || isset( $keys[ $key ] ) ) { do { $key = 'field_' . strtolower( wp_generate_password( 6, false ) ); } while ( isset( $keys[ $key ] ) ); } $keys[ $key ] = true; $mode = isset( $raw['mode'] ) && in_array( $raw['mode'], array( 'optional', 'required', 'hidden' ), true ) ? (string) $raw['mode'] : 'optional'; $applies = isset( $raw['applies'] ) && in_array( $raw['applies'], array( 'both', 'phone', 'email' ), true ) ? (string) $raw['applies'] : 'both'; $field = array( 'key' => $key, 'label' => $label, 'type' => $type, 'mode' => $mode, 'applies' => $applies, 'placeholder' => isset( $raw['placeholder'] ) ? substr( sanitize_text_field( (string) $raw['placeholder'] ), 0, 100 ) : '', 'help' => isset( $raw['help'] ) ? substr( sanitize_text_field( (string) $raw['help'] ), 0, 200 ) : '', 'options' => in_array( $type, array( 'select', 'radio', 'checkboxes' ), true ) ? self::sanitize_options( isset( $raw['options'] ) ? $raw['options'] : array() ) : array(), 'min' => '', 'max' => '', 'maxlength' => isset( $raw['maxlength'] ) ? max( 0, min( 5000, (int) $raw['maxlength'] ) ) : 0, 'pattern' => '', 'meta_key' => isset( $raw['meta_key'] ) ? self::sanitize_meta_key( (string) $raw['meta_key'] ) : '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- A field setting, not a query. ); if ( in_array( $type, array( 'select', 'radio', 'checkboxes' ), true ) && array() === $field['options'] ) { continue; } if ( 'number' === $type ) { $field['min'] = isset( $raw['min'] ) && is_numeric( $raw['min'] ) ? (string) ( 0 + $raw['min'] ) : ''; $field['max'] = isset( $raw['max'] ) && is_numeric( $raw['max'] ) ? (string) ( 0 + $raw['max'] ) : ''; } if ( 'date' === $type ) { $field['min'] = isset( $raw['min'] ) && self::is_date( (string) $raw['min'] ) ? (string) $raw['min'] : ''; $field['max'] = isset( $raw['max'] ) && self::is_date( (string) $raw['max'] ) ? (string) $raw['max'] : ''; } if ( in_array( $type, array( 'text', 'textarea' ), true ) && isset( $raw['pattern'] ) ) { $field['pattern'] = self::sanitize_pattern( (string) $raw['pattern'] ); } $out[] = $field; } return $out; } /** * Validates what a visitor entered. * * @param string $type Identifier type. * @param mixed $input Submitted values keyed by field key. * @return array|WP_Error Clean values keyed by field key. */ public static function validate( $type, $input ) { $input = is_array( $input ) ? $input : array(); $clean = array(); foreach ( self::for_type( $type ) as $field ) { $raw = isset( $input[ $field['key'] ] ) ? $input[ $field['key'] ] : ''; $required = 'required' === $field['mode']; $value = self::check( $field, $raw ); if ( is_wp_error( $value ) ) { return $value; } if ( '' === $value || array() === $value ) { if ( $required ) { return new WP_Error( 'aslansheed_custom_required', sprintf( /* translators: %s: name of a form field. */ __( 'Please fill in “%s”.', 'aslansheed-login' ), $field['label'] ) ); } continue; } $clean[ $field['key'] ] = $value; } return $clean; } /** * Stores the answers of a new user. * * @param int $user_id User id. * @param array $clean Values from validate(). * @return void */ public static function save( $user_id, array $clean ) { foreach ( self::all() as $field ) { if ( isset( $clean[ $field['key'] ] ) ) { update_user_meta( $user_id, self::meta_key( $field ), $clean[ $field['key'] ] ); } } } /** * Returns the meta key a field is stored under. * * @param array $field Definition. * @return string */ public static function meta_key( array $field ) { return '' !== $field['meta_key'] ? $field['meta_key'] : self::META_PREFIX . $field['key']; } /** * Returns the plugin-prefixed meta keys a user has, including those of fields removed from the settings. * * @param int $user_id User id. * @return string[] */ public static function stored_keys( $user_id ) { $all = get_user_meta( $user_id ); $keys = array(); if ( is_array( $all ) ) { foreach ( array_keys( $all ) as $key ) { if ( 0 === strpos( (string) $key, self::META_PREFIX ) ) { $keys[] = (string) $key; } } } return $keys; } /** * Returns a user's answers as label and readable text. * * @param int $user_id User id. * @return array */ public static function values( $user_id ) { $out = array(); foreach ( self::all() as $field ) { $stored = get_user_meta( $user_id, self::meta_key( $field ), true ); if ( '' === $stored || array() === $stored ) { continue; } $out[] = array( 'label' => $field['label'], 'value' => self::readable( $field, $stored ), ); } return $out; } /** * Turns a stored value into text for people. * * @param array $field Definition. * @param mixed $stored Stored value. * @return string */ private static function readable( array $field, $stored ) { if ( 'checkbox' === $field['type'] ) { return __( 'Yes', 'aslansheed-login' ); } $values = is_array( $stored ) ? $stored : array( $stored ); $names = array(); foreach ( $values as $value ) { $name = (string) $value; foreach ( $field['options'] as $option ) { if ( (string) $option[0] === $name ) { $name = (string) $option[1]; } } $names[] = $name; } return implode( ', ', $names ); } /** * Checks one value against its field. * * @param array $field Definition. * @param mixed $raw Submitted value. * @return string|string[]|WP_Error Empty string or array when nothing was entered. */ private static function check( array $field, $raw ) { $invalid = new WP_Error( 'aslansheed_custom_invalid', sprintf( /* translators: %s: name of a form field. */ __( 'Please check “%s”.', 'aslansheed-login' ), $field['label'] ) ); if ( 'checkboxes' === $field['type'] ) { $raw = is_array( $raw ) ? $raw : array(); $out = array(); foreach ( self::option_values( $field ) as $value ) { if ( in_array( $value, array_map( 'strval', array_filter( $raw, 'is_scalar' ) ), true ) ) { $out[] = $value; } } return $out; } if ( ! is_scalar( $raw ) ) { return $invalid; } $raw = trim( (string) $raw ); if ( 'checkbox' === $field['type'] ) { return in_array( $raw, array( '1', 'true', 'on', 'yes' ), true ) ? '1' : ''; } if ( '' === $raw ) { return ''; } switch ( $field['type'] ) { case 'select': case 'radio': return in_array( $raw, self::option_values( $field ), true ) ? $raw : $invalid; case 'number': $number = str_replace( array( '٫', ',' ), '.', Phone_Normalizer::ascii_digits( $raw ) ); if ( ! is_numeric( $number ) || strlen( $number ) > 30 ) { return $invalid; } if ( ( '' !== $field['min'] && (float) $number < (float) $field['min'] ) || ( '' !== $field['max'] && (float) $number > (float) $field['max'] ) ) { return $invalid; } return (string) ( 0 + $number ); case 'url': $url = esc_url_raw( $raw, array( 'http', 'https' ) ); return '' !== $url && strlen( $url ) <= 500 ? $url : $invalid; case 'tel': $phone = Phone_Normalizer::ascii_digits( $raw ); if ( ! preg_match( '/^\+?[0-9\s\-().]{5,30}$/', $phone ) || strlen( preg_replace( '/\D+/', '', $phone ) ) < 5 ) { return $invalid; } return $phone; case 'date': $date = Phone_Normalizer::ascii_digits( $raw ); if ( ! self::is_date( $date ) || ( '' !== $field['min'] && $date < $field['min'] ) || ( '' !== $field['max'] && $date > $field['max'] ) ) { return $invalid; } return $date; case 'textarea': $text = sanitize_textarea_field( $raw ); break; default: $text = sanitize_text_field( $raw ); } if ( self::text_length( $text ) > self::max_length( $field ) ) { return $invalid; } if ( '' !== $field['pattern'] && 1 !== preg_match( self::compile( $field['pattern'] ), $text ) ) { return $invalid; } return $text; } /** * Returns the allowed values of a choice field. * * @param array $field Definition. * @return string[] */ private static function option_values( array $field ) { return array_map( static function ( $option ) { return (string) $option[0]; }, $field['options'] ); } /** * Returns the longest text a field accepts. * * @param array $field Definition. * @return int */ private static function max_length( array $field ) { if ( ! in_array( $field['type'], array( 'text', 'textarea' ), true ) ) { return 0; } if ( $field['maxlength'] > 0 ) { return (int) $field['maxlength']; } return 'textarea' === $field['type'] ? 2000 : 255; } /** * Counts characters, not bytes. * * @param string $text Text. * @return int */ private static function text_length( $text ) { return function_exists( 'mb_strlen' ) ? (int) mb_strlen( $text, 'UTF-8' ) : strlen( $text ); } /** * Tells whether a string is an existing date written as YYYY-MM-DD. * * @param string $value Text. * @return bool */ private static function is_date( $value ) { return 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m ) && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ); } /** * Parses the options a builder posts: a list of value and label pairs, or lines like "value|Label". * * @param mixed $raw Posted options. * @return array */ private static function sanitize_options( $raw ) { if ( is_string( $raw ) ) { $raw = preg_split( '/\r\n|\r|\n/', $raw ); } $out = array(); $seen = array(); foreach ( (array) $raw as $line ) { if ( is_array( $line ) ) { $value = isset( $line[0] ) ? (string) $line[0] : ''; $label = isset( $line[1] ) ? (string) $line[1] : $value; } else { $parts = explode( '|', (string) $line, 2 ); $value = $parts[0]; $label = isset( $parts[1] ) ? $parts[1] : $parts[0]; } $value = substr( sanitize_text_field( $value ), 0, 100 ); $label = substr( sanitize_text_field( $label ), 0, 100 ); if ( '' === $value || isset( $seen[ $value ] ) || count( $out ) >= self::MAX_OPTIONS ) { continue; } $seen[ $value ] = true; $out[] = array( $value, '' !== $label ? $label : $value ); } return $out; } /** * Accepts an optional meta key that cannot touch accounts, roles or sessions. * * @param string $key Typed key. * @return string Empty when the key is not allowed. */ private static function sanitize_meta_key( $key ) { global $wpdb; $key = trim( $key ); if ( ! preg_match( '/^[A-Za-z][A-Za-z0-9_\-]{2,63}$/', $key ) ) { return ''; } $lower = strtolower( $key ); $prefix = isset( $wpdb->prefix ) ? strtolower( (string) $wpdb->prefix ) : 'wp_'; $reserved = array( 'id', 'user_login', 'user_pass', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_activation_key', 'user_status', 'display_name', 'role', 'roles', 'session_tokens', 'rich_editing', 'syntax_highlighting', 'comment_shortcuts', 'admin_color', 'use_ssl', 'show_admin_bar_front', 'locale', 'community-events-location', 'dismissed_wp_pointers' ); if ( in_array( $lower, $reserved, true ) || 0 === strpos( $lower, 'wp_' ) || 0 === strpos( $lower, $prefix ) || false !== strpos( $lower, 'capabilit' ) || false !== strpos( $lower, 'user_level' ) || false !== strpos( $lower, 'password' ) || false !== strpos( $lower, 'aslansheed_totp' ) || 0 === strpos( $lower, 'aslansheed_otp' ) || in_array( $lower, array( 'aslansheed_national_id', 'aslansheed_national_id_idx', 'aslansheed_email_verified' ), true ) ) { return ''; } return $key; } /** * Keeps a pattern only when it is a valid expression. * * The admin types it without delimiters. It is matched with the "u" flag. * * @param string $pattern Typed expression. * @return string The expression as typed, or an empty string when it is not valid. */ private static function sanitize_pattern( $pattern ) { $pattern = trim( $pattern ); if ( '' === $pattern || strlen( $pattern ) > 200 ) { return ''; } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- An invalid expression only means the pattern is dropped. return false === @preg_match( self::compile( $pattern ), '' ) ? '' : $pattern; } /** * Adds delimiters and the "u" flag to a typed expression. * * @param string $pattern Expression without delimiters. * @return string */ private static function compile( $pattern ) { return '~' . str_replace( '~', '\~', $pattern ) . '~u'; } }