0001# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
0002# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
0003#
0004import threading
0005import os
0006import socket
0007import logging
0008import atexit
0009import signal
0010import time
0011import posixpath
0012from paste import fileapp
0013from wphp import fcgi_app
0014
0015here = os.path.dirname(__file__)
0016default_php_ini = os.path.join(here, 'default-php.ini')
0017
0018class PHPApp(object):
0019
0020    def __init__(self, base_dir,
0021                 php_script='php-cgi',
0022                 php_ini=default_php_ini,
0023                 php_options=None,
0024                 fcgi_port=None,
0025                 search_fcgi_port_starting=10000,
0026                 logger='wphp'):
0027        """
0028        Create a WSGI wrapper around a PHP application.
0029
0030        `base_dir` is the root of the PHP application.  This
0031        contains .php files, and potentially other static files. (@@:
0032        Currently there is no way to indicate files that should not be
0033        served, like ``.inc`` files or certain directories)
0034
0035        `php_script` is the path to the ``php-cgi`` script you want
0036        to use.  By default it just looks on the ``$PATH`` for that
0037        file.
0038
0039        `php_ini` is the path to the ``php.ini`` file you want to use.
0040        An example (taken from the default PHP file) is in
0041        ``wphp/default-php.ini``.  This allows you to customize the
0042        language environment that the PHP files run in.
0043
0044        `php_options` is a dictionary of config-name: value, of
0045        specific overrides for PHP options.  For instance,
0046        ``{'magic_quotes_gpc': 'Off'}`` will turn off magic quotes.
0047
0048        PHP is started as a long-running FastCGI process.  PHP (from
0049        what I can tell) only supports listening over IP sockets, so
0050        we must get a port for it.  You may provide a specific port
0051        (with `fcgi_port`) or give a starting port number (default
0052        10000), and the first free port will be used.
0053        """
0054        self.base_dir = base_dir
0055        self.fcgi_port = fcgi_port
0056        self.php_script = php_script
0057        self.php_ini = php_ini
0058        if php_options is None:
0059            php_options = {}
0060        self.php_options = php_options
0061        self.search_fcgi_port_starting = search_fcgi_port_starting
0062        if isinstance(logger, basestring):
0063            logger = logging.getLogger(logger)
0064        self.logger = logger
0065
0066        self.lock = threading.Lock()
0067        self.child_pid = None
0068        self.fcgi_app = None
0069
0070    def __call__(self, environ, start_response):
0071        if self.child_pid is None:
0072            if environ['wsgi.multiprocess']:
0073                environ['wsgi.errors'].write(
0074                    "wphp doesn't support multiprocess apps very well yet")
0075            self.create_child()
0076        path_info = environ.get('PATH_INFO', '').lstrip('/')
0077        script_filename, path_info = self.find_script(self.base_dir, path_info)
0078        if script_filename is None:
0079            exc = httpexceptions.HTTPNotFound()
0080            return exc(environ, start_response)
0081        script_name = posixpath.join(environ.get('SCRIPT_NAME', ''), script_filename)
0082        script_filename = posixpath.join(self.base_dir, script_filename)
0083        environ['SCRIPT_NAME'] = script_name
0084        environ['SCRIPT_FILENAME'] = os.path.join(self.base_dir, script_filename)
0085        environ['PATH_INFO'] = path_info
0086        ext = posixpath.splitext(script_filename)[1]
0087        if ext != '.php':
0088            app = fileapp.FileApp(script_filename)
0089            return app(environ, start_response)
0090        if (environ['REQUEST_METHOD'] == 'POST'
0091            and not environ.get('CONTENT_TYPE')):
0092            environ['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
0093        return self.fcgi_app(environ, start_response)
0094
0095    def find_script(self, base, path):
0096        """
0097        Given a path, finds the file the path points to, and the extra
0098        portion of the path (PATH_INFO).
0099        """
0100        path_info = ''
0101        while 1:
0102            full_path = os.path.join(base, path)
0103            if not os.path.exists(full_path):
0104                if not path:
0105                    return None, None
0106                path_info = '/' + os.path.basename(path) + path_info
0107                path = os.path.dirname(path)
0108            else:
0109                return path, path_info
0110
0111    def create_child(self):
0112        """
0113        Creates the PHP subprocess, with some locking and whatnot, and
0114        creates the WSGI application wrapper around that.
0115        """
0116        self.lock.acquire()
0117        try:
0118            if self.child_pid:
0119                return
0120            if self.logger:
0121                self.logger.info('Spawning PHP process')
0122            if self.fcgi_port is None:
0123                self.fcgi_port = self.find_port()
0124            self.spawn_php(self.fcgi_port)
0125            self.fcgi_app = fcgi_app.FCGIApp(
0126                connect=('127.0.0.1', self.fcgi_port),
0127                filterEnviron=False)
0128        finally:
0129            self.lock.release()
0130
0131    def spawn_php(self, port):
0132        """
0133        Creates a PHP process that listens for FastCGI requests on the
0134        given port.
0135        """
0136        cmd = [self.php_script,
0137               '-b',
0138               '127.0.0.1:%s' % self.fcgi_port]
0139        if self.php_ini:
0140            cmd.extend([
0141                '-c', self.php_ini])
0142        for name, value in self.php_options.items():
0143            cmd.extend([
0144                '-d', '%s=%s' % (name, value)])
0145        pid = os.fork()
0146        if pid:
0147            self.child_pid = pid
0148            if self.logger:
0149                self.logger.info(
0150                    'PHP process spawned in PID %s, port %s'
0151                    % (pid, self.fcgi_port))
0152            atexit.register(self.close)
0153            # PHP doesn't start up *quite* right away, so we give it a
0154            # moment to be ready to accept connections
0155            time.sleep(0.1)
0156            # @@: It would be better to actually loop and check for
0157            # connection refused on the port.
0158            return
0159        os.execvpe(
0160            self.php_script,
0161            cmd,
0162            os.environ)
0163
0164    def find_port(self):
0165        """
0166        Finds a free port.
0167        """
0168        host = '127.0.0.1'
0169        port = self.search_fcgi_port_starting
0170        while 1:
0171            s = socket.socket(
0172                socket.AF_INET, socket.SOCK_STREAM)
0173            try:
0174                s.bind((host, port))
0175            except socket.error, e:
0176                port += 1
0177            else:
0178                s.close()
0179                return port
0180
0181    def close(self):
0182        """
0183        Kills the PHP subprocess.  Registered with atexit, so the
0184        subprocess is killed when this process dies.
0185        """
0186        # @@: Note, in a multiprocess setup this cannot
0187        # be handled this way
0188        if self.child_pid:
0189            if self.logger:
0190                self.logger.info(
0191                    "Killing PHP subprocess %s"
0192                    % self.child_pid)
0193            os.kill(self.child_pid, signal.SIGKILL)
0194
0195def make_app(global_conf, **kw):
0196    """
0197    Create a PHP application (with Paste Deploy).
0198    """
0199    if 'fcgi_port' in kw:
0200        kw['fcgi_port'] = int(kw['fcgi_port'])
0201    if 'search_fcgi_port_starting':
0202        kw['search_fcgi_port_starting'] = int(kw['search_fcgi_port_starting'])
0203    kw.setdefault('php_options', {})
0204    for name, value in kw.items():
0205        if name.startswith('option '):
0206            name = name[len('option '):].strip()
0207            kw['php_options'][name] = value
0208            del kw[name]
0209    if 'base_dir' not in kw:
0210        raise ValueError(
0211            "base_dir option is required")
0212    return PHPApp(**kw)