<?php

/**
 * Handle request startup, before loading the environment or libraries. This
 * class bootstraps the request state up to the point where we can enter
 * Phabricator code.
 *
 * NOTE: This class MUST NOT have any dependencies. It runs before libraries
 * load.
 *
 * @task info         Accessing Request Information
 * @task hook         Startup Hooks
 * @task apocalypse   In Case Of Apocalypse
 * @task validation   Validation
 */
final class PhabricatorStartup {

  private static $startTime;
  private static $globals = array();
  private static $capturingOutput;
  private static $rawInput;


/* -(  Accessing Request Information  )-------------------------------------- */


  /**
   * @task info
   */
  public static function getStartTime() {
    return self::$startTime;
  }


  /**
   * @task info
   */
  public static function getMicrosecondsSinceStart() {
    return (int)(1000000 * (microtime(true) - self::getStartTime()));
  }


  /**
   * @task info
   */
  public static function setGlobal($key, $value) {
    self::validateGlobal($key);

    self::$globals[$key] = $value;
  }


  /**
   * @task info
   */
  public static function getGlobal($key, $default = null) {
    self::validateGlobal($key);

    if (!array_key_exists($key, self::$globals)) {
      return $default;
    }

    return self::$globals[$key];
  }

  /**
   * @task info
   */
  public static function getRawInput() {
    return self::$rawInput;
  }


/* -(  Startup Hooks  )------------------------------------------------------ */


  /**
   * @task hook
   */
  public static function didStartup() {
    self::$startTime = microtime(true);
    self::$globals = array();

    static $registered;
    if (!$registered) {
      // NOTE: This protects us against multiple calls to didStartup() in the
      // same request, but also against repeated requests to the same
      // interpreter state, which we may implement in the future.
      register_shutdown_function(array(__CLASS__, 'didShutdown'));
      $registered = true;
    }

    self::setupPHP();
    self::verifyPHP();

    self::normalizeInput();

    self::verifyRewriteRules();

    self::detectPostMaxSizeTriggered();

    self::beginOutputCapture();

    self::$rawInput = (string)file_get_contents('php://input');
  }


  /**
   * @task hook
   */
  public static function didShutdown() {
    $event = error_get_last();

    if (!$event) {
      return;
    }

    switch ($event['type']) {
      case E_ERROR:
      case E_PARSE:
      case E_COMPILE_ERROR:
        break;
      default:
        return;
    }

    $msg = ">>> UNRECOVERABLE FATAL ERROR <<<\n\n";
    if ($event) {
      // Even though we should be emitting this as text-plain, escape things
      // just to be sure since we can't really be sure what the program state
      // is when we get here.
      $msg .= htmlspecialchars(
        $event['message']."\n\n".$event['file'].':'.$event['line'],
        ENT_QUOTES,
        'UTF-8');
    }

    // flip dem tables
    $msg .= "\n\n\n";
    $msg .= "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb\x20\xef\xb8\xb5\x20\xc2\xaf".
            "\x5c\x5f\x28\xe3\x83\x84\x29\x5f\x2f\xc2\xaf\x20\xef\xb8\xb5\x20".
            "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb";

    self::didFatal($msg);
  }

  public static function loadCoreLibraries() {
    $phabricator_root = dirname(dirname(__FILE__));
    $libraries_root = dirname($phabricator_root);

    $root = null;
    if (!empty($_SERVER['PHUTIL_LIBRARY_ROOT'])) {
      $root = $_SERVER['PHUTIL_LIBRARY_ROOT'];
    }

    ini_set(
      'include_path',
      $libraries_root.PATH_SEPARATOR.ini_get('include_path'));

    @include_once $root.'libphutil/src/__phutil_library_init__.php';
    if (!@constant('__LIBPHUTIL__')) {
      self::didFatal(
        "Unable to load libphutil. Put libphutil/ next to phabricator/, or ".
        "update your PHP 'include_path' to include the parent directory of ".
        "libphutil/.");
    }

    phutil_load_library('arcanist/src');

    // Load Phabricator itself using the absolute path, so we never end up doing
    // anything surprising (loading index.php and libraries from different
    // directories).
    phutil_load_library($phabricator_root.'/src');
  }

/* -(  Output Capture  )----------------------------------------------------- */


  public static function beginOutputCapture() {
    if (self::$capturingOutput) {
      self::didFatal("Already capturing output!");
    }
    self::$capturingOutput = true;
    ob_start();
  }


  public static function endOutputCapture() {
    if (!self::$capturingOutput) {
      return null;
    }
    self::$capturingOutput = false;
    return ob_get_clean();
  }


/* -(  In Case of Apocalypse  )---------------------------------------------- */


  /**
   * @task apocalypse
   */
  public static function didFatal($message) {
    self::endOutputCapture();
    $access_log = self::getGlobal('log.access');

    if ($access_log) {
      // We may end up here before the access log is initialized, e.g. from
      // verifyPHP().
      $access_log->setData(
        array(
          'c' => 500,
        ));
      $access_log->write();
    }

    header(
      'Content-Type: text/plain; charset=utf-8',
      $replace = true,
      $http_error = 500);

    error_log($message);
    echo $message;

    exit(1);
  }


/* -(  Validation  )--------------------------------------------------------- */


  /**
   * @task validation
   */
  private static function setupPHP() {
    error_reporting(E_ALL | E_STRICT);
    ini_set('memory_limit', -1);
  }

  /**
   * @task validation
   */
  private static function normalizeInput() {
    // Replace superglobals with unfiltered versions, disrespect php.ini (we
    // filter ourselves)
    $filter = array(INPUT_GET, INPUT_POST,
      INPUT_SERVER, INPUT_ENV, INPUT_COOKIE);
    foreach ($filter as $type) {
      $filtered = filter_input_array($type, FILTER_UNSAFE_RAW);
      if (!is_array($filtered)) {
        continue;
      }
      switch ($type) {
        case INPUT_SERVER:
          $_SERVER = array_merge($_SERVER, $filtered);
          break;
        case INPUT_GET:
          $_GET = array_merge($_GET, $filtered);
          break;
        case INPUT_COOKIE:
          $_COOKIE = array_merge($_COOKIE, $filtered);
          break;
        case INPUT_POST:
          $_POST = array_merge($_POST, $filtered);
          break;
        case INPUT_ENV;
          $_ENV = array_merge($_ENV, $filtered);
          break;
      }
    }

    // rebuild $_REQUEST, respecting order declared in ini files
    $order = ini_get('request_order');
    if (!$order) {
      $order = ini_get('variables_order');
    }
    if (!$order) {
      // $_REQUEST will be empty, leave it alone
      return;
    }
    $_REQUEST = array();
    for ($i = 0; $i < strlen($order); $i++) {
      switch ($order[$i]) {
        case 'G':
          $_REQUEST = array_merge($_REQUEST, $_GET);
          break;
        case 'P':
          $_REQUEST = array_merge($_REQUEST, $_POST);
          break;
        case 'C':
          $_REQUEST = array_merge($_REQUEST, $_COOKIE);
          break;
        default:
          // $_ENV and $_SERVER never go into $_REQUEST
          break;
      }
    }
  }

  /**
   * @task validation
   */
  private static function verifyPHP() {
    $required_version = '5.2.3';
    if (version_compare(PHP_VERSION, $required_version) < 0) {
      self::didFatal(
        "You are running PHP version '".PHP_VERSION."', which is older than ".
        "the minimum version, '{$required_version}'. Update to at least ".
        "'{$required_version}'.");
    }

    if (get_magic_quotes_gpc()) {
      self::didFatal(
        "Your server is configured with PHP 'magic_quotes_gpc' enabled. This ".
        "feature is 'highly discouraged' by PHP's developers and you must ".
        "disable it to run Phabricator. Consult the PHP manual for ".
        "instructions.");
    }

    if (extension_loaded('apc')) {
      $apc_version = phpversion('apc');
      $known_bad = array(
        '3.1.14' => true,
        '3.1.15' => true,
        '3.1.15-dev' => true,
      );
      if (isset($known_bad[$apc_version])) {
        self::didFatal(
          "You have APC {$apc_version} installed. This version of APC is ".
          "known to be bad, and does not work with Phabricator (it will ".
          "cause Phabricator to fatal unrecoverably with nonsense errors). ".
          "Downgrade to version 3.1.13.");
      }
    }
  }


  /**
   * @task validation
   */
  private static function verifyRewriteRules() {
    if (isset($_REQUEST['__path__']) && strlen($_REQUEST['__path__'])) {
      return;
    }

    if (php_sapi_name() == 'cli-server') {
      // Compatibility with PHP 5.4+ built-in web server.
      $url = parse_url($_SERVER['REQUEST_URI']);
      $_REQUEST['__path__'] = $url['path'];
      return;
    }

    if (!isset($_REQUEST['__path__'])) {
      self::didFatal(
        "Request parameter '__path__' is not set. Your rewrite rules ".
        "are not configured correctly.");
    }

    if (!strlen($_REQUEST['__path__'])) {
      self::didFatal(
        "Request parameter '__path__' is set, but empty. Your rewrite rules ".
        "are not configured correctly. The '__path__' should always ".
        "begin with a '/'.");
    }
  }


  /**
   * @task validation
   */
  private static function validateGlobal($key) {
    static $globals = array(
      'log.access' => true,
      'csrf.salt'  => true,
    );

    if (empty($globals[$key])) {
      throw new Exception("Access to unknown startup global '{$key}'!");
    }
  }


  /**
   * Detect if this request has had its POST data stripped by exceeding the
   * 'post_max_size' PHP configuration limit.
   *
   * PHP has a setting called 'post_max_size'. If a POST request arrives with
   * a body larger than the limit, PHP doesn't generate $_POST but processes
   * the request anyway, and provides no formal way to detect that this
   * happened.
   *
   * We can still read the entire body out of `php://input`. However according
   * to the documentation the stream isn't available for "multipart/form-data"
   * (on nginx + php-fpm it appears that it is available, though, at least) so
   * any attempt to generate $_POST would be fragile.
   *
   * @task validation
   */
  private static function detectPostMaxSizeTriggered() {
    // If this wasn't a POST, we're fine.
    if ($_SERVER['REQUEST_METHOD'] != 'POST') {
      return;
    }

    // If there's POST data, clearly we're in good shape.
    if ($_POST) {
      return;
    }

    // For HTML5 drag-and-drop file uploads, Safari submits the data as
    // "application/x-www-form-urlencoded". For most files this generates
    // something in POST because most files decode to some nonempty (albeit
    // meaningless) value. However, some files (particularly small images)
    // don't decode to anything. If we know this is a drag-and-drop upload,
    // we can skip this check.
    if (isset($_REQUEST['__upload__'])) {
      return;
    }

    // PHP generates $_POST only for two content types. This routing happens
    // in `main/php_content_types.c` in PHP. Normally, all forms use one of
    // these content types, but some requests may not -- for example, Firefox
    // submits files sent over HTML5 XMLHTTPRequest APIs with the Content-Type
    // of the file itself. If we don't have a recognized content type, we
    // don't need $_POST.
    //
    // NOTE: We use strncmp() because the actual content type may be something
    // like "multipart/form-data; boundary=...".
    //
    // NOTE: Chrome sometimes omits this header, see some discussion in T1762
    // and http://code.google.com/p/chromium/issues/detail?id=6800
    $content_type = isset($_SERVER['CONTENT_TYPE'])
      ? $_SERVER['CONTENT_TYPE']
      : '';

    $parsed_types = array(
      'application/x-www-form-urlencoded',
      'multipart/form-data',
    );

    $is_parsed_type = false;
    foreach ($parsed_types as $parsed_type) {
      if (strncmp($content_type, $parsed_type, strlen($parsed_type)) === 0) {
        $is_parsed_type = true;
        break;
      }
    }

    if (!$is_parsed_type) {
      return;
    }

    // Check for 'Content-Length'. If there's no data, we don't expect $_POST
    // to exist.
    $length = (int)$_SERVER['CONTENT_LENGTH'];
    if (!$length) {
      return;
    }

    // Time to fatal: we know this was a POST with data that should have been
    // populated into $_POST, but it wasn't.

    $config = ini_get('post_max_size');
    PhabricatorStartup::didFatal(
      "As received by the server, this request had a nonzero content length ".
      "but no POST data.\n\n".
      "Normally, this indicates that it exceeds the 'post_max_size' setting ".
      "in the PHP configuration on the server. Increase the 'post_max_size' ".
      "setting or reduce the size of the request.\n\n".
      "Request size according to 'Content-Length' was '{$length}', ".
      "'post_max_size' is set to '{$config}'.");
  }

}
