Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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§ion=branding' ) ),
+ 'href' => esc_url( admin_url( 'admin.php?page=' . SUREDASHBOARD_SLUG . '&tab=settings§ion=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