ok

Mini Shell

Direktori : /opt/cloudlinux/venv/lib/python3.11/site-packages/clwpos/optimization_features/
Upload File :
Current File : //opt/cloudlinux/venv/lib/python3.11/site-packages/clwpos/optimization_features/features.py

# -*- coding: utf-8 -*-
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2020 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
from pathlib import Path
from typing import Dict
from distutils.version import LooseVersion

from clcommon.clwpos_lib import get_wp_cache_plugin

from clwpos import gettext as _, constants
from clwpos.cl_wpos_exceptions import WposError, WpCliCommandError
from clwpos.constants import PULLZONE_DOMAIN_PROTOCOL, SMART_ADVISE_USER_UTILITY
from clwpos.utils import run_in_cagefs_if_needed
from dataclasses import dataclass, field, asdict
from enum import Enum
from clwpos.constants import (
    MINIMUM_SUPPORTED_PHP_OBJECT_CACHE,
    CL_DOC_USER_PLUGIN,
    CLSOP_ZIP_PATH
)
from clwpos.logsetup import setup_logging
from clwpos.object_cache.redis_utils import (
    get_cached_php_versions_with_redis_present,
    get_cached_php_versions_with_redis_loaded
)
from clwpos.utils import (
    is_conflict_modules_installed,
    clear_redis_cache_config,
    create_redis_cache_config,
    litespeed_is_running
)
from clwpos.php.base import PHP
from clwpos.wp_utils import (
    wordpress,
    WordpressError,
    is_plugin_activated,
    is_plugin_installed,
    obtain_wp_cli_env,
    diagnose_redis_connection_constants,
    is_multisite,
    list_active_plugins,
    get_plugin_data
)


class PluginStatus(Enum):
    UNINSTALLED = 'uninstalled'
    ACTIVE = 'active'
    INACTIVE = 'inactive'


@dataclass
class Issue:
    """
    Generic class for keeping compatibility/misconfiguration issues
    """
    header: str
    description: str
    fix_tip: str

    context: Dict[str, str] = field(default_factory=dict)

    @property
    def dict_repr(self):
        return asdict(self)


class UniqueId:
    PHP_NOT_SUPPORTED = 'PHP_NOT_SUPPORTED'
    PLUGIN_CONFLICT = 'PLUGIN_CONFLICT'
    WORDPRESS_MULTISITE_ENABLED = 'WORDPRESS_MULTISITE_ENABLED'
    MISCONFIGURED_WORDPRESS = 'MISCONFIGURED_WORDPRESS'

    WEBSERVER_NOT_SUPPORTED = 'WEBSERVER_NOT_SUPPORTED'
    PHP_MISCONFIGURATION = 'PHP_MISCONFIGURATION'
    UNCOMPATIBLE_WORDPRESS_VERSION = 'UNCOMPATIBLE_WORDPRESS_VERSION'
    AWP_NOT_SUPPORTS_CDN = 'AWP_NOT_SUPPORTS_CDN'
    AWP_NOT_SUPPORTS_IMAGE_OPTIMIZATION = 'AWP_NOT_SUPPORTS_IMAGE_OPTIMIZATION'
    AWP_NOT_SUPPORTS_CPCSS = 'AWP_NOT_SUPPORTS_CPCSS'
    NS_CDN_CONFLICT = 'NS_CDN_CONFLICT'

    CLOUDLINUX_MODULE_ALREADY_ENABLED = 'CLOUDLINUX_MODULE_ALREADY_ENABLED'

    FEATURE_HIDDEN_SERVER_WIDE = 'FEATURE_HIDDEN_SERVER_WIDE'
    FEATURE_NOT_MADE_VISIBLE = 'FEATURE_NOT_MADE_VISIBLE'


@dataclass
class CompatibilityIssue(Issue):
    """
    For compatibility issues
    """
    unique_id: str | None = None
    telemetry: Dict[str, str] = field(default_factory=dict)

    type: str = 'incompatibility'

    @property
    def dict_repr(self):
        representation = asdict(self)
        representation.pop('unique_id')
        representation.pop('telemetry')
        return representation


@dataclass
class MisconfigurationIssue(Issue):
    """
    For misconfiguration issues
    """
    type: str = 'misconfiguration'


WP_MISCONFIGURED_FIX_TIP = _(
    'Check that your website is working properly – '
    'try to run the specified command to find any obvious '
    'errors in the WordPress configuration. '
    'Otherwise, try to fix other issues first - '
    'it may help to resolve this issue as well.'
)
WP_BROKEN_CORE_REPAIR_TIP = _(
    '1) Utilize WordPress Toolkit: If your site was deployed using '
    'WordPress Toolkit, use this tool first for repairing or restoring '
    'missing core files.\n'
    '2) Manually re-upload WordPress core files: As a secondary option, '
    'download the latest version of WordPress from the official website and '
    're-upload the core files to your server. Be careful not to overwrite '
    'your wp-content folder or wp-config.php file.'
)

class BillableFeatureMixin:

    def _get_or_create_unique_identifier(self):
        """
        Wrapper for easy mocking
        """
        from clwpos.billing import get_unique_identifier_as_user
        return get_unique_identifier_as_user()


class Feature(str):
    """
    Helper class which hides differences of optimization features behind abstract methods.
    """
    NAME = ''
    WP_PLUGIN_NAME = ''

    HAS_LICENSE_TERMS = False
    LICENSE_TERMS_PATH = None

    _logger = setup_logging(f'{NAME.lower()}_feature')

    def __new__(cls, *args, **kwargs):
        if cls != Feature:
            return str.__new__(cls, *args)

        classes = {
            "object_cache": _ObjectCache,

            # yep, site_optimization and accelerate_wp names are same thing
            "site_optimization": _SiteOptimization,
            "accelerate_wp": _SiteOptimization,

            'cdn': _Cdn,
            'critical_css': _CriticalCSS,
            'image_optimization': _ImageOptimization
        }

        try:
            return classes[args[0]](*args)
        except KeyError:
            raise argparse.ArgumentTypeError(f"No such feature: {args[0]}.")

    @classmethod
    def optimization_feature(cls):
        return cls(cls.NAME.lower())

    @classmethod
    def included_optimization_features(cls):
        return [cls.optimization_feature()]

    @classmethod
    def redis_daemon_required(cls):
        raise NotImplementedError

    @classmethod
    def collect_docroot_issues(cls, wpos_user_obj, doc_root_info, visible_features=None):
        raise NotImplementedError

    @classmethod
    def is_php_supported(cls, php_version: PHP):
        raise NotImplementedError

    @classmethod
    def minimum_supported_wp_version(cls):
        raise NotImplementedError

    @staticmethod
    def collect_wordpress_issues(self, wordpress: Dict, docroot: str, module_is_enabled: bool):
        raise NotImplementedError

    @staticmethod
    def to_interface_name():
        raise NotImplementedError

    @staticmethod
    def get_wp_plugin_status(wordpress_abs_path, plugin_name) -> PluginStatus:
        """
        Get information about WordPress plugin current status.

        :param wordpress_abs_path:
            absolute path to wordpress installation
        :param plugin_name:
            name of plugin as it listed in plugins directory
        :return:
            PluginStatus
        """
        response = Feature.get_plugin_data(wordpress_abs_path, plugin_name)

        # in case of missing plugin wp-cli returns empty dict
        if not response:
            return PluginStatus.UNINSTALLED

        # in any other case we get list of one element with parameters
        return PluginStatus(response[0]['status'])

    @staticmethod
    def get_plugin_data(wordpress_abs_path, plugin_name):
        return get_plugin_data(wordpress_abs_path, plugin_name)

    @staticmethod
    def get_plugin_version(wordpress_abs_path, plugin_name) -> str:
        response = Feature.get_plugin_data(wordpress_abs_path, plugin_name)

        # in case of missing plugin wp-cli returns empty dict
        if not response:
            raise WposError(
                message=_(
                    'Malformed plugins information received from wp-cli, '
                    'unable to detect %(plugin)s version'),
                context={'plugin': plugin_name},
            )

        return response[0]['version']

    @classmethod
    def _get_wp_plugin_compatibility_issues(cls, docroot, wordpress):
        """
        Get issues that relates to currently installed WP plugin
        or None if everything is ok
        """
        try:
            plugin_status = cls.get_wp_plugin_status(
                wordpress_abs_path=os.path.join(docroot, wordpress["path"]),
                plugin_name=cls.WP_PLUGIN_NAME
            )
        except WposError as e:
            return CompatibilityIssue(
                header=_('Unexpected WordPress error'),
                description=_(
                    'Unable to detect the WordPress plugins '
                    'due to unexpected error. '
                    '\n\n'
                    'Technical details:\n%(error_message)s.\n'
                    '\nMost likely WordPress installation is not working properly.'
                ),
                fix_tip=WP_MISCONFIGURED_FIX_TIP,
                context=dict(
                    error_message=e.message % e.context
                ),
                unique_id=UniqueId.MISCONFIGURED_WORDPRESS,
                telemetry=dict(
                    error_message=e.message % e.context
                )
            )

        return cls._get_issues_from_wp_plugin_status(plugin_status)

    @classmethod
    def _get_issues_from_wp_plugin_status(cls, plugin_status):
        raise NotImplementedError

    @classmethod
    def install(cls, abs_wp_path: str):
        raise NotImplementedError

    @classmethod
    def enable(cls, abs_wp_path: str, *args, **kwargs):
        raise NotImplementedError

    @classmethod
    def disable(cls, abs_wp_path: str, **kwargs):
        raise NotImplementedError


class _ObjectCache(Feature):
    """Implementation for object caching"""

    NAME = 'OBJECT_CACHE'
    WP_PLUGIN_NAME = 'redis-cache'

    @classmethod
    def redis_daemon_required(cls):
        return True

    @staticmethod
    def to_interface_name():
        return 'object_cache'

    @classmethod
    def _get_issues_from_wp_plugin_status(cls, plugin_status):
        """
        Get issue that relates to currently installed redis-cache
        plugin or None if everything is ok
        """
        if plugin_status == PluginStatus.INACTIVE:
            return MisconfigurationIssue(
                header=_('"Redis Object Cache" plugin is deactivated'),
                description=_('Object caching is enabled, but the '
                              '"Redis Object Cache" plugin is deactivated in Wordpress admin page. Caching does not work'),
                fix_tip=_('Activate the Redis Object Cache plugin in the Wordpress admin page and '
                          'enable Object Cache Drop-in in the Redis Object Cache plugin settings. '
                          'As an alternative, rollback the feature and apply it again.')
            )
        elif plugin_status == PluginStatus.ACTIVE:
            return MisconfigurationIssue(
                header=_('The Object Cache Drop-in not installed'),
                description=_('The Object Cache Drop-In is not enabled. Caching does not work'),
                fix_tip=_('Enable the Object Cache using the Redis Object Cache plugin '
                          'settings page of Wordpress Admin. '
                          'As an alternative, rollback the feature and apply it again.')
            )
        elif plugin_status == PluginStatus.UNINSTALLED:
            return MisconfigurationIssue(
                header=_('"Redis Object Cache" plugin is not installed'),
                description=_('The "Redis Object Cache" WordPress plugin is not installed. '
                              'Caching does not work'),
                fix_tip=_('Rollback the feature and apply it again. '
                          'Contact your administrator if the issue persists.')
            )
        else:
            raise WposError(_('Unexpected plugin status: %(status)s'), context=dict(status=plugin_status))

    @classmethod
    def collect_docroot_issues(cls, wpos_user_obj, doc_root_info, visible_features=None):
        """
        Collects incompatibilities related to docroot (non-supported handler, etc)
        for object caching.
        """
        issues = []
        php_version = doc_root_info['php_version']
        is_modules_visible = None
        supported_php_versions = wpos_user_obj.supported_php_versions[OBJECT_CACHE_FEATURE]
        header__, fix_tip__, description__, uniq_id__, telemetry__ = None, None, None, None, None

        is_litespeed = litespeed_is_running()

        if visible_features is not None:
            is_modules_visible = 'object_cache' in visible_features

        if not cls.is_php_supported(php_version):
            header__ = _('PHP version is not supported')
            fix_tip__ = _('Please, set or ask your system administrator to set one of the '
                          'supported PHP versions: %(compatible_versions)s')
            description__ = _('Non supported PHP version %(php_version)s currently is used.')
            uniq_id__ = UniqueId.PHP_NOT_SUPPORTED
            telemetry__ = dict(
                reason='PHP_VERSION_TOO_LOW',
                php_version=php_version.identifier,
                supported_php_versions=supported_php_versions
            )

        elif php_version not in get_cached_php_versions_with_redis_present():
            header = _('Redis extension is not installed for selected php version')
            fix_tip = _('Please, install or ask your system administrator to install redis extension '
                        'for current %(php_version)s version, or use one of the compatible php versions: '
                        '%(compatible_versions)s for the domain.')
            description = _('Redis PHP extension is required for optimization feature, but not installed for '
                            'selected PHP version: %(php_version)s.')

            # in order to create advices when module is not visible yet
            if not is_modules_visible:
                issues.append(MisconfigurationIssue(
                    header=header,
                    fix_tip=fix_tip,
                    description=description,
                    context=dict(php_version=php_version.identifier, compatible_versions=supported_php_versions)))
            else:
                header__ = header
                fix_tip__ = fix_tip
                description__ = description
                uniq_id__ = UniqueId.PHP_NOT_SUPPORTED
                telemetry__ = dict(
                    php_version=php_version.identifier,
                    reason='PHP_REDIS_NOT_INSTALLED',
                    supported_php_versions=supported_php_versions
                )

        elif php_version not in get_cached_php_versions_with_redis_loaded():
            header = _('Redis extension is not loaded for selected php version')
            fix_tip = _('Please, load or ask your system administrator to load redis extension '
                        'for current %(php_version)s version, or use one of the compatible php versions: '
                        '%(compatible_versions)s for the domain.')
            description = _('Redis PHP extension is required for optimization feature, but not loaded for '
                            'selected PHP version: %(php_version)s.')
            if not is_modules_visible:
                issues.append(MisconfigurationIssue(
                    header=header,
                    fix_tip=fix_tip,
                    description=description,
                    context=dict(php_version=php_version.identifier, compatible_versions=supported_php_versions)))
            else:
                header__ = header
                fix_tip__ = fix_tip
                description__ = description
                uniq_id__ = UniqueId.PHP_NOT_SUPPORTED
                telemetry__ = dict(
                    php_version=php_version.identifier,
                    reason='PHP_REDIS_NOT_LOADED',
                    supported_php_versions=supported_php_versions
                )

        if not supported_php_versions:
            fix_tip__ = _('Please, ask your system administrator to setup at least '
                          'one of the recommended PHP version in accordance with docs (%(docs_url)s).')

        if header__ is not None:
            issues.append(
                CompatibilityIssue(
                    header=header__,
                    description=description__,
                    fix_tip=fix_tip__,
                    context=dict(php_version=php_version.identifier,
                                 compatible_versions=', '.join(supported_php_versions),
                                 docs_url=constants.CL_DOC_USER_PLUGIN),
                    unique_id=uniq_id__,
                    telemetry=telemetry__
                )
            )

        if not is_litespeed and doc_root_info["php_handler"] not in wpos_user_obj.supported_handlers:
            issues.append(
                CompatibilityIssue(
                    header=_('Unsupported PHP handler'),
                    description=_('Website uses unsupported PHP handler. Currently supported '
                                  'handler(s): %(supported_handlers)s.'),
                    fix_tip=_('Please, set or ask your system administrator to set one of the '
                              'supported PHP handlers for the domain: %(supported_handlers)s. '
                              'Or keep watching our blog: %(blog_url)s for supported handlers list updates.'),
                    context={
                        'supported_handlers': ", ".join(wpos_user_obj.supported_handlers),
                        'blog_url': 'https://blog.cloudlinux.com/'
                    },
                    unique_id=UniqueId.PHP_NOT_SUPPORTED,
                    telemetry=dict(
                        reason='PHP_UNSUPPORTED_HANDLER',
                        handler=doc_root_info["php_handler"],
                        supported_handlers=wpos_user_obj.supported_handlers,
                        php_version=php_version.identifier
                    )
                )
            )

        incompatible_php_modules = {}
        incompatible_module = 'snuffleupagus'
        if incompatible_php_modules.get(php_version) == incompatible_module or \
                is_conflict_modules_installed(php_version, incompatible_module):
            incompatible_php_modules[php_version] = incompatible_module
            issues.append(
                CompatibilityIssue(
                    header=_('Unsupported PHP module is loaded'),
                    description=_('Incompatible PHP module "%(incompatible_module)s" is currently used.'),
                    fix_tip=_('Please, disable or remove "%(incompatible_module)s" PHP extension.'),
                    context=dict(incompatible_module=incompatible_module),
                    unique_id=UniqueId.PHP_NOT_SUPPORTED,
                    telemetry=dict(
                        handler=doc_root_info["php_handler"],
                        supported_handlers=wpos_user_obj.supported_handlers,
                        php_version=php_version.identifier
                    )
                ))

        return issues

    @classmethod
    def is_php_supported(cls, php_version: PHP):
        """
        Check if passed php version >= minimum PHP version
        supported by object caching.
        """
        return LooseVersion(php_version.version) >= LooseVersion(MINIMUM_SUPPORTED_PHP_OBJECT_CACHE)

    @classmethod
    def minimum_supported_wp_version(cls):
        return constants.MINIMUM_SUPPORTED_WP_OBJECT_CACHE

    @classmethod
    def collect_wordpress_issues(cls, self, wordpress: Dict, docroot: str, module_is_enabled: bool):
        issues = []
        wp_dir = Path(docroot).joinpath(wordpress["path"])
        wp_content_dir = wp_dir.joinpath("wp-content")
        plugin_type = "object-cache"
        detected_object_cache_plugin = get_wp_cache_plugin(wp_dir, plugin_type)

        if module_is_enabled:
            if detected_object_cache_plugin != "redis-cache":
                issue = cls._get_wp_plugin_compatibility_issues(docroot, wordpress)
                if issue:
                    issues.append(issue)

            if not self.is_redis_running:
                issues.append(
                    MisconfigurationIssue(
                        header=_('Redis is not running'),
                        description=_('Object caching is enabled, but redis process is not running.'),
                        fix_tip=_('Redis will start automatically in 5 minutes. '
                                  'If the issue persists - contact your system administrator and report this issue')
                    )
                )
            try:
                diagnose_redis_connection_constants(docroot, wordpress['path'])
            except WpCliCommandError as e:
                issues.append(
                    MisconfigurationIssue(
                        header=_('Unable to identify redis constants in wordpress config'),
                        description=_('wp-cli utility returns malformed response, reason: "%(reason)s"'),
                        fix_tip=_('Please, try to check executed command and fix possible issues with it. '
                                  'If issue persists - please, contact CloudLinux support.'),
                        context=dict(
                            reason=e.message % e.context
                        )
                    )
                )
            except WposError as e:
                issues.append(
                    MisconfigurationIssue(
                        header=_('Missed redis constants in site config'),
                        description=_('WordPress config does not have needed constants '
                                      'for redis connection establishment.\n'
                                      'Details: %(reason)s'),
                        fix_tip=_('Please, try to disable and enable plugin again. '
                                  'If issue persists - please, contact CloudLinux support.'),
                        context=dict(
                            reason=e.message % e.context
                        )
                    )
                )
        if detected_object_cache_plugin == "Unknown":
            drop_in_file = wp_content_dir.joinpath(f'{plugin_type}.php')
            issues.append(
                CompatibilityIssue(
                    header=_('Conflicting object caching plugin enabled'),
                    description=_('Unknown custom object caching plugin is already enabled'),
                    fix_tip=_(f'Remove the drop-in ({drop_in_file}) file from the WordPress '
                              f'instance because it conflicts with AccelerateWP object caching.'),
                    unique_id=UniqueId.PLUGIN_CONFLICT,
                    telemetry=dict(
                        reason='OBJECT_CACHE_ALREADY_ENABLED',
                        plugin=detected_object_cache_plugin
                    )
                ))
        elif detected_object_cache_plugin == "w3-total-cache":
            issues.append(
                CompatibilityIssue(
                    header=_('Object Caching of W3 Total Cache plugin is incompatible'),
                    description=_('WordPress website already has Object Caching feature enabled '
                                  'with caching backend configured by the the W3 Total Cache plugin.'),
                    fix_tip=_('Deactivate Object Caching in W3 Total Cache plugin settings.'),
                    context=dict(),
                    unique_id=UniqueId.PLUGIN_CONFLICT,
                    telemetry=dict(
                        reason='OBJECT_CACHE_ALREADY_ENABLED',
                        plugin=detected_object_cache_plugin
                    )
                ))
        elif detected_object_cache_plugin not in (None, "redis-cache"):
            issues.append(
                CompatibilityIssue(
                    header=_('Conflicting object caching plugin enabled'),
                    description=_('The "%(detected_wp_plugin)s" plugin conflicts with AccelerateWP object caching.'),
                    fix_tip=_('Deactivate object caching in the plugin settings or completely uninstall'
                              'the conflicting plugin using the WordPress administration interface.'),
                    context=dict(detected_wp_plugin=detected_object_cache_plugin),
                    unique_id=UniqueId.PLUGIN_CONFLICT,
                    telemetry=dict(
                        reason='OBJECT_CACHE_ALREADY_ENABLED',
                        plugin=detected_object_cache_plugin
                    )
                ))
        try:
            if not self.check_installed_roc_plugin(os.path.join(docroot, wordpress['path'])):
                issues.append(
                    CompatibilityIssue(
                        header=_('Another Redis Object Cache plugin is installed'),
                        description=_('Non CloudLinux Redis Object Cache is installed for the website'),
                        fix_tip=_('Uninstall Redis Object Cache plugin using WordPress administration page'),
                        unique_id=UniqueId.PLUGIN_CONFLICT,
                        telemetry=dict(
                            reason='OBJECT_CACHE_ALREADY_ENABLED',
                            plugin=detected_object_cache_plugin
                        )
                    ))
        except WpCliCommandError as e:
            issues.append(
                MisconfigurationIssue(
                    header=_('Unable to identify installed object cache plugin in WordPress'),
                    description=_('wp-cli utility returns malformed response, reason: "%(reason)s"'),
                    fix_tip=_('Please, try to check executed command and fix possible issues with it. '
                              'If issue persists - please, contact CloudLinux support.'),
                    context=dict(
                        reason=e.message % e.context
                    )
                )
            )

        try:
            multisite = is_multisite(os.path.join(docroot, wordpress["path"]))
            if multisite:
                issues.append(
                    CompatibilityIssue(
                        header=_('WordPress Multisite mode is enabled'),
                        description=_('WordPress uses the Multisite mode which is currently not supported.'),
                        fix_tip=_('Install or configure WordPress in the single-site mode.'),
                        unique_id=UniqueId.WORDPRESS_MULTISITE_ENABLED,
                        telemetry=dict()
                    ))
        except WposError as e:
            issues.append(
                CompatibilityIssue(
                    header=_('Unexpected WordPress error'),
                    description=_('Unable to detect if the WordPress installation has the Multisite mode enabled '
                                  'mode due to unexpected error. '
                                  '\n\n'
                                  'Technical details:\n%(error_message)s.\n'
                                  '\nMost likely WordPress installation is not working properly.'),
                    fix_tip=_('If this is only one issue, please check that your website is working properly – '
                              'try to run the specified command to find any obvious '
                              'errors in the WordPress configuration. '
                              'Otherwise, try to fix other issues first - it may help to resolve this issue as well.'),
                    context=dict(
                        error_message=e.message % e.context
                    ),
                    unique_id=UniqueId.MISCONFIGURED_WORDPRESS,
                    telemetry=dict(
                        error_message=e.message % e.context
                    )
                ))
        return issues

    @classmethod
    def install(cls, abs_wp_path: str):
        """
        Install redis-cache plugin for user.
        :param abs_wp_path: absolute path to wp site
        :return:
        """
        res = wordpress(abs_wp_path, "plugin", "install", cls.WP_PLUGIN_NAME)
        if isinstance(res, WordpressError):
            raise WposError(message=res.message, context=res.context)

    @classmethod
    def enable(cls, abs_wp_path: str, *args, **kwargs):
        """
        Enable redis-cache plugin for user.
        :param abs_wp_path: absolute path to wp site
        :return:
        """
        create_redis_cache_config(abs_wp_path)
        errors = []

        res = wordpress(abs_wp_path, "plugin", "activate", cls.WP_PLUGIN_NAME)
        if isinstance(res, WordpressError):
            errors.append(res)

        if not errors:
            res = wordpress(abs_wp_path, "redis", "enable")
            if isinstance(res, WordpressError):
                errors.append(res)

        if errors:
            clear_redis_cache_config(abs_wp_path)
            raise WposError(message='Errors during enabling feature: %(error)s',
                            context=dict(error=str(errors)))


    @classmethod
    def disable(cls, abs_wp_path: str, **kwargs):
        """
        Delete cloudlinux info from wp-config.php,
        deactivate and delete redis-cache plugin for user.
        :param abs_wp_path: absolute path to wp site
        :return: list of errors that occurred during command execution
        """
        errors = []
        if is_plugin_activated(abs_wp_path, cls.WP_PLUGIN_NAME):
            res = wordpress(abs_wp_path, "plugin", "deactivate", cls.WP_PLUGIN_NAME)
            if isinstance(res, WordpressError):
                errors.append(res)

        if not errors and is_plugin_installed(abs_wp_path, cls.WP_PLUGIN_NAME):
            # continue procedure further only if previous step succeeded
            res = wordpress(abs_wp_path, "plugin", "delete", cls.WP_PLUGIN_NAME)
            if isinstance(res, WordpressError):
                errors.append(res)

        if not errors:
            # cleanup constants in the end only if deactivation/deletion succeeded,
            # because it may impact on deactivating/deleting plugin
            try:
                clear_redis_cache_config(abs_wp_path)
            except WposError as err:
                cls._logger.exception(err)
                errors.append(WordpressError(err.message, err.context))
            except Exception as e:
                cls._logger.exception(e)
                errors.append(
                    WordpressError(
                        message=_(
                            'Unexpected error happened while clearing cache: %(error)s'),
                        context=dict(error=str(e)))
                )

        return errors


class _SiteOptimization(Feature):
    """Implementation for site optimization feature"""

    NAME = 'SITE_OPTIMIZATION'
    WP_PLUGIN_NAME = 'clsop'
    WP_FEATURE_NAME = 'accelerate-wp'

    @classmethod
    def redis_daemon_required(cls):
        return False

    @staticmethod
    def to_interface_name():
        return 'accelerate_wp'

    @classmethod
    def collect_docroot_issues(cls, wpos_user_obj, doc_root_info, visible_features=None):
        """
        Collects incompatibilities related to docroot (non-supported handler, etc)
        for site optimizatin module.
        """
        issues = []
        php_version = doc_root_info['php_version']

        if not cls.is_php_supported(php_version):
            supported_php_versions = wpos_user_obj.supported_php_versions[SITE_OPTIMIZATION_FEATURE]
            issues.append(
                CompatibilityIssue(
                    header=_('PHP version is not supported'),
                    fix_tip=_('Please, set or ask your system administrator to set one of the '
                              'supported PHP version: %(compatible_versions)s for the domain.'),
                    description=_('Non supported PHP version %(php_version)s currently is used.'),
                    context=dict(php_version=php_version.identifier,
                                 compatible_versions=', '.join(supported_php_versions),
                                 docs_url=CL_DOC_USER_PLUGIN),
                    unique_id=UniqueId.PHP_NOT_SUPPORTED,
                    telemetry=dict(reason='PHP_VERSION_TOO_LOW')
                )
            )

        return issues

    @staticmethod
    def _requirements():
        with open("/opt/cloudlinux-site-optimization-module/requirements.json", "r") as f:
            # {
            #    "required_php_version": "7.0",
            #    "required_wp_version": "5.4",
            #    "incompatible_plugins": {
            #      "w3-total-cache": "w3-total-cache/w3-total-cache.php",
            #      "wp-super-cache": "wp-super-cache/wp-cache.php"
            #    }
            # }
            return json.load(f)

    @classmethod
    def incompatible_plugins(cls):
        return set(cls._requirements()["incompatible_plugins"].keys())

    @classmethod
    def is_php_supported(cls, php_version: PHP):
        """
        Check if passed php version >= minimum PHP version
        supported by site optimization feature.
        """
        return LooseVersion(php_version.version) >= LooseVersion(cls._requirements()["required_php_version"])

    @classmethod
    def minimum_supported_wp_version(cls):
        return cls._requirements()["required_wp_version"]

    @classmethod
    def collect_wordpress_issues(cls, self, wordpress_info: Dict, docroot: str, module_is_enabled: bool):
        issues = []
        abs_wp_path = Path(docroot).joinpath(wordpress_info["path"])
        wp_content_dir = abs_wp_path.joinpath("wp-content")
        plugin_type = "advanced-cache"

        detected_advanced_cache_plugin = get_wp_cache_plugin(abs_wp_path, plugin_type)

        try:
            plugins_data = list_active_plugins(str(abs_wp_path))
        except WposError as e:
            issues.append(
                MisconfigurationIssue(
                    header=_('Unable to identify module compatibility'),
                    description=_('Malformed output received from the following command: <br>&emsp;$/opt/clwpos/wp-cli plugin list --status=active --format=json'
                                  '<br><br>The raw command output is:<br>&emsp;\"%(wp_cli_response)s\"'),
                    fix_tip=_('Please, check the received command output and ensure it returns a valid JSON.'),
                    context=dict(
                        wp_cli_response=str(e)
                    )
                )
            )
            found_plugins = set()
        else:
            found_plugins = {item["name"] for item in plugins_data}

        result = found_plugins & cls.incompatible_plugins()
        if detected_advanced_cache_plugin:
            result.add(detected_advanced_cache_plugin)

        result.discard("AccelerateWP")

        # if our WP Rocket module is enabled it's not conflicting plugin
        if module_is_enabled:
            result.discard("WP Rocket")
            issue = cls._get_wp_plugin_compatibility_issues(docroot,
                                                            wordpress_info)
            if issue:
                issues.append(issue)

        # for more beautiful output
        if len(result) > 1:
            result.discard("Unknown")

        result = list(result)
        if len(result) == 1 and result[0] == 'Unknown':
            drop_in_file = wp_content_dir.joinpath(f'{plugin_type}.php')
            issues.append(
                CompatibilityIssue(
                    header=_("Conflicting advanced cache plugin enabled"),
                    description=_("Unknown advanced cache plugin is already enabled."),
                    fix_tip=_(f'Remove the drop-in ({drop_in_file}) file from the WordPress '
                              f'instance because it conflicts with AccelerateWP.'),
                    context=dict(plugins=", ".join(result)),
                    unique_id=UniqueId.PLUGIN_CONFLICT,
                    telemetry=dict(
                        reason='SOM_ALREADY_ENABLED',
                        plugin=list(result)
                    )
                )
            )
        elif result:
            issues.append(
                CompatibilityIssue(
                    header=_("Conflicting plugins are enabled"),
                    description=_("Found conflicting plugins: %(plugins)s."),
                    fix_tip=_("Deactivate and uninstall the conflicting plugin "
                              "using the WordPress administration interface."),
                    context=dict(plugins=", ".join(result)),
                    unique_id=UniqueId.PLUGIN_CONFLICT,
                    telemetry=dict(
                        reason='SOM_ALREADY_ENABLED',
                        plugin=list(result)
                    )
                )
            )
        return issues

    @classmethod
    def _get_issues_from_wp_plugin_status(cls, plugin_status):
        """
        Get issue that relates to currently installed redis-cache
        plugin or None if everything is ok
        """
        if plugin_status == PluginStatus.INACTIVE:
            return MisconfigurationIssue(
                header=_('"AccelerateWP" plugin is deactivated'),
                description=_('AccelerateWP feature is enabled, but the '
                              '"AccelerateWP" plugin is deactivated in Wordpress admin page. Caching does not work'),
                fix_tip=_(
                    'Activate the "AccelerateWP" plugin in the Wordpress admin page. '
                    'As an alternative, rollback the feature and apply it again.')
            )
        elif plugin_status == PluginStatus.UNINSTALLED:
            return MisconfigurationIssue(
                header=_('"AccelerateWP" plugin is not installed'),
                description=_(
                    'The "AccelerateWP" WordPress plugin is not installed. '
                    'Caching does not work'),
                fix_tip=_('Rollback the feature and apply it again. '
                          'Contact your administrator if the issue persists.')
            )

    @classmethod
    def install(cls, abs_wp_path: str):
        """
        Install cloudlinux-site-optimization plugin for user.
        :param abs_wp_path: absolute path to wp site
        :return:
        """
        env = obtain_wp_cli_env(abs_wp_path)
        if is_plugin_installed(abs_wp_path, 'clsop', env):
            return

        res = wordpress(abs_wp_path, "plugin", "install", CLSOP_ZIP_PATH,
                        env=env)
        if isinstance(res, WordpressError):
            raise WposError(message=res.message, context=res.context)

    @classmethod
    def enable(cls, abs_wp_path: str, *args, **kwargs):
        """
        Enable cloudlinux-site-optimization plugin for user.
        :param abs_wp_path: absolute path to wp site
        :return:
        """
        env = obtain_wp_cli_env(abs_wp_path)
        res = wordpress(abs_wp_path, "plugin", "activate", cls.WP_PLUGIN_NAME,
                        env=env)
        if isinstance(res, WordpressError):
            raise WposError(message=res.message, context=res.context)

    @classmethod
    def disable(cls, abs_wp_path: str, **kwargs):
        """
        Deactivate and delete cloudlinux-site-optimization plugin for user.
        :param abs_wp_path: absolute path to wp site
        :return: list of errors that occurred during command execution
        """
        errors = []

        env = obtain_wp_cli_env(abs_wp_path)

        if is_plugin_activated(abs_wp_path, cls.WP_PLUGIN_NAME, env):
            res = wordpress(abs_wp_path, "plugin", "deactivate",
                            cls.WP_PLUGIN_NAME, env=env)
            if isinstance(res, WordpressError):
                errors.append(res)

        if not errors and is_plugin_installed(abs_wp_path, cls.WP_PLUGIN_NAME,
                                              env):
            # continue procedure further only if previous step succeeded
            res = wordpress(abs_wp_path, "plugin", "uninstall",
                            cls.WP_PLUGIN_NAME, env=env)
            if isinstance(res, WordpressError):
                errors.append(res)

        return errors


class AWPDependentFeature(_SiteOptimization):
    """
    Introduces basic class for features, those strictly depend on AccelerateWP plugin -- e.g. are its sub-features
    """

    MINIMUM_AWP_PLUGIN_VERSION: str
    NOT_SUPPORTED_ID: str

    @classmethod
    def included_optimization_features(cls):
        """
        Dependent feature needs AccelerateWP plugin
        """
        return [_SiteOptimization.optimization_feature(), cls.optimization_feature()]

    @classmethod
    def is_plugin_version_supported(cls, abs_path):
        """
        Dependent feature carries its own MINIMUM_AWP_PLUGIN_VERSION supported
        """
        actual_version = cls.get_plugin_version(wordpress_abs_path=abs_path,
                                                plugin_name=_SiteOptimization.WP_PLUGIN_NAME)

        min_wp_plugin_version, min_rpm_version = cls.MINIMUM_AWP_PLUGIN_VERSION.split('-', 1)
        actual_version_info = actual_version.split('-', 1)
        # for old versions, which does not have separator "-"
        rpm_version = '1.1-1'
        wp_plugin_version = actual_version_info[0]

        if len(actual_version_info) == 2:
            rpm_version = actual_version_info[1]

        return LooseVersion(wp_plugin_version) >= LooseVersion(min_wp_plugin_version) \
            and LooseVersion(rpm_version) >= LooseVersion(min_rpm_version)

    @classmethod
    def collect_wordpress_issues(cls, self, wordpress_info: Dict, docroot: str, module_is_enabled: bool):
        """
        Collects all incompatibilities of AccelerateWP (Site Optimization plugin) + dependent plugin specific
        """
        abs_wp_path = os.path.join(docroot, wordpress_info['path'])
        is_accelerate_wp_plugin_activated = is_plugin_activated(abs_wp_path, _SiteOptimization.WP_PLUGIN_NAME)

        issues = super().collect_wordpress_issues(self, wordpress_info, docroot,
                                                  module_is_enabled=is_accelerate_wp_plugin_activated)

        if is_accelerate_wp_plugin_activated and not cls.is_plugin_version_supported(abs_wp_path):
            issues.append(
                CompatibilityIssue(
                    header=_("Incompatible version of AccelerateWP plugin"),
                    description=_(
                        f"Version of AccelerateWP plugin must be higher than {cls.MINIMUM_AWP_PLUGIN_VERSION}"),
                    fix_tip=_(f'Update AccelerateWP plugin in Wordpress Admin page'),
                    unique_id=cls.NOT_SUPPORTED_ID,
                    telemetry=dict(
                        reason=cls.NOT_SUPPORTED_ID
                    )
                )
            )

        if module_is_enabled:
            issue = cls._get_wp_plugin_compatibility_issues(docroot, wordpress_info)
            if issue:
                issues.append(issue)

        return issues

    @classmethod
    def install(cls, abs_wp_path: str):
        """
        Install basic AccelerateWP plugin
        """
        env = obtain_wp_cli_env(abs_wp_path)
        if is_plugin_installed(abs_wp_path, _SiteOptimization.WP_PLUGIN_NAME, env):
            return
        res = wordpress(abs_wp_path, "plugin", "install", CLSOP_ZIP_PATH, env=env)
        if isinstance(res, WordpressError):
            raise WposError(message=res.message, context=res.context)

    @classmethod
    def enable(cls, abs_wp_path: str, *args, **kwargs):
        # enable AccelerateWP itself first
        _SiteOptimization.enable(abs_wp_path)

        res = wordpress(abs_wp_path, _SiteOptimization.WP_FEATURE_NAME,
                        cls.WP_FEATURE_NAME, "enable", *args)
        if isinstance(res, WordpressError):
            raise WposError(message=res.message, context=res.context)

    @classmethod
    def disable(cls, abs_wp_path: str, **kwargs):
        """
        Disables a dependent optimization feature inside accelerate-wp plugin
        """
        env = obtain_wp_cli_env(abs_wp_path)

        if is_plugin_activated(abs_wp_path, _SiteOptimization.WP_PLUGIN_NAME, env):
            res = wordpress(abs_wp_path, _SiteOptimization.WP_FEATURE_NAME,
                            cls.WP_FEATURE_NAME, "disable")
            if isinstance(res, WordpressError):
                raise WposError(message=res.message, context=res.context)


class _Cdn(AWPDependentFeature, BillableFeatureMixin):
    """Implementation for CDN feature"""

    NAME = 'CDN'
    WP_FEATURE_NAME = 'cdn'

    MINIMUM_AWP_PLUGIN_VERSION = '3.13.4-1.1-3'
    NOT_SUPPORTED_ID = UniqueId.AWP_NOT_SUPPORTS_CDN

    # this feature has specific license terms which user should
    # apply before he can use the feature
    HAS_LICENSE_TERMS = True
    LICENSE_TERMS_PATH = '/opt/clwpos/agreements/cdn'

    KNOWN_CDN_NS = {
        'cloudflare': 'Cloudflare',
        '.fastly.net.': 'Fastly',
        '.akam.net': 'Akamai',
        'awsdns': 'Amazon CloudFront(AWS)',
        '.impervadns.net': 'Imperva',
        'azure': 'Azure CDN',
        'google': 'Google Cloud CDN',
        'sucuri': 'Sucuri',
        'cdn77': 'CDN77',
        'netlify': 'Netlify'
    }
    CDN_NS_PATTERN = re.compile(f"{'|'.join(KNOWN_CDN_NS.keys())}")

    @staticmethod
    def to_interface_name():
        return 'cdn'

    @classmethod
    def incompatible_plugins(cls) -> set:
        """
        CDN incompatible plugins.
          - cloudflare
          - litespeed-cache
          - speed-booster-pack
          - w3-total-cache
          - wp-fastest-cache
          - wp-super-cache
        are incompatible with CDN too,
        but they are already listed as SOM conflicting plugins
        """

        cdn_incompatible_plugins = {
            'autoptimize',
            'bunnycdn',
            'cdn-enabler',
            'cloudimage',
            'cloudinary-image-management-and-manipulation-in-the-cloud-cdn',
            'nazy-load',
            'optimole-wp',
            'sirv',
            'image-cdn',
            # TODO: AWP-435
            'jetpack',
            'nitropack',
            'shift8-cdn',
            'smartvideo',
            'wp-cloudflare-page-cache',
            'shapepress-dsgvo',
            'amazon-s3-and-cloudfront',
            'wp-cdn-yes',
            'aws-cdn-by-wpadmin'
        }
        return cdn_incompatible_plugins.union(super().incompatible_plugins())

    @classmethod
    def dig_ns(cls, domain_name: str) -> str:
        """Dig domain's NS"""
        dig_util = '/usr/bin/dig'
        if not os.path.isfile(dig_util):
            # dig is not installed, assume no NS detected
            return str()

        dig_cmd = [
            dig_util,
            'ns',
            domain_name,
            '+short'
        ]
        try:
            dig_result = subprocess.run(dig_cmd, capture_output=True,
                                        text=True)
            if dig_result.returncode:
                # dig command failed with returncode, assume no NS detected
                cls._logger.exception("dig domain failed with exitcode %s: \n"
                                      "stdout=%s\n"
                                      "stderr=%s",
                                      dig_result.returncode,
                                      dig_result.stdout, dig_result.stderr)
                return str()
            return dig_result.stdout.strip()
        except (OSError, IOError, ) as e:
            # subprocess failed to execute command, assume no NS detected
            cls._logger.exception(
                "Failed to dig domain, command crushed with: %s", e)
            return str()

    @classmethod
    def collect_docroot_issues(cls, wpos_user_obj, doc_root_info,
                               visible_features=None):
        """
        Collects incompatibilities related to docroot for CDN module:
            - site optimization inherited
            - CDN already enabled by NS
        """
        issues = super().collect_docroot_issues(wpos_user_obj, doc_root_info,
                                                visible_features)
        primary_domain = doc_root_info['domains'][0]
        ns_cdn_detected = cls.CDN_NS_PATTERN.search(
            cls.dig_ns(primary_domain))

        if ns_cdn_detected is not None:
            issues.append(CompatibilityIssue(
                header=_("CDN is already enabled"),
                description=_("Already enabled CDN found: %(cdn)s."),
                fix_tip=_("Deactivate the enabled CDN using "
                          "your service provider instructions."),
                context=dict(cdn=cls.KNOWN_CDN_NS[ns_cdn_detected.group(0)]),
                unique_id=UniqueId.NS_CDN_CONFLICT,
                telemetry=dict(
                    reason='CDN_ALREADY_ENABLED_BY_NS',
                    plugin=cls.KNOWN_CDN_NS[ns_cdn_detected.group(0)]
                )
            ))
        return issues

    def enable(self, abs_wp_path: str, *args, **kwargs):
        domain = f'{PULLZONE_DOMAIN_PROTOCOL}{kwargs.get("domain")}'
        website = f'/{kwargs.get("website")}'
        skip_checkers = kwargs.get("skip_checkers", False)
        get_pullzone_command = [SMART_ADVISE_USER_UTILITY,
                                'awp-cdn-get-pullzone',
                                '--domain', domain,
                                '--website', website]

        try:
            output = run_in_cagefs_if_needed(get_pullzone_command, check=True)
        except subprocess.CalledProcessError as error:
            self._logger.exception("Error during obtaining pullzone: \n"
                              "stdout=%s\n"
                              "stderr=%s",
                              error.stdout, error.stderr)
            raise WposError('Unable to obtain pullzone required for CDN optimization feature')

        pullzone_data = json.loads(output.stdout)['data']

        additional_args = list()
        if skip_checkers:
            additional_args.append('--skip-check')

        super().enable(abs_wp_path, '--account_id=%s' % pullzone_data['account_id'],
                       '--cdn_url=%s' % pullzone_data['cdn_url'],
                       '--api_key=%s' % self._get_or_create_unique_identifier(),
                       *additional_args)


    @classmethod
    def disable(cls, abs_wp_path: str, **kwargs):
        """
        Disables cdn feature inside accelerate wp plugin
        """
        domain = f'{PULLZONE_DOMAIN_PROTOCOL}{kwargs.get("domain")}'
        website = f'/{kwargs.get("website")}'
        remove_pullzone_command = [SMART_ADVISE_USER_UTILITY,
                                   'awp-cdn-remove-pullzone',
                                   '--domain', domain,
                                   '--website', website]

        try:
            run_in_cagefs_if_needed(remove_pullzone_command, check=True)
        except subprocess.CalledProcessError as error:
            cls._logger.exception("Error during removing pullzone: \n"
                              "stdout=%s\n"
                              "stderr=%s",
                              error.stdout, error.stderr)
            raise WposError('Unable to remove pullzone')

        super().disable(abs_wp_path)


class _ImageOptimization(AWPDependentFeature, BillableFeatureMixin):
    """Implementation for image optimization feature"""

    NAME = 'IMAGE_OPTIMIZATION'
    WP_FEATURE_NAME = 'image_optimization'

    MINIMUM_AWP_PLUGIN_VERSION = '3.12.6.1-1.1-1'
    NOT_SUPPORTED_ID = UniqueId.AWP_NOT_SUPPORTS_IMAGE_OPTIMIZATION

    @staticmethod
    def to_interface_name():
        return 'image_optimization'

    @classmethod
    def incompatible_plugins(cls) -> set:
        """
        Image Optimization incompatible plugins.
        """

        img_opt_incompatible_plugins = {
            'ewww-image-optimizer',
            'shortpixel-image-optimiser',
            'shortpixel-adaptive-images'
            'imagify',
            'optimole-wp',
            'wp-smushit',
            'resmushit-image-optimizer',
            'megaoptim-image-optimizer',
            'kraken-image-optimizer',
            'tiny-compress-images',
            'wp-compress-image-optimizer',
            'optimus',
            'imsanity',
            # TODO: AWP-435
            # jetpack skipped for now, since more smart detection required
        }
        return img_opt_incompatible_plugins.union(super().incompatible_plugins())

    def enable(self, abs_wp_path: str, *args, **kwargs):
        super().enable(abs_wp_path, '--unique_id=%s' % self._get_or_create_unique_identifier())


class _CriticalCSS(AWPDependentFeature, BillableFeatureMixin):
    """Implementation for Critical Path CSS feature"""

    NAME = 'CPCSS'
    WP_FEATURE_NAME = 'cpcss'

    MINIMUM_AWP_PLUGIN_VERSION = '3.12.6.1-1.1-1'
    NOT_SUPPORTED_ID = UniqueId.AWP_NOT_SUPPORTS_CPCSS

    @staticmethod
    def to_interface_name():
        return 'critical_css'

    @classmethod
    def optimization_feature(cls):
        return cls(cls.to_interface_name())

    def enable(self, abs_wp_path: str, *args, **kwargs):
        super().enable(abs_wp_path, '--unique_id=%s' % self._get_or_create_unique_identifier())


OBJECT_CACHE_FEATURE = Feature("object_cache")
SITE_OPTIMIZATION_FEATURE = Feature("site_optimization")
CDN_FEATURE = Feature('cdn')
CRITICAL_CSS_FEATURE = Feature("critical_css")
IMAGE_OPTIMIZATION_FEATURE = Feature('image_optimization')

ALL_OPTIMIZATION_FEATURES = [
    OBJECT_CACHE_FEATURE,
    SITE_OPTIMIZATION_FEATURE,
    CDN_FEATURE,
    CRITICAL_CSS_FEATURE,
    IMAGE_OPTIMIZATION_FEATURE
]

Zerion Mini Shell 1.0