Mini Shell

Direktori : /opt/boldgrid/
Upload File :
Current File : //opt/boldgrid/bg_api

#!/opt/imh-python/bin/python3
'''This is the BoldGrid-Installer script that
installs and activates Wordpress with BoldGrid'''
# Original written by Yuriy Shyyan (InMotion Hosting)
from datetime import datetime
import pwd
import json
import shlex
import sys
import os
import argparse
import shutil
import re
import platform
import random
import secrets
import string
import hashlib
import pprint
from subprocess import run, PIPE, DEVNULL, CalledProcessError, CompletedProcess
import time
from typing import Literal, NoReturn, Union, overload
from urllib.parse import urlparse
import requests
from requests.adapters import HTTPAdapter, Retry
import pymysql


SET_TEST = 0


def parse_call():
    """Argument Parser for each function"""
    # fmt: off
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(help="Function", dest='funct')
    parser_initialize = subparsers.add_parser(
        "initialize", help="Initialize User Session"
    )
    parser_initialize.add_argument(
        "--URL", required=True, help="Full Create User Session URL"
    )
    parser_check_doc_root = subparsers.add_parser(
        "check_doc_root", help="Document Root Check"
    )
    parser_check_doc_root.add_argument(
        "--cp_user", required=True, help="cPanel User"
    )
    parser_check_doc_root.add_argument(
        "--cp_domain", required=True, help="Domain Name"
    )
    parser_install = subparsers.add_parser(
        "install", help="Install Wordpress and BoldGrid"
    )
    parser_install.add_argument("--cpuser", required=True, help="cPanel User")
    parser_install.add_argument("--cpdomain", required=True, help="Domain Name")
    parser_install.add_argument(
        "--wp_uname", required=True, help="WordPress Admin Username",
    )
    parser_install.add_argument(
        "--wp_pass", required=True, help="WordPress Admin Password",
    )
    parser_install.add_argument(
        "--wp_email", required=True, help="WordPress Admin E-Mail",
    )
    parser_install.add_argument(
        "--bglicense", required=True, help="BoldGrid License",
    )
    parser_install.add_argument(
        "--temp_url",
        type=int, default=0, required=True,
        help="setting to 1 will overwrite the domain with TempURL",
    )
    parser_install.add_argument(
        "--wp_softdir",
        help="Directory in Document Root of Domain to install in, if any",
    )
    parser_list = subparsers.add_parser(
        "list", help="Returns a list of BG installations and their doc roots"
    )
    parser_list.add_argument("--bg_user", help="cPanel User", required=True)
    parser_list.add_argument("--staging", type=int, help="staging", default='1')
    parser_launch = subparsers.add_parser(
        "launch", help="Move old site, set staging BG install as new site"
    )
    parser_launch.add_argument(
        "--bg_directory",
        required=True,
        help="BG installation directory to set as site",
    )
    parser_launch.add_argument(
        "--domain", help="Domain to be moved", required=True
    )
    parser_launch.add_argument(
        "--ssl", type=int, default=0,
        help="setting to 1 will convert http to https",
    )
    parser_delete = subparsers.add_parser("delete", help="Removes installation")
    parser_delete.add_argument(
        "--bg_installation", required=True,
        help="Current absolute directory of installation",
    )
    args = parser.parse_args()
    # fmt: on
    return args


# -#-# SESSION MANAGEMENT #-#-#
def requests_retry_session(
    session=None,
    retries=3,
    backoff_factor=0.5,
    status_forcelist=(500, 502, 504),
):
    "Retry options for possible errors"
    retry = Retry(
        total=retries,
        read=retries,
        connect=retries,
        backoff_factor=backoff_factor,
        status_forcelist=status_forcelist,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount('http://', adapter)
    session.mount('https://', adapter)
    return session


def initialize_session(url: str):
    """Stores session coodie data as /home/user5/tmp/user5_bg_session for reuse.
    Saves session URL into a file in /home/user5/tmp"""
    user = get_user_from_url(url)
    check_valid_user(user)
    initialize_logging(user)
    cookie_file = get_cookie_name(user)
    sess = requests.Session()
    try:
        req = requests_retry_session(session=sess).get(url, timeout=4)
    except requests.exceptions.SSLError:
        res_out(2, 'Secure server SSL returned error', user)
    except requests.exceptions.ConnectionError as conerr:
        res_out(2, f'Error connecting to cPanel API: {conerr}', user)
    if req.status_code != 200:
        bg_log(user, req.content)
        res_out(
            8, f'Received non 200 response from server: {req.status_code}', user
        )
    try:
        cookie_data = requests.utils.dict_from_cookiejar(sess.cookies)
    except ValueError as val:
        bg_log(user, val)
        res_out(9, f'Unable to load session cookie data for {user}', user)
    try:
        with open(cookie_file, 'w', encoding='utf-8') as file:
            json.dump(cookie_data, file)
        sess_url = get_user_session_url(url, user)
        session_url_file = get_url_file_name(user)
        with open(session_url_file, 'w', encoding='utf-8') as file:
            json.dump(sess_url, file)
    except OSError as err:
        bg_log(user, err)
        res_out(
            12,
            f'Failed to save session file in /home/{user}/tmp: {err}',
            user,
        )
    try:
        os.chmod(cookie_file, 0o600)
        os.chmod(session_url_file, 0o600)
    except OSError as oserr:
        bg_log(user, oserr)
        res_out(
            13,
            f'Failed to set permissions on user files in /home/{user}/tmp',
            user,
        )
    print('Success')


def check_valid_user(user: str):
    """Checks given user across passwd and /home"""
    try:
        homedir = pwd.getpwnam(user)[5]
        user_id = pwd.getpwnam(user)[2]
    except KeyError:
        res_out(3, f'User {user} does not exist on server', user)
    if not os.path.lexists(homedir):
        res_out(6, f'User homedir: {homedir} does not exist', user)
    if not os.path.isdir(homedir):
        res_out(5, f'User homedir: {homedir} is not a directory', user)
    if user_id < 500:
        res_out(4, 'Specified user id below 500', user)


def get_user_from_url(url: str) -> str:
    """Parses username for session, returns username"""
    try:
        user = url.split('=')[1].split(':')[0].split('%')[0]
        return user
    except IndexError:
        res_out(2, f"Invalid url: {url}", user)


def get_cookie_name(user: str) -> str:
    """Returns the appropriate cookie location"""
    user_hash_name = define_userhash(user)
    cookie_name = f"/home/{user}/tmp/{user_hash_name}_bg_sess"
    return cookie_name


def get_url_file_name(user: str) -> str:
    """Returns the URL file for the cookie session"""
    user_hash_name = define_userhash(user)
    cookie_name = f"/home/{user}/tmp/{user_hash_name}_bg_url"
    return cookie_name


def get_soft_log_file_name(user: str) -> str:
    """Returns the name of the log file for user"""
    user_hash_name = define_userhash(user)
    soft_log_name = f"/home/{user}/tmp/{user_hash_name}_bg_log"
    return soft_log_name


def define_userhash(user: str) -> str:
    """Returns first 24 characters of the md5 hash of user name"""
    try:
        user_hash_name = hashlib.md5(user).hexdigest()[0:23]
    except IndexError:
        user_hash_name = user
    return user_hash_name


def load_session(user: str) -> requests.Session:
    """Returns the full session with the cookie loaded in"""
    check_valid_user(user)
    bg_s = requests.Session()
    cookie_file = get_cookie_name(user)
    try:
        with open(cookie_file, encoding='utf-8') as file:
            cookie = json.load(file)
    except OSError as exc:
        bg_log(user, exc)
        res_out(14, f'Unable to read session cookie file for {user}', user)
    try:
        bg_s.cookies = requests.utils.cookiejar_from_dict(cookie)
        return bg_s
    except ValueError as exc:
        bg_log(user, exc)
        res_out(15, f'Unable to load session cookie for {user}', user)


def get_url_from_file(user: str) -> str:
    "Retrieves individual user's stored session URL"
    session_url_file = get_url_file_name(user)
    try:
        with open(session_url_file, encoding='utf-8') as file:
            val = json.load(file)
    except OSError as exc:
        bg_log(user, exc)
        val = None
    else:
        if isinstance(val, str):
            return val
        bg_log(user, repr(val))
    res_out(16, f'Unable to read URL session file for {user}', user)


def get_user_session_url(url: str, user: str) -> str:
    """Parses out user session info from the URL"""
    try:
        ses = url.split('/')[0:4]
    except IndexError:
        res_out(10, f'Incomplete cp_session url: {url}', user)
    ses = '/'.join(ses)
    if "cpsess" not in ses:
        bg_log(user, ses)
        res_out(11, f'Invalid cp_session url: {url}', user)
    return ses


def delete_file(given_file: str) -> bool:
    """Deletes provided absolute path of file"""
    try:
        if os.path.lexists(given_file):
            if not os.path.isfile(given_file):
                return False
            os.remove(given_file)
    except OSError:
        return False
    return True


def delete_session_files(user: str) -> None:
    """Deletes session files - cookie and url"""
    url_file = get_url_file_name(user)
    cookie_file = get_cookie_name(user)
    if not delete_file(url_file) and not delete_file(cookie_file):
        bg_log(user, f"ER17: Failed to delete session files for {user}", True)


def touch_pid(action: str, user: str, state: Union[str, None] = None) -> None:
    """This makes sure we lock in our action"""
    pid_file = f"/home/{user}/tmp/{action}.pid"
    pid = str(os.getpid())
    with open(pid_file, 'w', encoding='utf-8') as pidf:
        if state:
            pidf.write(state)
        else:
            pidf.write(pid)
    os.chmod(pid_file, 0o600)


def remove_pid(action: str, user: str) -> None:
    pid_file = f"/home/{user}/tmp/{action}.pid"
    delete_file(pid_file)


def check_min(pid_file: str) -> bool:
    if not os.path.isfile(pid_file):
        return False
    use_by = time.time() - 3 * 60
    return os.path.getmtime(pid_file) > use_by


def check_pid(action: str, user: str) -> None:
    pid_file = f"/home/{user}/tmp/{action}.pid"
    if not check_min(pid_file):
        return
    with open(pid_file, encoding='utf-8') as file:
        content = file.read()
    if 'Success' in content:
        # We are going to assume that since the request for action is in the
        # last 5 minutes, it's a duplicate request, and wants to know current
        # last state.
        remove_pid(action, user)
        print("Success")
        sys.exit(0)
    res_out(99, f"{pid_file} for {action} already exists for {user}", user)


def initialize_logging(user: str) -> None:
    """Clears previous log if any"""
    user_soft_logfile = get_soft_log_file_name(user)
    date = datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
    with open(user_soft_logfile, 'a', encoding='utf-8') as file:
        file.write(f"{date}\n")
    os.chmod(user_soft_logfile, 0o600)


def get_user_theme(user: str) -> Union[str, None]:
    """gets theme using /var/cpanel/users/$user file"""
    path = f'/var/cpanel/users/{user}'
    try:
        with open(path, encoding='utf-8') as file:
            data = file.read()
    except OSError:
        data = ''
    if match := re.search("RS=([a-z_0-9]+)", data):
        return match.group(1)
    bg_log(user, f"ER99: Unable to determine theme from {path}")
    return None


# -#-# DOCUMENT ROOT CHECK #-#-#
def check_document_root(user: str, cp_domain: str) -> None:
    """Gets and checks the doc root of the domain for conflicting files"""
    initialize_logging(user)
    docroot_dirs = check_addon_domains(user)
    session_domain_docroot = doc_root_call(user, cp_domain)
    if session_domain_docroot is None:
        bg_log(user, cp_domain)
        res_out(27, 'Failed to retrieve document root of domain', user)
    install_pid_file = f"/home/{user}/tmp/install.pid"
    if check_min(install_pid_file):
        return None
    return file_check(cp_domain, session_domain_docroot, docroot_dirs, user)


def check_addon_domains(user: str) -> list[str]:
    """Pulls addon and sub domains and returns the list"""
    sub_params = {
        'cpanel_jsonapi_user': user,
        'cpanel_jsonapi_apiversion': 2,
        'cpanel_jsonapi_module': 'SubDomain',
        'cpanel_jsonapi_func': 'listsubdomains',
    }
    add_params = {
        'cpanel_jsonapi_user': user,
        'cpanel_jsonapi_apiversion': 2,
        'cpanel_jsonapi_module': 'AddonDomain',
        'cpanel_jsonapi_func': 'listaddondomains',
    }
    main_domain = get_main_domain(user)
    main_domain_docroot = doc_root_call(user, main_domain)
    cdr_sess = load_session(user)
    cdr_user_ses_url = get_url_from_file(user)
    url = f"{cdr_user_ses_url}/json-api/cpanel"
    try:
        res1_res = cdr_sess.get(url, params=sub_params).json()
        res2_res = cdr_sess.get(url, params=add_params).json()
    except (requests.RequestException, ValueError) as exc:
        bg_log(user, exc)
        res_out(
            20,
            "Unable to gather data from API call. "
            f"Reinitialize the session. {exc}",
            user,
        )
    unique = set()
    unique.update(get_list_of(res1_res))
    unique.update(get_list_of(res2_res))
    unique.add(main_domain_docroot)
    return list(unique)


def get_main_domain(user: str) -> str:
    """Gets the document root of main domain"""
    cdr_sess = load_session(user)
    cdr_user_ses_url = get_url_from_file(user)
    cp_params = {
        'cpanel_jsonapi_user': user,
        'cpanel_jsonapi_apiversion': 2,
        'cpanel_jsonapi_module': 'DomainLookup',
        'cpanel_jsonapi_func': 'getmaindomain',
    }
    url = f"{cdr_user_ses_url}/json-api/cpanel"
    try:
        api_res = cdr_sess.get(url, params=cp_params).json()
    except (requests.RequestException, ValueError) as exc:
        bg_log(user, exc)
        res_out(
            21,
            "Unable to gather data from API call. "
            f"Reinitialize the session. {exc}",
            user,
        )
    try:
        main_domain = api_res['cpanelresult']['data'][0]['main_domain']
    except (IndexError, KeyError, TypeError) as exc:
        bg_log(user, exc)
        res_out(23, 'Bad session data. Please re-initialize session.', user)
    if main_domain is None:
        bg_log(user, api_res)
        res_out(22, 'Failed to retrieve document root of main domain', user)
    return main_domain


def doc_root_call(user: str, cp_domain: str) -> str:
    """Gets the document root of a domain"""
    cdr_sess = load_session(user)
    cdr_user_ses_url = get_url_from_file(user)
    params = {
        'cpanel_jsonapi_apiversion': 2,
        'cpanel_jsonapi_module': 'DomainLookup',
        'cpanel_jsonapi_func': 'getdocroot',
        'domain': cp_domain,
    }
    dom_url = f"{cdr_user_ses_url}/execute/DomainInfo/list_domains"
    api_url = f"{cdr_user_ses_url}/json-api/cpanel"
    try:
        api_res = cdr_sess.get(api_url, params=params).json()
        dom_res = cdr_sess.get(dom_url).json()
    except (requests.RequestException, ValueError) as exc:
        bg_log(user, exc)
        res_out(
            26,
            "Unable to gather data from API call. "
            f"Reinitialize the session. {exc}",
            user,
        )
    found = False
    for key in ('addon_domains', 'sub_domains', 'parked_domains'):
        for dom in dom_res['data'][key]:
            if cp_domain == dom:
                found = True
                break
        if dom_res['data']['main_domain'] == cp_domain:
            found = True
    if not found:
        bg_log(user, dom_res)
        res_out(25, 'Domain not in user cPanel', user)
    try:
        session_domain_docroot = api_res['cpanelresult']['data'][0]['docroot']
        if session_domain_docroot is None:
            bg_log(user, api_res)
            res_out(27, 'Failed to retrieve document root of domain.', user)
        return session_domain_docroot
    except (IndexError, KeyError, TypeError) as exc:
        bg_log(user, exc)
        res_out(28, 'Bad session data. Re-initialize session.', user)


def get_list_of(result_dict: dict) -> list[str]:
    """Returns list of installations"""
    try:
        res_list = result_dict['cpanelresult']['data']
        return [x['dir'] for x in res_list]
    except (KeyError, TypeError):
        print(result_dict)
        res_out(24, 'Unable to parse domain docroot dict')
        return []


def file_check(
    session_domain: str,
    session_domain_docroot: str,
    docroot_dirs: list[str],
    user: str,
) -> Union[str, None]:
    """Main function of the document root check"""
    file_list = get_file_list(session_domain_docroot)
    if not file_list:
        return None
    for docroot_dir in docroot_dirs:
        if session_domain_docroot in docroot_dir:
            dirname = docroot_dir.split('/')[-1]
            if dirname in file_list:
                file_list.remove(dirname)
    if compare_files(file_list, user):
        return None
    return suggest_bg_dir(file_list, session_domain)


def get_file_list(directory: str) -> list[str]:
    """This returns a list of files in a directory, treat 0 files like the dir
    doesn't exist, we'll overwrite/create it anyway"""
    try:
        if not os.path.lexists(directory):
            return []
        if not os.path.isdir(directory):
            res_out(29, f'{directory} is not a directory')
        return os.listdir(directory)
    except OSError as exc:
        res_out(30, (f'Unable to list {directory} because of {exc}'))


def compare_files(file_list: list[str], user: str) -> bool:
    """compares listing of a directory to our default file names"""
    our_files = {
        '.ftpquota',
        '.well-known',
        'cgi-bin',
        'robots.txt',
        'favicon.ico',
        'phpinfo.php',
        'default.htm',
        'default.php',
        'under_construction.html',
        '.htaccess',
        '404.shtml',
        'logo-imh.svg',
    }
    for file in file_list:
        if file not in our_files:
            bg_log(user, f"Bad File: {file}")
            return False
    return True


def suggest_bg_dir(file_list: list[str], domain: str) -> str:
    'If the test fails, suggest bg_domain'
    bgdirname = f"bg_{domain}"
    if bgdirname not in file_list:
        return bgdirname
    num = 0
    bgdirname = f"bg{num}_{domain}"
    while bgdirname in file_list:
        num += 1
        bgdirname = f"bg{num}_{domain}"
    return bgdirname


# -#-# WORDPRESS INSTALLATION #-#-#
def install_wp(
    user: str,
    domain: str,
    wp_admin_username: str,
    wp_admin_password: str,
    wp_admin_email: str,
    bg_license: str,
    temp_url: Literal[0, 1],
    wp_softdir: Union[str, None] = None,
):
    "Makes an api call to softaculous to install WP and BG with provided info."
    initialize_logging(user)
    check_pid('install', user)
    cdr_sess = load_session(user)
    sess_url = get_url_from_file(user)
    touch_pid('install', user)
    user_theme = get_user_theme(user)
    soft_id = '10001'  # bgrid
    if not check_if_soft(user, soft_id):
        soft_id = '26'  # wp
        if not check_if_soft(user, soft_id):
            res_out(100, 'BGrid and WP both missing from Softaculous', user)
    url = f'{sess_url}/frontend/{user_theme}/softaculous/index.live.php'
    params = {'api': 'json', 'act': 'software', 'soft': soft_id}
    main_domain = get_main_domain(user)
    main_domain_docroot = doc_root_call(user, main_domain)
    if wp_softdir == 'None':
        wp_softdir = ''
    data = set_wp_install_info(
        domain,
        wp_admin_username,
        wp_admin_password,
        wp_admin_email,
        wp_softdir,
    )
    try:
        inst_result = cdr_sess.post(url, params=params, data=data).json()
    except ValueError:
        res_out(40, 'cPanel API returned no content. Re-initialize session.')
    if inst_result is None:
        res_out(41, 'cPanel API returned no content. Re-initialize session.')
    if 'error' in inst_result:
        if isinstance(inst_result["error"], dict):
            del data["dbuserpass"]
            del data["admin_pass"]
            bg_log(user, data)
            error = ''.join(list(inst_result['error'].values()))
        else:
            del data["dbuserpass"]
            del data["admin_pass"]
            bg_log(user, data)
            error = ''.join(inst_result["error"])
        res_out(43, error, user)
    try:
        dbname = inst_result["__settings"]["softdb"]
        dbpass = inst_result["__settings"]["softdbpass"]
        dbuser = inst_result["__settings"]["softdbuser"]
        wpdir = inst_result["__settings"]["softpath"]
        soft_url = inst_result["__settings"]["softurl"]
    except KeyError as kerr:
        bg_log(user, inst_result)
        bg_log(user, f"KeyError: {kerr}")
        res_out(44, 'Softaculous returned no results, check log', user)
    try:
        if temp_url == 1:
            insid = pull_insid(user, wpdir)
            host_domain = platform.node()
            new_url = "//" + host_domain + '/~' + user
            dom_trp = soft_url.split(':')[1]
            if main_domain_docroot not in wpdir:
                bg_log(user, main_domain_docroot)
                res_out(
                    51, 'Primary domain docroot not in installation path', user
                )
            if main_domain_docroot != wpdir:
                dir_add = wpdir.rsplit(main_domain_docroot)[1]
                new_url = new_url + dir_add
                full_new_url = "http://" + domain + dir_add
            else:
                full_new_url = "http://" + domain
            search_replace(user, wpdir, dom_trp, new_url)
            # This is the part where we ignore tempURL for softaculous
            # or it fails, while still updating wpdir
            update_softaculous(
                user, insid, wpdir, full_new_url, dbname, dbuser, dbpass
            )
    except Exception as exc:
        res_out(45, exc, user)
    bg_license = hashlib.md5(bytes(bg_license, 'ascii')).hexdigest()
    try:
        install_bg(dbuser, dbname, dbpass, wpdir, bg_license, soft_id)
    except Exception as exc:
        res_out(45, exc, user)
    curl_purge_if_any(user, domain, wp_softdir, wpdir)
    touch_pid('install', user, state='Success')
    if SET_TEST == 0:
        delete_session_files(user)
    print("Success")


def check_if_soft(user: str, soft_id: str) -> bool:
    try:
        sess = load_session(user)
        sess_url = get_url_from_file(user)
        user_theme = get_user_theme(user)
        url = f"{sess_url}/frontend/{user_theme}/softaculous/index.live.php"
        params = {"api": "json", "act": "home"}
        inst_result = sess.get(url, params=params).json()
        return str(soft_id) in json.dumps(inst_result)
    except ValueError:
        res_out(46, "Softaculous software query returned bad result", user)


def curl_purge_if_any(
    user: str, domain: str, bgdir: Union[str, None], directory: str
) -> None:
    """curls url/purge/dir if any"""
    try:
        if bgdir:
            url = f"http://{domain}/purge/{bgdir}/"
        else:
            url = f"http://{domain}/purge/"
        requests.get(url, headers={'Host': domain}, timeout=30.0)
    except requests.ConnectionError as req_exc:
        bg_log(user, f"Error: Does DNS exist for {domain}")
        bg_log(user, req_exc)
    except requests.RequestException as req_exc:
        bg_log(user, req_exc)
    wp_kwargs = {'cwd': directory, 'log_user': user}
    try:
        wp_cmd("transient", "delete-all", **wp_kwargs)
        wp_cmd("cron", "event", "run", "wp_version_check", **wp_kwargs)
    except (OSError, CalledProcessError) as exc:
        bg_log(user, exc)
        bg_log(
            user,
            f"Failed to delete transient and version check in {directory}. "
            "Run Manually",
        )


def set_wp_install_info(
    domain: str,
    wp_admin_username: str,
    wp_admin_password: str,
    wp_admin_email: str,
    wp_softdir: Union[str, None] = None,
) -> dict:
    'gets the actual wp installation info, returns dict'
    wp_softsubmit = '1'
    chars = string.digits + string.ascii_letters
    wp_softdb = "".join([random.choice(chars) for i in range(7)])
    wp_dbuserpass = "".join([secrets.choice(chars) for i in range(13)])
    wp_hostname = 'localhost'
    wp_language = 'en'
    wp_site_name = 'WordPress Site'
    wp_site_desc = 'My Blog'
    wp_overwrite = '1'
    wp_installation = {
        'softsubmit': wp_softsubmit,
        'softdomain': domain,
        'softdb': wp_softdb,
        'dbuserpass': wp_dbuserpass,
        'hostname': wp_hostname,
        'admin_username': wp_admin_username,
        'admin_pass': wp_admin_password,
        'admin_email': wp_admin_email,
        'language': wp_language,
        'site_name': wp_site_name,
        'site_desc': wp_site_desc,
        'overwrite_existing': wp_overwrite,
        'noemail': 1,
    }
    if wp_softdir is not None and wp_softdir != '':
        wp_installation['softdirectory'] = wp_softdir
    return wp_installation


def install_bg(
    uname: str,
    dbname: str,
    dbpw: str,
    inst_dir: str,
    bg_license: str,
    soft_id: str,
) -> None:
    user = inst_dir.split('/')[2]
    plugin_urls = [
        'https://repo.boldgrid.com/boldgrid-inspirations.zip',
        'https://downloads.wordpress.org/plugin/post-and-page-builder.zip',
        'https://repo.boldgrid.com/boldgrid-connect.zip',
    ]
    theme_url = 'https://repo.boldgrid.com/themes/boldgrid-gridone.zip'
    content_url = 'https://repo.boldgrid.com/assets/boldgrid-default.html'
    # os.chdir(inst_dir)
    timestamp = int(time.time())
    check_handler(inst_dir)
    if soft_id == '26':
        for url in plugin_urls:
            install_plugin(inst_dir, url)
    elif soft_id == '10001':
        for url in plugin_urls:
            if 'boldgrid-connect.zip' in url:
                install_plugin(inst_dir, url)
    db_creds = {
        'host': 'localhost',
        'user': uname,
        'password': dbpw,
        'database': dbname,
    }
    try:
        with pymysql.connect(**db_creds) as conn, conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO `wp_options`
                    (`option_id`, `option_name`, `option_value`, `autoload`)
                VALUES(NULL, 'boldgrid_api_key', %s, 'yes')
                """,
                (bg_license,),
            )
            conn.commit()
            cur.execute(
                """
                INSERT INTO `wp_options` (`option_id`, `option_name`,
                    `option_value`, `autoload`)
                VALUES (NULL, '_transient_timeout_boldgrid_valid_api_key',
                    %s, 'no')
                """,
                (timestamp,),
            )
            conn.commit()
    except pymysql.Error as err:
        bg_log(user, err)
        res_out(48, f'Failed to insert BG API data via MySQL: {err}', user)
    db_creds = {
        'host': 'localhost',
        'user': uname,
        'password': dbpw,
        'database': dbname,
    }
    wp_kwargs = {'cwd': inst_dir, 'log_user': user}
    try:
        wp_cmd("theme", "install", theme_url, "--activate", **wp_kwargs)
        sess = requests.Session()
        req = sess.get(content_url)
        wp_cmd(
            'post',
            'create',
            '--post_type=page',
            '--post_title=WEBSITE COMING SOON',
            '--post_status=publish',
            '--comment_status=closed',
            f'--post_content={req.content}',
            **wp_kwargs,
        )
        with pymysql.connect(**db_creds) as conn, conn.cursor() as cur:
            cur.execute(
                """
                SELECT ID FROM wp_posts
                WHERE post_title LIKE 'WEBSITE COMING SOON' LIMIT 1
                """
            )
            try:
                post_id = int(cur.fetchone()[0])
            except IndexError:
                res_out(48, 'Failed to get page id for default home page', user)
            cur.execute(
                """
                UPDATE wp_options SET option_value="MY SITE TITLE"
                WHERE option_name="blogname"
                """
            )
            cur.execute(
                """
                UPDATE wp_options SET option_value="My Tagline"
                WHERE option_name="blogdescription"
                """
            )
            conn.commit()
        wp_cmd('option', 'update', 'page_on_front', post_id, **wp_kwargs)
        wp_cmd('option', 'update', 'show_on_front', 'page', **wp_kwargs)
        with pymysql.connect(**db_creds) as conn, conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO `wp_postmeta` (`post_id`, `meta_key`, `meta_value`)
                VALUES(%s, 'boldgrid_hide_page_title', '0')
                """,
                (post_id,),
            )
            conn.commit()
    except CalledProcessError as exc:
        res_out(49, f'Failed to install/update the default theme: {exc}', user)
    except pymysql.Error as exc:
        res_out(48, f'Failed to insert BG API data via MySQL: {exc}', user)
    except OSError as exc:
        res_out(76, f'Failed to backup database prior to move: {exc}', user)


def install_plugin(wpdir: str, plugin_zip_url: str) -> None:
    """Downloads and installs, activates the plugin from the URL provided"""
    plugin_name = plugin_zip_url.split('/')[-1:][0]
    dest_zip = os.path.join(wpdir, plugin_name)
    user = wpdir.split('/')[2]
    try:
        with requests.get(plugin_zip_url, stream=True, timeout=30.0) as req:
            req.raise_for_status()
            with open(dest_zip, 'wb') as file:
                # Download and write in 8 KiB chunks (limiting mem usage)
                for chunk in req.iter_content(chunk_size=8192):
                    file.write(chunk)
    except requests.RequestException as exc:
        bg_log(
            user,
            f"ERROR 44: Unable to download {plugin_name} "
            f"plugin from {plugin_zip_url} due to {exc}. Continuing",
            True,
        )
    except OSError as exc:
        bg_log(user, exc)
        bg_log(user, f"ERROR 45: Failed to install plugin: {plugin_name}", True)
    wp_kwargs = {'cwd': wpdir, 'log_user': user}
    try:
        if os.path.isfile(dest_zip):
            wp_cmd("plugin", "install", "--activate", dest_zip, **wp_kwargs)
        else:
            bg_log(user, dest_zip)
            bg_log(
                user,
                f"ERROR 46: Unable to download {plugin_name} "
                f"plugin from {plugin_zip_url}. Continuing",
                True,
            )
    except (CalledProcessError, OSError) as exc:
        bg_log(
            user,
            f"ERROR 47: failed to install plugin {plugin_name}: {exc}",
            True,
        )


@overload
def pull_insid(
    user: str, bgdir: str, optional: Literal[True]
) -> Union[str, None]:
    ...


@overload
def pull_insid(user: str, bgdir: str, optional: Literal[False] = False) -> str:
    ...


def pull_insid(user: str, bgdir: str, optional: bool = False):
    """Queries softDB for WP db info"""
    installs = []
    wp_lists = check_installations(user)
    for wordpress, data in wp_lists.items():
        wordpress: str
        if wordpress.startswith('26_') or wordpress.startswith('10001_'):
            if data['softpath'] == bgdir:
                installs.append(data)
    if not installs:
        if optional:
            return None
        bg_log(user, wp_lists)
        res_out(
            50,
            'No installations with the directory provided found in SoftDB.',
            user,
        )
    elif len(installs) == 1:
        return installs[0]['insid']
    itime_count = []
    for install in installs:
        itime_count.append(install['itime'])
    itime_count.sort()
    intended_install_time = itime_count[-1]
    for install in installs:
        if install['itime'] == intended_install_time:
            intended_installation = install
    return intended_installation['insid']


def update_softaculous(
    user: str,
    insid: str,
    new_dir: str,
    new_url: str,
    db_name: str,
    db_user: str,
    db_pass: str,
):
    "This function updates the softaculous entry"
    sess = load_session(user)
    user_theme = get_user_theme(user)
    sess_url = get_url_from_file(user)
    url = f"{sess_url}/frontend/{user_theme}/softaculous/index.live.php"
    params = {'api': 'json', 'act': 'editdetail', 'insid': insid}
    payload = {
        'editins': '1',
        'edit_dir': new_dir,
        'edit_url': new_url,
        'edit_dbname': db_name,
        'edit_dbuser': db_user,
        'edit_dbpass': db_pass,
        'edit_dbhost': 'localhost',
        'noemail': 1,
    }
    data = sess.post(url, params=params, data=payload).json()
    for key in ('fatal_error_text', 'error'):
        if key in data:
            bg_log(user, payload)
            bg_log(user, data[key], True)


# -#-# LIST INSTALLATION #-#-#


def print_wp_list(user: str, staging: int = 1) -> None:
    """Prints out a list of WP domains and docroots"""
    wp_dict = {}
    for soft_id, data in check_installations(user).items():
        soft_id: str
        if not soft_id.startswith('26_') and not soft_id.startswith('10001_'):
            continue
        if staging == 0:
            wp_dict[get_site_url(user, data['softpath'])] = data['softpath']
            continue
        if re.match(r".*/bg\d*_", data['softpath']):
            # Forced bg_ staging
            wp_dict[get_site_url(user, data['softpath'])] = data['softpath']
        elif re.match(".*.temporary.link", data['softurl']):
            # IMH
            wp_dict[get_site_url(user, data['softpath'])] = data['softpath']
        elif re.match(".*.tempsite.link", data['softurl']):
            # HUB
            wp_dict[get_site_url(user, data['softpath'])] = data['softpath']
        elif re.match(f".*/~{user}", get_site_url(user, data['softpath'])):
            # TempURL
            wp_dict[get_site_url(user, data['softpath'])] = data['softpath']
    print(json.dumps(wp_dict, indent=4))


def get_site_url(user: str, install_dir: str) -> str:
    try:
        ret = wp_cmd(
            f"--path={install_dir}",
            'option',
            'list',
            '--search=siteurl',
            '--field=option_value',
            cwd=install_dir,
            log_user=user,
            encoding='utf-8',
        )
    except (OSError, CalledProcessError) as exc:
        bg_log(
            user, f"ERROR: Failed to run get site_url from {install_dir}: {exc}"
        )
        return None
    return ret.stdout.strip()


def check_installations(user: str) -> dict:
    "This runs softaculous api call to get a list of installations"
    soft_dict = {}
    cdr_sess = load_session(user)
    cdr_user_ses_url = get_url_from_file(user)
    user_theme = get_user_theme(user)
    params = {'api': 'json', 'act': 'installations'}
    url = f"{cdr_user_ses_url}/frontend/{user_theme}/softaculous/index.live.php"
    res = cdr_sess.get(url, params=params)
    if "Login Attempt Failed" in res.content:
        res_out(60, 'Failed to Login to API. Re-initialize session')
    inst_result = json.loads(res.content)
    if inst_result is None:
        res_out(61, 'cPanel API returned no content. Re-initialize session.')
    soft_lists = inst_result['installations']
    if 'error' in inst_result:
        error = inst_result["error"]
        if isinstance(error, dict):
            err = ''.join(list(error.values()))
        else:
            err = ''.join(error)
        res_out(63, err, user)
    for installation in soft_lists:
        for soft_id, install_info in soft_lists[installation].items():
            if soft_id.startswith('26_') or soft_id.startswith('10001_'):
                soft_dict[soft_id] = install_info
    return soft_dict


# -#-# ACTIVATION #-#-#
def ready_to_move(bgdir: str, domain: str, ssl: Literal[0, 1]) -> None:
    "This will move content one directory up"
    user = bgdir.split('/')[2]
    check_pid('launch', user)
    touch_pid('launch', user)
    initialize_logging(user)
    try:
        if bgdir.split('/')[1] != 'home':
            res_out(70, f'Not the server home path: {bgdir}', user)
    except IndexError:
        res_out(
            71,
            'The absolute path was not provided or does not '
            f'contain the "home" directory: {bgdir}',
            user,
        )
    if not os.path.exists(bgdir):
        res_out(72, f'{bgdir} does not exist', user)
    if not os.path.isdir(bgdir):
        res_out(73, f'{bgdir} is not a directory', user)
    if len(domain) < 3:
        res_out(74, f"Domain too short or empty: {domain}", user)
    full_path_filenames = []
    files_to_be_moved = []
    docroots = check_addon_domains(user)
    if bgdir in docroots:
        predir = bgdir
        move = 0
    else:
        predir = os.path.split(bgdir)[0]
        move = 1
    try:
        db_name, db_user, db_pass, db_url, insid = pull_db_data(user, bgdir)
        db_creds = {
            'host': 'localhost',
            'user': db_user,
            'password': db_pass,
            'database': db_name,
        }
        with pymysql.connect(**db_creds) as conn, conn.cursor() as cur:
            cur.execute(
                """
                SELECT option_value FROM wp_options
                WHERE option_name='siteurl' LIMIT 1
                """
            )
            db_url = str(cur.fetchone()[0])
        new_url = '/'.join(db_url.split('/')[:-1])
        match_url = db_url.split('//')[-1]
        match_domain = platform.node() + '/~' + user
        if match_domain in match_url:
            new_url = domain
        elif domain in match_url:
            new_url = domain
        else:
            new_url = '/'.join(db_url.split('/')[:-1])
        if new_url == db_url:
            bg_log(user, db_url)
            res_out(
                82,
                'The installation already appears to be for this domain.',
                user,
            )
        backup_db(user, db_user, db_pass, db_name, match_url, bgdir, predir)
    except Exception as exc:
        res_out(45, exc, user)
    if move == 1:
        files_in_predir = os.listdir(predir)
        old_dir_name = os.path.join(predir, suggest_olddir(files_in_predir))
        try:
            os.makedirs(old_dir_name)
        except OSError as err:
            bg_log(user, err)
            bg_log(user, 'ER74: Could not create old site directory.')
            res_out(74, 'Could not create old site directory.')
        for filename in files_in_predir:
            if not re.match(r'old_site.*', filename):
                file_name = os.path.join(predir, filename)
                full_path_filenames.append(file_name)
        for a_dc in docroots:
            if a_dc in full_path_filenames:
                full_path_filenames.remove(a_dc)
        if bgdir in full_path_filenames:
            full_path_filenames.remove(bgdir)
        for f_list in full_path_filenames:
            shutil.move(f_list, old_dir_name)
        for j_list in os.listdir(bgdir):
            file_name = os.path.join(bgdir, j_list)
            files_to_be_moved.append(file_name)
        for z_list in files_to_be_moved:
            shutil.move(z_list, predir)
        try:
            os.rmdir(bgdir)
        except OSError as exc:
            bg_log(user, exc)
            bg_log(user, f'ER79: Unable to delete {bgdir}', True)
        try:
            update_old_softaculous_entry(user, predir, old_dir_name)
        except Exception as exc:
            res_out(45, exc, user)
    try:
        search_replace(user, predir, bgdir, predir)
        search_replace(user, predir, match_url, new_url)
        update_softaculous(
            user, insid, predir, new_url, db_name, db_user, db_pass
        )
        update_htaccess(predir, user)
        if ssl == 1:
            convert_current_to_https(user, predir)
    except Exception as exc:
        res_out(45, exc, user)
    curl_purge_if_any(user, domain, None, predir)
    touch_pid('launch', user, state='Success')
    if SET_TEST == 0:
        delete_session_files(user)
    print("Success")


def update_old_softaculous_entry(user: str, predir: str, olddir: str) -> None:
    insid = pull_insid(user, predir, optional=True)
    if not insid:
        return
    db_name, db_user, db_pass, db_url, insid = pull_db_data(user, predir)
    old_db_url = db_url + "/" + str(olddir.split('/')[-1])
    backup_db(user, db_user, db_pass, db_name, db_url, predir, olddir)
    update_softaculous(
        user, insid, olddir, old_db_url, db_name, db_user, db_pass
    )


def suggest_olddir(file_list: list[str]) -> str:
    """Suggests the name of the old_site directory if already exists"""
    bgdirname = 'old_site'
    regdirname = 'old_site'
    if bgdirname not in file_list:
        return bgdirname
    num = 0
    bgdirname = f"{regdirname}_{num}"
    while bgdirname in file_list:
        num += 1
        bgdirname = f"{regdirname}_{num}"
    return bgdirname


def pull_db_data(user: str, bgdir: str) -> tuple[str, str, str, str, str]:
    """Queries softDB for WP db info"""
    installs = []
    wp_lists = check_installations(user)
    for soft_id, data in wp_lists.items():
        soft_id: str
        if soft_id.startswith('26_') or soft_id.startswith('10001_'):
            if data['softpath'] == bgdir:
                installs.append(data)
    if not installs:
        bg_log(user, wp_lists)
        res_out(
            75,
            'No installations with the directory provided found in SoftDB',
            user,
        )
    if len(installs) == 1:
        found = installs[0]
    else:
        itime_count = []
        for install in installs:
            itime_count.append(install['itime'])
        itime_count.sort()
        intended_install_time = itime_count[-1]
        for install in installs:
            if install['itime'] == intended_install_time:
                found = install
    db_name = found['softdb']
    db_user = found['softdbuser']
    db_pass = found['softdbpass']
    db_url = found['softurl']
    insid = found['insid']
    return db_name, db_user, db_pass, db_url, insid


def backup_db(
    user: str,
    dbuser: str,
    dbpass: str,
    dbname: str,
    old_url: str,
    bg_dir: str,
    dump_dir: str,
) -> None:
    """Backup the db"""
    date = datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
    user = bg_dir.split('/')[2]
    if old_url.startswith('http'):
        domain = urlparse(old_url)[1]
    elif old_url.startswith('//'):
        domain = urlparse(old_url)[1]
    else:
        domain = urlparse(old_url)[2].split('/')[0]
    path = os.path.join(dump_dir, f"{domain}-{dbname}-{date}.sql")
    try:
        mysqldump(dbuser, dbpass, dbname, path)
    except (CalledProcessError, OSError) as exc:
        res_out(76, f'Failed to backup database prior to move: {exc}', user)


def mysqldump(dbuser: str, dbpass: str, dbname: str, path: str) -> None:
    with open(path, 'wb') as file:
        run(
            ["/usr/bin/mysqldump", f"--user={dbuser}", dbname],
            env={'MYSQL_PWD': dbpass},
            stdout=file,
            encoding='utf-8',
            errors='ignore',
            stderr=PIPE,
            check=True,
        )


def convert_current_to_https(user: str, path: str) -> bool:
    "Will find current site URL and search-replace to https"
    current_site_url = get_site_url(user, path)
    ssl_site_url = re.sub(r'http', r'https', current_site_url)
    return search_replace(user, path, current_site_url, ssl_site_url)


def wp_cmd(
    *args: str,
    cwd: str,
    log_user: Union[str, None] = None,
    stdout=DEVNULL,
    stderr=DEVNULL,
    check: bool = True,
    **kwargs,
) -> CompletedProcess:
    """Runs the wp command"""
    if log_user:
        bg_log(log_user, f"Running /usr/local/bin/wp {shlex.join(args)}")
    return run(
        ["/usr/local/bin/wp", *args],
        stdout=stdout,
        stderr=stderr,
        cwd=cwd,
        check=check,
        **kwargs,
    )


def update_htaccess(predir: str, user: str) -> None:
    """Updates htaccess file after moving site"""
    wp_kwargs = {'log_user': user, 'cwd': predir}
    try:
        wp_cmd("rewrite", "flush", **wp_kwargs)
        wp_yml = os.path.join(predir, "wp-cli.yml")
        with open(wp_yml, 'w', encoding='utf-8') as file:
            file.write("apache_modules:\n  - mod_rewrite\n")
        wp_cmd("rewrite", "structure", "/%postname%/", "--hard", **wp_kwargs)
    except (OSError, CalledProcessError) as exc:
        bg_log(user, exc)
        bg_log(user, 'ER81 The .htaccess conversion to new location failed')
        res_out(81, 'The .htaccess conversion to new location failed')


def search_replace(user: str, basedir: str, old_url: str, new_url: str) -> bool:
    """Updates database to new URL after moving site"""
    try:
        wp_cmd(
            f"--path={basedir}",
            'search-replace',
            old_url,
            new_url,
            '--all-tables',
            log_user=user,
            cwd=basedir,
        )
    except (OSError, CalledProcessError) as exc:
        bg_log(user, f"ERROR: Failed to run wp-cli search-replace: {exc}")
        return False
    bg_log(user, f"Ran wp-cli search-replace from {old_url!r} --> {new_url!r}")
    return True


def check_handler(inst_dir: str) -> None:
    """Inserts proper add handler if php version =/< 5.2"""
    server_php = "AddHandler application/x-httpd-php56 .php"
    php_re = re.compile(r'^AddHandler\sapplication\/x-httpd-php5[2-4]\s\.php')
    htaccfile = os.path.join(inst_dir, '.htaccess')
    userhome_ht = os.path.join(
        os.path.join(os.path.sep, *inst_dir.split('/')[1:3]), '.htaccess'
    )
    if not os.path.isfile(userhome_ht):
        return
    try:
        with open(userhome_ht, encoding='utf-8') as file:
            data = file.read().splitlines()
        edit = False
        for line in data:
            if php_re.search(line):
                edit = True
                break
        if not edit:
            return
        if not os.path.exists(htaccfile):
            with open(htaccfile, 'a', encoding='utf-8'):
                os.utime(htaccfile, None)
        with open(htaccfile, 'r+', encoding='utf-8') as file:
            content = file.read()
            file.seek(0, 0)
            file.write(server_php.rstrip('\r\n') + '\n' + content)
    except OSError as exc:
        sys.exit(f".htaccess convert failed: {exc}")


def delete_installation(installdir: str) -> None:
    """This removes the provided installation if exists in softdb"""
    user = installdir.split('/')[2]
    check_pid('delete', user)
    touch_pid('delete', user)
    check_valid_user(user)
    if not os.path.isdir(installdir):
        res_out(85, f"{installdir} does not exist.", user)
    cdr_sess = load_session(user)
    sess_url = get_url_from_file(user)
    user_theme = get_user_theme(user)
    dbname, dbuser, dbpass, _, insid = pull_db_data(user, installdir)
    date = datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
    path = os.path.join(installdir, f"{insid}-{dbname}-{date}_predelete.sql")
    try:
        mysqldump(dbuser, dbpass, dbname, path)
    except (CalledProcessError, OSError) as exc:
        res_out(86, f'Failed to backup database prior to deletion: {exc}', user)
    url = f"{sess_url}/frontend/{user_theme}/softaculous/index.live.php"
    params = {'noemail': 1, 'api': 'json', 'act': 'remove', 'insid': insid}
    data = {
        'removeins': '1',
        'remove_db': '1',
        'remove_dbuser': '1',
        'noemail': '1',
    }
    inst_result = cdr_sess.post(url, params=params, data=data).json()
    dirname = installdir.split('/')[-1]
    if not 'public_html' in dirname:
        # We are not moving things manually out of public_html
        predir = os.path.split(installdir)[0]
        renamed_dir = f"{dirname}_deleted"
        num = 0
        file_list = get_file_list(predir)
        while renamed_dir in file_list:
            num = num + 1
            renamed_dir = f"{dirname}_deleted_{num}"
        os.rename(installdir, renamed_dir)
        if 'old_site' not in dirname:
            os.makedirs(installdir)
    if inst_result['done'] is not True:
        bg_log(user, inst_result)
        res_out(84, 'Unable to remove installation. Check logs', user)
    if SET_TEST == 0:
        delete_session_files(user)
    touch_pid('delete', user, state='Success')
    print("Success")


def res_out(code: int, msg, log_user: Union[str, None] = None) -> NoReturn:
    """Exits with given error numerical for documentation"""
    print(f"ERROR {code}: {msg}")
    if log_user:
        bg_log(log_user, f'ER{code}: {msg}')
    sys.exit(code)


def bg_log(user: str, log_content, show: bool = False):
    "logs/appends given info to to /home/user5/tmp"
    if show:
        print(log_content)
    user_soft_logfile = get_soft_log_file_name(user)
    try:
        serialized = json.dumps(log_content, indent=4)
    except (TypeError, ValueError):
        serialized = pprint.pformat(log_content)
    try:
        with open(user_soft_logfile, 'a', encoding='utf-8') as file:
            file.write(f"{serialized}\n")
    except OSError as exc:
        print(f"ERROR 90: Unable to log result. {exc}. Continuing.")


def main():
    "Main funct of BG_API"
    args = parse_call()
    if args.funct == "initialize":
        initialize_session(args.URL)
    elif args.funct == "check_doc_root":
        check_document_root(args.cp_user, args.cp_domain)
    elif args.funct == "install":
        install_wp(
            args.cpuser,
            args.cpdomain,
            args.wp_uname,
            args.wp_pass,
            args.wp_email,
            args.bglicense,
            args.temp_url,
            args.wp_softdir,
        )
    elif args.funct == "list":
        print_wp_list(args.bg_user, args.staging)
    elif args.funct == "launch":
        ready_to_move(args.bg_directory, args.domain, args.ssl)
    elif args.funct == "delete":
        delete_installation(args.bg_installation)
    else:
        res_out(1, "Bad Arguments")


if __name__ == '__main__':
    main()

Zerion Mini Shell 1.0