From fc2c5456e4b20a23558862de617c6a132134fb52 Mon Sep 17 00:00:00 2001 From: Mykyta Synelnikov Date: Thu, 11 Dec 2025 17:36:42 +0200 Subject: [PATCH 1/3] Implement directory privacy settings and add rate limiting Adds configurable privacy options for member directories, allowing restrictions on visibility based on roles or login status. Introduces rate limiting for unauthenticated AJAX requests to prevent brute-force attacks or abuse. --- includes/admin/class-admin.php | 6 + includes/admin/class-secure.php | 7 + includes/admin/core/class-admin-metabox.php | 3 +- includes/admin/core/class-admin-settings.php | 3 + .../admin/templates/directory/privacy.php | 48 ++++++ includes/class-config.php | 3 + includes/class-functions.php | 31 ++++ includes/class-init.php | 1 - includes/core/class-files.php | 17 ++ includes/core/class-form.php | 4 + includes/core/class-member-directory-meta.php | 8 + includes/core/class-member-directory.php | 146 ++++++++++++++++-- includes/core/class-shortcodes.php | 3 + includes/core/class-user-posts.php | 8 + includes/core/um-actions-ajax.php | 49 ------ templates/members-grid.php | 6 +- templates/members-list.php | 10 +- templates/members.php | 10 +- 18 files changed, 285 insertions(+), 78 deletions(-) create mode 100644 includes/admin/templates/directory/privacy.php delete mode 100644 includes/core/um-actions-ajax.php diff --git a/includes/admin/class-admin.php b/includes/admin/class-admin.php index 08a40d4d..635cf10d 100644 --- a/includes/admin/class-admin.php +++ b/includes/admin/class-admin.php @@ -284,6 +284,12 @@ if ( ! class_exists( 'um\admin\Admin' ) ) { '_um_mode' => array( 'sanitize' => 'key', ), + '_um_privacy' => array( + 'sanitize' => 'absint', + ), + '_um_privacy_roles' => array( + 'sanitize' => array( $this, 'sanitize_existed_role' ), + ), '_um_view_types' => array( 'sanitize' => array( $this, 'sanitize_md_view_types' ), ), diff --git a/includes/admin/class-secure.php b/includes/admin/class-secure.php index a8a37d61..58b90c9f 100644 --- a/includes/admin/class-secure.php +++ b/includes/admin/class-secure.php @@ -209,6 +209,13 @@ if ( ! class_exists( 'um\admin\Secure' ) ) { $scanner_content .= ''; $secure_fields = array( + array( + 'id' => 'ajax_nopriv_rate_limit', + 'type' => 'checkbox', + 'label' => __( 'Rate Limit', 'ultimate-member' ), + 'checkbox_label' => __( 'Enable Rate Limiting', 'ultimate-member' ), + 'description' => __( 'This prevents brute-force enumeration attempts in guest AJAX requests.', 'ultimate-member' ), + ), array( 'id' => 'banned_capabilities', 'type' => 'multi_checkbox', diff --git a/includes/admin/core/class-admin-metabox.php b/includes/admin/core/class-admin-metabox.php index 9332880f..aad37b29 100644 --- a/includes/admin/core/class-admin-metabox.php +++ b/includes/admin/core/class-admin-metabox.php @@ -728,7 +728,7 @@ if ( ! class_exists( 'um\admin\core\Admin_Metabox' ) ) { * @param $object * @param $box */ - function load_metabox_directory( $object, $box ) { + public function load_metabox_directory( $object, $box ) { $box['id'] = str_replace( 'um-admin-form-', '', $box['id'] ); preg_match('#\{.*?\}#s', $box['id'], $matches ); @@ -857,6 +857,7 @@ if ( ! class_exists( 'um\admin\core\Admin_Metabox' ) ) { public function add_metabox_directory() { add_meta_box( 'submitdiv', __( 'Publish', 'ultimate-member' ), array( &$this, 'custom_submitdiv' ), 'um_directory', 'side', 'high' ); add_meta_box( 'um-admin-form-general', __( 'General Options', 'ultimate-member' ), array( &$this, 'load_metabox_directory' ), 'um_directory', 'normal', 'default' ); + add_meta_box( 'um-admin-form-privacy', __( 'Privacy Options', 'ultimate-member' ), array( &$this, 'load_metabox_directory' ), 'um_directory', 'normal', 'default' ); add_meta_box( 'um-admin-form-sorting', __( 'Sorting', 'ultimate-member' ), array( &$this, 'load_metabox_directory' ), 'um_directory', 'normal', 'default' ); add_meta_box( 'um-admin-form-profile', __( 'Profile Card', 'ultimate-member' ), array( &$this, 'load_metabox_directory' ), 'um_directory', 'normal', 'default' ); add_meta_box( 'um-admin-form-search', __( 'Search Options', 'ultimate-member' ), array( &$this, 'load_metabox_directory' ), 'um_directory', 'normal', 'default' ); diff --git a/includes/admin/core/class-admin-settings.php b/includes/admin/core/class-admin-settings.php index 19dedc40..bf5c6d71 100644 --- a/includes/admin/core/class-admin-settings.php +++ b/includes/admin/core/class-admin-settings.php @@ -1032,6 +1032,9 @@ if ( ! class_exists( 'um\admin\core\Admin_Settings' ) ) { 'display_login_form_notice' => array( 'sanitize' => 'bool', ), + 'ajax_nopriv_rate_limit' => array( + 'sanitize' => 'bool', + ), 'banned_capabilities' => array( 'sanitize' => array( UM()->admin(), 'sanitize_wp_capabilities_assoc' ), ), diff --git a/includes/admin/templates/directory/privacy.php b/includes/admin/templates/directory/privacy.php new file mode 100644 index 00000000..01517152 --- /dev/null +++ b/includes/admin/templates/directory/privacy.php @@ -0,0 +1,48 @@ + '_um_privacy', + 'type' => 'select', + 'label' => __( 'Who can see this member directory', 'ultimate-member' ), + 'description' => __( 'Select which users can view this member directory. Minimum recommended visibility is `Members only`. Please pay attention that visible for guests (anyone) member directory can have sensitive information.', 'ultimate-member' ), + 'options' => array( + 0 => __( 'Anyone', 'ultimate-member' ), + 1 => __( 'Guests only', 'ultimate-member' ), + 2 => __( 'Members only', 'ultimate-member' ), + 3 => __( 'Only specific roles', 'ultimate-member' ), + ), + 'value' => UM()->query()->get_meta_value( '_um_privacy', null, 2 ), + ), + array( + 'id' => '_um_privacy_roles', + 'type' => 'select', + 'multi' => true, + 'label' => __( 'Allowed roles', 'ultimate-member' ), + 'description' => __( 'Select the the user roles allowed to view this member directory.', 'ultimate-member' ), + 'options' => UM()->roles()->get_roles(), + 'placeholder' => __( 'Choose user roles...', 'ultimate-member' ), + 'conditional' => array( '_um_privacy', '=', '3' ), + 'value' => UM()->query()->get_meta_value( '_um_privacy_roles', null, 'na' ), + ), +); + +$fields = apply_filters( 'um_admin_extend_directory_options_privacy', $fields ); +?> + +
+ admin_forms( + array( + 'class' => 'um-member-directory-privacy um-half-column', + 'prefix_id' => 'um_metadata', + 'fields' => $fields, + ) + )->render_form(); + ?> + +
+
diff --git a/includes/class-config.php b/includes/class-config.php index f77a229f..ad1834dc 100644 --- a/includes/class-config.php +++ b/includes/class-config.php @@ -141,6 +141,8 @@ if ( ! class_exists( 'um\Config' ) ) { '_um_core' => 'members', '_um_template' => 'members', '_um_mode' => 'directory', + '_um_privacy' => 2, + '_um_privacy_roles' => array(), '_um_view_types' => array( 'grid' ), '_um_default_view' => 'grid', '_um_roles' => array(), @@ -729,6 +731,7 @@ if ( ! class_exists( 'um\Config' ) ) { 'lock_register_forms' => false, 'display_login_form_notice' => false, 'secure_ban_admins_accounts' => false, + 'ajax_nopriv_rate_limit' => true, 'banned_capabilities' => array( 'manage_options', 'promote_users', 'level_10' ), 'secure_notify_admins_banned_accounts' => false, 'secure_notify_admins_banned_accounts__interval' => 'instant', diff --git a/includes/class-functions.php b/includes/class-functions.php index 61124c73..1959f4b7 100644 --- a/includes/class-functions.php +++ b/includes/class-functions.php @@ -47,6 +47,37 @@ if ( ! class_exists( 'UM_Functions' ) ) { } } + /** + * Check if the user rate limit has been reached based on the provided context + * + * @param string $context The context for which the rate limit check is being performed + * @param int|null $requests_num Number requests per minute. Default 10. + * + * @return bool True if the rate limit has been reached, false otherwise + */ + public function is_rate_limited( $context, $requests_num = 10 ) { + if ( is_user_logged_in() ) { + return false; + } + + if ( ! UM()->options()->get( 'ajax_nopriv_rate_limit' ) ) { + return false; + } + + $transient_name = 'um_ajax_nopriv_rate_limit_' . md5( $context . '_' . $_SERVER['REMOTE_ADDR'] ); + $rate_limit = get_transient( $transient_name ); + $requests_num = apply_filters( 'um_ajax_rate_limit_requests_num', $requests_num, $context ); // 10 requests per minute per IP + + if ( false === $rate_limit ) { + set_transient( $transient_name, 1, MINUTE_IN_SECONDS ); + } elseif ( $rate_limit < $requests_num ) { + set_transient( $transient_name, $rate_limit + 1, MINUTE_IN_SECONDS ); + } else { + return true; + } + + return false; + } /** * What type of request is this? diff --git a/includes/class-init.php b/includes/class-init.php index 0d0f976e..d9e76433 100644 --- a/includes/class-init.php +++ b/includes/class-init.php @@ -1375,7 +1375,6 @@ if ( ! class_exists( 'UM' ) ) { require_once 'core/um-actions-form.php'; require_once 'core/um-actions-access.php'; require_once 'core/um-actions-wpadmin.php'; - require_once 'core/um-actions-ajax.php'; require_once 'core/um-actions-login.php'; require_once 'core/um-actions-register.php'; require_once 'core/um-actions-profile.php'; diff --git a/includes/core/class-files.php b/includes/core/class-files.php index be091959..edb1f730 100644 --- a/includes/core/class-files.php +++ b/includes/core/class-files.php @@ -266,6 +266,10 @@ if ( ! class_exists( 'um\core\Files' ) ) { public function ajax_remove_file() { UM()->check_ajax_nonce(); + if ( UM()->is_rate_limited( 'remove_file' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + if ( empty( $_POST['src'] ) ) { wp_send_json_error( __( 'Wrong path', 'ultimate-member' ) ); } @@ -314,6 +318,11 @@ if ( ! class_exists( 'um\core\Files' ) ) { */ public function ajax_resize_image() { UM()->check_ajax_nonce(); + + if ( UM()->is_rate_limited( 'resize_image' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + // phpcs:disable WordPress.Security.NonceVerification -- verified by the `check_ajax_nonce()` if ( ! isset( $_REQUEST['src'], $_REQUEST['coord'], $_REQUEST['key'] ) ) { wp_send_json_error( esc_js( __( 'Invalid parameters', 'ultimate-member' ) ) ); @@ -447,6 +456,10 @@ if ( ! class_exists( 'um\core\Files' ) ) { * @throws Exception */ public function ajax_image_upload() { + if ( UM()->is_rate_limited( 'upload_image' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + $ret['error'] = null; $ret = array(); @@ -645,6 +658,10 @@ if ( ! class_exists( 'um\core\Files' ) ) { * @throws Exception */ public function ajax_file_upload() { + if ( UM()->is_rate_limited( 'upload_file' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + $ret['error'] = null; $ret = array(); diff --git a/includes/core/class-form.php b/includes/core/class-form.php index 8c34c24b..8a8c6e3e 100644 --- a/includes/core/class-form.php +++ b/includes/core/class-form.php @@ -116,6 +116,10 @@ if ( ! class_exists( 'um\core\Form' ) ) { public function ajax_select_options() { UM()->check_ajax_nonce(); + if ( UM()->is_rate_limited( 'select_options' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + // phpcs:disable WordPress.Security.NonceVerification $arr_options = array(); diff --git a/includes/core/class-member-directory-meta.php b/includes/core/class-member-directory-meta.php index 6ebcefb1..8bc883f2 100644 --- a/includes/core/class-member-directory-meta.php +++ b/includes/core/class-member-directory-meta.php @@ -595,6 +595,10 @@ if ( ! class_exists( 'um\core\Member_Directory_Meta' ) ) { public function ajax_get_members() { UM()->check_ajax_nonce(); + if ( UM()->is_rate_limited( 'member_directory' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + global $wpdb; $blog_id = get_current_blog_id(); @@ -609,6 +613,10 @@ if ( ! class_exists( 'um\core\Member_Directory_Meta' ) ) { } // phpcs:enable WordPress.Security.NonceVerification -- verified via `UM()->check_ajax_nonce();`. + if ( ! $this->can_view_directory( $directory_id ) ) { + wp_send_json_error( __( 'You cannot see this member directory', 'ultimate-member' ) ); + } + $directory_data = UM()->query()->post_data( $directory_id ); // Predefined result for user without capabilities to see other members. diff --git a/includes/core/class-member-directory.php b/includes/core/class-member-directory.php index 4241720f..20c5c2f8 100644 --- a/includes/core/class-member-directory.php +++ b/includes/core/class-member-directory.php @@ -2,6 +2,7 @@ namespace um\core; use Exception; +use WP_Post; use WP_User_Query; if ( ! defined( 'ABSPATH' ) ) { @@ -123,11 +124,79 @@ if ( ! class_exists( 'um\core\Member_Directory' ) ) { public function __construct() { add_filter( 'init', array( &$this, 'init_variables' ) ); + add_action( 'wp_insert_post', array( &$this, 'set_token' ), 10, 3 ); add_action( 'template_redirect', array( &$this, 'access_members' ), 555 ); } + /** + * Set member directory token as soon as it's created. + * + * @param int $post_ID + * @param WP_Post $post + * @param bool $update + * + * @return void + */ + public function set_token( $post_ID, $post, $update ) { + if ( 'um_directory' === $post->post_type && ! $update ) { + $this->set_directory_hash( $post_ID ); + } + } + + /** + * Check if the user can view the member directory. + * + * @param int $directory_id + * @param int|null $user_id + * + * @return bool + */ + public function can_view_directory( $directory_id, $user_id = null ) { + if ( is_null( $user_id ) && is_user_logged_in() ) { + $user_id = get_current_user_id(); + } + + $can_view = false; + $privacy = get_post_meta( $directory_id, '_um_privacy', true ); + if ( '' === $privacy ) { + $can_view = true; + } else { + $privacy = absint( $privacy ); + + switch ( $privacy ) { + case 0: + $can_view = true; + break; + case 1: + if ( ! is_user_logged_in() ) { + $can_view = true; + } + break; + case 2: + if ( is_user_logged_in() ) { + $can_view = true; + } + break; + case 3: + if ( is_user_logged_in() ) { + $privacy_roles = get_post_meta( $directory_id, '_um_privacy_roles', true ); + $privacy_roles = ! empty( $privacy_roles ) && is_array( $privacy_roles ) ? $privacy_roles : array(); + + $current_user_roles = um_user( 'roles' ); + if ( ! empty( $current_user_roles ) && count( array_intersect( $current_user_roles, $privacy_roles ) ) > 0 ) { + $can_view = true; + } + } + break; + } + } + + return apply_filters( 'um_directory_user_can_view', $can_view, $directory_id, $user_id ); + } + /** * Get the WordPress core searching fields in wp_users query. + * * @since 2.6.10 * @version 2.10.2 * @param array|null $qv WP_User_Query variables. @@ -202,30 +271,71 @@ if ( ! class_exists( 'um\core\Member_Directory' ) ) { * * @return bool|int */ - function get_directory_by_hash( $hash ) { + public function get_directory_by_hash( $hash ) { global $wpdb; - $directory_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE SUBSTRING( MD5( ID ), 11, 5 ) = %s", $hash ) ); - + $directory_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT post_id + FROM {$wpdb->postmeta} + WHERE meta_key = '_um_directory_token' AND + meta_value = %s + LIMIT 1", + $hash + ) + ); if ( empty( $directory_id ) ) { - return false; + // Fallback, use old value. + $directory_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID + FROM {$wpdb->posts} + WHERE SUBSTRING( MD5( ID ), 11, 5 ) = %s", + $hash + ) + ); + if ( empty( $directory_id ) ) { + return false; + } } return (int) $directory_id; } + /** + * Generate a secure random token for each directory + * + * @param int $id + * + * @return false|string + */ + public function set_directory_hash( $id ) { + $unique_hash = wp_generate_password( 32, false ); + $result = update_post_meta( $id, '_um_directory_token', $unique_hash ); + if ( false === $result ) { + return false; + } + return $unique_hash; + } /** * @param $id * * @return bool|string */ - function get_directory_hash( $id ) { - $hash = substr( md5( $id ), 10, 5 ); + public function get_directory_hash( $id ) { + $hash = get_post_meta( $id, '_um_directory_token', true ); + if ( '' === $hash ) { + // Set the hash if empty. + $hash = $this->set_directory_hash( $id ); + } + if ( empty( $hash ) ) { + // Fallback, use old value. + $hash = substr( md5( $id ), 10, 5 ); + } return $hash; } - /** * Get view Type template * @param string $type @@ -673,7 +783,7 @@ if ( ! class_exists( 'um\core\Member_Directory' ) ) { */ $attrs = apply_filters( 'um_search_fields', $attrs, $field_key, $directory_data['form_id'] ); - $unique_hash = substr( md5( $directory_data['form_id'] ), 10, 5 ); + $unique_hash = $this->get_directory_hash( $directory_data['form_id'] ); ob_start(); @@ -2546,15 +2656,14 @@ if ( ! class_exists( 'um\core\Member_Directory' ) ) { $data_array = array( 'card_anchor' => esc_html( substr( md5( $user_id ), 10, 5 ) ), - 'id' => absint( $user_id ), - 'role' => esc_html( um_user( 'role' ) ), - 'account_status' => esc_html( UM()->common()->users()->get_status( $user_id ) ), - 'account_status_name' => esc_html( UM()->common()->users()->get_status( $user_id, 'formatted' ) ), + 'role' => is_user_logged_in() ? esc_html( um_user( 'role' ) ) : 'undefined', // make the role hidden for the nopriv requests. + 'account_status' => is_user_logged_in() ? esc_html( UM()->common()->users()->get_status( $user_id ) ) : 'undefined', // make the status hidden for the nopriv requests. + 'account_status_name' => is_user_logged_in() ? esc_html( UM()->common()->users()->get_status( $user_id, 'formatted' ) ) : __( 'Undefined', 'ultimate-member' ), // make the status hidden for the nopriv requests. 'cover_photo' => wp_kses( um_user( 'cover_photo', $this->cover_size ), UM()->get_allowed_html( 'templates' ) ), 'display_name' => esc_html( um_user( 'display_name' ) ), 'profile_url' => esc_url( um_user_profile_url() ), 'can_edit' => (bool) $can_edit, - 'edit_profile_url' => esc_url( um_edit_profile_url() ), + 'edit_profile_url' => $can_edit ? esc_url( um_edit_profile_url() ) : '', 'avatar' => wp_kses( get_avatar( $user_id, $this->avatar_size ), UM()->get_allowed_html( 'templates' ) ), 'display_name_html' => wp_kses( um_user( 'display_name', 'html' ), UM()->get_allowed_html( 'templates' ) ), 'dropdown_actions' => $dropdown_actions, @@ -2788,13 +2897,16 @@ if ( ! class_exists( 'um\core\Member_Directory' ) ) { } } - /** * Main Query function for getting members via AJAX */ - function ajax_get_members() { + public function ajax_get_members() { UM()->check_ajax_nonce(); + if ( UM()->is_rate_limited( 'member_directory' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + global $wpdb; if ( empty( $_POST['directory_id'] ) ) { @@ -2807,6 +2919,10 @@ if ( ! class_exists( 'um\core\Member_Directory' ) ) { wp_send_json_error( __( 'Wrong member directory data', 'ultimate-member' ) ); } + if ( ! $this->can_view_directory( $directory_id ) ) { + wp_send_json_error( __( 'You cannot see this member directory', 'ultimate-member' ) ); + } + $directory_data = UM()->query()->post_data( $directory_id ); //predefined result for user without capabilities to see other members diff --git a/includes/core/class-shortcodes.php b/includes/core/class-shortcodes.php index 4b73767d..d23e5e31 100644 --- a/includes/core/class-shortcodes.php +++ b/includes/core/class-shortcodes.php @@ -794,6 +794,9 @@ if ( ! class_exists( 'um\core\Shortcodes' ) ) { } if ( 'directory' === $args['mode'] ) { + if ( ! UM()->member_directory()->can_view_directory( $this->form_id ) ) { + return ''; // Checking for privacy settings of the member directory + } wp_enqueue_script( 'um_members' ); wp_enqueue_style( 'um_members' ); } diff --git a/includes/core/class-user-posts.php b/includes/core/class-user-posts.php index 469717a4..a3ad786c 100644 --- a/includes/core/class-user-posts.php +++ b/includes/core/class-user-posts.php @@ -105,6 +105,10 @@ if ( ! class_exists( 'um\core\User_posts' ) ) { function load_posts() { UM()->check_ajax_nonce(); + if ( UM()->is_rate_limited( 'paginate_posts' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + $author = ! empty( $_POST['author'] ) ? absint( $_POST['author'] ) : get_current_user_id(); $page = ! empty( $_POST['page'] ) ? absint( $_POST['page'] ) : 0; @@ -153,6 +157,10 @@ if ( ! class_exists( 'um\core\User_posts' ) ) { function load_comments() { UM()->check_ajax_nonce(); + if ( UM()->is_rate_limited( 'paginate_comments' ) ) { + wp_send_json_error( __( 'Too many requests', 'ultimate-member' ) ); + } + $user_id = ! empty( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : get_current_user_id(); $page = ! empty( $_POST['page'] ) ? absint( $_POST['page'] ) : 0; diff --git a/includes/core/um-actions-ajax.php b/includes/core/um-actions-ajax.php deleted file mode 100644 index 3aabce90..00000000 --- a/includes/core/um-actions-ajax.php +++ /dev/null @@ -1,49 +0,0 @@ -check_ajax_nonce(); - - $username = isset( $_REQUEST['username'] ) ? sanitize_user( $_REQUEST['username'] ) : ''; - $exists = username_exists( $username ); - - /** - * UM hook - * - * @type filter - * @title um_validate_username_exists - * @description Change username exists validation - * @input_vars - * [{"var":"$exists","type":"bool","desc":"Exists?"}, - * {"var":"$username","type":"string","desc":"Username"}] - * @change_log - * ["Since: 2.0"] - * @usage - * - * @example - * - */ - $exists = apply_filters( 'um_validate_username_exists', $exists, $username ); - - if ( $exists ) { - echo 1; - } else { - echo 0; - } - - die(); -} -add_action('wp_ajax_nopriv_ultimatemember_check_username_exists', 'ultimatemember_check_username_exists'); -add_action('wp_ajax_ultimatemember_check_username_exists', 'ultimatemember_check_username_exists'); diff --git a/templates/members-grid.php b/templates/members-grid.php index e6e02c15..ed5d4795 100644 --- a/templates/members-grid.php +++ b/templates/members-grid.php @@ -6,7 +6,7 @@ * * Page: "Members" * - * @version 2.6.1 + * @version 2.11.1 * * @var array $args * @var bool $cover_photos @@ -23,8 +23,8 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -$unique_hash = substr( md5( $args['form_id'] ), 10, 5 ); ?> - +$unique_hash = UM()->member_directory()->get_directory_hash( $args['form_id'] ); +?>