Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions modules/notifications/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,28 @@ class API {

const NOTIFICATIONS_URL = 'https://assets.elementor.com/notifications/v1/notifications.json';

/**
* Allowlist of WP.org plugin slugs that may be installed via the What's New panel.
* Any `installPlugin` value not in this list is stripped before the notification
* reaches the browser, protecting against a compromised CDN response.
*/
const ALLOWED_INSTALL_SLUGS = [
'site-mailer',
'image-optimization',
'pojo-accessibility',
'cookiez',
'manage',
'angie',
];

public static function get_notifications_by_conditions( $force_request = false ) {
$notifications = static::get_notifications( $force_request );

$filtered_notifications = [];

foreach ( $notifications as $notification ) {
$notification = static::sanitize_install_fields( $notification );

if ( empty( $notification['conditions'] ) ) {
$filtered_notifications = static::add_to_array( $filtered_notifications, $notification );

Expand Down Expand Up @@ -65,6 +81,23 @@ private static function check_conditions( $groups ) {
return false;
}

private static function sanitize_install_fields( array $notification ): array {
if ( empty( $notification['installPlugin'] ) ) {
return $notification;
}

if ( ! in_array( $notification['installPlugin'], static::ALLOWED_INSTALL_SLUGS, true ) ) {
unset( $notification['installPlugin'] );
unset( $notification['installLabel'] );
unset( $notification['installErrorText'] );
unset( $notification['ctaActivate'] );
unset( $notification['postInstallLink'] );
unset( $notification['postInstallText'] );
}

return $notification;
}

private static function check_group( $group ) {
$is_or_relation = ! empty( $group['relation'] ) && 'OR' === $group['relation'];
unset( $group['relation'] );
Expand Down
123 changes: 122 additions & 1 deletion modules/notifications/assets/js/components/whats-new-item.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,118 @@
import { useState } from 'react';
import { __ } from '@wordpress/i18n';
import { Box, Button, Divider, Link, Typography } from '@elementor/ui';
import { WhatsNewItemTopicLine } from './whats-new-item-topic-line';
import { WrapperWithLink } from './wrapper-with-link';
import { WhatsNewItemThumbnail } from './whats-new-item-thumbnail';
import { WhatsNewItemChips } from './whats-new-item-chips';

const InstallPluginButton = ( { slug, notificationId, installLabel, activateLabel, errorText, postInstallLink, postInstallText } ) => {
const [ status, setStatus ] = useState( 'idle' ); // Idle | installing | activating | done | error
const [ errorMsg, setErrorMsg ] = useState( '' );

const dismissCard = () => {
elementorCommon.ajax.addRequest( 'notifications_mark_installed', {
data: { notification_id: notificationId },
} );
};

const findAndActivate = async () => {
setStatus( 'activating' );
try {
const plugins = await wp.apiFetch( { path: '/wp/v2/plugins' } );
const plugin = plugins.find( ( p ) => p.plugin.startsWith( slug + '/' ) );

if ( ! plugin ) {
setStatus( 'error' );
setErrorMsg( errorText || __( 'Plugin not found.', 'elementor' ) );
return;
}

if ( 'active' !== plugin.status ) {
await wp.apiFetch( {
path: `/wp/v2/plugins/${ encodeURIComponent( plugin.plugin ) }`,
method: 'POST',
data: { status: 'active' },
} );
}

setStatus( 'done' );
dismissCard();
} catch ( err ) {
setStatus( 'error' );
setErrorMsg( errorText || err?.message || __( 'Activation failed', 'elementor' ) );
}
};

const handleInstall = async () => {
setStatus( 'installing' );
try {
await wp.apiFetch( {
path: '/wp/v2/plugins',
method: 'POST',
data: { slug, status: 'active' },
} );
setStatus( 'done' );
dismissCard();
} catch ( err ) {
// Plugin already installed — find it and activate
if ( 'folder_exists' === err?.code ) {
await findAndActivate();
} else {
setStatus( 'error' );
setErrorMsg( errorText || err?.message || __( 'Installation failed', 'elementor' ) );
}
}
};

const getButtonLabel = () => {
switch ( status ) {
case 'installing': return __( 'Installing...', 'elementor' );
case 'activating': return activateLabel || __( 'Activating...', 'elementor' );
case 'done': return __( 'Installed ✓', 'elementor' );
default: return installLabel;
}
};

const isDisabled = [ 'installing', 'activating', 'done' ].includes( status );

return (
<Box>
<Button
variant="contained"
size="small"
color="primary"
disabled={ isDisabled }
onClick={ ! isDisabled ? handleInstall : undefined }
>
{ getButtonLabel() }
</Button>
{ 'done' === status && postInstallLink && (
<Typography variant="caption" color="text.secondary" sx={ { display: 'block', mt: 0.5 } }>
<Link href={ postInstallLink } target="_blank" color="inherit" underline="always">
{ postInstallText || __( 'Explore and get started', 'elementor' ) }
</Link>
</Typography>
) }
{ 'error' === status && (
<Typography variant="caption" color="error.main" sx={ { display: 'block', mt: 0.5 } }>
{ errorMsg }
</Typography>
) }
</Box>
);
};

InstallPluginButton.propTypes = {
slug: PropTypes.string.isRequired,
notificationId: PropTypes.string.isRequired,
installLabel: PropTypes.string.isRequired,
activateLabel: PropTypes.string,
errorText: PropTypes.string,
postInstallLink: PropTypes.string,
postInstallText: PropTypes.string,
};

export const WhatsNewItem = ( { item, itemIndex, itemsLength, setIsOpen } ) => {
return (
<Box
Expand Down Expand Up @@ -67,7 +176,19 @@ export const WhatsNewItem = ( { item, itemIndex, itemsLength, setIsOpen } ) => {
) }
</Typography>
) }
{ item.cta && item.ctaLink && (
{ item.installPlugin ? (
<Box sx={ { pb: 2 } }>
<InstallPluginButton
slug={ item.installPlugin }
notificationId={ item.id }
installLabel={ item.installLabel || item.cta || __( 'Install Plugin', 'elementor' ) }
activateLabel={ item.ctaActivate }
errorText={ item.installErrorText }
postInstallLink={ item.postInstallLink }
postInstallText={ item.postInstallText }
/>
</Box>
) : item.cta && item.ctaLink && (
<Box
sx={ {
pb: 2,
Expand Down
1 change: 1 addition & 0 deletions modules/notifications/assets/js/components/whats-new.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const WhatsNew = ( props ) => {

useEffect( () => {
if ( ! isOpen ) {
queryClient.invalidateQueries( { queryKey: [ 'e-notifications' ] } );
return;
}

Expand Down
33 changes: 32 additions & 1 deletion modules/notifications/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function __construct() {
$this->get_app_js_config()
);

wp_set_script_translations( 'e-editor-notifications', 'elementor' );
wp_set_script_translations( 'e-admin-notifications', 'elementor' );
}, 5 /* Before Elementor's admin enqueue scripts */ );

add_action( 'elementor/editor/v2/scripts/enqueue', [ $this, 'enqueue_editor_scripts' ] );
Expand Down Expand Up @@ -84,13 +84,44 @@ private function get_app_js_config(): array {

public function register_ajax_actions( $ajax ) {
$ajax->register_ajax_action( 'notifications_get', [ $this, 'ajax_get_notifications' ] );
$ajax->register_ajax_action( 'notifications_mark_installed', [ $this, 'ajax_mark_notification_installed' ] );
}

public function ajax_get_notifications() {
if ( ! current_user_can( 'edit_posts' ) ) {
throw new \Exception( 'Insufficient permissions' );
}

$notifications = API::get_notifications_by_conditions( true );

$is_admin = current_user_can( 'manage_options' );

$installed = Options::get_notifications_installed();
$notifications = array_values( array_filter( $notifications, function( $n ) use ( $installed, $is_admin ) {
if ( ! empty( $n['installPlugin'] ) && ! $is_admin ) {
return false;
}
return ! in_array( $n['id'], $installed, true );
} ) );

Options::mark_notification_read( $notifications );

return $notifications;
}

public function ajax_mark_notification_installed( $data ) {
if ( ! current_user_can( 'manage_options' ) ) {
throw new \Exception( 'Insufficient permissions' );
}

$notification_id = sanitize_text_field( $data['notification_id'] ?? '' );

if ( empty( $notification_id ) ) {
throw new \Exception( 'Missing notification_id' );
}

Options::mark_notification_installed( $notification_id );

return true;
}
}
34 changes: 33 additions & 1 deletion modules/notifications/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static function has_unread_notifications(): bool {
$notifications = API::get_notifications_by_conditions();
$notifications_ids = wp_list_pluck( $notifications, 'id' );

$unread_notifications = array_diff( $notifications_ids, static::get_notifications_dismissed() );
$unread_notifications = array_diff( $notifications_ids, static::get_notifications_dismissed(), static::get_notifications_installed() );

set_transient( "elementor_unread_notifications_{$current_user->ID}", $unread_notifications, HOUR_IN_SECONDS );
}
Expand All @@ -40,6 +40,38 @@ public static function get_notifications_dismissed() {
return $notifications_dismissed;
}

public static function get_notifications_installed(): array {
$current_user = wp_get_current_user();

if ( ! $current_user->exists() ) {
return [];
}

$installed = get_user_meta( $current_user->ID, '_e_notifications_installed', true );

return is_array( $installed ) ? $installed : [];
}

public static function mark_notification_installed( string $notification_id ): bool {
$current_user = wp_get_current_user();

if ( ! $current_user->exists() ) {
return false;
}

$installed = static::get_notifications_installed();

if ( ! in_array( $notification_id, $installed, true ) ) {
$installed[] = $notification_id;
}

update_user_meta( $current_user->ID, '_e_notifications_installed', $installed );

delete_transient( "elementor_unread_notifications_{$current_user->ID}" );

return true;
}

public static function mark_notification_read( $notifications ): bool {
$current_user = wp_get_current_user();

Expand Down