Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 28, 2026

CVE-2026-54813: SureDash – Community, Courses & Member Dashboard <= 1.8.0 Authenticated (Subscriber+) SQL Injection PoC, Patch Analysis & Rule

Plugin suredash
Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 1.8.0
Patched Version 1.8.1
Disclosed June 16, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-54813:

This vulnerability is an authenticated SQL injection in the SureDash – Community, Courses & Member Dashboard plugin for WordPress versions up to and including 1.8.0. It allows attackers with subscriber-level access or higher to execute arbitrary SQL queries by appending malicious input to existing database queries. The vulnerability resides in the model layer where SQL joins are constructed, specifically in the `suredash/core/models/feeds.php` and `suredash/core/models/navigation.php` files.

The root cause is the misuse of the `where()` method instead of `whereColumn()` when comparing database columns in JOIN clauses. In `suredash/core/models/feeds.php`, lines 77 and 87 (in the patched version, lines 74-84 in the vulnerable), the code previously used `$q->where( ‘p.ID’, ‘=’, ‘c.comment_post_ID’ )` and `$q->where( ‘p.ID’, ‘=’, ‘pm.post_id’ )`. These were intended to compare column names within the query but instead passed the second parameter as a string literal, which could be user-controlled if not properly sanitized. Similarly, in `suredash/core/models/navigation.php`, lines 43, 46, and 49 used `$q->where( ‘t.term_id’, ‘=’, ‘tm.term_id’ )` and other column comparisons. The `where()` method treats the third argument as a plain value, not a column reference, meaning an attacker could inject SQL if they could control the comparison value.

Exploitation requires an authenticated user with at least subscriber role. The vulnerable code executes during AJAX actions or REST API calls that trigger feed or navigation model queries. The attacker can send a crafted request to an endpoint that invokes the affected model methods, such as the feed display or navigation menu retrieval. By injecting SQL through the parameter that ends up in the comparison value, the attacker can append UNION-based or time-based SQL injection payloads. The exact parameter depends on which front-end action invokes the model, but common vectors include `base_id`, `category`, `post_type`, or other parameters processed in `suredash/core/routers/misc.php` around line 231-235, where `taxonomy` and `post_type` are sanitized using `sanitize_text_field()` but earlier versions used less strict sanitization that could allow SQL injection through the model layer.

The patch changes the vulnerable `where()` calls to `whereColumn()`. The `whereColumn()` method explicitly treats both arguments as column names, preventing SQL injection through the value parameter. In `feeds.php`, lines 77 and 87 now use `$q->whereColumn( ‘p.ID’, ‘=’, ‘c.comment_post_ID’ )` and `$q->whereColumn( ‘p.ID’, ‘=’, ‘pm.post_id’ )`. In `navigation.php`, lines 43, 46, and 49 now use `whereColumn()`. This change ensures that the second argument is always treated as a column reference rather than a potentially user-controlled value. The patch also improves input sanitization in other areas, such as using `sanitize_key()` instead of `sanitize_text_field()` for taxonomy and post_type parameters in `misc.php`, and introduces safer file path handling through `Helper::get_safe_uploads_path()`.

If exploited, this SQL injection vulnerability could allow an authenticated attacker to extract sensitive information from the WordPress database, including user credentials (hashed passwords), user meta data, private posts and pages, and potentially WordPress configuration details. The attacker could also modify or delete data, create new administrative users, or escalate privileges. The CVSS score of 6.5 reflects the impact of data confidentiality and integrity compromise, though it requires authenticated access.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/suredash/assets/build/portals-app.asset.php
+++ b/suredash/assets/build/portals-app.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-plugins'), 'version' => '52ceb521d97c6b7dbc2d');
+<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-editor', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-plugins'), 'version' => 'fa5d605232dccf446e1b');
--- a/suredash/core/assets.php
+++ b/suredash/core/assets.php
@@ -318,8 +318,6 @@
 			SUREDASHBOARD_VER
 		);

-		wp_add_inline_style( 'portal-archive-group', self::get_css( 'archive' ) );
-
 		// Enqueue lightbox assets.
 		if ( Helper::get_option( 'enable_lightbox', true ) ) {

@@ -859,6 +857,13 @@
 	 * @return string
 	 */
 	public static function get_archive_group_css() {
+		// On singular and home views get_single_item_css() owns --portal-content-aside-margin.
+		// Both enqueue paths run on every portal page (renderer.php), so skipping the
+		// override here prevents the archive default from clobbering the layout-aware value.
+		if ( is_singular() || suredash_is_home() || suredash_cpt() ) {
+			return apply_filters( 'suredashboard_archive_group_dynamic_css', '' );
+		}
+
 		$css = '
 			:root {
 				--portal-content-aside-margin: 0 auto 32px;
--- a/suredash/core/blocks/interactivity/build/Navigation/index.asset.php
+++ b/suredash/core/blocks/interactivity/build/Navigation/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n'), 'version' => '8dfcb2a48f7588734e02');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-i18n'), 'version' => 'f00d205f1f606931a428');
--- a/suredash/core/blocks/interactivity/build/Navigation/view.php
+++ b/suredash/core/blocks/interactivity/build/Navigation/view.php
@@ -18,6 +18,44 @@

 $elements = ! empty( $attributes['style']['elements'] ) ? $attributes['style']['elements'] : []; // Extended color options support.

+/**
+ * Helper function to convert typography object to CSS string
+ *
+ * @param array $typography Typography attributes array.
+ * @return string CSS string with typography properties.
+ */
+if ( ! function_exists( 'suredash_get_typography_css' ) ) {
+	function suredash_get_typography_css( $typography ) {
+		if ( empty( $typography ) || ! is_array( $typography ) ) {
+			return '';
+		}
+
+		$css = '';
+
+		if ( ! empty( $typography['fontSize'] ) ) {
+			$css .= 'font-size: ' . esc_attr( $typography['fontSize'] ) . ';';
+		}
+
+		if ( ! empty( $typography['fontWeight'] ) ) {
+			$css .= 'font-weight: ' . esc_attr( $typography['fontWeight'] ) . ';';
+		}
+
+		if ( ! empty( $typography['lineHeight'] ) ) {
+			$css .= 'line-height: ' . esc_attr( $typography['lineHeight'] ) . ';';
+		}
+
+		return $css;
+	}
+}
+
+// Extract typography attributes.
+$space_group_typography = ! empty( $attributes['spaceGroupTypography'] ) ? $attributes['spaceGroupTypography'] : [];
+$space_typography       = ! empty( $attributes['spaceTypography'] ) ? $attributes['spaceTypography'] : [];
+
+// Generate CSS from typography attributes.
+$space_group_typo_css = suredash_get_typography_css( $space_group_typography );
+$space_typo_css       = suredash_get_typography_css( $space_typography );
+
 ?>
 <div <?php echo do_shortcode( get_block_wrapper_attributes( [ 'class' => 'portal-content' ] ) ); ?>>
 	<?php
@@ -27,12 +65,17 @@
 					margin-bottom:%1$s;
 					padding-bottom: unset;
 				}
-				.wp-block-suredash-navigation .portal-aside-group-link {
+				.portal-aside-group-list a.portal-aside-group-link {
 					padding-top:%2$s;
 					padding-bottom:%2$s;
+					%8$s
+				}
+				.portal-aside-group-list a.portal-aside-group-link .portal-aside-item-title {
+					%8$s
 				}
-				.wp-block-suredash-navigation .portal-aside-group-header {
+				.portal-aside-group-header {
 					margin-bottom:%3$s;
+					background: %7$s;
 				}
 				.wp-block-suredash-navigation ul a.active {
 					background: %5$s;
@@ -40,11 +83,9 @@
 				.wp-block-suredash-navigation ul a.active, .wp-block-suredash-navigation ul a.active * {
 					color: %4$s;
 				}
-				.wp-block-suredash-navigation .portal-aside-group-header {
-					background: %7$s;
-				}
-				.wp-block-suredash-navigation .portal-aside-group-header .portal-aside-group-title {
+				.portal-aside-group .portal-aside-group-header .portal-aside-group-title {
 					color: %6$s;
+					%9$s
 				}
 			</style>',
 			esc_attr( ! empty( $attributes['spacegroupsgap'] ) ? suredash_get_default_value_with_unit( $attributes['spacegroupsgap'] ) : '' ),
@@ -54,6 +95,8 @@
 			esc_attr( ! empty( $elements['spaceactivebackground']['color']['background'] ) ? $elements['spaceactivebackground']['color']['background'] : '' ),
 			esc_attr( ! empty( $elements['spacegrouptext']['color']['color'] ) ? $elements['spacegrouptext']['color']['color'] : '' ),
 			esc_attr( ! empty( $elements['spacegroupbackground']['color']['background'] ) ? $elements['spacegroupbackground']['color']['background'] : '' ),
+			wp_strip_all_tags( $space_typo_css ),
+			wp_strip_all_tags( $space_group_typo_css ),
 		);

 		$content  = '';
--- a/suredash/core/blocks/login.php
+++ b/suredash/core/blocks/login.php
@@ -42,6 +42,110 @@

 		add_action( 'wp_ajax_suredash_reset_password', [ self::class, 'process_reset_password' ] );
 		add_action( 'wp_ajax_nopriv_suredash_reset_password', [ self::class, 'process_reset_password' ] );
+
+		// Redirect already-logged-in users only when the block has an explicit
+		// redirectAfterLoginURL configured. Without it, the block renders a
+		// friendly "already signed in" screen (see render method).
+		add_action( 'wp', [ self::class, 'maybe_redirect_logged_in_user' ], 1 );
+
+		// Stylesheet for the "already signed in" screen used by both the
+		// Login and Register blocks. Conditionally enqueued — only on
+		// singular pages that contain one of those blocks.
+		add_action( 'wp_enqueue_scripts', [ self::class, 'enqueue_logged_in_screen_style' ] );
+	}
+
+	/**
+	 * Register and enqueue the stylesheet for the "already signed in" screen.
+	 *
+	 * Loaded only when the current post contains the Login or Register block,
+	 * because SureDash's main CSS is not enqueued on login/register pages.
+	 * Dynamic button colors are injected as CSS custom properties via
+	 * `wp_add_inline_style()` so the button matches the portal's palette.
+	 *
+	 * @since 1.8.1
+	 * @return void
+	 */
+	public static function enqueue_logged_in_screen_style(): void {
+		if ( ! is_user_logged_in() || ! is_singular() ) {
+			return;
+		}
+
+		if ( ! has_block( 'suredash/login' ) && ! has_block( 'suredash/register' ) ) {
+			return;
+		}
+
+		$handle = 'suredash-logged-in-screen';
+		$file   = ( is_rtl() ? 'logged-in-screen-rtl' : 'logged-in-screen' ) . SUREDASHBOARD_CSS_SUFFIX;
+
+		wp_enqueue_style( $handle, SUREDASHBOARD_CSS_ASSETS_FOLDER . $file, [], SUREDASHBOARD_VER );
+
+		$bg_raw   = Helper::get_option( 'primary_button_background_color' );
+		$text_raw = Helper::get_option( 'primary_button_color' );
+		$bg       = is_string( $bg_raw ) ? sanitize_hex_color( $bg_raw ) : '';
+		$text     = is_string( $text_raw ) ? sanitize_hex_color( $text_raw ) : '';
+		$bg       = $bg ? $bg : '#4338CA';
+		$text     = $text ? $text : '#FFFFFF';
+
+		wp_add_inline_style(
+			$handle,
+			sprintf(
+				'.suredash-logged-in-screen { --sd-lis-btn-bg: %1$s; --sd-lis-btn-text: %2$s; }',
+				esc_html( $bg ),
+				esc_html( $text )
+			)
+		);
+	}
+
+	/**
+	 * Redirect already-logged-in users away from pages containing the login block,
+	 * but only when the block has an explicit `redirectAfterLoginURL` configured.
+	 *
+	 * Fires on the `wp` hook at priority 1 — before Dynamic::wp_action (priority 10)
+	 * outputs inline CSS/JS, which would send headers and prevent wp_safe_redirect().
+	 *
+	 * @since 1.8.1
+	 * @return void
+	 */
+	public static function maybe_redirect_logged_in_user(): void {
+		if ( ! is_user_logged_in() ) {
+			return;
+		}
+
+		if ( ! is_singular() || ! has_block( 'suredash/login' ) ) {
+			return;
+		}
+
+		$post_id = get_the_ID();
+		if ( ! $post_id ) {
+			return;
+		}
+
+		$post_content = get_post_field( 'post_content', $post_id );
+		if ( ! is_string( $post_content ) ) {
+			return;
+		}
+
+		$blocks   = parse_blocks( $post_content );
+		$redirect = '';
+
+		foreach ( $blocks as $block ) {
+			if ( $block['blockName'] !== 'suredash/login' ) {
+				continue;
+			}
+			$attr = $block['attrs'] ?? [];
+			if ( ! empty( $attr['redirectAfterLoginURL']['url'] ) ) {
+				$redirect = (string) $attr['redirectAfterLoginURL']['url'];
+			}
+			break;
+		}
+
+		// No explicit redirect URL configured — render the friendly logged-in screen instead.
+		if ( $redirect === '' ) {
+			return;
+		}
+
+		wp_safe_redirect( esc_url_raw( $redirect ) );
+		exit;
 	}

 	/**
@@ -1171,17 +1275,23 @@
 			<div class="<?php echo esc_attr( implode( ' ', $wrapper_classes ) ); ?>" style="<?php echo esc_attr( implode( '', $z_index_wrap ) ); ?>">
 				<?php
 				if ( is_user_logged_in() ) {
-					?>
-					<div class="wp-block-spectra-pro-login__logged-in-message">
-						<?php
-							$user_name   = suredash_get_user_display_name();
-							$a_tag       = '<a href="' . esc_url( wp_logout_url( is_array( $attributes['redirectAfterLogoutURL'] ) && $attributes['redirectAfterLogoutURL']['url'] ? $attributes['redirectAfterLogoutURL']['url'] : home_url( suredash_get_community_slug() ) ) ) . '">';
-							$close_a_tag = '</a>';
-							/* translators: %1$s user name */
-							printf( esc_html__( 'You are logged in as %1$s (%2$sLogout%3$s)', 'suredash' ), wp_kses_post( $user_name ), wp_kses_post( $a_tag ), wp_kses_post( $close_a_tag ) );
-						?>
-					</div>
-					<?php
+					$portal_url = home_url( '/' . suredash_get_community_slug() . '/' );
+					if ( isset( $attributes['redirectAfterLoginURL'] ) && is_array( $attributes['redirectAfterLoginURL'] ) && ! empty( $attributes['redirectAfterLoginURL']['url'] ) ) {
+						$portal_url = (string) $attributes['redirectAfterLoginURL']['url'];
+					}
+					$logout_target = home_url( suredash_get_community_slug() );
+					if ( isset( $attributes['redirectAfterLogoutURL'] ) && is_array( $attributes['redirectAfterLogoutURL'] ) && ! empty( $attributes['redirectAfterLogoutURL']['url'] ) ) {
+						$logout_target = (string) $attributes['redirectAfterLogoutURL']['url'];
+					}
+					suredash_get_template_part(
+						'parts',
+						'logged-in-screen',
+						[
+							'user_name'  => suredash_get_user_display_name(),
+							'portal_url' => $portal_url,
+							'logout_url' => wp_logout_url( $logout_target ),
+						]
+					);
 				} else {
 					// inner block content will be here.
 					echo wp_kses_post( $content );
@@ -1274,8 +1384,8 @@
 											</button>
 										</div>
 									</div>
+									<div class="suredash-reset-status"></div>
 								</form>
-								<div class="suredash-reset-status"></div>
 							</div>
 							<?php
 							break;
@@ -1881,6 +1991,34 @@
 			' .spectra-pro-login-form__field-error-message' => [
 				'text-align' => $attr['overallAlignment'],
 			],
+			' .suredash-forgot-password-form .spectra-pro-login-form__field-error-message' => [
+				'display'       => 'block',
+				'font-size'     => '13px',
+				'color'         => '#ef4444',
+				'margin-top'    => '-16px',
+				'margin-bottom' => '12px',
+			],
+			' .suredash-forgot-password-form .suredash-reset-status' => [
+				'font-size'   => '14px',
+				'line-height' => '20px',
+				'color'       => '#6b7280',
+				'margin-top'  => '16px',
+				'min-height'  => '20px',
+			],
+			' .suredash-forgot-password-form .suredash-reset-status.success' => [
+				'color'            => '#059669',
+				'padding'          => '12px 16px',
+				'background-color' => '#d1fae5',
+				'border-left'      => '4px solid #10b981',
+				'border-radius'    => '4px',
+			],
+			' .suredash-forgot-password-form .suredash-reset-status.error' => [
+				'color'            => '#dc2626',
+				'padding'          => '12px 16px',
+				'background-color' => '#fee2e2',
+				'border-left'      => '4px solid #ef4444',
+				'border-radius'    => '4px',
+			],
 			'.wp-block-spectra-pro-login .spectra-pro-login-form__user-login' => [
 				'margin-bottom' => Helper::get_css_value( $attr['formRowsGapSpace'], $attr['formRowsGapSpaceUnit'] ),
 			],
--- a/suredash/core/blocks/register.php
+++ b/suredash/core/blocks/register.php
@@ -453,11 +453,15 @@
 			?>
 				<div class="<?php echo esc_attr( implode( ' ', $wrapper_classes ) ); ?>">
 					<?php
-					$user_name   = suredash_get_user_display_name();
-					$a_tag       = '<a href="' . esc_url( wp_logout_url( ! empty( $attributes['redirectAfterLogoutURL'] ) ? $attributes['redirectAfterLogoutURL'] : home_url( suredash_get_community_slug() ) ) ) . '">';
-					$close_a_tag = '</a>';
-					/* translators: %1$s user name */
-					printf( esc_html__( 'You are logged in as %1$s (%2$sLogout%3$s)', 'suredash' ), wp_kses_post( $user_name ), wp_kses_post( $a_tag ), wp_kses_post( $close_a_tag ) );
+					suredash_get_template_part(
+						'parts',
+						'logged-in-screen',
+						[
+							'user_name'  => suredash_get_user_display_name(),
+							'portal_url' => home_url( '/' . suredash_get_community_slug() . '/' ),
+							'logout_url' => wp_logout_url( ! empty( $attributes['redirectAfterLogoutURL'] ) ? $attributes['redirectAfterLogoutURL'] : home_url( suredash_get_community_slug() ) ),
+						]
+					);
 					?>
 				</div>
 			<?php
--- a/suredash/core/models/feeds.php
+++ b/suredash/core/models/feeds.php
@@ -74,7 +74,7 @@
 				'comments AS c',
 				// @phpstan-ignore-next-line.
 				static function( $q ): void {
-					$q->where( 'p.ID', '=', 'c.comment_post_ID' )
+					$q->whereColumn( 'p.ID', '=', 'c.comment_post_ID' )
 						->where( 'c.comment_approved', '=', '1' );
 				}
 			);
@@ -84,7 +84,7 @@
 				'postmeta AS pm',
 				// @phpstan-ignore-next-line.
 				static function( $q ) use ( $meta_key ): void {
-					$q->where( 'p.ID', '=', 'pm.post_id' )
+					$q->whereColumn( 'p.ID', '=', 'pm.post_id' )
 						->where( 'pm.meta_key', '=', $meta_key );
 				}
 			);
--- a/suredash/core/models/navigation.php
+++ b/suredash/core/models/navigation.php
@@ -40,13 +40,13 @@
 			'termmeta AS tm',
 			// @phpstan-ignore-next-line
 			static function( $q ): void {
-				$q->where( 't.term_id', '=', 'tm.term_id' )->whereIn( 'tm.meta_key', [ 'group_tax_position', '_link_order', 'hide_label', 'homegrid_spaces' ] );
+				$q->whereColumn( 't.term_id', '=', 'tm.term_id' )->whereIn( 'tm.meta_key', [ 'group_tax_position', '_link_order', 'hide_label', 'homegrid_spaces' ] );
 			}
 		)->leftJoin(
 			'postmeta AS pm',
 			// @phpstan-ignore-next-line
 			static function( $q ) use ( $left_join_match ): void {
-				$q->where( 'p.ID', '=', 'pm.post_id' )->whereIn( 'pm.meta_key', $left_join_match );
+				$q->whereColumn( 'p.ID', '=', 'pm.post_id' )->whereIn( 'pm.meta_key', $left_join_match );
 			}
 		)->where( 'tt.taxonomy', '=', SUREDASHBOARD_TAXONOMY )->whereRaw( '(tm.meta_value IS NOT NULL OR tm.meta_key IS NULL)' )->group_by( 't.term_id, p.ID' )
 			->get( ARRAY_A );
--- a/suredash/core/renderer.php
+++ b/suredash/core/renderer.php
@@ -298,7 +298,7 @@
 				'id'     => 'suredash_settings',
 				'parent' => 'suredash_admin_link',
 				'title'  => __( 'Settings', 'suredash' ),
-				'href'   => esc_url( admin_url( 'admin.php?page=' . SUREDASHBOARD_SLUG . '&tab=settings&section=branding' ) ),
+				'href'   => esc_url( admin_url( 'admin.php?page=' . SUREDASHBOARD_SLUG . '&tab=settings&section=general' ) ),
 				'meta'   => [
 					'title' => __( 'Go to Settings Page', 'suredash' ),
 				],
--- a/suredash/core/routers/backend.php
+++ b/suredash/core/routers/backend.php
@@ -975,6 +975,7 @@
 		$terms_added = wp_set_object_terms( $object_id, $term_id, 'portal_group' );

 		if ( ! is_wp_error( $terms_added ) ) {
+			$this->clear_first_space_option();
 			wp_send_json_success( __( 'Successfully updated.', 'suredash' ) );
 		}

@@ -1040,6 +1041,8 @@
 						$post_meta['pp_course_section_loop'][ $key ]['section_medias'][ $key2 ]['comment_status']  = sd_get_post_field( $media['value'], 'comment_status' );
 						$post_meta['pp_course_section_loop'][ $key ]['section_medias'][ $key2 ]['post_status']     = sd_get_post_field( $media['value'], 'post_status' );
 						$post_meta['pp_course_section_loop'][ $key ]['section_medias'][ $key2 ]['lesson_duration'] = sd_get_post_meta( absint( $media['value'] ), 'lesson_duration', true );
+						$use_space_thumbnail = sd_get_post_meta( absint( $media['value'] ), 'use_space_thumbnail', true );
+						$post_meta['pp_course_section_loop'][ $key ]['section_medias'][ $key2 ]['use_space_thumbnail'] = (bool) $use_space_thumbnail;
 					}
 				}
 			}
@@ -2963,6 +2966,9 @@
 		if ( isset( $data['lesson_duration'] ) ) {
 			update_post_meta( $post_id, 'lesson_duration', sanitize_text_field( $data['lesson_duration'] ) );
 		}
+		if ( isset( $data['use_space_thumbnail'] ) ) {
+			update_post_meta( $post_id, 'use_space_thumbnail', filter_var( $data['use_space_thumbnail'], FILTER_VALIDATE_BOOLEAN ) );
+		}

 		// Resource meta fields.
 		if ( isset( $data['resource_type'] ) ) {
--- a/suredash/core/routers/misc.php
+++ b/suredash/core/routers/misc.php
@@ -130,15 +130,14 @@

 		if ( is_wp_error( $post_id ) ) {
 			foreach ( $uploaded_images as $image ) {
-				// Delete the uploaded image.
-				$upload_dir  = wp_upload_dir();
-				$upload_path = $upload_dir['basedir'] . '/suredashboard/' . $current_user_id . '/assets/';
-				$upload_url  = $upload_dir['baseurl'] . '/suredashboard/' . $current_user_id . '/assets/';
-				$image_path  = str_replace( $upload_url, $upload_path, $image );
+				$image_path = Helper::get_safe_uploads_path( (string) $image, [ 'gif', 'png', 'jpg', 'jpeg', 'webp' ] );
+				if ( $image_path === null ) {
+					continue;
+				}

 				/** This action is documented in inc/compatibility/comment.php */
 				do_action( 'suredash_before_file_delete', $image_path, $image );
-				unlink($image_path); // phpcs:ignore -- This is a safe operation.
+				wp_delete_file( $image_path );
 			}
 			wp_send_json_error( [ 'message' => $post_id->get_error_message() ] );
 		}
@@ -231,8 +230,8 @@
 		ob_start();

 		$base_id     = ! empty( $_POST['base_id'] ) ? absint( $_POST['base_id'] ) : 0;
-		$taxonomy    = ! empty( $_POST['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_POST['taxonomy'] ) ) : SUREDASHBOARD_FEED_TAXONOMY;
-		$post_type   = ! empty( $_POST['post_type'] ) ? sanitize_text_field( wp_unslash( $_POST['post_type'] ) ) : SUREDASHBOARD_FEED_POST_TYPE;
+		$taxonomy    = ! empty( $_POST['taxonomy'] ) ? sanitize_key( wp_unslash( $_POST['taxonomy'] ) ) : SUREDASHBOARD_FEED_TAXONOMY;
+		$post_type   = ! empty( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : SUREDASHBOARD_FEED_POST_TYPE;
 		$category_id = ! empty( $_POST['category'] ) ? absint( $_POST['category'] ) : 0;
 		$paged       = ! empty( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
 		$user_id     = ! empty( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
@@ -745,14 +744,9 @@
 			);
 		}

-		// Set email headers.
-		$headers = [
-			'Content-Type: text/html; charset=UTF-8',
-			'From: ' . $site_name . ' <' . get_option( 'admin_email' ) . '>',
-		];
-
-		// Send the email.
-		$sent = wp_mail( $user_email, $subject, $message, $headers );
+		// Send the email through the central helper so the From header is
+		// properly encoded and the body is wrapped in a full HTML document.
+		$sent = suredash_send_email( $user_email, $subject, $message );

 		if ( $sent ) {
 			wp_send_json_success(
@@ -1104,15 +1098,14 @@

 		if ( is_wp_error( $comment_id ) ) {
 			foreach ( $uploaded_images as $image ) {
-				// Delete the uploaded image.
-				$upload_dir  = wp_upload_dir();
-				$upload_path = $upload_dir['basedir'] . '/suredashboard/' . $current_user_id . '/assets/';
-				$upload_url  = $upload_dir['baseurl'] . '/suredashboard/' . $current_user_id . '/assets/';
-				$image_path  = str_replace( $upload_url, $upload_path, $image );
+				$image_path = Helper::get_safe_uploads_path( (string) $image, [ 'gif', 'png', 'jpg', 'jpeg', 'webp' ] );
+				if ( $image_path === null ) {
+					continue;
+				}

 				/** This action is documented in inc/compatibility/comment.php */
 				do_action( 'suredash_before_file_delete', $image_path, $image );
-				unlink( $image_path ); // phpcs:ignore -- This is a safe operation.
+				wp_delete_file( $image_path );
 			}
 			wp_send_json_error( [ 'message' => $this->get_rest_event_error( 'default' ) ] );
 		}
@@ -1831,17 +1824,15 @@
 			return false;
 		}

-		$upload_dir = wp_upload_dir();
-		$file_path  = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $media_url );
-
-		if ( file_exists( $file_path ) ) {
-			/** This action is documented in inc/compatibility/comment.php */
-			do_action( 'suredash_before_file_delete', $file_path, $media_url );
-			wp_delete_file( $file_path );
-			return true;
+		$file_path = Helper::get_safe_uploads_path( (string) $media_url );
+		if ( $file_path === null ) {
+			return false;
 		}

-		return false;
+		/** This action is documented in inc/compatibility/comment.php */
+		do_action( 'suredash_before_file_delete', $file_path, $media_url );
+		wp_delete_file( $file_path );
+		return true;
 	}

 	/**
@@ -1941,16 +1932,14 @@
 			return false;
 		}

-		$upload_dir = wp_upload_dir();
-		$file_path  = str_replace( $upload_dir['baseurl'], $upload_dir['basedir'], $image_url );
-
-		if ( file_exists( $file_path ) ) {
-			/** This action is documented in inc/compatibility/comment.php */
-			do_action( 'suredash_before_file_delete', $file_path, $image_url );
-			wp_delete_file( $file_path );
-			return true;
+		$file_path = Helper::get_safe_uploads_path( (string) $image_url, [ 'gif', 'png', 'jpg', 'jpeg', 'webp' ] );
+		if ( $file_path === null ) {
+			return false;
 		}

-		return false;
+		/** This action is documented in inc/compatibility/comment.php */
+		do_action( 'suredash_before_file_delete', $file_path, $image_url );
+		wp_delete_file( $file_path );
+		return true;
 	}
 }
--- a/suredash/core/routers/social-logins.php
+++ b/suredash/core/routers/social-logins.php
@@ -259,7 +259,7 @@
 		}

 		if ( empty( $_POST['username'] ) ) {
-			wp_send_json_error( esc_html__( 'The username/password field is empty. Please add a valid username/email to reset your password.', 'suredash' ) );
+			wp_send_json_error( [ 'message' => esc_html__( 'The username/password field is empty. Please add a valid username/email to reset your password.', 'suredash' ) ] );
 		}

 		$user_login = sanitize_text_field( wp_unslash( $_POST['username'] ) );
@@ -273,7 +273,7 @@

 		// We need to check $user_data again since get_user_by() used above might return false value.
 		if ( ! $user_data instanceof WP_User ) {
-			wp_send_json_error( esc_html__( 'No user found. Please add a registered username/email to reset your password, else create an account.', 'suredash' ) );
+			wp_send_json_error( [ 'message' => esc_html__( 'No user found. Please add a registered username/email to reset your password, else create an account.', 'suredash' ) ] );
 		}

 		$user_login = $user_data->user_login;
@@ -282,7 +282,7 @@
 		$key = get_password_reset_key( $user_data );

 		if ( is_wp_error( $key ) ) {
-			wp_send_json_error( $key );
+			wp_send_json_error( [ 'message' => $key->get_error_message() ] );
 		}

 		$reset_url = suredash_get_login_page_url();
@@ -320,9 +320,9 @@

 		// Check if email is sent and reply accordingly.
 		if ( $send_wp_mail ) {
-			wp_send_json_success( esc_html__( 'Please check your email for the password reset link.', 'suredash' ) );
+			wp_send_json_success( [ 'message' => esc_html__( 'Please check your email for the password reset link.', 'suredash' ) ] );
 		} else {
-			wp_send_json_error( esc_html__( 'Email failed to send.', 'suredash' ) );
+			wp_send_json_error( [ 'message' => esc_html__( 'Email failed to send.', 'suredash' ) ] );
 		}
 	}

--- a/suredash/core/routes.php
+++ b/suredash/core/routes.php
@@ -515,7 +515,7 @@
 	 * @param WP_REST_Server   $server  Server instance.
 	 * @param WP_REST_Request  $request Request used to generate the response.
 	 * @return WP_REST_Response Filtered response.
-	 * @since x.x.x
+	 * @since 1.8.1
 	 */
 	public function filter_search_results( $result, $server, $request ) {
 		// Only filter the core search endpoint.
--- a/suredash/core/shortcodes/widgets/recent-activities.php
+++ b/suredash/core/shortcodes/widgets/recent-activities.php
@@ -51,7 +51,7 @@
 	 *
 	 * @param array<int|string> $post_ids Candidate post IDs.
 	 * @return array<int, int> Visible post IDs, re-indexed.
-	 * @since x.x.x
+	 * @since 1.8.1
 	 */
 	private static function filter_visible_post_ids( array $post_ids ): array {
 		if ( empty( $post_ids ) ) {
--- a/suredash/email-templates/wrapper.php
+++ b/suredash/email-templates/wrapper.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * Email HTML Wrapper Template
+ *
+ * Wraps email content fragments in a complete HTML document so email clients
+ * that require DOCTYPE / <html> / <body> render the message correctly.
+ *
+ * Override: copy to your-theme/suredash/email-templates/wrapper.php
+ *
+ * Available variables:
+ *   $email_body_content — The rendered inner template HTML.
+ *
+ * @package SureDash
+ * @since 1.8.1
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$site_name = get_bloginfo( 'name' );
+?>
+<!DOCTYPE html>
+<html lang="<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>">
+<head>
+	<meta charset="UTF-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title><?php echo esc_html( $site_name ); ?></title>
+</head>
+<body style="margin:0; padding:0; background-color:#f9fafb; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; color:#1f2937; line-height:1.6; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;">
+	<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;">
+		<tr>
+			<td align="center" style="padding:24px 16px;">
+				<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; border:1px solid #e5e7eb;">
+					<tr>
+						<td style="padding:32px 24px;">
+							<?php echo $email_body_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped in inner templates. ?>
+						</td>
+					</tr>
+				</table>
+			</td>
+		</tr>
+	</table>
+</body>
+</html>
--- a/suredash/inc/compatibility/comment.php
+++ b/suredash/inc/compatibility/comment.php
@@ -11,6 +11,7 @@
 namespace SureDashboardIncCompatibility;

 use SureDashboardIncTraitsGet_Instance;
+use SureDashboardIncUtilsHelper;

 defined( 'ABSPATH' ) || exit;

@@ -94,40 +95,44 @@
 	/**
 	 * Delete media files associated with content.
 	 *
+	 * Each `<img>` URL is resolved through {@see Helper::get_safe_uploads_path()},
+	 * which validates the path is strictly inside the WordPress uploads directory.
+	 * URLs containing path-traversal sequences (e.g. `../`) or pointing outside
+	 * uploads are silently skipped.
+	 *
 	 * @param string $content The content containing media URLs.
 	 * @since 0.0.6
 	 * @return void
 	 */
 	public function delete_related_media( $content ): void {
-		// Extract image URLs from the content.
-		preg_match_all( '/<img[^>]+src="([^">]+)"/', $content, $matched_images );
+		preg_match_all( '/<img[^>]+src="([^">]+)"/', (string) $content, $matched_images );

-		if ( ! empty( $matched_images[1] ) ) {
-			// Get the upload directory paths.
-			$upload_dir  = wp_upload_dir();
-			$upload_path = $upload_dir['basedir'];
-			$upload_url  = $upload_dir['baseurl'];
-
-			// Loop through each image URL and delete the corresponding file.
-			foreach ( $matched_images[1] as $image_url ) {
-				$image_path = str_replace( $upload_url, $upload_path, $image_url );
-				if ( file_exists( $image_path ) ) {
-					/**
-					 * Fires before a SureDash-managed file is deleted.
-					 *
-					 * This hook allows developers to perform cleanup on associated resources,
-					 * such as removing the file from remote storage (e.g., S3, Cloudflare R2)
-					 * or deleting the corresponding WordPress attachment post.
-					 *
-					 * @since 1.6.3
-					 *
-					 * @param string $image_path The absolute server path of the file being deleted.
-					 * @param string $image_url  The URL of the file being deleted.
-					 */
-					do_action( 'suredash_before_file_delete', $image_path, $image_url );
-					unlink( $image_path ); // phpcs:ignore -- This is a safe operation.
-				}
+		if ( empty( $matched_images[1] ) ) {
+			return;
+		}
+
+		$allowed_extensions = [ 'gif', 'png', 'jpg', 'jpeg', 'webp' ];
+
+		foreach ( $matched_images[1] as $image_url ) {
+			$image_path = Helper::get_safe_uploads_path( (string) $image_url, $allowed_extensions );
+			if ( $image_path === null ) {
+				continue;
 			}
+
+			/**
+			 * Fires before a SureDash-managed file is deleted.
+			 *
+			 * This hook allows developers to perform cleanup on associated resources,
+			 * such as removing the file from remote storage (e.g., S3, Cloudflare R2)
+			 * or deleting the corresponding WordPress attachment post.
+			 *
+			 * @since 1.6.3
+			 *
+			 * @param string $image_path The absolute server path of the file being deleted.
+			 * @param string $image_url  The URL of the file being deleted.
+			 */
+			do_action( 'suredash_before_file_delete', $image_path, $image_url );
+			wp_delete_file( $image_path );
 		}
 	}
 }
--- a/suredash/inc/functions/functions.php
+++ b/suredash/inc/functions/functions.php
@@ -107,6 +107,46 @@
 }

 /**
+ * Wrap an email HTML fragment in a complete HTML document.
+ *
+ * Some email clients (older Outlook, certain enterprise gateways) render bare
+ * fragments as plain text, exposing raw tags to the recipient. Wrapping the
+ * content in a DOCTYPE / <html> / <body> envelope avoids that.
+ *
+ * Theme override: copy `email-templates/wrapper.php` to
+ * `your-theme/suredash/email-templates/wrapper.php`.
+ *
+ * @param string $content The inner email HTML fragment.
+ * @return string Complete HTML document, or original content if wrapper not found.
+ * @since 1.8.1
+ */
+function suredash_wrap_email_html( string $content ): string {
+	if ( $content === '' || stripos( $content, '<html' ) !== false ) {
+		return $content;
+	}
+
+	$wrapper = locate_template( [ 'suredash/email-templates/wrapper.php' ] );
+
+	if ( ! $wrapper && defined( 'SUREDASHBOARD_DIR' ) ) {
+		$default = SUREDASHBOARD_DIR . 'email-templates/wrapper.php';
+		if ( file_exists( $default ) ) {
+			$wrapper = $default;
+		}
+	}
+
+	if ( ! $wrapper ) {
+		return $content;
+	}
+
+	$email_body_content = $content;
+	ob_start();
+	require $wrapper;
+	$wrapped = (string) ob_get_clean();
+
+	return $wrapped !== '' ? $wrapped : $content;
+}
+
+/**
  * Get template part implementation for restricted content.
  *
  * @param int          $post_id Post ID.
@@ -318,6 +358,7 @@
 			'feeds',
 			'screen',
 			'leaderboard',
+			'members',
 		]
 	);
 }
--- a/suredash/inc/functions/markup.php
+++ b/suredash/inc/functions/markup.php
@@ -349,9 +349,15 @@
 		$user_display_name = sd_get_user_meta( absint( $user_id ), 'display_name', true );
 		$alt_text          = ! empty( $user_display_name ) ? $user_display_name . ' profile photo' : 'User profile photo';

-		$markup = wp_kses_post( apply_filters( 'suredash_user_avatar_markup', '<img class="portal-user-avatar ' . esc_attr( $size_class ) . '" src="' . esc_url( $profile_photo ) . '" alt="' . esc_attr( $alt_text ) . '" />' ) );
+		$markup = wp_kses_post( apply_filters( 'suredash_user_avatar_markup', '<img class="portal-user-avatar ' . esc_attr( $size_class ) . '" src="' . esc_url( $profile_photo ) . '" alt="' . esc_attr( $alt_text ) . '" />', $user_id ) );
 	} else {
-		// No profile photo - show initials avatar.
+		// No profile photo. Render the initials avatar as the base layer and
+		// overlay a Gravatar <img> on top using `?d=404`: when the user has
+		// a real Gravatar the image loads and covers the initials; when they
+		// don't, Gravatar returns 404, the browser fires `onerror`, the img
+		// removes itself, and the initials beneath show through. This avoids
+		// any server-side HTTP probe so the avatar pipeline stays O(1) per
+		// user even on pages listing thousands of members.
 		$alt_text = $user ? suredash_get_user_display_name( $user_id ) . ' avatar' : 'User avatar';

 		// Determine font size class based on avatar size.
@@ -362,15 +368,43 @@
 			$font_class = 'sd-font-14';
 		}

+		// Request Gravatar at 2× the requested size so the overlay stays
+		// sharp when surrounding CSS upscales the avatar container (e.g.
+		// the leaderboard hero card forces 64×64 even on a 48px request).
+		// The overlay always paints at 100% of the outer box, so the
+		// extra resolution costs nothing on smaller renders.
+		$gravatar_size = max( 96, (int) $size * 2 );
+		$gravatar_url  = get_avatar_url(
+			$user_id,
+			[
+				'size'    => $gravatar_size,
+				'default' => '404',
+			]
+		);
+
+		$gravatar_overlay = '';
+		if ( ! empty( $gravatar_url ) ) {
+			// `!important` on width/height/max-* defeats the global
+			// `.portal-avatar-XX img { width: XXpx; max-width: XXpx; ... }`
+			// rule in badges.css, which would otherwise pin the overlay
+			// to the requested size even when surrounding layout (e.g.
+			// the leaderboard hero card) upscales the outer container.
+			$gravatar_overlay = sprintf(
+				'<img class="portal-user-gravatar" src="%s" alt="" loading="lazy" onerror="this.remove()" style="position:absolute;top:0;left:0;width:100%% !important;height:100%% !important;max-width:none !important;max-height:none !important;object-fit:cover;border-radius:inherit;display:block;" />',
+				esc_url( $gravatar_url )
+			);
+		}
+
 		$markup = sprintf(
-			'<div class="portal-user-avatar portal-avatar-initials %s %s %s" style="line-height: 1;" title="%s">%s</div>',
+			'<div class="portal-user-avatar portal-avatar-initials %s %s %s" style="position:relative;line-height:1;" title="%s">%s%s</div>',
 			esc_attr( $size_class ),
 			esc_attr( $initials_arr['color'] ),
 			esc_attr( $font_class ),
 			esc_attr( $alt_text ),
-			esc_html( $initials_arr['initials'] )
+			esc_html( $initials_arr['initials'] ),
+			$gravatar_overlay
 		);
-		$markup = apply_filters( 'suredash_user_avatar_markup', $markup );
+		$markup = apply_filters( 'suredash_user_avatar_markup', $markup, $user_id );
 	}

 	// Add data wrapper for profile pages that need JS fallback.
--- a/suredash/inc/modules/email-notifications/email-dispatcher.php
+++ b/suredash/inc/modules/email-notifications/email-dispatcher.php
@@ -370,14 +370,11 @@
 		// Convert plain text to HTML.
 		$body = wpautop( $body );

-		// Set up email headers.
-		$headers = [
-			'Content-Type: text/html; charset=UTF-8',
-			'From: ' . Helper::get_option( 'portal_name', get_bloginfo( 'name' ) ) . ' <' . Helper::get_option( 'email_from_mail_id', get_option( 'admin_email' ) ) . '>',
-		];
-
-		// Send the email.
-		return wp_mail( $user->user_email, $subject, $body, $headers );
+		// Send the email through the central helper so the From header is
+		// properly encoded (PHPMailer handles RFC 2047 via wp_mail_from*) and
+		// the body is wrapped in a complete HTML document for clients that
+		// reject bare fragments.
+		return suredash_send_email( $user->user_email, $subject, $body );
 	}

 	/**
--- a/suredash/inc/modules/email-notifications/emails.php
+++ b/suredash/inc/modules/email-notifications/emails.php
@@ -16,28 +16,40 @@
 /**
  * Send email.
  *
+ * Wraps the body in a full HTML document so clients that require DOCTYPE/<html>
+ * render it correctly, and scopes wp_mail_from / wp_mail_from_name filters to
+ * this call so PHPMailer handles RFC 2047 encoding of non-ASCII / special
+ * characters in the From name (commas, accents, etc.).
+ *
  * @since 1.0.0
  *
  * @param string $to          Email address.
  * @param string $subject     Email subject.
- * @param string $message     Email message.
+ * @param string $message     Email message (HTML fragment or full document).
  *
  * @return bool               Email status.
  */
 function suredash_send_email( $to, $subject, $message ) {
-	// Get email settings.
-	$from_name = Helper::get_option( 'portal_name' );
-	$from_mail = Helper::get_option( 'email_from_mail_id' );
-
-	$headers = [
-		'Reply-To: ' . $from_name . ' <' . $from_mail . '>',
-		'Content-Type: text/html; charset=UTF-8',
-		'Content-Transfer-Encoding: 8bit',
-		'From: ' . $from_name . ' <' . $from_mail . '>',
-	];
+	$message = suredash_wrap_email_html( (string) $message );
+
+	$from_email_cb = static function () {
+		$email = Helper::get_option( 'email_from_mail_id', get_option( 'admin_email' ) );
+		return is_string( $email ) && $email !== '' ? $email : (string) get_option( 'admin_email' );
+	};
+	$from_name_cb  = static function () {
+		$name = Helper::get_option( 'portal_name', get_option( 'blogname' ) );
+		return is_string( $name ) && $name !== '' ? $name : (string) get_option( 'blogname' );
+	};
+
+	add_filter( 'wp_mail_from', $from_email_cb );
+	add_filter( 'wp_mail_from_name', $from_name_cb );
+
+	$sent = wp_mail( $to, $subject, $message, [ 'Content-Type: text/html; charset=UTF-8' ] );
+
+	remove_filter( 'wp_mail_from', $from_email_cb );
+	remove_filter( 'wp_mail_from_name', $from_name_cb );

-	// Send email.
-	return wp_mail( $to, $subject, $message, $headers );
+	return $sent;
 }

 /**
--- a/suredash/inc/modules/mcp/module.php
+++ b/suredash/inc/modules/mcp/module.php
@@ -12,6 +12,8 @@
 namespace SureDashboardIncModulesMCP;

 use SureDashboardIncTraitsGet_Instance;
+use SureDashboardIncUtilsHelper;
+use SureDashboardIncUtilsSettings;

 defined( 'ABSPATH' ) || exit;

@@ -31,9 +33,6 @@
 	public function __construct() {
 		add_action( 'rest_api_init', [ $this, 'register_routes' ] );

-		// Sync MCP options when portal settings are saved via the main save button.
-		add_action( 'suredash_settings_updated', [ $this, 'sync_from_portal_settings' ], 10, 2 );
-
 		// Register MCP server with MCP Adapter plugin when enabled.
 		if ( self::mcp_adapter_enabled() ) {
 			add_action( 'mcp_adapter_init', [ $this, 'register_mcp_server' ] );
@@ -49,7 +48,7 @@
 	public static function mcp_adapter_enabled(): bool {
 		return function_exists( 'wp_register_ability' )
 			&& class_exists( 'WPMCPPlugin' )
-			&& (bool) get_option( 'suredash_mcp_server', false );
+			&& (bool) Helper::get_option( 'suredash_mcp_server', false );
 	}

 	/**
@@ -75,95 +74,46 @@
 	/**
 	 * Get current MCP settings.
 	 *
+	 * Reads from the unified `portal_settings` array — same source the rest of
+	 * the dashboard reads through `Helper::get_option()`, so there's nowhere
+	 * for the values to drift.
+	 *
 	 * @since 1.7.3
 	 * @return array<string, bool>
 	 */
 	public static function get_settings(): array {
-		$grouped = get_option( 'suredash_mcp_settings_options', [] );
-
-		if ( ! empty( $grouped ) && is_array( $grouped ) ) {
-			return $grouped;
-		}
-
 		return [
-			'suredash_abilities_api'        => (bool) get_option( 'suredash_abilities_api', false ),
-			'suredash_abilities_api_edit'   => (bool) get_option( 'suredash_abilities_api_edit', false ),
-			'suredash_abilities_api_delete' => (bool) get_option( 'suredash_abilities_api_delete', false ),
-			'suredash_mcp_server'           => (bool) get_option( 'suredash_mcp_server', false ),
+			'suredash_abilities_api'        => (bool) Helper::get_option( 'suredash_abilities_api', false ),
+			'suredash_abilities_api_edit'   => (bool) Helper::get_option( 'suredash_abilities_api_edit', false ),
+			'suredash_abilities_api_delete' => (bool) Helper::get_option( 'suredash_abilities_api_delete', false ),
+			'suredash_mcp_server'           => (bool) Helper::get_option( 'suredash_mcp_server', false ),
 		];
 	}

 	/**
-	 * Save MCP settings.
+	 * Save MCP settings into the unified `portal_settings` array.
 	 *
-	 * @since 1.7.3
-	 * @param array<string, mixed> $settings Settings to save.
-	 * @return bool
-	 */
-	public static function save_settings( array $settings ): bool {
-		$abilities_api        = ! empty( $settings['suredash_abilities_api'] );
-		$abilities_api_edit   = ! empty( $settings['suredash_abilities_api_edit'] );
-		$abilities_api_delete = ! empty( $settings['suredash_abilities_api_delete'] );
-		$mcp_server           = ! empty( $settings['suredash_mcp_server'] );
-
-		// Save as individual options for ability permission_callback lookups.
-		update_option( 'suredash_abilities_api', $abilities_api );
-		update_option( 'suredash_abilities_api_edit', $abilities_api_edit );
-		update_option( 'suredash_abilities_api_delete', $abilities_api_delete );
-		update_option( 'suredash_mcp_server', $mcp_server );
-
-		// Save grouped option for the settings UI fetch.
-		return update_option(
-			'suredash_mcp_settings_options',
-			[
-				'suredash_abilities_api'        => $abilities_api,
-				'suredash_abilities_api_edit'   => $abilities_api_edit,
-				'suredash_abilities_api_delete' => $abilities_api_delete,
-				'suredash_mcp_server'           => $mcp_server,
-			]
-		);
-	}
-
-	/**
-	 * Sync MCP options from portal settings when the main Save button is used.
-	 *
-	 * Hooked to 'suredash_settings_updated'. Extracts MCP-specific keys from
-	 * the portal settings array and mirrors them to individual options that
-	 * the ability permission callbacks read.
+	 * Writes all four MCP keys in a single DB round-trip.
 	 *
 	 * @since 1.7.3
-	 * @param array<string, mixed> $old_settings Previous settings.
-	 * @param array<string, mixed> $new_settings Updated settings.
+	 * @param array<string, mixed> $settings Settings to save.
 	 * @return void
 	 */
-	public function sync_from_portal_settings( array $old_settings, array $new_settings ): void {
-		unset( $old_settings );
-
-		$mcp_keys = [
-			'suredash_abilities_api',
-			'suredash_abilities_api_edit',
-			'suredash_abilities_api_delete',
-			'suredash_mcp_server',
-		];
-
-		$has_mcp_keys = false;
-		foreach ( $mcp_keys as $key ) {
-			if ( isset( $new_settings[ $key ] ) ) {
-				$has_mcp_keys = true;
-				break;
-			}
+	public static function save_settings( array $settings ): void {
+		$portal_settings = Settings::get_settings();
+		if ( ! is_array( $portal_settings ) ) {
+			$portal_settings = [];
 		}

-		if ( ! $has_mcp_keys ) {
-			return;
-		}
+		$portal_settings['suredash_abilities_api']        = ! empty( $settings['suredash_abilities_api'] );
+		$portal_settings['suredash_abilities_api_edit']   = ! empty( $settings['suredash_abilities_api_edit'] );
+		$portal_settings['suredash_abilities_api_delete'] = ! empty( $settings['suredash_abilities_api_delete'] );
+		$portal_settings['suredash_mcp_server']           = ! empty( $settings['suredash_mcp_server'] );

-		$settings = [];
-		foreach ( $mcp_keys as $key ) {
-			$settings[ $key ] = ! empty( $new_settings[ $key ] );
-		}
+		update_option( SUREDASHBOARD_SETTINGS, $portal_settings );

-		self::save_settings( $settings );
+		// Drop the static cache so subsequent Helper::get_option() reads see fresh data.
+		Settings::$dashboard_options = [];
 	}

 	/**
--- a/suredash/inc/services/abilities/ability.php
+++ b/suredash/inc/services/abilities/ability.php
@@ -10,6 +10,8 @@

 namespace SureDashboardIncServicesAbilities;

+use SureDashboardIncUtilsHelper;
+
 defined( 'ABSPATH' ) || exit;

 /**
@@ -103,7 +105,7 @@
 	 * @return bool
 	 */
 	public function is_enabled(): bool {
-		if ( ! empty( $this->gated ) && ! get_option( $this->gated, true ) ) {
+		if ( ! empty( $this->gated ) && ! Helper::get_option( $this->gated, true ) ) {
 			return false;
 		}

@@ -295,7 +297,7 @@
 	 */
 	public function check_permission(): bool {
 		// Master toggle — all abilities off when disabled.
-		if ( ! get_option( 'suredash_abilities_api', false ) ) {
+		if ( ! Helper::get_option( 'suredash_abilities_api', false ) ) {
 			return false;
 		}

--- a/suredash/inc/services/abilities/handlers/update-space-settings.php
+++ b/suredash/inc/services/abilities/handlers/update-space-settings.php
@@ -289,6 +289,15 @@
 			}
 		}

+		// When hidden_space is turned off, clear the SureMembers access rules so
+		// the saved data matches the UI (which only shows SM options while
+		// hidden_space is on). Prevents orphaned rules from silently gating the
+		// space after the toggle is flipped off. Pro-only meta, so guard.
+		if ( isset( $params['hidden_space'] ) && ! $params['hidden_space'] && suredash_is_pro_active() ) {
+			delete_post_meta( $post_id, 'sm_space_ruleset' );
+			delete_post_meta( $post_id, 'sm_space_membership_rule' );
+		}
+
 		// Comments toggle — also updates WP comment_status.
 		if ( isset( $params['comments'] ) ) {
 			$comments_enabled = (bool) $params['comments'];
--- a/suredash/inc/services/abilities/registry.php
+++ b/suredash/inc/services/abilities/registry.php
@@ -12,6 +12,7 @@
 namespace SureDashboardIncServicesAbilities;

 use SureDashboardIncTraitsGet_Instance;
+use SureDashboardIncUtilsHelper;

 defined( 'ABSPATH' ) || exit;

@@ -145,7 +146,7 @@
 		}

 		// Master toggle — don't register any abilities when disabled.
-		if ( ! get_option( 'suredash_abilities_api', false ) ) {
+		if ( ! Helper::get_option( 'suredash_abilities_api', false ) ) {
 			return;
 		}

--- a/suredash/inc/services/cli/abilities-command.php
+++ b/suredash/inc/services/cli/abilities-command.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * WP-CLI command to manage SureDash AI abilities.
+ *
+ * @package SureDash
+ * @since 1.8.1
+ */
+
+namespace SureDashboardIncServicesCLI;
+
+use SureDashboardIncModulesMCPModule as MCP_Module;
+use WP_CLI;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Manage SureDash AI abilities from the command line.
+ *
+ * Writes go through `MCP_Module::save_settings()` so the same individual
+ * options that ability permission callbacks read are updated — the dashboard
+ * UI reads from those same options, so changes are reflected on next page load.
+ *
+ * @since 1.8.1
+ */
+class Abilities_Command {
+	/**
+	 * Enable SureDash abilities for AI agents.
+	 *
+	 * Turns on the master "Enable Abilities" toggle. By default only read
+	 * abilities become available — pass --with-edit and/or --with-delete
+	 * to also enable the destructive ability groups. Existing values for
+	 * flags you don't pass are preserved.
+	 *
+	 * ## OPTIONS
+	 *
+	 * [--with-edit]
+	 * : Also enable edit abilities (create, update, reorder, settings).
+	 *
+	 * [--with-delete]
+	 * : Also enable delete abilities (irreversible removals).
+	 *
+	 * ## EXAMPLES
+	 *
+	 *     # Enable read-only abilities.
+	 *     $ wp suredash abilities enable
+	 *
+	 *     # Enable read + edit abilities.
+	 *     $ wp suredash abilities enable --with-edit
+	 *
+	 *     # Enable everything.
+	 *     $ wp suredash abilities enable --with-edit --with-delete
+	 *
+	 * @when after_wp_load
+	 *
+	 * @param array<int, string>         $args       Positional arguments (unused).
+	 * @param array<string, string|bool> $assoc_args Associative flags.
+	 * @return void
+	 */
+	public function enable( array $args, array $assoc_args ): void {
+		unset( $args );
+
+		$with_edit   = (bool) WP_CLIUtilsget_flag_value( $assoc_args, 'with-edit', false );
+		$with_delete = (bool) WP_CLIUtilsget_flag_value( $assoc_args, 'with-delete', false );
+
+		// Read current state so unspecified flags retain their existing value.
+		$settings = MCP_Module::get_settings();
+
+		$settings['suredash_abilities_api'] = true;
+
+		if ( $with_edit ) {
+			$settings['suredash_abilities_api_edit'] = true;
+		}
+
+		if ( $with_delete ) {
+			$settings['suredash_abilities_api_delete'] = true;
+		}
+
+		MCP_Module::save_settings( $settings );
+
+		WP_CLI::log( sprintf( '  Enable Abilities:        %s', $this->yes_no( (bool) $settings['suredash_abilities_api'] ) ) );
+		WP_CLI::log( sprintf( '  Enable Edit Abilities:   %s', $this->yes_no( (bool) $settings['suredash_abilities_api_edit'] ) ) );
+		WP_CLI::log( sprintf( '  Enable Delete Abilities: %s', $this->yes_no( (bool) $settings['suredash_abilities_api_delete'] ) ) );
+
+		WP_CLI::success( 'SureDash abilities enabled.' );
+	}
+
+	/**
+	 * Format a boolean as a human-readable yes/no for log output.
+	 *
+	 * @param bool $value Boolean to format.
+	 * @return string
+	 */
+	private function yes_no( bool $value ): string {
+		return $value ? 'yes' : 'no';
+	}
+}
--- a/suredash/inc/services/query.php
+++ b/suredash/inc/services/query.php
@@ -277,10 +277,19 @@
 			return $this;
 		}

-		$param2 = is_array( $param2 ) ? ( '("' . implode( '","', $param2 ) . '")' ) : ( $param2 === null
-			? 'null'
-			: ( strpos( $param2, '.' ) !== false || strpos( $param2, $wpdb->prefix ) !== false ? $param2 : $wpdb->prepare( is_numeric( $param2 ) ? '%d' : '%s', $param2 ) )
-		);
+		if ( is_array( $param2 ) ) {
+			$escaped = array_map(
+				static function ( $val ) use ( $wpdb ) {
+					return $wpdb->prepare( is_numeric( $val ) ? '%d' : '%s', $val );
+				},
+				$param2
+			);
+			$param2  = '(' . implode( ',', $escaped ) . ')';
+		} elseif ( $param2 === null ) {
+			$param2 = 'null';
+		} else {
+			$param2 = $wpdb->prepare( is_numeric( $param2 ) ? '%d' : '%s', $param2 );
+		}

 		$this->where[] = [
 			'joint'     => $joint,
@@ -313,6 +322,38 @@
 	}

 	/**
+	 * Create a column-to-column comparison (no value escaping).
+	 *
+	 * Use this instead of where() when both sides are column references,
+	 * e.g. inside JOIN closures: $q->whereColumn( 'p.ID', '=', 'pm.post_id' ).
+	 *
+	 * Only safe identifiers (letters, digits, underscores, dots) are allowed.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $column   Left-hand column reference.
+	 * @param string $operator Comparison operator.
+	 * @param string $column2  Right-hand column reference.
+	 * @param string $joint    The where type (and, or).
+	 *
+	 * @return Query The current query builder.
+	 */
+	public function whereColumn( $column, $operator, $column2, $joint = 'and' ) {
+		// Validate both sides are safe SQL identifiers (table.column or column).
+		$identifier_pattern = '/^[a-zA-Z_][a-zA-Z0-9_.]*$/';
+		if ( ! preg_match( $identifier_pattern, $column ) || ! preg_match( $identifier_pattern, $column2 ) ) {
+			$this->exception( 'Invalid column identifier in whereColumn().' );
+		}
+
+		$this->where[] = [
+			'joint'     => $joint,
+			'condition' => implode( ' ', [ $column, $operator, $column2 ] ),
+		];
+
+		return $this;
+	}
+
+	/**
 	 * Create an or where statement
 	 *
 	 * This is the same as the normal where just with a fixed type
@@ -548,11 +589,24 @@
 			$operator     = '=';
 		}

-		$referenceKey = is_array( $referenceKey ) ? ( '('' . implode( '','', $referenceKey ) . '')' )
-			: ( $referenceKey === null
-				? 'null'
-				: ( strpos( $referenceKey, '.' ) !== false || strpos( $referenceKey, $wpdb->prefix ) !== false ? $referenceKey : $wpdb->prepare( is_numeric( $referenceKey ) ? '%d' : '%s', $referenceKey ) )
+		// JOIN references are always column identifiers — validate as safe identifiers.
+		$identifier_pattern = '/^[a-zA-Z_][a-zA-Z0-9_.]*$/';
+		if ( is_array( $referenceKey ) ) {
+			$escaped      = array_map(
+				static function ( $val ) use ( $wpdb ) {
+					return $wpdb->prepare( is_numeric( $val ) ? '%d' : '%s', $val );
+				},
+				$referenceKey
 			);
+			$referenceKey = '(' . implode( ',', $escaped ) . ')';
+		} elseif ( $referenceKey === null ) {
+			$referenceKey = 'null';
+		} elseif ( preg_match( $identifier_pattern, $referenceKey ) ) {
+			// Safe column identifier like 'tr.object_id' — use as-is.
+			$referenceKey = $referenceKey;
+		} else {
+			$referenceKey = $wpdb->prepare( is_numeric( $referenceKey ) ? '%d' : '%s', $referenceKey );
+		}

 		$join['on'][] = [
 			'joint'     => $joint,
--- a/suredash/inc/utils/activity-tracker.php
+++ b/suredash/inc/utils/activity-tracker.php
@@ -279,7 +279,7 @@
 	 * @param int $space_id Space ID (term_id from community-forum taxonomy).
 	 * @param int $user_id  User ID.
 	 * @return int Visible post count.
-	 * @since x.x.x
+	 * @since 1.8.1
 	 */
 	private function get_visible_posts_count_in_space( $space_id, $user_id ): int {
 		$post_ids = $this->get_post_ids_in_space( $space_id );
--- a/suredash/inc/utils/analytics.php
+++ b/suredash/inc/utils/analytics.php
@@ -1,6 +1,37 @@
 <?php
 /**
- * Analytics.
+ * Analytics — BSF usage tracking and KPI reporting.
+ *
+ * Integrates with BSF Analytics to send:
+ *
+ * 1. Plugin data: free_version, site_language, pro_active.
+ *
+ * 2. One-time events (via BSF_Analytics_Events, deduplicated):
+ *    - plugin_activated    — first activation, with install source.
+ *    - plugin_updated      — on each version bump, with from_version.
+ *    - first_space_published       — first portal space goes live.
+ *    - first_community_post_created — first community post published.
+ *    - onboarding_completed — yes/no, with skipped_on_step if skipped.
+ *    - integration_enabled  — comma-separated list (google_login, facebook_login, surecart, suremembers).
+ *    - feeds_enabled        — community feeds turned on.
+ *    - global_sidebar_enabled — at least one sidebar widget configured.
+ *
+ * 3. Daily KPI records (last 2 days, sent with each analytics ping):
+ *    - community_posts   — new discussion posts published (DB query).
+ *    - community_content — new lessons/resources/events published (DB query).
+ *    - comments          — approved comments on SureDash post types (DB query).
+ *    - members_joined    — new suredash_user registrations (DB query).
+ *    - reactions         — post/comment likes by members (daily counter, increment-on-action).
+ *    - logins            — suredash_user role logins (daily counter, increment-on-action).
+ *    - bookmarks         — items bookmarked by members (daily counter, increment-on-action).
+ *
+ *    Daily counters use wp_options with autoload=false (key: suredash_kpi_{metric}_{date}).
+ *    Counters are cleaned up after being reported.
+ *
+ *    Activity thresholds (30-day sum of all KPIs):
+ *    - Inactive:     0–10   (abandoned or freshly installed).
+ *    - Active:       11–100 (regular community usage).
+ *    - Super Active: 101+   (thriving, high-engagement community).
  *
  * @package SureDashboard
  * @since 0.0.6
@@ -13,8 +44,9 @@
 defined( 'ABSPATH' ) || exit;

 /**
- * Update Compatibility
+ * Analytics class.
  *
+ * @since 0.0.6
  * @package SureDashboard
  */
 class Analytics {
@@ -47,6 +79,11 @@
 		if ( get_transient( 'suredash_state_events_checked' ) === false ) {
 			add_action( 'init', [ $this, 'detect_state_events' ], 98 );
 		}
+
+		// KPI daily counters — lightweight hooks for engagement tracking.
+		add_action( 'suredash_entity_like_reaction', [ $this, 'track_kpi_reaction' ], 10, 4 );
+		add_action( 'suredash_item_bookmark', [ $this, 'track_kpi_bookmark' ], 10, 4 );
+		add_action( 'wp_login', [ $this, 'track_kpi_login' ], 10, 2 );
 	}

 	/**
@@ -271,6 +308,60 @@
 	}

 	/**
+	 * Track a reaction event for daily KPI counter.
+	 *
+	 * @since 1.8.1
+	 * @param int    $entity_id   Entity ID.
+	 * @param string $entity_type Entity type (post or comment).
+	 * @param string $like_status Like status (liked or unliked).
+	 * @param int    $user_id     User ID who reacted.
+	 * @return void
+	 */
+	public function track_kpi_reaction( $entity_id, $entity_type, $like_status, $user_id ): void {
+		if ( $like_status !== 'liked' ) {
+			return;
+		}
+
+		$this->increment_kpi_counter( 'reactions' );
+	}
+
+	/**
+	 * Track a bookmark event for daily KPI counter.
+	 *
+	 * @since 1.8.1
+	 * @param int    $item_id   Item ID.
+	 * @param string $item_type Item type.
+	 * @param string $status    Bookmark status (bookmarked or un-bookmarked).
+	 * @param int    $user_id   User ID.
+	 * @return void
+	 */
+	public function track_kpi_bookmark( $item_id, $item_type, $status, $user_id ): void {
+		if ( $status !== 'bookmarked' ) {
+			return;
+		}
+
+		$this->increment_kpi_counter( 'bookmarks' );
+	}
+
+	/**
+	 * Track a login event for daily KPI counter.
+	 *
+	 * Only counts users with the suredash_user role.
+	 *
+	 * @since 1.8.1
+	 * @param string   $user_login Username.
+	 * @param WP_User $user       WP_User object.
+	 * @return void
+	 */
+	public function track_kpi_login( $user_login, $user ): void {
+		if ( ! in_array( 'suredash_user', (array) $user->roles, true ) ) {
+			return;
+		}
+
+		$this->increment_kpi_counter( 'logins' );
+	}
+
+	/**
 	 * Track plugin_activated (once per install).
 	 *
 	 * @param BSF_Analytics_Events $events Event tracker.
@@ -378,6 +469,18 @@
 		if ( ! empty( $settings['enable_feeds'] ) ) {
 			$events->track( 'feeds_enabled', 'yes' );
 		}
+
+		// abilities_api_enabled — admin turned on the WordPress Abilities API
+		// surface that exposes SureDash actions to AI agents.
+		if ( ! empty( $settings['suredash_abilities_api'] ) ) {
+			$events->track( 'abilities_api_enabled', 'yes' );
+		}
+
+		// mcp_server_enabled — admin turned on the MCP server (gates the
+		// abilities through the MCP Adapter for external AI clients).
+		if ( ! empty( $settings['suredash_mcp_server'] ) ) {
+			$events->track( 'mcp_server_enabled', 'yes' );
+		}
 	}

 	/**
@@ -414,14 +517,67 @@
 					'community_content' => $this->get_daily_community_content_count( $date ),
 					'comments'          => $this->get_daily_comments_count( $date ),
 					'members_joined'    => $this->get_daily_members_joined_count( $date ),
+					'reactions'         => $this->get_kpi_counter( 'reactions', $date ),
+					'logins'            => $this->get_kpi_counter( 'logins', $date ),
+					'bookmarks'         => $this->get_kpi_counter( 'bookmarks', $date ),
 				],
 			];
+
+			// Clean up counters for reported dates.
+			$this->cleanup_kpi_counters( $date );
 		}

 		return $kpi_data;
 	}

 	/**
+	 * Increment a daily KPI counter.
+	 *
+	 * Uses a lightweight option per metric per day. Autoload is off
+	 * so counters don't affect every page load.
+	 *
+	 * @since 1.8.1
+	 * @param string $metric Metric name (reactions, logins, bookmarks).
+	 * @return void
+	 */
+	private function increment_kpi_counter( string $metric ): void {
+		$date = (string) wp_date( 'Y-m-d' );
+		$key  = 'suredash_kpi_' . $metric . '_' . $date;
+
+		$current = (int) get_option( $key, 0 );
+		update_option( $key, $current + 1, false );
+	}
+
+	/**
+	 * Get a daily KPI counter value.
+	 *
+	 * @since 1.8.1
+	 * @param string $metric Metric name.
+	 * @param string $date   Date in Y-m-d format.
+	 * @return int Counter value.
+	 */
+	private function get_kpi_counter( string $metric, string $date ): int {
+		return (int) get_option( 'suredash_kpi_' . $metric . '_' . $date, 0 );
+	}
+
+	/**
+	 * Clean up KPI counter options for a reported date.
+	 *
+	 * Called after data is included in analytics payload to prevent
+	 * stale options from accumulating in the database.
+	 *
+	 * @since 1.8.1
+	 * @param string $date Date in Y-m-d format.
+	 * @return void
+	 */
+	private function cleanup_kpi_counters( string $date ): void {
+		$metrics = [ 'reactions', 'logins', 'bookmarks' ];
+		foreach ( $metrics as $metric ) {
+			delete_option( 'suredash_kpi_' . $metric . '_' . $date );
+		}
+	}
+
+	/**
 	 * Get daily community posts count for a specific date.
 	 *
 	 * @since 1.6.1
--- a/suredash/inc/utils/helper.php
+++ b/suredash/inc/utils/helper.php
@@ -113,6 +113,80 @@
 	}

 	/**
+	 * Resolve a media URL to a safe absolute path inside the WordPress uploads directory.
+	 *
+	 * Prevents path traversal in user-supplied media URLs. Re

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School