webserver.php
author Dan
Fri, 25 Apr 2008 12:42:26 -0400
changeset 18 69af47034212
parent 17 66c3eb4cdc4c
child 21 74edc873234f
permissions -rw-r--r--
Added page-titling functionality that changes document.title to match the current track; made position slider reset to zero on stop or playlist end

<?php

/**
 * Webserver class
 * 
 * Greyhound - real web management for Amarok
 * Copyright (C) 2008 Dan Fuhry
 *
 * This program is Free Software; you can redistribute and/or modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for details.
 */

/**
 * Version of the server
 * @const string
 */

define('HTTPD_VERSION', '0.1b1');

/**
 * Simple web server written in PHP.
 * @package Amarok
 * @subpackage WebControl
 * @author Dan Fuhry
 * @license Public domain
 */

class WebServer
{
  
  /**
   * IP address we're bound to
   * @var string
   */
  
  var $bind_address = '127.0.0.1';
  
  /**
   * Socket resource
   * @var resource
   */
  
  var $sock = null;
  
  /**
   * Server string
   * @var string
   */
  
  var $server_string = 'PhpHttpd';
  
  /**
   * Default document (well default handler)
   * @var string
   */
  
  var $default_document = false;
  
  /**
   * HTTP response code set by the handler function
   * @var int
   */
  
  var $response_code = 0;
  
  /**
   * Content type set by the current handler function
   * @var string
   */
  
  var $content_type = '';
  
  /**
   * Response headers to send back to the client
   * @var array
   */
  
  var $response_headers = array();
  
  /**
   * List of handlers
   * @var array
   */
   
  var $handlers = array();
  
  /**
   * Switch to control if directory listing is enabled
   * @var bool
   */
  
  var $allow_dir_list = false;
  
  /**
   * Switch to control forking support.
   * @var bool
   */
  
  var $allow_fork = true;
  
  /**
   * Keep-alive support uses this to track what the client requested.
   * Only used if $allow_fork is set to true.
   * @var bool
   */
  
  var $in_keepalive = false;
  
  /**
   * Constructor.
   * @param string IPv4 address to bind to
   * @param int Port number
   */
  
  function __construct($address = '127.0.0.1', $port = 8080)
  {
    @set_time_limit(0);
    @ini_set('memory_limit', '256M');
    
    // do we have socket functions?
    if ( !function_exists('socket_create') )
    {
      burnout('System does not support socket functions. Please rebuild your PHP or install an appropriate extension.');
    }
    
    $this->sock = socket_create(AF_INET, SOCK_STREAM, getprotobyname('tcp'));
    if ( !$this->sock )
      throw new Exception('Could not create socket');
    $result = socket_bind($this->sock, $address, $port);
    if ( !$result )
      throw new Exception("Could not bind to $address:$port");
    $result = socket_listen($this->sock, SOMAXCONN);
    if ( !$result )
      throw new Exception("Could not listen for connections $address:$port");
    $this->bind_address = $address;
    $this->server_string = "PhpHttpd/" . HTTPD_VERSION . " PHP/" . PHP_VERSION . "\r\n";
  }
  
  /**
   * Destructor.
   */
  
  function __destruct()
  {
    if ( !defined('HTTPD_WS_CHILD') )
    {
      status('WebServer: destroying socket');
      @socket_shutdown($this->sock, 2);
      @socket_close($this->sock);
    }
  }
  
  /**
   * Main server loop
   */
  
  function serve()
  {
    while ( true )
    {
      // if this is a child process, we're finished - close up shop
      if ( defined('HTTPD_WS_CHILD') && !$this->in_keepalive )
      {
        status('Exiting child process');
        @socket_shutdown($remote);
        @socket_close($remote);
        exit(0);
      }
      
      // wait for connection...
      // trick from http://us.php.net/manual/en/function.socket-accept.php
      if ( !defined('HTTPD_WS_CHILD') )
      {
        $remote = false;
        $timeout = 5;
        switch(@socket_select($r = array($this->sock), $w = array($this->sock), $e = array($this->sock), $timeout)) {
          case 2:
            break;
          case 1:
            $remote = @socket_accept($this->sock);
            break;
          case 0:
            break;
        }
      }
         
      if ( !$remote )
      {
        $this->in_keepalive = false;
        continue;
      }
      
      // fork off if possible
      if ( function_exists('pcntl_fork') && $this->allow_fork && !$this->in_keepalive )
      {
        $pid = pcntl_fork();
        if ( $pid == -1 )
        {
          // do nothing; continue responding to request in single-threaded mode
        }
        else if ( $pid )
        {
          // we are the parent, continue listening
          socket_close($remote);
          continue;
        }
        else
        {
          // this is the child
          define('HTTPD_WS_CHILD', 1);
          socket_close($this->sock);
        }
      }
      
      $this->in_keepalive = false;
      
      // read request
      $last_line = '';
      $client_headers = '';
      while ( $line = @socket_read($remote, 1024, PHP_NORMAL_READ) )
      {
        $line = str_replace("\r", "", $line);
        if ( empty($line) )
          continue;
        if ( $line == "\n" && $last_line == "\n" )
          break;
        $client_headers .= $line;
        $last_line = $line;
      }
      
      // parse request
      $client_headers = trim($client_headers);
      $client_headers = explode("\n", $client_headers);
      
      // first line
      $request = $client_headers[0];
      if ( !preg_match('/^(GET|POST) \/([^ ]*) HTTP\/1\.[01]$/', $request, $match) )
      {
        $this->send_http_error($remote, 400, 'Your client issued a malformed or illegal request.');
        continue;
      }
      $method =& $match[1];
      $uri =& $match[2];
      
      // set client headers
      unset($client_headers[0]);
      foreach ( $client_headers as $line )
      {
        if ( !preg_match('/^([A-z0-9-]+): (.+)$/is', $line, $match) )
          continue;
        $key = 'HTTP_' . strtoupper(str_replace('-', '_', $match[1]));
        $_SERVER[$key] = $match[2];
      }
      
      // enable keep-alive if requested
      if ( isset($_SERVER['HTTP_CONNECTION']) && defined('HTTPD_WS_CHILD') )
      {
        $this->in_keepalive = ( strtolower($_SERVER['HTTP_CONNECTION']) === 'keep-alive' );
      }
      
      if ( isset($_SERVER['HTTP_AUTHORIZATION']) )
      {
        $data = $_SERVER['HTTP_AUTHORIZATION'];
        $data = substr(strstr($data, ' '), 1);
        $data = base64_decode($data);
        $_SERVER['PHP_AUTH_USER'] = substr($data, 0, strpos($data, ':'));
        $_SERVER['PHP_AUTH_PW'] = substr(strstr($data, ':'), 1);
      }
      
      $postdata = '';
      $_POST = array();
      if ( $method == 'POST' )
      {
        // read POST data
        if ( isset($_SERVER['HTTP_CONTENT_LENGTH']) )
        {
          $postdata = socket_read($remote, intval($_SERVER['HTTP_CONTENT_LENGTH']), PHP_BINARY_READ);
        }
        else
        {
          $postdata = socket_read($remote, 8388608, PHP_NORMAL_READ);
        }
        if ( preg_match_all('/(^|&)([a-z0-9_\.\[\]-]+)(=[^ &]+)?/', $postdata, $matches) )
        {
          if ( isset($matches[1]) )
          {
            foreach ( $matches[0] as $i => $_ )
            {
              $_POST[$matches[2][$i]] = ( !empty($matches[3][$i]) ) ? urldecode(substr($matches[3][$i], 1)) : true;
            }
          }
        }
      }
      
      // parse URI
      $params = '';
      if ( strstr($uri, '?') )
      {
        $params = substr(strstr($uri, '?'), 1);
        $uri    = substr($uri, 0, strpos($uri, '?'));
      }
      
      $_SERVER['REQUEST_URI'] = '/' . rawurldecode($uri);
      $_SERVER['REQUEST_METHOD'] = $method;
      socket_getpeername($remote, $_SERVER['REMOTE_ADDR'], $_SERVER['REMOTE_PORT']);
      
      $_GET = array();
      if ( preg_match_all('/(^|&)([a-z0-9_\.\[\]-]+)(=[^ &]+)?/', $params, $matches) )
      {
        if ( isset($matches[1]) )
        {
          foreach ( $matches[0] as $i => $_ )
          {
            $_GET[$matches[2][$i]] = ( !empty($matches[3][$i]) ) ? urldecode(substr($matches[3][$i], 1)) : true;
          }
        }
      }
      
      if ( $uri == '' )
      {
        $uri = strval($this->default_document);
      }
      
      $uri_parts = explode('/', $uri);
      
      // loop through URI parts, see if a handler is set
      $handler = false;
      for ( $i = count($uri_parts) - 1; $i >= 0; $i-- )
      {
        $handler_test = implode('/', $uri_parts);
        if ( isset($this->handlers[$handler_test]) )
        {
          $handler = $this->handlers[$handler_test];
          $handler['id'] = $handler_test;
          break;
        }
        unset($uri_parts[$i]);
      }
      
      if ( !$handler )
      {
        $this->send_http_error($remote, 404, "The requested URL /$uri was not found on this server.");
        continue;
      }
      
      $this->send_standard_response($remote, $handler, $uri, $params);
      
      if ( !$this->in_keepalive && defined('HTTPD_WS_CHILD') )
      {
        // if ( defined('HTTPD_WS_CHILD') )
        //   status('Closing connection');
        @socket_shutdown($remote);
        @socket_close($remote);
        status('Exiting child process');
        exit(0);
      }
      else if ( defined('HTTPD_WS_CHILD') )
      {
        // if ( defined('HTTPD_WS_CHILD') )
        //   status('Continuing connection');
        // @socket_write($remote, "\r\n\r\n");
      }
    }
  }
  
  /**
   * Sends the client appropriate response headers.
   * @param resource Socket connection to client
   * @param int HTTP status code, defaults to 200
   * @param string Content type, defaults to text/html
   * @param string Additional headers to send, optional
   */                     
  
  function send_client_headers($socket, $http_code = 200, $contenttype = 'text/html', $headers = '')
  {
    global $http_responses;
    $reason_code = ( isset($http_responses[$http_code]) ) ? $http_responses[$http_code] : 'Unknown';
    
    $_SERVER['HTTP_USER_AGENT'] = ( isset($_SERVER['HTTP_USER_AGENT']) ) ? $_SERVER['HTTP_USER_AGENT'] : '(no user agent)';
    status("{$_SERVER['REMOTE_ADDR']} {$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']} $http_code {$_SERVER['HTTP_USER_AGENT']}");
    
    $headers = str_replace("\r\n", "\n", $headers);
    $headers = str_replace("\n", "\r\n", $headers);
    $headers = preg_replace("#[\r\n]+$#", '', $headers);
    $connection = ( $this->in_keepalive ) ? 'keep-alive' : 'close';
    
    @socket_write($socket, "HTTP/1.1 $http_code $reason_code\r\n");
    @socket_write($socket, "Server: $this->server_string");
    @socket_write($socket, "Connection: $connection\r\n");
    @socket_write($socket, "Content-Type: $contenttype\r\n");
    if ( !empty($headers) )
    {
      @socket_write($socket, "$headers\r\n");
    }
    @socket_write($socket, "\r\n");
  }
  
  /**
   * Sends a normal response
   * @param resource Socket connection to client
   * @param array Handler
   */
  
  function send_standard_response($socket, $handler)
  {
    switch ( $handler['type'] )
    {
      case 'dir':
        // security
        $uri = str_replace("\000", '', $_SERVER['REQUEST_URI']);
        if ( preg_match('#(\.\./|\/\.\.)#', $uri) || strstr($uri, "\r") || strstr($uri, "\n") )
        {
          $this->send_http_error($socket, 403, 'Access to this resource is forbidden.');
        }
        
        // import mimetypes
        global $mime_types;
        
        // trim handler id from uri
        $uri = substr($uri, strlen($handler['id']) + 1);
        
        // get file path
        $file_path = rtrim($handler['dir'], '/') . $uri;
        if ( file_exists($file_path) )
        {
          // found it :-D
          
          // is this a directory?
          if ( is_dir($file_path) )
          {
            if ( !$this->allow_dir_list )
            {
              $this->send_http_error($socket, 403, "Directory listing is not allowed.");
              return true;
            }
            // yes, list contents
            $root = '/' . $handler['id'] . rtrim($uri, '/');
            $parent = substr($root, 0, strrpos($root, '/')) . '/';
              
            $contents = <<<EOF
<html>
  <head>
    <title>Index of: $root</title>
  </head>
  <body>
    <h1>Index of $root</h1>
    <ul>
      <li><a href="$parent">Parent directory</a></li>
    
EOF;
            $dirs = array();
            $files = array();
            $d = @opendir($file_path);
            while ( $dh = readdir($d) )
            {
              if ( $dh == '.' || $dh == '..' )
                continue;
              if ( is_dir("$file_path/$dh") )
                $dirs[] = $dh;
              else
                $files[] = $dh;
            }
            asort($dirs);
            asort($files);
            foreach ( $dirs as $dh )
            {
              $contents .= '  <li><a href="' . $root . '/' . $dh . '">' . $dh . '/</a></li>' . "\n    ";
            }
            foreach ( $files as $dh )
            {
              $contents .= '  <li><a href="' . $root . '/' . $dh . '">' . $dh . '</a></li>' . "\n    ";
            }
            $contents .= "\n    </ul>\n    <address>Served by {$this->server_string}</address>\n</body>\n</html>\n\n";
            
            $sz = strlen($contents);
            $this->send_client_headers($socket, 200, 'text/html', "Content-length: $sz\r\n");
            
            @socket_write($socket, $contents);
            
            return true;
          }
          
          // try to open the file
          $fh = @fopen($file_path, 'r');
          if ( !$fh )
          {
            // can't open it, send a 404
            $this->send_http_error($socket, 404, "The requested URL " . htmlspecialchars($_SERVER['REQUEST_URI']) . " was not found on this server.");
          }
          
          // get size
          $sz = filesize($file_path);
          
          // mod time
          $time = date('r', filemtime($file_path));
          
          // all good, send headers
          $fileext = substr($file_path, strrpos($file_path, '.') + 1);
          $mimetype = ( isset($mime_types[$fileext]) ) ? $mime_types[$fileext] : 'application/octet-stream';
          $this->send_client_headers($socket, 200, $mimetype, "Content-length: $sz\r\nLast-Modified: $time\r\n");
          
          // send body
          while ( $blk = @fread($fh, 768000) )
          {
            @socket_write($socket, $blk);
          }
          fclose($fh);
          return true;
        }
        else
        {
          $this->send_http_error($socket, 404, "The requested URL " . htmlspecialchars($_SERVER['REQUEST_URI']) . " was not found on this server.");
        }
        
        break;
      case 'file':
        
        // import mimetypes
        global $mime_types;
        
        // get file path
        $file_path = $handler['file'];
        if ( file_exists($file_path) )
        {
          // found it :-D
          
          // is this a directory?
          if ( is_dir($file_path) )
          {
            $this->send_http_error($socket, 500, "Host script mapped a directory as a file entry.");
            return true;
          }
          
          // try to open the file
          $fh = @fopen($file_path, 'r');
          if ( !$fh )
          {
            // can't open it, send a 404
            $this->send_http_error($socket, 404, "The requested URL " . htmlspecialchars($_SERVER['REQUEST_URI']) . " was not found on this server.");
          }
          
          // get size
          $sz = filesize($file_path);
          
          // mod time
          $time = date('r', filemtime($file_path));
          
          // all good, send headers
          $fileext = substr($file_path, strrpos($file_path, '.') + 1);
          $mimetype = ( isset($mime_types[$fileext]) ) ? $mime_types[$fileext] : 'application/octet-stream';
          $this->send_client_headers($socket, 200, $mimetype, "Content-length: $sz\r\nLast-Modified: $time\r\n");
          
          // send body
          while ( $blk = @fread($fh, 768000) )
          {
            @socket_write($socket, $blk);
          }
          fclose($fh);
          return true;
        }
        else
        {
          $this->send_http_error($socket, 404, "The requested URL " . htmlspecialchars($_SERVER['REQUEST_URI']) . " was not found on this server.");
        }
        
        break;
      case 'function':
        // init vars
        $this->content_type = 'text/html';
        $this->response_code = 200;
        $this->response_headers = array();
        
        // error handling
        @set_error_handler(array($this, 'function_error_handler'), E_ALL);
        try
        {
          ob_start();
          $result = @call_user_func($handler['function'], $this);
          $output = ob_get_contents();
          ob_end_clean();
        }
        catch ( Exception $e )
        {
          restore_error_handler();
          $this->send_http_error($socket, 500, "A handler crashed with an exception; see the command line for details.");
          status("caught exception in handler {$handler['id']}:\n$e");
          return true;
        }
        restore_error_handler();
        
        // the handler function should return this magic string if it writes its own headers and socket data
        if ( $output == '__break__' )
        {
          return true;
        }
        
        // $this->header('Transfer-encoding: chunked');
        $this->header("Content-length: " . strlen($output));
        $headers = implode("\r\n", $this->response_headers);
        
        // write headers
        $this->send_client_headers($socket, $this->response_code, $this->content_type, $headers);
        
        // chunk output
        // $output = dechex(strlen($output)) . "\r\n$output";
        
        // write body
        @socket_write($socket, $output);
        
        break;
    }
  }
  
  /**
   * Adds an HTTP header value to send back to the client
   * @var string Header
   */
  
  function header($str)
  {
    if ( preg_match('#HTTP/1\.[01] ([0-9]+) (.+?)[\s]*$#', $str, $match) )
    {
      $this->response_code = intval($match[1]);
      return true;
    }
    else if ( preg_match('#Content-type: ([^ ;]+)#i', $str, $match) )
    {
      $this->content_type = $match[1];
      return true;
    }
    $this->response_headers[] = $str;
    return true;
  }
  
  /**
   * Sends the client an HTTP error page
   * @param resource Socket connection to client
   * @param int HTTP status code
   * @param string Detailed error string
   */
  
  function send_http_error($socket, $http_code, $errstring)
  {
    global $http_responses;
    $reason_code = ( isset($http_responses[$http_code]) ) ? $http_responses[$http_code] : 'Unknown';
    $this->send_client_headers($socket, $http_code);
    $html = <<<EOF
<html>
  <head>
    <title>$http_code $reason_code</title>
  </head>
  <body>
    <h1>$http_code $reason_code</h1>
    <p>$errstring</p>
    <hr />
    <address>Served by $this->server_string</address>
  </body>
</html>
EOF;
    @socket_write($socket, $html);
    @socket_close($socket);
  }
  
  /**
   * Adds a new handler
   * @param string URI, minus the initial /
   * @param string Type of handler - function or dir
   * @param string Value - function name or absolute/relative path to directory
   */
  
  function add_handler($uri, $type, $value)
  {
    switch($type)
    {
      case 'dir':
        $this->handlers[$uri] = array(
            'type' => 'dir',
            'dir' => $value
          );
        break;
      case 'file':
        $this->handlers[$uri] = array(
            'type' => 'file',
            'file' => $value
          );
        break;
      case 'function':
        $this->handlers[$uri] = array(
            'type' => 'function',
            'function' => $value
          );
        break;
    }
  }
  
  /**
   * Error handling function
   * @param see <http://us.php.net/manual/en/function.set-error-handler.php>
   */
  
  function function_error_handler($errno, $errstr, $errfile, $errline, $errcontext)
  {
    echo '<div style="border: 1px solid #AA0000; background-color: #FFF0F0; padding: 10px;">';
    echo "<b>PHP warning/error:</b> type $errno ($errstr) caught in <b>$errfile</b> on <b>$errline</b><br />";
    // echo "Error context:<pre>" . htmlspecialchars(print_r($errcontext, true)) . "</pre>";
    echo '</div>';
  }
  
}

/**
 * Array of known HTTP status/error codes
 */
 
$http_responses = array(
    200 => 'OK',
    302 => 'Found',
    307 => 'Temporary Redirect',
    400 => 'Bad Request',
    401 => 'Unauthorized',
    403 => 'Forbidden',
    404 => 'Not Found',
    405 => 'Method Not Allowed',
    406 => 'Not Acceptable',
    500 => 'Internal Server Error',
    501 => 'Not Implemented'
  );

/**
 * Array of default mime type->html mappings
 */

$mime_types = array(
    'html' => 'text/html',
    'htm'  => 'text/html',
    'png'  => 'image/png',
    'gif'  => 'image/gif',
    'jpeg' => 'image/jpeg',
    'jpg'  => 'image/jpeg',
    'js'   => 'text/javascript',
    'json' => 'text/x-javascript-json',
    'css'  => 'text/css',
    'php'  => 'application/x-httpd-php'
  );