%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /proc/227033/root/opt/alt/python37/lib/python3.7/site-packages/clwpos/
Upload File :
Create Path :
Current File : //proc/227033/root/opt/alt/python37/lib/python3.7/site-packages/clwpos/optimization_modules.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


# TODO: convert this file into python package
#       and move logic of modules manipulations here
import json
import os
import logging
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional, Dict, Tuple

import argparse

from clcommon.clwpos_lib import get_wp_cache_plugin

import cpanel
from clwpos.cl_wpos_exceptions import WposError
from clwpos.utils import uid_by_name, is_conflict_modules_installed, PHP
from clcommon.lib.cledition import is_cl_solo_edition
from clcommon.cpapi import cpusers
from clwpos.constants import CLWPOS_VAR_DIR, ALLOWED_MODULES_JSON, CLWPOS_UIDS_PATH, MINIMUM_SUPPORTED_PHP_OBJECT_CACHE, \
    CL_DOC_USER_PLUGIN
from clwpos import gettext as _, constants
from cpanel import wordpress, WordpressError

ALLOWED_MODULES_CONFIG_VERSION = 1


@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)


@dataclass
class CompatibilityIssue(Issue):
    """
    For compatibility issues
    """
    type: str = 'incompatibility'


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


class Module(str):
    """
    Helper class which hides differences of optimization modules behind abstract methods.
    """

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

        classes = {
            "object_cache": _ObjectCache,
            "site_optimization": _SiteOptimization
        }
        try:
            return classes[args[0]](*args)
        except KeyError:
            raise argparse.ArgumentTypeError(f"No such module: {args[0]}.")

    @classmethod
    def collect_docroot_issues(cls, wpos_user_obj, doc_root_info):
        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


class _ObjectCache(Module):
    """Implementation for object cache module"""

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

        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.')

        elif php_version not in cpanel.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 module, but not installed for '
                              'selected PHP version: %(php_version)s.')

        elif php_version not in cpanel.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 module, but not loaded for '
                              'selected PHP version: %(php_version)s.')

        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,
                                 compatible_versions=', '.join(supported_php_versions),
                                 docs_url=constants.CL_DOC_USER_PLUGIN)
                ).dict_repr
            )

        if 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/'
                    }
                ).dict_repr
            )

        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)
                ).dict_repr)

        return issues

    @classmethod
    def is_php_supported(cls, php_version: PHP):
        """
        Check if passed php version >= minimum PHP version
        supported by object cache module.
        """
        return php_version.digits >= 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 = []
        detected_object_cache_plugin = get_wp_cache_plugin(Path(docroot).joinpath(wordpress["path"]), "object-cache")

        if module_is_enabled:
            if detected_object_cache_plugin != "redis-cache":
                issue = self._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 cache module 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')
                    ).dict_repr
                )
            try:
                cpanel.diagnose_redis_connection_constants(docroot, wordpress['path'])
            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
                        )
                    ).dict_repr
                )
        if detected_object_cache_plugin == "Unknown":
            issues.append(
                CompatibilityIssue(
                    header=_('Conflicting object cache plugin enabled'),
                    description=_('Unknown custom object cache plugin is already enabled'),
                    fix_tip=_('Disable WordPress plugin that is being used for the object caching '
                              'using the WordPress settings.')
                ).dict_repr)
        elif detected_object_cache_plugin == "w3-total-cache":
            issues.append(
                CompatibilityIssue(
                    header=_('Object Cache module of W3 Total Cache plugin is incompatible'),
                    description=_('WordPress website already has Object Cache feature enabled '
                                  'with caching backend configured by the the W3 Total Cache plugin.'),
                    fix_tip=_('Deactivate Object Cache in W3 Total Cache plugin settings.'),
                    context=dict()
                ).dict_repr)
        elif detected_object_cache_plugin not in (None, "redis-cache"):
            issues.append(
                CompatibilityIssue(
                    header=_('Conflicting object cache plugin enabled'),
                    description=_('The "%(detected_wp_plugin)s" plugin conflicts with the '
                                  '"Redis Object Cache" that is required by optimization module.'),
                    fix_tip=_('Deactivate Object Cache in plugin settings or '
                              'completely turn off conflicting plugin using WordPress administration page'),
                    context=dict(detected_wp_plugin=detected_object_cache_plugin)
                ).dict_repr)
        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')
                ).dict_repr)

        try:
            multisite = cpanel.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.')
                    ).dict_repr)
        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
                    )
                ).dict_repr)
        return issues


class _SiteOptimization(Module):
    """Implementation for site optimization module"""

    @classmethod
    def collect_docroot_issues(cls, wpos_user_obj, doc_root_info):
        """
        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_MODULE]
            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,
                                 compatible_versions=', '.join(supported_php_versions),
                                 docs_url=CL_DOC_USER_PLUGIN)
                ).dict_repr
            )

        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 is_php_supported(cls, php_version: PHP):
        """
        Check if passed php version >= minimum PHP version
        supported by site optimization module.
        """
        return php_version.digits >= int(cls._requirements()["required_php_version"].replace(".", ""))

    @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):
        abs_wp_path = Path(docroot).joinpath(wordpress_info["path"])
        detected_advanced_cache_plugin = get_wp_cache_plugin(abs_wp_path, "advanced-cache")

        plugins_data = wordpress(str(abs_wp_path), "plugin", "list", "--status=active", "--format=json")

        if isinstance(plugins_data, WordpressError):
            found_plugins = set()
        else:
            found_plugins = {item["name"] for item in json.loads(plugins_data)}
        incompatible_plugins = set(cls._requirements()["incompatible_plugins"].keys())
        result = found_plugins & incompatible_plugins
        if detected_advanced_cache_plugin:
            result.add(detected_advanced_cache_plugin)

        # if our WP Rocket module is enabled it's not conflicting plugin
        if module_is_enabled:
            result.discard("WP Rocket")

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

        if result:
            return [
                CompatibilityIssue(
                    header=_("Conflicting plugins are enabled"),
                    description=_("Found conflicting plugins: %(plugins)s."),
                    fix_tip=_("Turn off conflicting plugin using WordPress administration page"),
                    context=dict(plugins=", ".join(result))
                ).dict_repr
            ]
        return []


OBJECT_CACHE_MODULE = Module("object_cache")
SITE_OPTIMIZATION_MODULE = Module("site_optimization")
ALL_OPTIMIZATION_MODULES = [
    OBJECT_CACHE_MODULE,
    SITE_OPTIMIZATION_MODULE
]

# on CloudLinux Solo we enable modules by default while on other
# editions we want it to be disabled by default
IS_MODULE_ALLOWED_BY_DEFAULT = bool(is_cl_solo_edition())


def get_admin_config_directory(uid: Optional[int]) -> str:
    """
    Get directory path in which admin's config files are stored.
    Hides logic of detecting current OS edition environment.
    :param uid: uid
    :return: admin's config directory path
    """
    is_solo = is_cl_solo_edition()
    if is_solo:
        admin_config_dir = os.path.join(CLWPOS_VAR_DIR, 'solo')
    else:
        if uid is None:
            raise WposError(
                message=_('Internal error: obtaining config path without uid is only '
                          'available for CloudLinux OS Solo. '
                          'Please contact support for help: '
                          'https://cloudlinux.zendesk.com'))
        admin_config_dir = os.path.join(CLWPOS_UIDS_PATH, str(uid))
    return admin_config_dir


def get_modules_allowed_path(uid: Optional[int]) -> str:
    """
    Get modules_allowed file path for user.
    :param uid: uid
    :return: modules_allowed file path
    """
    admin_config_dir = get_admin_config_directory(uid)
    modules_allowed_path = os.path.join(admin_config_dir, ALLOWED_MODULES_JSON)
    return modules_allowed_path


def get_allowed_modules(uid: int) -> list:
    """
    Reads configuration file (which is manipulated by admin)
    and returns only that modules which are allowed
    to be enabled by endusers.
    :param uid: uid (used only for CL Shared, not used on solo)
    @return: list of module unique ids
    """
    modules_admin_config = get_admin_modules_config(uid)
    return [
        module
        for module, is_allowed in modules_admin_config['modules'].items()
        if is_allowed
    ]


def is_module_allowed_for_user(module: str) -> bool:
    """
    Checks whether <module> enabled for at least one user
    """
    if is_cl_solo_edition(skip_jwt_check=True):
        data = get_admin_modules_config(uid=None)['modules']
        return data.get(module)
    else:
        users = list(cpusers())
        for username in users:
            uid = uid_by_name(username)
            if not uid:
                continue
            if get_admin_modules_config(uid)['modules'].get(module):
                return True
        return False


def any_module_allowed_on_server() -> bool:
    """
    Check if there are any optimization module allowed on server
    """
    return any(is_module_allowed_for_user(module) for module in ALL_OPTIMIZATION_MODULES)


def get_admin_modules_config(uid=None):
    """
    Reads modules statuses from .json.
    In case if config does not exist returns defaults.
    """
    defaults = {
        "version": str(ALLOWED_MODULES_CONFIG_VERSION),
        "modules": dict.fromkeys(ALL_OPTIMIZATION_MODULES, IS_MODULE_ALLOWED_BY_DEFAULT),
    }
    modules_json_path = get_modules_allowed_path(uid)
    if not os.path.exists(modules_json_path):
        return defaults

    # TODO: locking and tempfiles
    # https://cloudlinux.atlassian.net/browse/LU-2073
    try:
        # modules_allowed.json contents:
        # {
        #     "version": "1",
        #     "modules": {
        #         "object_cache": true,
        #         "site_optimization": true
        #     }
        # }
        with open(modules_json_path, "r") as f:
            modules_from_file = json.load(f)
        # update admin's config with modules that are not in it (values are taken from defaults)
        # case: new module was added in the lve-utils update and it is not in the config yet
        for module, status in defaults['modules'].items():
            modules_from_file['modules'].setdefault(module, status)

    except (json.JSONDecodeError, KeyError) as e:
        logging.warning('Config %s is malformed, using defaults instead, error: %s', modules_json_path, e)
        return defaults

    return modules_from_file


def write_modules_allowed(uid: int, gid: int, data_dict_to_write: dict):
    """
    Writes modules_allowed file for user
    :param uid: User uid
    :param gid: User gid
    :param data_dict_to_write: Data to write
    """
    modules_allowed_path = get_modules_allowed_path(uid)
    json_data = json.dumps(data_dict_to_write, indent=4)

    with open(modules_allowed_path, "w") as f:
        f.write(json_data)

    owner, group, mode = get_admin_config_permissions(gid)
    os.chown(modules_allowed_path, owner, group)
    os.chmod(modules_allowed_path, mode)


def get_admin_config_permissions(gid: int) -> Tuple[int, int, int]:
    """
    Return owner, group and permission which files inside
    admin's config directory should have.
    User should have rights to read (not write) config,
    so we set owner root, group depends on CL edition (see comment above)
    """
    if is_cl_solo_edition(skip_jwt_check=True):
        # root:root 644 - CL Solo
        owner, group, mode = 0, 0, 0o644
    else:
        # root:username 640 - CL Shared Pro
        owner, group, mode = 0, gid, 0o640
    return owner, group, mode

Zerion Mini Shell 1.0