#coding:utf-8
# +-------------------------------------------------------------------
# | 宝塔Linux面板
# +-------------------------------------------------------------------
# | Copyright (c) 2015-2099 宝塔软件(http://bt.cn) All rights reserved.
# +-------------------------------------------------------------------
# | Author: hwliang <hwl@bt.cn>
# +-------------------------------------------------------------------

# +-------------------------------------------------------------------
# | PHP插件兼容模块
# +-------------------------------------------------------------------

import json,os,public,time,re,sys,psutil
if __name__ != "__main__":
    from BTPanel import request,abort,send_file,Resp,cache
import struct
FCGI_Header = '!BBHHBx'

setup_path = public.format_path(os.getenv("BT_PANEL"))
soft_path = public.format_path(os.getenv("BT_SETUP"))
import fastcgiClient as fcgi_client
import BTPanel
from io import BytesIO as StringIO

class panelPHP:
    re_io = None
    _puri = None
    php_port = None
    __route = None

    def __init__(self,plugin_name = None):
        if plugin_name:
            self.__plugin_name = plugin_name
            self.__plugin_path = "%s/plugin/%s" % (setup_path,plugin_name)
            self.__args_dir = self.__plugin_path + '/args'
            self.__args_tmp = self.__args_dir + '/' + public.GetRandomString(32)
            if not os.path.exists(self.__args_dir): os.makedirs(self.__args_dir, 384)
        
    #调用PHP插件
    def exec_php_script(self,args):
        #取PHP执行文件和CLI配置参数
        php_bin = self.__get_php_bin()
        if not php_bin: return public.returnMsg(False,'没有找到兼容的PHP版本，请先安装')
        #是否将参数写到文件
        self.__write_args(args)
        shell = "cd " + self.__plugin_path + " && %s %s/class/panel_php_run.php --args_tmp=\"%s\" --plugin_name=\"%s\" --fun=\"%s\"" % (php_bin,setup_path,self.__args_tmp,self.__plugin_name,args.s)
        result = os.popen(shell).read()
        print(shell)
        try:
            #解析执行结果
            result = json.loads(result)
        except: pass
        #删除参数文件
        if os.path.exists(self.__args_tmp): 
            os.remove(self.__args_tmp)
        return result
    
    #将参数写到文件
    def __write_args(self,args):
        if os.path.exists(self.__args_tmp): os.remove(self.__args_tmp)
        self.__clean_args_file()
        data = {}
        data['GET'] = request.args.to_dict()
        data['POST'] = request.form.to_dict()
        data['POST']['client_ip'] = public.GetClientIp()
        data = json.dumps(data)
        public.writeFile(self.__args_tmp,data)
    
    #清理参数文件
    def __clean_args_file(self):
        args_dir = self.__plugin_path + '/args'
        if not os.path.exists(args_dir): return False 
        now_time = time.time()
        for f_name in os.listdir(args_dir):
            filename = args_dir + '/' + f_name
            if not os.path.exists(filename): continue
            #清理创建时间超过60秒的参数文件
            if now_time - os.path.getctime(filename) > 60: os.remove(filename)
    
    #取PHP-CLI执行命令
    def __get_php_bin(self):
        #如果有指定兼容的PHP版本
        php_v_file = self.__plugin_path + '/php_version.json'
        if os.path.exists(php_v_file): 
             php_vs = json.loads(public.readFile(php_v_file).replace('.',''))
        else:
            #否则兼容所有版本
            php_vs = ["80","74","73","72","71","70","56","55","54","53","52"]
        #判段兼容的PHP版本是否安装
        php_path = soft_path + "/php/"
        php_v = None
        for pv in php_vs:
            php_bin = php_path + pv + "/php.exe"
            if os.path.exists(php_bin): 
                php_v = pv
                break;
        #如果没安装直接返回False
        if not php_v: return False
        #处理PHP-CLI-INI配置文件
        php_ini = self.__plugin_path + '/php_cli_'+php_v+'.ini'
        if not os.path.exists(php_ini):
            #如果不存在，则从PHP安装目录下复制一份
            src_php_ini = php_path + php_v + '/php.ini'
            import shutil
            shutil.copy(src_php_ini,php_ini)
            #解除所有禁用函数
            php_ini_body = public.readFile(php_ini)
            php_ini_body = re.sub("disable_functions\s*=.*","disable_functions = ",php_ini_body)
            public.writeFile(php_ini,php_ini_body)
        return php_path + php_v + '/php.exe -c ' + php_ini
            
    def get_local_php_version(self):
        php_vs = ["80","74","73","72","71","70","56","55","54","53","52"]

        php_path = "{}/php/".format(soft_path)
        php_list = []
        for pv in php_vs:
            php_bin = php_path + pv + "/php.exe"           
            if os.path.exists(php_bin): 
                 php_list.append(pv)                
        return php_list
    
    def get_php_version(self,php_version):
        if php_version:
            if not isinstance(php_version,list):
                php_vs = [php_version]
            else:
                php_vs = sorted(php_version,reverse=True)
        else:
            php_vs = ["80","74","73","72","71","70","56","55","54","53","52"]
        php_path = "{}/php/".format(soft_path)
        php_v = None
        for pv in php_vs:
            php_bin = php_path + pv + "/php.exe"
            if os.path.exists(php_bin): 
                php_v = pv
                break
        return php_v

    def get_phpmyadmin_phpversion(self):
        '''
            @name 获取当前phpmyadmin设置的PHP版本
            @author hwliang<2020-07-13>
            @return string
        '''
        try:
            ikey = 'pma_phpv'
            phpv = cache.get(ikey)
            if phpv: return phpv
            import panelSite
            get = public.dict_obj()
            get.siteName = 'phpmyadmin'        
            phpv  = panelSite.panelSite().GetSitePHPVersion(get)['phpversion']

            cache.set(ikey,phpv,3)
        except :
            return None
       
        return phpv

    def get_pma_root(self):
        '''
            @name 获取phpmyadmin根目录
            @author hwliang<2020-07-13>
            @return string
        '''
        pma_path = '{}/phpmyadmin/'.format(soft_path)
        if not os.path.exists(pma_path):
            os.makedirs(pma_path)
        for dname in os.listdir(pma_path):
            if dname.find('phpmyadmin_') != -1:
                return os.path.join(pma_path,dname)
        return None

    def check_phpmyadmin_phpversion(self):
        '''
            @name 检查当前phpmyadmin版本可用的php版本列表
            @author hwliang<2020-07-13>
            @return list
        '''
        pma_path = '{}/phpmyadmin'.format(soft_path)
        pma_version_f1 = '{}/version.pl'.format(pma_path)
        pma_root = '{}/pma'.format(pma_path)
        pma_version_f2 =  '{}/version.pl'.format(pma_root)
        v_sync = public.readFile(pma_version_f1) == public.readFile(pma_version_f2)

        if not os.path.exists(pma_root + '/index.php') or not v_sync:
            o_pma_root = self.get_pma_root()
            
            if o_pma_root:
                if not os.path.exists(pma_root): os.makedirs(pma_root)
                
                os.system("echo f|xcopy /s /c /e /y /r {} {}".format(public.to_path(o_pma_root),public.to_path(pma_root)))              
                public.writeFile(pma_version_f2,public.readFile(pma_version_f1))               
             
                index = public.readFile(pma_root + '/index.php')
                if index:
                    if index.find("use PhpMyAdmin\\Util") != -1:
                        resp = "use PhpMyAdmin\\Util;\nif(function_exists('opcache_invalidate')) opcache_invalidate('{}/phpmyadmin/pma/config.inc.php');".format(soft_path)
                        index = index.replace("use PhpMyAdmin\\Util;",resp)
                    elif index.find("use PMA\libraries\LanguageManager;") != -1:
                        resp = "use PMA\libraries\LanguageManager;\nif(function_exists('opcache_invalidate')) opcache_invalidate('{}/phpmyadmin/pma/config.inc.php');".format(soft_path)
                        index = index.replace("use PMA\libraries\LanguageManager;",resp)
                    elif index.find("require_once 'libraries/common.inc.php';") != -1:
                        resp = "if(function_exists('opcache_invalidate')) opcache_invalidate('{}/phpmyadmin/pma/config.inc.php');\nrequire_once 'libraries/common.inc.php';".format(soft_path)
                        index = index.replace("require_once 'libraries/common.inc.php';",resp)
                    
                    public.writeFile(pma_root + '/index.php',index)

                conf = public.readFile(pma_root + '/config.inc.php')
                conf = conf.replace("$cfg['Servers'][$i]['host'] = 'localhost';","")
                conf = conf.replace("$cfg['Servers'][$i]['port'] = '3306';","")
                public.writeFile(pma_root + '/config.inc.php',conf)
        
        if not os.path.exists(pma_version_f2):
            return False

        pma_version = public.readFile(pma_version_f2)
        self.pma_version = pma_version
        if pma_version:
            pma_version = pma_version[:3]

        if pma_version == '4.4':
            return ['53','54','55','56']
        elif pma_version == '4.0':
            return ['52','53']
        elif pma_version == '4.6':
            return None
        elif pma_version == '4.7':
            return ['55','56','70','71','72']
        elif pma_version in ['4.8','4.9','5.0']:
            return ['70','71','72','73','74']
        else:
            return ['55','56','70','71','72']

    def get_mysql_port(self):
        '''
            @name 获取mysql当前端口号
            @author hwliang<2020-07-13>
            @return int
        '''
        try:

            path = public.get_server_path('mysql')
            file = re.search('([MySQL|MariaDB-]+\d+\.\d+)',path).groups()[0] 
            myconf = public.readFile(public.GetConfigValue('setup_path') + '/mysql/' + file + '/my.ini');
            rep = "port\s*=\s*([0-9]+)"
            port = int(re.search(rep,myconf).groups()[0]);

            if not port: port = 3306
            return port
        except:
            return 3306

    def write_pma_passwd(self,username,password):
        '''
            @name 写入mysql帐号密码到配置文件
            @author hwliang<2020-07-13>
            @param username string(用户名)
            @param password string(密码)
            @return bool
        '''
        self.check_phpmyadmin_phpversion()
        pconfig = 'cookie'      
        if username:
            pconfig = 'config'
        pma_path = '{}/phpmyadmin/'.format(soft_path)
        pma_config_file = os.path.join(pma_path,'pma/config.inc.php')
        conf = public.readFile(pma_config_file)
        if not conf: return False
        conf = conf.replace('localhost','127.0.0.1')

        rep = r"/\* Authentication type \*/(.|\n)+/\* Server parameters \*/"
        rstr = '''/* Authentication type */
$cfg['Servers'][$i]['auth_type'] = '{}';
$cfg['Servers'][$i]['host'] = '127.0.0.1'; 
$cfg['Servers'][$i]['port'] = {};
$cfg['Servers'][$i]['user'] = '{}'; 
$cfg['Servers'][$i]['password'] = '{}'; 
/* Server parameters */'''.format(pconfig,self.get_mysql_port(),username,password)

        conf = re.sub(rep,rstr,conf)
        public.writeFile(pma_config_file,conf)
        return True


    def get_random_port(self):
        """
        随机生成端口
        """
        import random
        while True:
            port = random.randint(2000,65000);       
            if not port in [8080,80,3389,888,8888,11211,6379]:            
                if not port in range(3000,4000) or not port in range(39000,40000):
                    ret = public.ExecShell("netstat -aon | findstr {}".format(port))[0]
                    tmps = re.findall("(TCP|UDP)\s+(.+?):(\d+)\s+(.+?)\s+LISTENING\s+(\d+)",ret)
                    data = []
                    is_ret = False
                    for x in tmps:
                        if(int(x[2]) == port):
                            is_ret = True
                            
                    #为占用则直接退出
                    if not is_ret: break                
        return port

    def start_cgi(self,p_name): 
        """
        启动cgi进程
        """
        import subprocess
        
        ver_key = '{}_ver'.format(p_name)
        conf_path =  None
        if p_name == 'phpwebadmin':
            conf_path = '{}/hMailServer/php/php.ini'.format(os.getenv('BT_SETUP'))
            
        while True:               
            try:  
                php_version = cache.get(ver_key)                
                if os.path.exists(conf_path):                    
                    cmdstr = "{}/php/{}/php-cgi.exe -b 127.0.0.1:{} -c {}".format(soft_path,php_version,self.php_port,conf_path)                                            
                else:
                    cmdstr = "{}/php/{}/php-cgi.exe -b 127.0.0.1:{}".format(soft_path,php_version,self.php_port)                         
                print('cmdstr',cmdstr)
                sub = subprocess.Popen(cmdstr,shell = True, stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
                cache.set('{}_php_curr_version'.format(p_name),php_version,86400 * 365 * 10)
                cache.set('{}_php_pid'.format(p_name),sub.pid,86400 * 365 * 10)
                sub.wait()          
                
            except :pass
            time.sleep(1)

    def request_php(self,uri):
        '''
            @name 发起fastcgi请求到PHP-FPM
            @author hwliang<2020-07-11>
            @param puripuri string(URI地址)
            @return socket
        '''
        php_unix_socket = '127.0.0.1:{}'.format(self.php_port)        
        f = FPM(php_unix_socket,self.document_root,self.last_path)

        full_path = request.full_path.strip('?')
        if full_path.find('?') != -1:
            uri = request.full_path[request.full_path.find(uri):]

        if self.re_io:
            sock = f.load_url(uri,content = self.re_io)
        else:
            sock = f.load_url(uri,content=request.stream)
        return sock


    def get_php_pid(self):
        pid = 0
        ret = public.ExecShell('netstat -aon|findstr "{}"'.format(self.php_port))[0]    
        tmp = re.search('TCP.+?:{}.+\s+(\d+)'.format(self.php_port),ret)
        if tmp:
            pid = int(tmp.groups()[0])
        return pid


    def start(self,p_name,puri,document_root,last_path = ''):       
        '''
            @name 开始处理PHP请求
            @plugin_name 路由地址
            @author hwliang<2020-07-11>
            @param puri string(URI地址)
            @return socket or Response
        '''         
        if puri in ['/','',None]: puri = 'index.php'
        if puri[0] == '/': puri = puri[1:]
        self.document_root = document_root
        self.last_path = last_path
        filename = '{}/{}/{}'.format(document_root.strip('/'),p_name,puri)
        
        if not os.path.exists(filename): return abort(404)
       
        php_vers = self.get_local_php_version()  #获取所有php版本
                  
        if puri[-4:] == '.php':            
            ver_key = '{}_ver'.format(p_name)
            port_key = '{}_port'.format(p_name)

            self.php_version = cache.get(ver_key)
            if not self.php_version:

                if p_name == 'phpwebadmin':
                    v_list = ['54','53']
                    for x in v_list:
                        if x in php_vers:
                            self.php_version = x
                            break;
                if not self.php_version: return Resp('没有找到支持的PHP版本: {}'.format('、'.join(v_list)))                    
           
            if not BTPanel.start_php: BTPanel.start_php = {}
            if not p_name in BTPanel.start_php: BTPanel.start_php[p_name] = False

            try:
                if BTPanel.start_php[p_name]:
                    self.php_port = int(cache.get(port_key))

                    curr_php_ver = cache.get('{}_php_curr_version'.format(p_name))
                    if curr_php_ver and curr_php_ver != self.php_version:
                        php_pid = cache.get('{}_php_pid'.format(p_name))
                        os.system('taskkill /pid {} -t -f'.format(php_pid))
                        time.sleep(1)
                else:
                    BTPanel.start_php[p_name] = True
                    self.php_port  = self.get_random_port()
                    cache.set(port_key,self.php_port,86400 * 365 * 10)
                    cache.set(ver_key,self.php_version,86400 * 365 * 10)

                    import threading
                    job = threading.Thread(target=self.start_cgi,args=(p_name,))                    
                    job.start()
                    time.sleep(0.5)
            
                try:
                    php_pid = cache.get('{}_php_pid'.format(p_name))
                    pro = psutil.Process(php_pid)         
                except :
                    return Resp('指定PHP版本: {}，未启动，或无法连接!'.format(self.php_version))
            
                self._puri = puri
                return self.request_php(puri)
            except :
                self._puri = None
              
                return Resp(public.get_error_info())

        #如果是静态文件 
        return send_file(filename)

    def get_sock_data(self,sock,arrs = []):

        headers_data = b''
        total_len = 0
        header_len = 1024 * 32

        pack_arrs = [] #记录每包大小
        try:
            i = 0
            #正常流程
            while True:              
                if len(arrs) > i:
                    fast_pack = arrs[i]
                   
                    if fast_pack[1] == 3: break
                    fastcgi_header = sock.recv(8 + fast_pack[3] + fast_pack[4])                   
                    if len(fastcgi_header) > 8:                  
                        headers_data += fastcgi_header[8:len(fastcgi_header) - (8 + fast_pack[4])]                               
                else:
                    fastcgi_header = sock.recv(8)
                    if not fastcgi_header: break
                    if len(fastcgi_header) != 8:
                        headers_data += fastcgi_header
                        break
                    fast_pack = struct.unpack(FCGI_Header, fastcgi_header)
                    if fast_pack[1] == 3: break
                
                    tlen = fast_pack[3]
                    pack_arrs.append(fast_pack)  #记录每包的大小
  
                    while tlen > 0:
                        sd = sock.recv(tlen)
                        if not sd: break
                        headers_data += sd
                        tlen -= len(sd)      
                    total_len += fast_pack[3]
                    if fast_pack[4]:
                        sock.recv(fast_pack[4])
                    if total_len > header_len: break
                
                i += 1
        except :
            sock = self.request_php(self._puri)
            headers_data = self.get_sock_data(sock,pack_arrs)

        return headers_data


    #获取头部32KB数据
    def get_header_data(self,sock):
        '''
            @name 获取头部32KB数据
            @author hwliang<2020-07-11>
            @param sock socketobject(fastcgi套接字对象)
            @return bytes
        '''
        headers_data = self.get_sock_data(sock)   
       
        return headers_data
    
    #格式化响应头
    def format_header_data(self,headers_data):
        '''
            @name 格式化响应头
            @author hwliang<2020-07-11>
            @param headers_data bytes(fastcgi头部32KB数据)
            @return status int(响应状态), headers dict(响应头), bdata bytes(格式化响应头后的多余数据)
        '''
        status = '200 OK'
        headers = {}
        pos = 0
       
        while True:
            eolpos = headers_data.find(b'\n', pos)
            if eolpos < 0: break
            line = headers_data[pos:eolpos-1]
            pos = eolpos + 1
            line = line.strip()
            if len(line) < 2: break
           
            header, value = line.split(b':', 1)

            header = header.strip().decode('utf8')
            value = value.strip().decode('utf8')
            if header == 'Status':
                status = value
                if status.find(' ') < 0:
                    status += ' BTPanel'
            else:
                headers[header] = value
        bdata = headers_data[pos:]
        #headers['Transfer-Encoding'] = 'chunked'
        status = int(status.split(' ')[0])
        return status,headers,bdata

    #以流的方式发送剩余数据
    def resp_sock(self,sock,bdata):
        '''
            @name 以流的方式发送剩余数据
            @author hwliang<2020-07-11>
            @param sock socketobject(fastcgi套接字对象)
            @param bdata bytes(格式化响应头后的多余数据)
            @return yield bytes
        '''
        #发送除响应头以外的多余头部数据
        yield bdata
      
        while True:         
            try:
                 fastcgi_header = sock.recv(8)
            except : return
            
            if not fastcgi_header: break
            if len(fastcgi_header) != 8:
                yield fastcgi_header
                break
            fast_pack = struct.unpack(FCGI_Header, fastcgi_header)
            if fast_pack[1] == 3: break
            tlen = fast_pack[3]
            while tlen > 0:
                sd = sock.recv(tlen)
                if not sd: break
                tlen -= len(sd)
                if sd:
                    yield sd

            if fast_pack[4]:
                sock.recv(fast_pack[4])                     
        sock.close()

class FPM(object):
    def __init__(self,sock=None, document_root='',last_path = ''):
        '''
            @name 实例化FPM对象
            @author hwliang<2020-07-11>
            @param sock string(unixsocket路径)
            @param document_root string(PHP文档根目录)
            @return FPM
        '''
        if sock:
            self.fcgi_sock = sock
            if document_root[-1:] != '/':
                document_root += '/'
            self.document_root = document_root
            self.last_path = last_path

    def load_url(self, url, content=b''):
        '''
            @name 转发URL到PHP-FPM
            @author hwliang<2020-07-11>
            @param url string(URI地址)
            @param content stream(POST数据io对象)
            @return fastcgi-socket
        '''
        
        host,port = self.fcgi_sock.split(":",1)
        fcgi = fcgi_client.FCGIApp(host=host,port=int(port))
        try:
            script_name, query_string = url.split('?')
        except ValueError:
            script_name = url
            query_string = ''

        env = {
            'SCRIPT_FILENAME':'%s%s%s' % (self.document_root.strip('/'),self.last_path, script_name), 
            'PATH_TRANSLATED': '%s%s%s' % (self.document_root.strip('/'),self.last_path, script_name),
            'QUERY_STRING': query_string,
            'REQUEST_METHOD': request.method,
            'SCRIPT_NAME': self.last_path + script_name,
            'REQUEST_URI': self.last_path + url,
            'URL': self.last_path + url,
            'ORIG_PATH_INFO':self.last_path + url,
            'GATEWAY_INTERFACE': 'CGI/1.1',
            'SERVER_SOFTWARE': 'BT-Panel',
            'REDIRECT_STATUS': '200',
            'CONTENT_TYPE': request.headers.get('Content-Type','application/x-www-form-urlencoded'),
            'CONTENT_LENGTH': str(request.headers.get('Content-Length','0')),
            'DOCUMENT_URI':  request.path,
            'DOCUMENT_ROOT': self.document_root,
            'CONTEXT_DOCUMENT_ROOT':self.document_root,
            'SERVER_PROTOCOL' : 'HTTP/1.1',
            'REQUEST_SCHEME':'http',
            'REMOTE_ADDR': request.remote_addr.replace('::ffff:',''),
            'REMOTE_PORT': str(request.environ.get('REMOTE_PORT')),
            'SERVER_ADDR': request.headers.get('host') ,
            'SERVER_PORT': '80',
            'SERVER_NAME': 'BT-Panel',            
            'PHP_SELF':self.last_path + script_name,
        }
        for k in request.headers.keys():
            key = 'HTTP_' + k.replace('-','_').upper()
            if key in ['HTTP_AUTHORIZATION']: continue
            env[key] = request.headers[k]
        fpm_sock = fcgi(env, content)
        return fpm_sock

    def load_url_public(self,url,content = b'',method='GET',content_type='application/x-www-form-urlencoded'):
        '''
            @name 转发URL到PHP-FPM 公共
            @author hwliang<2020-07-11>
            @param url string(URI地址)
            @param content stream(POST数据io对象)
            @return fastcgi-socket
        '''
        host,port = self.fcgi_sock.split(":",1)
        fcgi = fcgi_client.FCGIApp(host=host,port=int(port))
        try:
            script_name, query_string = url.split('?')
        except ValueError:
            script_name = url
            query_string = ''

        content_length = len(content)
        if content:
            content = StringIO(content)

        env = {
            'SCRIPT_FILENAME': '%s%s' % (self.document_root, script_name),
            'QUERY_STRING': query_string,
            'REQUEST_METHOD': method,
            'SCRIPT_NAME': self.last_path + script_name,
            'REQUEST_URI': url,
            'GATEWAY_INTERFACE': 'CGI/1.1',
            'SERVER_SOFTWARE': 'BT-Panel',
            'REDIRECT_STATUS': '200',
            'CONTENT_TYPE': content_type,
            'CONTENT_LENGTH': str(content_length),
            'DOCUMENT_URI': script_name,
            'DOCUMENT_ROOT': self.document_root,
            'SERVER_PROTOCOL' : 'HTTP/1.1',
            'REMOTE_ADDR': '127.0.0.1',
            'REMOTE_PORT': '8888',
            'SERVER_ADDR': '127.0.0.1',
            'SERVER_PORT': '80',
            'SERVER_NAME': 'BT-Panel',
        }

        fpm_sock = fcgi(env, content)
        _data = b''
        while True:
            fastcgi_header = fpm_sock.recv(8)
            if not fastcgi_header: break
            if len(fastcgi_header) != 8:
                _data += fastcgi_header
                break
            fast_pack = struct.unpack(FCGI_Header, fastcgi_header)
            if fast_pack[1] == 3: break
            tlen = fast_pack[3]
            while tlen > 0:
                sd = fpm_sock.recv(tlen)
                if not sd: break
                tlen -= len(sd)
                _data += sd
            if fast_pack[4]:
                fpm_sock.recv(fast_pack[4])
        status,headers,data = panelPHP().format_header_data(_data)
        return Resp(data,status=status,headers=headers)
