%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /opt/cloudlinux/venv/lib/python3.11/site-packages/xray/adviser/
Upload File :
Create Path :
Current File : //opt/cloudlinux/venv/lib/python3.11/site-packages/xray/adviser/cli_api.py

# -*- coding: utf-8 -*-

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

"""
This module contains X Ray Smart Advice local utility main class
"""
import json
import logging
import os
import hashlib
import pwd
import subprocess
from contextlib import contextmanager
from dataclasses import asdict, dataclass
from typing import Any, Tuple, Optional, List, Dict
from enum import Enum

from clwpos.user.config import LicenseApproveStatus

from clwpos.papi import (
    is_feature_allowed,
    is_subscription_pending,
    get_subscription_upgrade_url,
    get_license_approve_status,
    approve_license_agreement,
    get_license_agreement_text,
    get_subscriptions_info,
    get_user_auth_key,
    is_smart_advice_notifications_disabled_server_wide,
    is_smart_advice_reminders_disabled_server_wide,
    is_smart_advice_wordpress_plugin_disabled_server_wide
)
from clcommon.cpapi import (
    docroot,
    is_panel_feature_supported,
    get_user_emails_list,
    panel_awp_link,
    getCPName,
    userdomains
)
from clcommon.clwpos_lib import is_wp_path
from clcommon.clpwd import drop_privileges
from clcommon.const import Feature
from xray import gettext as _
from .advice_types import get_advice_instance
from .progress import SmartAdviceProgress
from ..apiclient import get_client, api_client
from ..internal.constants import (
    advice_pending_storage,
    advice_processed_storage,
    advice_list_cache,
    advice_reason_max_len
)
from ..internal.user_limits import get_lve_limits, get_lve_usage
from ..internal.exceptions import XRayError, SmartAdvicePluginError
from ..internal.user_plugin_utils import user_mode_advice_verification, username_verification, get_xray_exec_user
from ..internal.utils import timestamp, safe_move, get_user_php_version, filelock
from ..analytics.utils import report_usage_action_or_error
from xray.adviser.clwpos_get import ClWposGetter
from xray.smart_advice_plugin_helpers import (
    get_mu_directory,
    create_mu_plugins_dir_if_not_exist
)
from xray.smart_advice_plugin_manager import plugin_installed, prepare_system_analytics_data
from xray.analytics import report_analytics
from xray.imunify_manager import ImunifyManager, ADV_TYPE

advice_cache_separator = ';'


class AdviceActions(Enum):
    APPLY = 'apply'
    ROLLBACK = 'rollback'


@dataclass
class SmartAdviceOptions:
    panel_type: str
    panel_url: str
    panel_emails: str
    upgrade_url: str
    upgrade_url_cdn: str
    subscription: dict
    notifications: dict


class SmartAdviceUtil:
    """Main Smart Advice local utility class"""

    def __init__(self):
        self.logger = logging.getLogger('smart_advice')
        # check existence of pending and apply storage and create it if missing
        for stor in (advice_pending_storage, advice_processed_storage):
            self.create_dir(stor)
        # initialize Adviser API client
        adviser_client_object = get_client('adviser')
        self.adviser_client: api_client.SmartAdviceAPIClient = adviser_client_object()
        self.imunify_manager = ImunifyManager()

    @staticmethod
    def create_dir(dpath: str) -> None:
        """Create dir if missing"""
        if not os.path.isdir(dpath):
            os.mkdir(dpath)

    @staticmethod
    def response(**kwargs) -> str:
        """
        Create JSON response message with result field == success
        and given keyword arguments in other fields
        :return: json packed string
        """
        initial = {'result': 'success', 'timestamp': timestamp()}
        if kwargs:
            initial.update(kwargs)
        return json.dumps(dict(sorted(initial.items())))

    @staticmethod
    def _apply_datafile(a_id: int) -> str:
        """Per-advice file with results of apply"""
        return f'{advice_processed_storage}/{a_id}'

    def _apply_results(self, a_id: int) -> Optional[str]:
        """Retrieve data stored in per-advice file with results of apply"""
        datafile = self._apply_datafile(a_id)
        if os.path.isfile(datafile):
            try:
                with open(datafile) as _f:
                    data = json.load(_f)
            except OSError:
                data = None
            except json.JSONDecodeError:
                with open(datafile) as _f:
                    data = _f.read()
            finally:
                os.unlink(datafile)
            return data

    @staticmethod
    def _pending_flag(a_id: int) -> str:
        """Per-advice pending flag"""
        return f'{advice_pending_storage}/{a_id}'

    def is_advice_pending(self, advice_id: int) -> bool:
        """Is advice in pending state"""
        return os.path.exists(self._pending_flag(advice_id))

    def _progress_file(self, a_id: int) -> str:
        """Per-advice progress storage"""
        return self._pending_flag(a_id) + '.progress'

    def _get_user_awp_link(self, username):
        try:
            return panel_awp_link(username)
        except Exception:
            self.logger.exception('Error while getting user login link')
            return ''

    @user_mode_advice_verification
    def get_detailed_advice(self, advice_id: int) -> Tuple[dict, object]:
        """
        Get advice details from API along with an
        appropriate Advice object by obtained advice type
        """
        response = self.adviser_client.advice_details(advice_id)
        return response, get_advice_instance(response['advice']['type'])

    @staticmethod
    def dump_to_file(dst: str, data: Any, as_json: bool = False) -> None:
        """Dump data inside given dst using .bkp file and then move"""
        _tmp_dst = dst + '.bkp'
        with open(_tmp_dst, 'w') as _f:
            if as_json:
                try:
                    json.dump(data, _f)
                except TypeError:
                    _f.write(data)
            else:
                _f.write(data)
        safe_move(_tmp_dst, dst)

    def progress(self, advice_id: int, current: SmartAdviceProgress) -> SmartAdviceProgress:
        """
        Smart Advice apply own progress,
        based on current and previous results of 'clwpos get-progress'.
        Returns the maximum progress values among current and previous.
        Previous (latest) is stored inside a pending file-flag.
        """

        @contextmanager
        def resolve_progress() -> SmartAdviceProgress:
            """
            Get progress stored in file and return the maximum one among
            current and stored results
            """
            self.logger.debug('Current progress value: %s', current)
            progress_dst = self._progress_file(advice_id)
            try:
                # read stored result
                with open(progress_dst) as prev:
                    prev_stages = SmartAdviceProgress(**json.load(prev))
            except (OSError, json.JSONDecodeError) as e:
                self.logger.debug('Error during reading stored value: %s',
                                  str(e))
                # or set a dummy one
                prev_stages = SmartAdviceProgress()

            self.logger.debug('Stored progress value: %s', prev_stages)
            yield current if current > prev_stages else prev_stages

            if self.is_advice_pending(advice_id) and current > prev_stages:
                # update stored result only if pending file exists
                self.logger.debug('Updating stored progress: %s', current)
                self.dump_to_file(progress_dst, asdict(current),
                                  as_json=True)

        return resolve_progress()

    def get_current_status(self, _id: int, advice_obj: object,
                           advice_data: dict) -> Tuple[str, dict]:
        """
        Resolve if advice is in pending status.
        Progress is retrieved for pending advice only.
        Dummy result (0, 0) is returned for other advice states.
        """
        if self.is_advice_pending(_id):
            status = 'pending'
            with self.progress(_id, advice_obj.get_progress(
                    advice_data['metadata']['username'])) as p:
                stages = p
        else:
            status = advice_data['advice']['status']
            stages = SmartAdviceProgress()
        return status, asdict(stages)

    def advice_list(self, extended=False) -> str:
        """Load validated advice list and update it before returning"""
        api_response = self.adviser_client.advice_list()

        response_advice_list = self.prepare_advices_response(api_response, extended=extended)

        # cl-smart-advice plugin syncing should not break whole command
        try:
            self.sync_advices_wordpress_plugin(current_advices=api_response)
        except Exception:
            self.logger.exception('Unable to sync cl-smart-advice plugin during getting list of advices')

        return self.response(data=response_advice_list)

    @username_verification
    def get_site_statuses(self, username) -> str:
        api_response = self.adviser_client.site_info(username)
        site_statuses_data = {}
        for website in api_response:
            domain = website['domain']
            if domain not in site_statuses_data:
                site_statuses_data[domain] = []
            site_statuses_data[domain].append({
                'website': website['website'],
                'urls_scanned': len(website['urls']),
                'advices': self.prepare_advices_response(website['advices'])
            })
        result = [{'domain': domain, 'websites': websites} for domain, websites in site_statuses_data.items()]
        return self.response(data=result)

    def prepare_advices_response(self, from_api, status_from_microservice=False, extended=False):
        advices = from_api.copy()

        visible_advices = []
        for item in advices:
            # TODO: would be great to rewrite this part and avoid
            #       changing array inplace, instead create new one with proper typing
            advice_itself = item['advice']
            advice_type = advice_itself['type']

            # It's only for smart advice wp plugin
            if advice_type.startswith(ADV_TYPE):
                visible_advices.append(item)
                continue

            advice_instance = get_advice_instance(advice_type)
            advice_itself['description'] = advice_instance.short_description

            # when we need real response from microservice - only before sending json to wp plugin
            if status_from_microservice:
                status = advice_itself['status']
                stages = {'total_stages': 0, 'completed_stages': 0}
            else:
                status, stages = self.get_current_status(advice_itself['id'],
                                                         advice_instance, item)
            if extended:
                advice_itself['detailed_description'] = advice_instance.detailed_description

            advice_itself['status'] = status
            advice_itself['is_premium'] = advice_instance.is_premium_feature
            advice_itself['module_name'] = advice_instance.module_name

            advice_itself['license_status'] = get_license_approve_status(
                advice_instance.module_name, item['metadata']['username']).name

            if advice_itself['is_premium']:
                subscription_status = self._get_subscription_status(
                    advice_instance.module_name, item['metadata']['username'])
                advice_itself['subscription'] = dict(
                    status=subscription_status,
                    upgrade_url=get_subscription_upgrade_url(
                        advice_instance.module_name, item['metadata']['username'])
                )
            advice_itself.update(stages)

            visible_advices.append(item)
        return visible_advices

    def filter_plugin_advices(self, username, website, domain, advice_list):
        return [advice for advice in advice_list
                if advice.get('metadata', {}).get('domain') == domain
                and advice.get('metadata', {}).get('username') == username
                and advice.get('metadata', {}).get('website') == website
                and advice.get('advice', {}).get('status') != 'outdated']

    def _get_subscription_status(self, module_name, username):
        """
        Determines current subscription status based on feature status.
        """
        subscription_status = 'no'
        if is_feature_allowed(module_name, username):
            subscription_status = 'active'
        if is_subscription_pending(module_name, username):
            subscription_status = 'pending'
        return subscription_status

    def advice_details(self, advice_id: int) -> str:
        """Load validated advice details and update it before returning"""
        api_response, advice_instance = self.get_detailed_advice(advice_id)

        advice_itself = api_response['advice']

        """
        The microservice responds with field names: title and description.
        We must assign:
        [local advice] detailed_description = [microservice] description
        [local advice] description          = [microservice] title
        """

        # Until we won't rename detailed_description to description
        if advice_itself['description']:
            advice_itself['detailed_description'] = advice_itself['description']
        else:
            advice_itself['detailed_description'] = advice_instance.detailed_description

        # Until we won't rename description to title
        if advice_itself['title']:
            advice_itself['description'] = advice_itself['title']
        else:
            advice_itself['description'] = advice_instance.short_description
        del advice_itself['title']

        return self.response(data=api_response)

    def manage_advice(self, action, advice_id: int, ignore_errors: bool = False,
                      async_mode: bool = False, source: str = 'ACCELERATE_WP',
                      reason: str = None, accept_terms: bool = False,
                      analytics_data: str = None) -> Optional[str]:

        open(self._pending_flag(advice_id), 'w').close()

        if async_mode:
            # put itself to background in async mode
            # and return current status
            child = os.fork()
            if child:
                return self.manage_advice_status(advice_id)
        output = self._exec_advice_managing(action, advice_id, ignore_errors, async_mode,
                                            source, reason, accept_terms)
        if analytics_data is not None:
            report_usage_action_or_error(analytics_data, advice_id, source, output, action)
        if async_mode:
            # retrun no output in async mode
            output = None
        return output

    def _exec_advice_managing(self, action: str, advice_id: int, ignore_errors: bool = False,
                              async_mode: bool = False, source: str = 'ACCELERATE_WP',
                              reason: str = None, accept_terms: bool = False) -> Optional[str]:
        """Execute managing advice with passed action: apply/rollback"""
        try:
            api_response, advice_instance = self.get_detailed_advice(advice_id)

            if action == AdviceActions.APPLY.value:
                if accept_terms:
                    approve_license_agreement(
                        advice_instance.module_name, api_response['metadata']['username'])

                if get_license_approve_status(
                        advice_instance.module_name,
                        api_response['metadata']['username']) == LicenseApproveStatus.NOT_APPROVED:
                    return json.dumps({
                        'result': 'LICENCE_TERMS_APPROVE_REQUIRED',
                        'text': _('License approve required to use this feature. '
                                  'Open AccelerateWP plugin in your control panel, apply advice and '
                                  'accept terms and conditions to proceed with installation.')
                    })
                action_result, output = advice_instance.apply(**api_response['metadata'],
                                                              ignore_errors=ignore_errors)
                if action_result:
                    self.logger.debug('Applied successfully')
                    self.adviser_client.update_advice(advice_id=advice_id,
                                                      status='applied',
                                                      source=source)

                    try:
                        self._sync_advice(api_response['metadata']['username'],
                                          api_response['metadata']['domain'],
                                          api_response['metadata']['website'])
                    except Exception:
                        self.logger.exception('Error while syncing cl-smart-advice plugin on advice apply')

            elif action == AdviceActions.ROLLBACK.value:
                action_result, output = advice_instance.rollback(**api_response['metadata'])
                if action_result:
                    self.logger.debug('Rollback successfully')
                    if reason:
                        self.adviser_client.update_advice(advice_id=advice_id,
                                                          status='review',
                                                          source=source,
                                                          reason=reason[:advice_reason_max_len])
                    else:
                        self.adviser_client.update_advice(advice_id=advice_id,
                                                          status='review',
                                                          source=source)

                    try:
                        self._sync_advice(api_response['metadata']['username'],
                                          api_response['metadata']['domain'],
                                          api_response['metadata']['website'])
                    except Exception:
                        self.logger.exception('Error while syncing cl-smart-advice plugin on advice rollback')
            else:
                raise ValueError(_('Unsupported action with advice, passed action: %s') % str(action))

        finally:
            if os.path.isfile(self._pending_flag(advice_id)):
                os.unlink(self._pending_flag(advice_id))

        if async_mode:
            # in async mode write result to a special file
            self.dump_to_file(self._apply_datafile(advice_id),
                              output)
            # return output for analytics will be cleared after reporting
            return output
        else:
            # in sync mode return output
            return output

    def advice_counters(self) -> str:
        """Return advice counters for a server"""
        try:
            api_response = self.adviser_client.advice_list()
        except XRayError:
            # bad API responses (500 for example)
            # or JWT token failed check (CL Shared for example)
            result = dict.fromkeys(['total', 'applied'], None)
        else:
            result = dict(
                total=len(api_response),
                applied=len([advice for advice in api_response if
                             advice['advice']['status'] == 'applied'])
            )
        return self.response(data=result)

    def manage_advice_status(self, advice_id: int) -> str:
        """
        Return current status and progress of managing particular advice.
        """
        api_response, advice_instance = self.get_detailed_advice(advice_id)
        status, stages = self.get_current_status(advice_id, advice_instance,
                                                 api_response)

        subscription_status = self._get_subscription_status(
            advice_instance.module_name, api_response['metadata']['username'])

        if status != 'pending':
            # include result of apply/rollback for non-pending advice only
            result = self._apply_results(advice_id)
            # drop stored progress
            _progress = self._progress_file(advice_id)
            if os.path.isfile(_progress):
                os.unlink(_progress)

            if result:
                return self.response(
                    status=status,
                    subscription=dict(
                        object_cache=subscription_status
                    ),
                    upgrade_url=get_subscription_upgrade_url(
                        advice_instance.module_name,
                        api_response['metadata']['username']),
                    **stages,
                    data=result)

        return self.response(
            status=status,
            subscription=dict(
                object_cache=subscription_status
            ),
            upgrade_url=get_subscription_upgrade_url(
                advice_instance.module_name,
                api_response['metadata']['username']),
            **stages
        )

    def update_advices_metadata(self):
        """
        Updates incompatibilities only, but could be extended to more magic
        how to make microservice know that users/domains/websites no longer exist
        """
        current_advices = self.adviser_client.advice_list(filtered=False, show_all=True)
        getter = ClWposGetter()

        user_domain_pairs = {}

        for advice_object in current_advices:
            current_adv_metadata = advice_object.get('metadata')

            if not current_adv_metadata:
                self.logger.error('Malformed advice metadata, does not have metadata key: %s',
                                  str(advice_object))
                continue
            username, domain = current_adv_metadata.get('username'), current_adv_metadata.get('domain')

            if not all([domain, username]):
                self.logger.error('Malformed advice metadata, does not have required username or domain field: %s',
                                  str(current_adv_metadata))

            user_domain_pairs[username] = domain

        for user, user_domain in user_domain_pairs.items():
            try:
                updated_metadata = getter.get_updated_extended_metadata(user, user_domain, current_advices)

                if not updated_metadata:
                    logging.warning('Updated metadata for user: %s will not be sent', str(user))
                    continue

                getter.send(updated_metadata)
            except Exception as e:
                logging.error('Failed to update advice metadata with Smart Advice: %s',
                              e,
                              extra={
                                  'fingerprint': e,
                              })
                continue

        return self.response(data=_('Advices metadata update finished'))

    def _sync_advice(self, username, domain, website):
        current_advices = self.adviser_client.advice_list()

        # Get IM360 advice cache
        im360_advice_list = self.imunify_manager.im360_advice(cache_only=True)
        current_advices.extend(im360_advice_list)

        advice_hash_map = self.get_advice_hash_map(current_advices)
        try:
            filtered_advice_list = self.prepare_advices_response(current_advices,
                                                                 status_from_microservice=True, extended=True)
            filtered_advice_list = self.filter_plugin_advices(username, website, domain, filtered_advice_list)
            self._run_smart_advice_script(username, website, domain, filtered_advice_list)
        except Exception:
            self.logger.exception('Smart Advice plugin sync failed for website: %s', str(website))
            return

        cached_advices_by_site = self._read_advices_cache()
        key = f'{domain}{advice_cache_separator}{username}{advice_cache_separator}{website}'
        if key not in cached_advices_by_site:
            cached_advices_by_site[key] = {}
        cached_advices_by_site[key] = advice_hash_map[key]
        self._update_advices_cache(cached_advices_by_site)

    def _get_cdn_usage_statistics(self, username):
        from xray.adviser.awp_provision_api import AWPProvisionAPI
        acc_id = get_user_auth_key(username)
        cdn_usage = AWPProvisionAPI().awp_client_api.get_usage(acc_id)
        return cdn_usage

    @username_verification
    def analytics_report(self, username, feature, source, event, advice_id=None,
                         journey_id=None, user_hash=None, variant_id=None):
        analytics_data = prepare_system_analytics_data(username, user_hash, journey_id)
        report_analytics(data=analytics_data,
                         advice_id=advice_id,
                         source=source.lower(),
                         event=event,
                         feature=feature,
                         variant_id=variant_id)
        return self.response(data=_('Analytics sent'))

    @username_verification
    def get_options(self, username):
        """
        gets options:
        "panel_type": "cpanel",
        "panel_url": "https:/cpanel-foo.com/", # or https://plesk-bar.com/
        "panel_emails": "root@hosting.com,manager@hostring.com,foo@bar.com",
        "upgrade_url": "https://...",
        "upgrade_url_cdn": "https://...",
        "subscription": dict,
        "notifications": dict,
        """
        try:
            # [("domain.com"),("/path/to/docroot")]
            domain = userdomains(username)[0][0]
            email = get_user_emails_list(username, domain)
        except Exception:
            self.logger.exception('Unable to get user emails')
            email = ''
        try:
            subscription = get_subscriptions_info(username)
        except Exception:
            self.logger.exception('Cannot obtain subscription info')
            subscription = {}

        try:
            im360_advice_notification = self.imunify_manager.get_im360_settings().get('advice_email_notification')
        except Exception:
            self.logger.exception('Cannot obtain im360 advice notification setting')
            im360_advice_notification = False

        try:
            protection_status = 'active' if self.imunify_manager.get_im360_protection_status(username) is True else 'no'
        except Exception:
            self.logger.exception('Cannot obtain im360 protection status')
            protection_status = 'no'

        subscription['protection'] = {'status': protection_status}

        notifications = dict(
            email_status="disabled_server" if is_smart_advice_notifications_disabled_server_wide() is True else "enabled",
            reminders_status="disabled_server" if is_smart_advice_reminders_disabled_server_wide() is True else "enabled",
            imunify_email_status="enabled" if im360_advice_notification is True else "disabled_server",
        )

        return self.response(**asdict(
            SmartAdviceOptions(panel_type=getCPName(),
                               panel_url=self._get_user_awp_link(username),
                               panel_emails=email,
                               upgrade_url=get_subscription_upgrade_url('object_cache', username),
                               upgrade_url_cdn=get_subscription_upgrade_url('cdn', username),
                               subscription=subscription,
                               notifications=notifications)
        ))

    @username_verification
    def get_limits(self, username):
        """
        Retrieves the LVE limits for a specified user.
        {
            "lve_cpu": {
                "limit": int
            },
            "lve_ep": {
                "limit": int
            },
            "lve_pmem": {
                "limit": int
            },
            "lve_iops": {
                "limit": int
            },
            "lve_io": {
                "limit": int
            },
            "lve_nproc": {
                "limit": int
            },
        }
        or
        {
            "error": str
        }
        """
        result = get_lve_limits(username)
        return json.dumps(result, indent=2)

    @username_verification
    def get_usage(self, username):
        """
        Retrieves the current usage for a specified user.
        {
            "lve_cpu": {
                "usage": int
            },
            "lve_ep": {
                "usage": int
            },
            "lve_pmem": {
                "usage": int
            },
            "lve_iops": {
                "usage": int
            },
            "lve_io": {
                "usage": int
            },
            "lve_nproc": {
                "usage": int
            },
        }
        or
        {
            "error": str
        }
        """
        result = get_lve_usage(username)
        return json.dumps(result, indent=2)

    def sync_advices_wordpress_plugin(self, current_advices=None, im360_cached=True):
        if not current_advices:
            current_advices = self.adviser_client.advice_list()

        # Get IM360 advice fresh data by cron, or return cache
        im360_advice_list = self.imunify_manager.im360_advice(cache_only=im360_cached)
        current_advices.extend(im360_advice_list)

        new_hash_cache_by_site = self.get_advice_hash_map(current_advices)
        existing_hash_cache_by_site = self._read_advices_cache()

        if not new_hash_cache_by_site and not existing_hash_cache_by_site:
            return self.response(data='No advices found on the server')

        updated_cache = self._sync_sa_plugin(
            new_hash_cache_by_site,
            existing_hash_cache_by_site,
            current_advices_json=self.prepare_advices_response(current_advices, extended=True)
        )
        self._update_advices_cache(updated_cache)
        return self.response(data='Plugin installed')

    def uninstall_wordpress_plugins(self):
        """
        Uninstalls all Smart Advice WordPress plugins on server.
        """
        existing_hash_cache_by_site = self._read_advices_cache()

        if not existing_hash_cache_by_site:
            return self.response(data=_('No plugins found on the server'))

        for key in existing_hash_cache_by_site:
            (domain, username, website) = key.split(advice_cache_separator)

            try:
                self._run_smart_advice_script(username, website, domain, advices_json=None)
            except Exception:
                self.logger.exception('Smart advice plugin removal failed for website: %s', str(website))
                continue

        self._update_advices_cache(new_cached_advices={})
        return self.response(data=_('Plugin uninstalled'))

    def _sync_sa_plugin(self, new_hash_advice_cache, existing_hash_advice_cache, current_advices_json=None):
        logging.info('Synchronization of smart advice plugin begin')

        updated_advices = {}

        for key, id_status_hash in new_hash_advice_cache.items():
            (domain, username, website) = key.split(advice_cache_separator)
            if not self._should_sync_website(domain, username, website, id_status_hash,
                                             existing_hash_advice_cache.get(key)):
                logging.debug('Website %s does not need update because cache hash matches', key)
                updated_advices[key] = id_status_hash
                continue

            # keep only current user/website advices
            if current_advices_json:
                filtered_advice_list = self.filter_plugin_advices(username, website, domain, current_advices_json)
            else:
                filtered_advice_list = None

            try:
                self._run_smart_advice_script(username, website, domain, filtered_advice_list)
            except Exception:
                self.logger.exception('Smart advice plugin sync failed for website: %s', str(website))
                continue

            updated_advices[key] = id_status_hash

        # keep tracking of requests that no longer exist in new cache
        orphan_hash_records = set(existing_hash_advice_cache) - set(new_hash_advice_cache)
        if orphan_hash_records:
            # loop over orphan records and remove plugins on those websites
            logging.info('Found outdated records in cache, '
                         'removing wordpress plugins for those websites')
            for key in orphan_hash_records:
                (domain, username, website) = key.split(advice_cache_separator)

                logging.info('Removing outdated cache record %s', key)

                try:
                    self._run_smart_advice_script(username, website, domain, advices_json=None)
                except Exception:
                    self.logger.exception('Smart advice plugin sync failed for website: %s', str(website))
                    continue
        return updated_advices

    @staticmethod
    def _update_advices_cache(new_cached_advices: dict):
        with open(f'{advice_list_cache}.lock', 'a+') as lock_fd, \
                filelock(lock_fd), \
                open(advice_list_cache, 'w') as f:
            logging.debug('Updating advice hash map cache with values %s', new_cached_advices)
            json.dump(new_cached_advices, f, indent=2)

    def _read_advices_cache(self) -> dict:
        cached_advices_by_site = {}
        if os.path.exists(advice_list_cache):
            try:
                with open(advice_list_cache) as f:
                    cached_advices_by_site = json.load(f)
            except Exception:
                self.logger.exception('Unable to read advices cache json')
                cached_advices_by_site = {}
        return cached_advices_by_site

    @staticmethod
    def _should_sync_website(domain, username, website, current_hash, cached_hash):
        if current_hash != cached_hash:
            return True

        full_website_path = docroot(domain)[0] + website
        mu_plugin_dir = get_mu_directory(username, full_website_path)

        if not plugin_installed(mu_plugin_dir):
            return True

        return False

    @staticmethod
    def make_hash(id_status_pairs):
        h = hashlib.md5()
        for advice_id, status in id_status_pairs:
            h.update(str(advice_id).encode())
            h.update(status.encode())
        return h.hexdigest()

    def get_advice_hash_map(self, advices) -> Dict[str, str]:
        """
        Prepare fingerprints for each of the advices generated,
        grouped by the website that advice is assigned to.

        Result example:
        {
          "demo-10-194-0-9.traefik.me;demowp;/": "f43c7bcdccc28fc1ac92b2384f35f9dd"
        }

        Key is combination of domain, usernane and webiste path and
        value is hash against list of advices and their current statuses.
        """
        websites = {}
        for advice in advices:
            metadata = advice['metadata']
            advice_data = advice['advice']

            key = f'{metadata["domain"]}{advice_cache_separator}{metadata["username"]}' \
                  f'{advice_cache_separator}{metadata["website"]}'
            if key not in websites:
                websites[key] = []
            websites[key].append((advice_data['id'], advice_data['status']))

        return {
            key: self.make_hash(tuple(sorted(id_status_pairs, key=lambda item: str(item[0]))))
            for key, id_status_pairs in websites.items()
        }

    @staticmethod
    def _save_custom_php(path, content):
        """
        For easy mocking
        """
        with open(path, 'w') as f:
            f.write(content)

    def _prepare_php_for_plesk(self, username, domain):
        clwpos_dir = f'{pwd.getpwnam(username).pw_dir}/.clwpos'
        custom_php = '.php-ini'
        custom_php_full = os.path.join(clwpos_dir, custom_php)
        extra_paths = [
            '/opt/alt/php-xray/',
            '/opt/cloudlinux-site-optimization-module/',
            '/opt/cloudlinux/'
        ]
        updated_setting = None

        open_basedir_setting = subprocess.run(
            f'/usr/sbin/plesk bin site --show-php-settings {domain} | /usr/bin/grep open_basedir',
            text=True, capture_output=True, shell=True)

        open_basedir_setting_sanitized = open_basedir_setting.stdout.replace('open_basedir =', '').replace(
            'open_basedir=', '').strip()
        self.logger.info('Current open_basedir settings: %s', str(open_basedir_setting.stdout))

        # empty string means no restrictions; no need to add the extra paths
        if len(open_basedir_setting_sanitized) == 0:
            updated_setting = 'open_basedir ='
        # non-empty string and not "none"
        elif len(open_basedir_setting_sanitized) > 0 and open_basedir_setting_sanitized != 'none':
            # if 'none' is in the list of values obtained by splitting the string
            if 'none' in open_basedir_setting_sanitized.split(':'):
                updated_setting = 'open_basedir = none'
            elif open_basedir_setting_sanitized.startswith(':'):
                updated_setting = 'open_basedir = {WEBSPACEROOT}{/}{:}{TMP}{/}:' + ":".join(extra_paths)
            else:
                # collect missing extra paths
                missing_paths = []
                for extra_path in extra_paths:
                    # extra path not present
                    if extra_path not in open_basedir_setting.stdout:
                        missing_paths.append(extra_path)
                # some extra paths are missing, settings update is required
                if missing_paths:
                    updated_setting = f'open_basedir = {open_basedir_setting_sanitized}:{":".join(missing_paths)}'

        if updated_setting:
            self.logger.info('Updating open_basedir setting: %s for domain: %s', updated_setting, domain)
            # Both folder for custom PHP ini file and the file itself should be owned by appropriate user. We don't want
            # to leave files owned by root in user's home directory.
            with drop_privileges(username):
                if not os.path.exists(custom_php_full):
                    if not os.path.isdir(clwpos_dir):
                        os.mkdir(clwpos_dir, mode=0o700)
                self._save_custom_php(custom_php_full, updated_setting)

            result = subprocess.run(['/usr/sbin/plesk', 'bin', 'site', '--update-php-settings', domain, '-settings',
                                     custom_php_full],
                                    text=True, capture_output=True)
            self.logger.info('Plesk settings update result: %s', str(result.stdout))
            self.logger.error('open_basedir setting updated for domain "%s" from "%s" to "%s"',
                              str(domain),
                              str(open_basedir_setting.stdout),
                              str(updated_setting))

    def _uninstall_wordpress_plugin(self, username, website, domain):
        """
        Uninstall WordPress SmartAdvice plugin.
        """
        try:
            json_string = self.response(data=[])
            attrs = self._prepare_smart_advice_script_args(username, website, domain, json_string, 'true')
        except ValueError:
            # fixme: why?
            return

        command = self._get_smart_advice_script_cmd(username, attrs)
        logging.debug('Uninstalling Smart Advice plugin for website %s with advices: %s',
                      website, json_string)
        result = subprocess.run(command, text=True, capture_output=True)
        if result.returncode != 0:
            self.logger.error('Smart advice plugin uninstalling failed with exit code: %s, stdout: %s, stderr: %s',
                              str(result.returncode),
                              str(result.stdout),
                              str(result.stderr),
                              extra={
                                  'fingerprint': f'smart-advice-plugin-sh-returncode-{result.returncode}',
                              })
            raise SmartAdvicePluginError(_('Smart Advice plugin uninstalling failed'))

    def _install_wordpress_plugin(self, username, website, domain, advices_json):
        """
        Install plugin and sync advice
        plugin creation is part of main process -> to handle quota exceeded
        """
        try:
            json_string = self.response(data=advices_json)
            attrs = self._prepare_smart_advice_script_args(username, website, domain, json_string, 'false')
        except ValueError:
            return

        command = self._get_smart_advice_script_cmd(username, attrs)
        logging.info('Updating Smart Advice plugin for website %s with advices: %s',
                     website, json_string)
        result = subprocess.run(command, text=True, capture_output=True)
        if result.returncode != 0:
            self.logger.error('Smart advice plugin failed with exit code: %s, stdout: %s, stderr: %s',
                              str(result.returncode),
                              str(result.stdout),
                              str(result.stderr),
                              extra={
                                  'fingerprint': f'smart-advice-plugin-sh-returncode-{result.returncode}',
                              })
            raise SmartAdvicePluginError(_('Smart Advice plugin installation failed'))

    def _should_wordpress_plugin_installed(self):
        is_smart_advice_disabled = is_smart_advice_wordpress_plugin_disabled_server_wide()
        is_im360_mu_disabled = self.imunify_manager.is_im360_mu_plugin_disabled_server_wide()
        self.logger.info("Checking whether to install MU plugin: "
                         "is_smart_advice_disabled=%s, "
                         "is_im360_mu_disabled=%s",
                         str(is_smart_advice_disabled),
                         str(is_im360_mu_disabled))
        return not is_smart_advice_disabled or not is_im360_mu_disabled

    def _run_smart_advice_script(self, username, website, domain, advices_json=None):
        def _filter_by_advice_type(advices_data):
            if not advices_data:
                return advices_data

            filtered_data = advices_data
            if is_smart_advice_wordpress_plugin_disabled_server_wide():
                # leave only im360 advices
                filtered_data = [item for item in filtered_data if item.get("advice", {}).get("type") == ADV_TYPE]
                self.logger.info("AccelerateWP advices are filtered, advices=%s", str(filtered_data))

            if self.imunify_manager.is_im360_mu_plugin_disabled_server_wide():
                # leave only non-im360 advices
                filtered_data = [item for item in filtered_data if item.get("advice", {}).get("type") != ADV_TYPE]
                self.logger.info("IM360 advices are filtered, advices=%s", str(filtered_data))
            self.logger.info("Filtered advices for Smart Advice plugin, advices=%s", str(filtered_data))
            return filtered_data

        filtered = _filter_by_advice_type(advices_json)
        # Uninstall when none or empty list
        if not filtered:
            self._uninstall_wordpress_plugin(username, website, domain)
        # install only if wordpress plugin feature is enabled by hoster
        elif self._should_wordpress_plugin_installed():
            self.logger.info("Smart Advice plugin installation is allowed, advices list=%s",
                             str(advices_json))
            self._install_wordpress_plugin(username, website, domain, filtered)

    def _prepare_smart_advice_script_args(self, username, website, domain: List[str], advices_json: str,
                                          uninstall='false') -> List[str]:
        panel = getCPName()
        script_name = 'run.py'
        script_path = f'/opt/alt/php-xray/php/smart-advice-plugin/{script_name}'
        panel_user_awp_link = self._get_user_awp_link(username)

        self.logger.info('Got panel login link: %s', panel_user_awp_link)

        try:
            email = get_user_emails_list(username, domain)
        except Exception:
            self.logger.exception('Unable to get user emails')
            email = ''

        full_website_path = docroot(domain)[0] + website

        if not is_wp_path(full_website_path):
            self.logger.info('[Smart Advice Plugin]: wordpress: %s does not exist, skipped',
                             full_website_path)
            raise ValueError(_('Non-existing wordpress site'))

        php_info = get_user_php_version(username)

        for php_data in php_info:
            if php_data.get('vhost') == domain:
                php_binary_path = php_data.get('php_binary')
                break
        else:
            self.logger.error('Php data for domain %s was not found in %s', domain, str(php_info))
            raise SmartAdvicePluginError(_('Unable to get php version for domain %s') % domain)

        if not php_binary_path:
            self.logger.error('Php version for user: %s was not obtained',
                              username)
            raise SmartAdvicePluginError(_('Php binary was not identified for domain %s') % domain)

        if panel == 'Plesk':
            try:
                self._prepare_php_for_plesk(username, domain)
            except Exception:
                self.logger.exception('Setting up php for Plesk for SA plugin failed')

        mu_plugin_dir = get_mu_directory(username, full_website_path)

        if not mu_plugin_dir:
            raise SmartAdvicePluginError(_('Unable to detect MU plugin directory'))

        # mu_plugins dir creation is part of main process -> to handle quota exceeded
        create_mu_plugins_dir_if_not_exist(username, mu_plugin_dir)

        # ATTENTION: this order is important in further code, add new atts with caution
        res = [
            script_path,
            php_binary_path,
            full_website_path,
            mu_plugin_dir,
            advices_json,
            email,
            panel_user_awp_link,
            uninstall,
        ]
        return res

    @staticmethod
    def _get_smart_advice_script_cmd(username: str, attrs: List[str]) -> List[str]:
        if is_panel_feature_supported(Feature.CAGEFS):
            res = ['/sbin/cagefs_enter_user', username]
            res.extend(attrs)
        else:
            attrs[3] = "'" + attrs[3] + "'"  # escape json string
            command_str = ' '.join(attrs)
            res = ['sudo', '-u', username, '-s', '/bin/bash', '-c', command_str]
        return res

    def get_agreement_text(self, feature):
        """
        Method with handles work with agreements.
        """
        user_context = get_xray_exec_user()
        if not user_context:
            raise SmartAdvicePluginError(_('This command can only be executed as user'))

        return json.dumps({
            'result': 'success',
            'timestamp': timestamp(),
            'text': get_license_agreement_text(feature, user_context)
        })

    def create_subscription(self, advice_id):
        """
        Proxy method to AWP which creates subscription
        listening for the status of module and automatic advice appy.
        """
        user_context = get_xray_exec_user()
        if not user_context:
            raise SmartAdvicePluginError(_('This command can only be executed as user'))

        resp, advice_instance = self.get_detailed_advice(advice_id)

        result = subprocess.run([
            'cloudlinux-awp-user',
            '--user', str(user_context),
            'subscription',
            '--listen',
            '--advice-id', str(advice_id),
            '--feature', advice_instance.module_name
        ], text=True, capture_output=True)

        if result.returncode != 0:
            self.logger.error('Create subscription smart advice plugin failed with exit code: '
                              '%s, stdout: %s, stderr: %s',
                              str(result.returncode),
                              str(result.stdout),
                              str(result.stderr),
                              extra={
                                  'fingerprint': f'smart-advice-plugin-subscription-returncode-{result.returncode}',
                              })
            raise SmartAdvicePluginError(_('cloudlinux-awp-user call failed %s') % result.stderr)

Zerion Mini Shell 1.0