ok

Mini Shell

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

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

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

# configurations.py - configuration helpers for AccelerateWP feature suites
import datetime
import json
import logging
import pwd
import os
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Tuple, Dict, Union, Iterable, Any

from clcommon.clcagefs import _remount_cagefs
from clcommon.cpapi import cpusers
from clwpos import gettext as _
from clwpos.constants import (
    CLWPOS_VAR_DIR,
    CLWPOS_UIDS_PATH,
    ALLOWED_MODULES_JSON
)
from clwpos.utils import uid_by_name, acquire_lock, get_server_wide_options, ServerWideOptions, ExtendedJSONEncoder

from .suites import (
    ALL_SUITES,
    AWPSuite,
    PremiumSuite,
    CDNSuite,
    CDNSuitePro,
    OLD_NEW_SUITE_NAME_PAIRS
)

ALLOWED_SUITES_CONFIG_VERSION = 3
ALLOWED_SUITES_JSON = 'suites_allowed.json'


class FeatureStatusEnum(Enum):
    # means that user does not have
    # any custom option set
    DEFAULT = 'default'
    # means that user is specifically forbidden
    # to use accelerate wp
    DISABLED = 'disabled'
    # means that user can see the feature,
    # but cannot install the plugin
    VISIBLE = 'visible'
    # user can both see and use feature
    ALLOWED = 'allowed'


@dataclass
class AdminSuitesConfig:
    version: int
    suites: Dict[str, FeatureStatusEnum]
    sources: Dict[str, str]
    attributes: Dict[str, Any]
    unique_id: Optional[str]

    purchase_dates: Dict[str, datetime.date]

    def __post_init__(self):
        """
        Remove unknown suites from resulting structure.
        Actual for downgrade cases, see AWP-272 for details
        """
        for suite in set(self.suites).difference(set(ALL_SUITES)):
            self.suites.pop(suite)


class StatusSource(Enum):
    DEFAULT = _('default')
    COMMAND_LINE = _('manual')
    BILLING_OVERRIDE = _('billing')


@dataclass
class FeatureStatus:
    status: FeatureStatusEnum
    source: StatusSource = StatusSource.DEFAULT


def extract_features(
        admin_config: AdminSuitesConfig,
        server_wide_options: ServerWideOptions,
        allowed_state: FeatureStatusEnum = FeatureStatusEnum.DEFAULT
    ) -> Dict[str, FeatureStatus]:
    """
    Construct feature allowance dict based on given suites.

    We require both primary_features and feature_set
    iterables. Consider the following corner cases:
    feature_set iterable:
    1. All suites are disallowed
    2. We want to allow only accelerate_wp_cdn suite
    3. We aim to allow both cdn and site_optimization features,
       thus we need feature_set iterable

    primary_features iterable:
    1. All suites are allowed
    2. We want to disallow only accelerate_wp_cdn suite
    3. We aim to disallow only cdn feature, not site_optimization,
       thus the need for a primary_features iterable
    """
    def _feature_state(suite: str, feature: str, status_for_user: FeatureStatusEnum):
        if status_for_user == FeatureStatusEnum.DEFAULT:
            if feature in server_wide_options.allowed_features:
                return FeatureStatus(status=FeatureStatusEnum.ALLOWED, source=StatusSource.DEFAULT)
            if feature in server_wide_options.visible_features:
                return FeatureStatus(status=FeatureStatusEnum.VISIBLE, source=StatusSource.DEFAULT)
            else:
                return FeatureStatus(status=FeatureStatusEnum.DISABLED, source=StatusSource.DEFAULT)
        else:
            return FeatureStatus(status=status_for_user,
                                 source=admin_config.sources.get(
                                     suite, StatusSource.COMMAND_LINE))

    def _choose_feature_status(s1: FeatureStatus, s2: FeatureStatus) -> FeatureStatus:
        """
        Choose final feature status according to priority
        CDN is included to multiple suites, so need to decide is it allowed/visible/etc

        e.g if at least one suite with cdn feature is allowed -> feature is allowed
        """
        if s1 is None:
            return s2
        if s2 is None:
            return s1
        status_priority = [FeatureStatusEnum.ALLOWED, FeatureStatusEnum.VISIBLE, FeatureStatusEnum.DISABLED]
        return sorted([s1, s2], key=lambda item: status_priority.index(item.status))[0]

    features_state = {}

    for suite, status_for_user in admin_config.suites.items():
        is_disallowed_by_default = (
            ALL_SUITES[suite].primary_features not in server_wide_options.visible_features
        )

        if allowed_state == FeatureStatusEnum.DISABLED or \
           allowed_state == FeatureStatusEnum.DEFAULT and is_disallowed_by_default:
            iterable = 'primary_features'
        else:
            iterable = 'feature_set'

        for feature in getattr(ALL_SUITES[suite], iterable):
            features_state[feature] = _choose_feature_status(
                features_state.get(feature),
                _feature_state(suite, feature, status_for_user))

    return features_state


def extract_suites(
        admin_config: AdminSuitesConfig,
        server_wide_options: ServerWideOptions) -> Dict[str, FeatureStatus]:
    """
    Construct feature dict based on given suites
    """
    def _suite_state(suite: str, status_for_user: FeatureStatusEnum):
        if status_for_user == FeatureStatusEnum.DEFAULT:
            if suite in server_wide_options.allowed_suites:
                return FeatureStatus(status=FeatureStatusEnum.ALLOWED, source=StatusSource.DEFAULT)
            if suite in server_wide_options.visible_suites:
                return FeatureStatus(status=FeatureStatusEnum.VISIBLE, source=StatusSource.DEFAULT)
            else:
                return FeatureStatus(status=FeatureStatusEnum.DISABLED, source=StatusSource.DEFAULT)
        else:
            return FeatureStatus(status=status_for_user,
                                 source=admin_config.sources.get(
                                     suite, StatusSource.COMMAND_LINE))

    return {
        suite: _suite_state(suite, status_for_user=state)
        for suite, state in admin_config.suites.items()
    }


def get_admin_config_directory(uid: 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
    """
    admin_config_dir = os.path.join(CLWPOS_UIDS_PATH, str(uid))
    return admin_config_dir


def get_suites_allowed_path(uid: Optional[int], old=False) -> str:
    """
    Get suites_allowed file path for user.
    :param uid: uid
    :param old: if "old" allowed modules config needed
    :return: suites_allowed file path
    """
    admin_config_dir = get_admin_config_directory(uid)
    if not old:
        suites_allowed_path = os.path.join(admin_config_dir, ALLOWED_SUITES_JSON)
    else:
        suites_allowed_path = os.path.join(admin_config_dir, ALLOWED_MODULES_JSON)
    return suites_allowed_path


def get_allowed_suites(uid: int) -> list:
    """
    Reads configuration file (which is manipulated by admin)
    and returns only that suites 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
    """
    suites_admin_config = get_admin_suites_config(uid)
    return [
        suite
        for suite, status in suites_admin_config.suites.items()
        if status == FeatureStatusEnum.ALLOWED
    ]


def _is_suite_in_states_for_any_user(suite: str, states: Iterable[FeatureStatusEnum]):
    """
    Checks whether <suite> is in one of the passed states for any server user.
    """
    users = list(cpusers())
    for username in users:
        uid = uid_by_name(username)
        if not uid:
            continue
        if get_admin_suites_config(uid).suites.get(suite) in states:
            return True
    return False


def is_suite_allowed_for_user(suite: str) -> bool:
    """
    Checks whether <suite> enabled for at least one user
    """
    return _is_suite_in_states_for_any_user(suite, (FeatureStatusEnum.ALLOWED, ))


def is_suite_visible_for_user(suite: str) -> bool:
    """
    Checks whether <suite> visible for at least one user
    """

    return _is_suite_in_states_for_any_user(suite, (FeatureStatusEnum.ALLOWED, FeatureStatusEnum.VISIBLE))


def any_suite_allowed_on_server() -> bool:
    """
    Check if there are any feature suite allowed on server
    """
    return any(is_suite_allowed_for_user(suite) for suite in ALL_SUITES)


def any_suite_visible_on_server() -> bool:
    """
    Check if there are any feature suite allowed on server
    """
    return any(is_suite_visible_for_user(suite) for suite in ALL_SUITES)


def get_admin_suites_config(uid=None) -> AdminSuitesConfig:
    """
    Reads suites statuses from .json.
    In case if config does not exist returns defaults.
    """
    suites = {
        suite.name: (
            FeatureStatusEnum.ALLOWED
            if suite.is_allowed_by_default
            else FeatureStatusEnum.DEFAULT
        )
        for suite in ALL_SUITES.values()
    }

    defaults = AdminSuitesConfig(
        version=ALLOWED_SUITES_CONFIG_VERSION,
        suites=suites,
        sources={},
        purchase_dates={},
        attributes={},
        unique_id=None
    )
    suites_json_path = get_suites_allowed_path(uid)
    old_suited_json_path = get_suites_allowed_path(uid, old=True)
    if os.path.exists(suites_json_path):
        return read_json_with_allowed_suites(defaults, suites_json_path)
    elif os.path.exists(old_suited_json_path):
        return read_json_with_allowed_suites(defaults, old_suited_json_path, old_config=True)
    else:
        return defaults


def read_json_with_allowed_suites(
        defaults: AdminSuitesConfig,
        suites_json_path,
        old_config=False) -> AdminSuitesConfig:
    """
    Reads json with suites statuses
    for new awp version:
    {
        "version": "3",
        "suites": {
            "accelerate_wp": "allowed",
            "accelerate_wp_premium": "visible"
        },
        "sources": {
            "accelerate_wp": "ssh",
            "accelerate_wp_premium": "billing"
        }
    }
    for older awp version:
    {
        "version": "2",
        "suites": {
            "accelerate_wp": true,
            "accelerate_wp_premium": true
        }
    }
    for oldest awp version:
    {
        "version": "1",
        "modules": {
            "object_cache": true,
            "site_optimization": true
        }
    }
    """
    suites_key = 'suites' if not old_config else 'modules'
    # TODO: locking and tempfiles
    # https://cloudlinux.atlassian.net/browse/LU-2073
    try:
        with open(suites_json_path, "r") as f:
            suites_from_file = json.load(f)

        if old_config:
            suites = {
                'version': ALLOWED_SUITES_CONFIG_VERSION,
                'suites': {
                    OLD_NEW_SUITE_NAME_PAIRS[k]: FeatureStatusEnum.ALLOWED if v else FeatureStatusEnum.DISABLED
                    for k, v in suites_from_file[suites_key].items()
                },
                'sources': {},
                'purchase_dates': {},
                'attributes': {},
                'unique_id': None
            }
        # both versions can be in config based on the time when it was created first time
        elif int(suites_from_file['version']) == 2 or int(suites_from_file['version']) == 1:
            suites = {
                'version': ALLOWED_SUITES_CONFIG_VERSION,
                'suites': {
                    k: FeatureStatusEnum.ALLOWED if v else FeatureStatusEnum.DISABLED
                    for k, v in suites_from_file[suites_key].items()
                },
                'sources': {},
                'purchase_dates': {},
                'attributes': {},
                'unique_id': None
            }
        else:
            sources = {}
            # sources actually should always be here and it's more
            # fault-tolerance then real need to use get() instead of []
            for k, source in suites_from_file.get('sources', {}).items():
                try:
                    sources[k] = StatusSource(source)
                except ValueError:
                    logging.warning('Unknown source %s, skipping' % source)

            attributes = {}
            for suite, suite_config in ALL_SUITES.items():
                suite_attrs = suites_from_file.get('attributes', {}).get(suite, {})
                suite_valid_attrs = {}
                for attribute in suite_config.allowed_attrubites:
                    attr_value = suite_attrs.get(attribute.name)
                    suite_valid_attrs[attribute.name] = attribute.type(attr_value) \
                        if attr_value else attribute.default
                if suite_valid_attrs:
                    attributes[suite] = suite_valid_attrs

            suites = {
                'version': ALLOWED_SUITES_CONFIG_VERSION,
                'suites': {
                    k: FeatureStatusEnum(v)
                    for k, v in suites_from_file[suites_key].items()
                },
                'sources': sources,
                'purchase_dates': {
                    suite: datetime.datetime.strptime(date, '%Y-%m-%d').date()
                    for suite, date in suites_from_file.get('purchase_dates', {}).items()
                },
                'attributes': attributes,
                'unique_id': suites_from_file.get('unique_id')
            }
        # 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 suite, status in defaults.suites.items():
            suites['suites'].setdefault(suite, status)

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

    return AdminSuitesConfig(**suites)


def write_suites_allowed(uid: int, gid: int,
                         data_dict_to_write: Union[Dict, AdminSuitesConfig],
                         custom_allowed_path: str = None):
    """
    Writes modules_allowed file for user
    :param uid: User uid
    :param gid: User gid
    :param data_dict_to_write: Data to write
    :param custom_allowed_path: custom path of allowed config
    """
    modules_allowed_path = custom_allowed_path or get_suites_allowed_path(uid)
    json_data = json.dumps(data_dict_to_write, indent=4, cls=ExtendedJSONEncoder)

    try:
        os.makedirs(os.path.dirname(modules_allowed_path), 0o755, exist_ok=False)
    except OSError:
        pass
    else:
        # this won't happen a lot of time because in --allowed-for-all
        # loop we create path manually
        # the purpose of this is to handle situation when we create config when
        # we are trying to get unique_id and it still does not exists
        _remount_cagefs(pwd.getpwuid(uid).pw_name)

    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)
    """
    # usually os.getuid() will be root here
    # but for example in unit tests it could be mockbuild
    # and we should live with that
    # root:username 640 - CL Shared Pro
    owner, group, mode = os.getuid(), gid, 0o640
    return owner, group, mode


def _get_modules_by_status(uid: int, statues: Iterable[FeatureStatusEnum]):
    """
    Reads configuration file (which is manipulated by admin)
    and returns only that modules which are in any of given status
    :param uid: uid (used only for CL Shared, not used on solo)
    @return: list of module unique ids
    """
    default_config = get_server_wide_options()
    suites_admin_config = get_admin_suites_config(uid)

    return [
        feature
        for feature, feature_status in extract_features(suites_admin_config, default_config).items()
        if feature_status.status in statues
    ]


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
    """
    return _get_modules_by_status(uid, (FeatureStatusEnum.ALLOWED, ))


def get_visible_modules(uid: int) -> list:
    """
    Reads configuration file (which is manipulated by admin)
    and returns only that modules which are visible by endusers.
    :param uid: uid (used only for CL Shared, not used on solo)
    @return: list of module unique ids
    """
    return _get_modules_by_status(uid, (FeatureStatusEnum.ALLOWED, FeatureStatusEnum.VISIBLE))


def _get_features_dict_in_statuses(uid: int, statuses: Iterable[FeatureStatusEnum]):
    """
    Dict with features per feature-set which are in <statuses>
    """
    allowed_features = _get_modules_by_status(uid, statuses)
    allowed_suites = get_allowed_suites(uid)
    free, premium, cdn_pro = [], [], []
    for feature in allowed_features:
        if feature in AWPSuite.feature_set or feature in CDNSuite.feature_set:
            free.append(feature)
        elif feature in PremiumSuite.feature_set:
            premium.append(feature)
        if feature in CDNSuitePro.primary_features and CDNSuitePro.name in allowed_suites:
            cdn_pro.append(feature)
    return {
        'accelerate_wp': free,
        'accelerate_wp_premium': premium,
        'accelerate_wp_cdn_pro': cdn_pro,
    }


def get_allowed_features_dict(uid: int):
    """
    Dict with allowed features per feature-set
    """
    return _get_features_dict_in_statuses(uid, (FeatureStatusEnum.ALLOWED, ))


def get_visible_features_dict(uid: int):
    """
    Dict with visible features per feature-set
    """
    return _get_features_dict_in_statuses(uid, (FeatureStatusEnum.ALLOWED, FeatureStatusEnum.VISIBLE))


def _is_module_in_state_for_user(module: str, statuses: Iterable[FeatureStatusEnum]) -> bool:
    """
    Checks whether <module> is in any of <statuses> for at least one user
    """
    server_config = get_server_wide_options()
    users = list(cpusers())
    for username in users:
        uid = uid_by_name(username)
        if not uid:
            continue
        if extract_features(get_admin_suites_config(uid),
                            server_wide_options=server_config).get(module).status in statuses:
            return True
    return False


def is_module_allowed_for_user(module: str) -> bool:
    """
    Checks whether <module> enabled for at least one user
    """
    return _is_module_in_state_for_user(module, (FeatureStatusEnum.ALLOWED, ))


def is_module_visible_for_user(module: str) -> bool:
    """
    Checks whether <module> enabled for at least one user
    """
    return _is_module_in_state_for_user(module, (FeatureStatusEnum.ALLOWED, FeatureStatusEnum.VISIBLE))


def _sync_allowed_configs(username):
    """
    Syncing allowed configs (needed for downgrade)
    """
    uid = pwd.getpwnam(username).pw_uid
    gid = pwd.getpwnam(username).pw_gid
    suites_json_path = get_suites_allowed_path(uid)

    # means there is no custom settings in config
    if not os.path.exists(suites_json_path):
        return

    suites_admin_config = get_admin_suites_config(uid).suites
    config_to_sync = get_suites_allowed_path(uid, old=True)

    modules_states = {
        'version': 1,
        'modules': {
            'object_cache': suites_admin_config['accelerate_wp_premium'] == FeatureStatusEnum.ALLOWED,
            'site_optimization': suites_admin_config['accelerate_wp'] == FeatureStatusEnum.ALLOWED
        }
    }
    with acquire_lock(config_to_sync):
        write_suites_allowed(uid, gid, modules_states, custom_allowed_path=config_to_sync)


def sync_allowed_configs():
    users = list(cpusers())
    for user in users:
        try:
            _sync_allowed_configs(user)
        except Exception:
            logging.exception('Error while syncing the allowed configs for user %s', user)
            continue

Zerion Mini Shell 1.0