Page MenuHomePhabricator

D9941.id23851.diff
No OneTemporary

D9941.id23851.diff

diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -15,12 +15,32 @@
'AASTTree' => 'parser/aast/api/AASTTree.php',
'AbstractDirectedGraph' => 'utils/AbstractDirectedGraph.php',
'AbstractDirectedGraphTestCase' => 'utils/__tests__/AbstractDirectedGraphTestCase.php',
+ 'Aphront304Response' => 'aphront/response/Aphront304Response.php',
+ 'Aphront400Response' => 'aphront/response/Aphront400Response.php',
+ 'Aphront403Response' => 'aphront/response/Aphront403Response.php',
+ 'Aphront404Response' => 'aphront/response/Aphront404Response.php',
+ 'AphrontAjaxResponse' => 'aphront/response/AphrontAjaxResponse.php',
+ 'AphrontApplicationConfiguration' => 'aphront/configuration/AphrontApplicationConfiguration.php',
+ 'AphrontCSRFException' => 'aphront/exception/AphrontCSRFException.php',
+ 'AphrontController' => 'aphront/AphrontController.php',
'AphrontDatabaseConnection' => 'aphront/storage/connection/AphrontDatabaseConnection.php',
'AphrontDatabaseTransactionState' => 'aphront/storage/connection/AphrontDatabaseTransactionState.php',
+ 'AphrontDefaultApplicationConfiguration' => 'aphront/configuration/AphrontDefaultApplicationConfiguration.php',
+ 'AphrontDialogResponse' => 'aphront/response/AphrontDialogResponse.php',
+ 'AphrontException' => 'aphront/exception/AphrontException.php',
+ 'AphrontFileResponse' => 'aphront/response/AphrontFileResponse.php',
+ 'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php',
+ 'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php',
+ 'AphrontHTTPSinkTestCase' => 'aphront/sink/__tests__/AphrontHTTPSinkTestCase.php',
'AphrontIsolatedDatabaseConnection' => 'aphront/storage/connection/AphrontIsolatedDatabaseConnection.php',
+ 'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php',
+ 'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php',
'AphrontMySQLDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnection.php',
'AphrontMySQLDatabaseConnectionBase' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnectionBase.php',
'AphrontMySQLiDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php',
+ 'AphrontPHPHTTPSink' => 'aphront/sink/AphrontPHPHTTPSink.php',
+ 'AphrontPlainTextResponse' => 'aphront/response/AphrontPlainTextResponse.php',
+ 'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php',
'AphrontQueryAccessDeniedException' => 'aphront/storage/exception/AphrontQueryAccessDeniedException.php',
'AphrontQueryCharacterSetException' => 'aphront/storage/exception/AphrontQueryCharacterSetException.php',
'AphrontQueryConnectionException' => 'aphront/storage/exception/AphrontQueryConnectionException.php',
@@ -34,7 +54,15 @@
'AphrontQueryParameterException' => 'aphront/storage/exception/AphrontQueryParameterException.php',
'AphrontQueryRecoverableException' => 'aphront/storage/exception/AphrontQueryRecoverableException.php',
'AphrontQuerySchemaException' => 'aphront/storage/exception/AphrontQuerySchemaException.php',
+ 'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php',
+ 'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php',
+ 'AphrontRequest' => 'aphront/AphrontRequest.php',
+ 'AphrontRequestTestCase' => 'aphront/__tests__/AphrontRequestTestCase.php',
+ 'AphrontResponse' => 'aphront/response/AphrontResponse.php',
'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php',
+ 'AphrontURIMapper' => 'aphront/AphrontURIMapper.php',
+ 'AphrontUsageException' => 'aphront/exception/AphrontUsageException.php',
+ 'AphrontWebpageResponse' => 'aphront/response/AphrontWebpageResponse.php',
'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php',
'AphrontWriteGuardExitEventListener' => 'aphront/writeguard/event/AphrontWriteGuardExitEventListener.php',
'BaseHTTPFuture' => 'future/http/BaseHTTPFuture.php',
@@ -469,11 +497,29 @@
1 => 'Countable',
),
'AbstractDirectedGraphTestCase' => 'PhutilTestCase',
+ 'Aphront304Response' => 'AphrontResponse',
+ 'Aphront400Response' => 'AphrontResponse',
+ 'Aphront403Response' => 'AphrontHTMLResponse',
+ 'Aphront404Response' => 'AphrontHTMLResponse',
+ 'AphrontAjaxResponse' => 'AphrontResponse',
+ 'AphrontCSRFException' => 'AphrontException',
+ 'AphrontController' => 'Phobject',
'AphrontDatabaseConnection' => 'PhutilQsprintfInterface',
+ 'AphrontDefaultApplicationConfiguration' => 'AphrontApplicationConfiguration',
+ 'AphrontDialogResponse' => 'AphrontResponse',
+ 'AphrontException' => 'Exception',
+ 'AphrontFileResponse' => 'AphrontResponse',
+ 'AphrontHTMLResponse' => 'AphrontResponse',
+ 'AphrontHTTPSinkTestCase' => 'PhutilTestCase',
'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection',
+ 'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink',
+ 'AphrontJSONResponse' => 'AphrontResponse',
'AphrontMySQLDatabaseConnection' => 'AphrontMySQLDatabaseConnectionBase',
'AphrontMySQLDatabaseConnectionBase' => 'AphrontDatabaseConnection',
'AphrontMySQLiDatabaseConnection' => 'AphrontMySQLDatabaseConnectionBase',
+ 'AphrontPHPHTTPSink' => 'AphrontHTTPSink',
+ 'AphrontPlainTextResponse' => 'AphrontResponse',
+ 'AphrontProxyResponse' => 'AphrontResponse',
'AphrontQueryAccessDeniedException' => 'AphrontQueryRecoverableException',
'AphrontQueryCharacterSetException' => 'AphrontQueryException',
'AphrontQueryConnectionException' => 'AphrontQueryException',
@@ -487,6 +533,11 @@
'AphrontQueryParameterException' => 'AphrontQueryException',
'AphrontQueryRecoverableException' => 'AphrontQueryException',
'AphrontQuerySchemaException' => 'AphrontQueryException',
+ 'AphrontRedirectResponse' => 'AphrontResponse',
+ 'AphrontReloadResponse' => 'AphrontRedirectResponse',
+ 'AphrontRequestTestCase' => 'PhutilTestCase',
+ 'AphrontUsageException' => 'AphrontException',
+ 'AphrontWebpageResponse' => 'AphrontHTMLResponse',
'AphrontWriteGuardExitEventListener' => 'PhutilEventListener',
'BaseHTTPFuture' => 'Future',
'CommandException' => 'Exception',
diff --git a/src/aphront/AphrontController.php b/src/aphront/AphrontController.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/AphrontController.php
@@ -0,0 +1,83 @@
+<?php
+
+abstract class AphrontController extends Phobject {
+
+ private $request;
+ private $currentApplication;
+ private $delegatingController;
+
+ public function setDelegatingController(
+ AphrontController $delegating_controller) {
+ $this->delegatingController = $delegating_controller;
+ return $this;
+ }
+
+ public function getDelegatingController() {
+ return $this->delegatingController;
+ }
+
+ public function willBeginExecution() {
+ return;
+ }
+
+ public function willProcessRequest(array $uri_data) {
+ return;
+ }
+
+ public function didProcessRequest($response) {
+ return $response;
+ }
+
+ abstract public function processRequest();
+
+ final public function __construct(AphrontRequest $request) {
+ $this->request = $request;
+ }
+
+ final public function getRequest() {
+ return $this->request;
+ }
+
+ final public function delegateToController(AphrontController $controller) {
+ $controller->setDelegatingController($this);
+
+ $application = $this->getCurrentApplication();
+ if ($application) {
+ $controller->setCurrentApplication($application);
+ }
+
+ return $controller->processRequest();
+ }
+
+ final public function setCurrentApplication(
+ PhabricatorApplication $current_application) {
+
+ $this->currentApplication = $current_application;
+ return $this;
+ }
+
+ final public function getCurrentApplication() {
+ return $this->currentApplication;
+ }
+
+ public function getDefaultResourceSource() {
+ throw new Exception(
+ pht(
+ 'A Controller must implement getDefaultResourceSource() before you '.
+ 'can invoke requireResource() or initBehavior().'));
+ }
+
+ public function requireResource($symbol) {
+ $response = CelerityAPI::getStaticResourceResponse();
+ $response->requireResource($symbol, $this->getDefaultResourceSource());
+ return $this;
+ }
+
+ public function initBehavior($name, $config = array()) {
+ Javelin::initBehavior(
+ $name,
+ $config,
+ $this->getDefaultResourceSource());
+ }
+
+}
diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/AphrontRequest.php
@@ -0,0 +1,611 @@
+<?php
+
+/**
+ * @task data Accessing Request Data
+ * @task cookie Managing Cookies
+ *
+ */
+final class AphrontRequest {
+
+ // NOTE: These magic request-type parameters are automatically included in
+ // certain requests (e.g., by phabricator_form(), JX.Request,
+ // JX.Workflow, and ConduitClient) and help us figure out what sort of
+ // response the client expects.
+
+ const TYPE_AJAX = '__ajax__';
+ const TYPE_FORM = '__form__';
+ const TYPE_CONDUIT = '__conduit__';
+ const TYPE_WORKFLOW = '__wflow__';
+ const TYPE_CONTINUE = '__continue__';
+ const TYPE_PREVIEW = '__preview__';
+ const TYPE_HISEC = '__hisec__';
+
+ private $host;
+ private $path;
+ private $requestData;
+ private $user;
+ private $applicationConfiguration;
+
+ final public function __construct($host, $path) {
+ $this->host = $host;
+ $this->path = $path;
+ }
+
+ final public function setApplicationConfiguration(
+ $application_configuration) {
+ $this->applicationConfiguration = $application_configuration;
+ return $this;
+ }
+
+ final public function getApplicationConfiguration() {
+ return $this->applicationConfiguration;
+ }
+
+ final public function setPath($path) {
+ $this->path = $path;
+ return $this;
+ }
+
+ final public function getPath() {
+ return $this->path;
+ }
+
+ final public function getHost() {
+ // The "Host" header may include a port number, or may be a malicious
+ // header in the form "realdomain.com:ignored@evil.com". Invoke the full
+ // parser to extract the real domain correctly. See here for coverage of
+ // a similar issue in Django:
+ //
+ // https://www.djangoproject.com/weblog/2012/oct/17/security/
+ $uri = new PhutilURI('http://'.$this->host);
+ return $uri->getDomain();
+ }
+
+
+/* -( Accessing Request Data )--------------------------------------------- */
+
+
+ /**
+ * @task data
+ */
+ final public function setRequestData(array $request_data) {
+ $this->requestData = $request_data;
+ return $this;
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getRequestData() {
+ return $this->requestData;
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getInt($name, $default = null) {
+ if (isset($this->requestData[$name])) {
+ return (int)$this->requestData[$name];
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getBool($name, $default = null) {
+ if (isset($this->requestData[$name])) {
+ if ($this->requestData[$name] === 'true') {
+ return true;
+ } else if ($this->requestData[$name] === 'false') {
+ return false;
+ } else {
+ return (bool)$this->requestData[$name];
+ }
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getStr($name, $default = null) {
+ if (isset($this->requestData[$name])) {
+ $str = (string)$this->requestData[$name];
+ // Normalize newline craziness.
+ $str = str_replace(
+ array("\r\n", "\r"),
+ array("\n", "\n"),
+ $str);
+ return $str;
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getArr($name, $default = array()) {
+ if (isset($this->requestData[$name]) &&
+ is_array($this->requestData[$name])) {
+ return $this->requestData[$name];
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getStrList($name, $default = array()) {
+ if (!isset($this->requestData[$name])) {
+ return $default;
+ }
+ $list = $this->getStr($name);
+ $list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY);
+ return $list;
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getExists($name) {
+ return array_key_exists($name, $this->requestData);
+ }
+
+ final public function getFileExists($name) {
+ return isset($_FILES[$name]) &&
+ (idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE);
+ }
+
+ final public function isHTTPGet() {
+ return ($_SERVER['REQUEST_METHOD'] == 'GET');
+ }
+
+ final public function isHTTPPost() {
+ return ($_SERVER['REQUEST_METHOD'] == 'POST');
+ }
+
+ final public function isAjax() {
+ return $this->getExists(self::TYPE_AJAX);
+ }
+
+ final public function isJavelinWorkflow() {
+ return $this->getExists(self::TYPE_WORKFLOW);
+ }
+
+ final public function isConduit() {
+ return $this->getExists(self::TYPE_CONDUIT);
+ }
+
+ public static function getCSRFTokenName() {
+ return '__csrf__';
+ }
+
+ public static function getCSRFHeaderName() {
+ return 'X-Phabricator-Csrf';
+ }
+
+ final public function validateCSRF() {
+ $token_name = self::getCSRFTokenName();
+ $token = $this->getStr($token_name);
+
+ // No token in the request, check the HTTP header which is added for Ajax
+ // requests.
+ if (empty($token)) {
+ $token = self::getHTTPHeader(self::getCSRFHeaderName());
+ }
+
+ $valid = $this->getUser()->validateCSRFToken($token);
+ if (!$valid) {
+
+ // Add some diagnostic details so we can figure out if some CSRF issues
+ // are JS problems or people accessing Ajax URIs directly with their
+ // browsers.
+ $more_info = array();
+
+ if ($this->isAjax()) {
+ $more_info[] = pht('This was an Ajax request.');
+ } else {
+ $more_info[] = pht('This was a Web request.');
+ }
+
+ if ($token) {
+ $more_info[] = pht('This request had an invalid CSRF token.');
+ } else {
+ $more_info[] = pht('This request had no CSRF token.');
+ }
+
+ // Give a more detailed explanation of how to avoid the exception
+ // in developer mode.
+ if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
+ // TODO: Clean this up, see T1921.
+ $more_info[] =
+ "To avoid this error, use phabricator_form() to construct forms. ".
+ "If you are already using phabricator_form(), make sure the form ".
+ "'action' uses a relative URI (i.e., begins with a '/'). Forms ".
+ "using absolute URIs do not include CSRF tokens, to prevent ".
+ "leaking tokens to external sites.\n\n".
+ "If this page performs writes which do not require CSRF ".
+ "protection (usually, filling caches or logging), you can use ".
+ "AphrontWriteGuard::beginScopedUnguardedWrites() to temporarily ".
+ "bypass CSRF protection while writing. You should use this only ".
+ "for writes which can not be protected with normal CSRF ".
+ "mechanisms.\n\n".
+ "Some UI elements (like PhabricatorActionListView) also have ".
+ "methods which will allow you to render links as forms (like ".
+ "setRenderAsForm(true)).";
+ }
+
+ // This should only be able to happen if you load a form, pull your
+ // internet for 6 hours, and then reconnect and immediately submit,
+ // but give the user some indication of what happened since the workflow
+ // is incredibly confusing otherwise.
+ throw new AphrontCSRFException(
+ pht(
+ "You are trying to save some data to Phabricator, but the request ".
+ "your browser made included an incorrect token. Reload the page ".
+ "and try again. You may need to clear your cookies.\n\n%s",
+ implode("\n", $more_info)));
+ }
+
+ return true;
+ }
+
+ final public function isFormPost() {
+ $post = $this->getExists(self::TYPE_FORM) &&
+ !$this->getExists(self::TYPE_HISEC) &&
+ $this->isHTTPPost();
+
+ if (!$post) {
+ return false;
+ }
+
+ return $this->validateCSRF();
+ }
+
+ final public function isFormOrHisecPost() {
+ $post = $this->getExists(self::TYPE_FORM) &&
+ $this->isHTTPPost();
+
+ if (!$post) {
+ return false;
+ }
+
+ return $this->validateCSRF();
+ }
+
+
+ final public function setCookiePrefix($prefix) {
+ $this->cookiePrefix = $prefix;
+ return $this;
+ }
+
+ final private function getPrefixedCookieName($name) {
+ if (strlen($this->cookiePrefix)) {
+ return $this->cookiePrefix.'_'.$name;
+ } else {
+ return $name;
+ }
+ }
+
+ final public function getCookie($name, $default = null) {
+ $name = $this->getPrefixedCookieName($name);
+ $value = idx($_COOKIE, $name, $default);
+
+ // Internally, PHP deletes cookies by setting them to the value 'deleted'
+ // with an expiration date in the past.
+
+ // At least in Safari, the browser may send this cookie anyway in some
+ // circumstances. After logging out, the 302'd GET to /login/ consistently
+ // includes deleted cookies on my local install. If a cookie value is
+ // literally 'deleted', pretend it does not exist.
+
+ if ($value === 'deleted') {
+ return null;
+ }
+
+ return $value;
+ }
+
+ final public function clearCookie($name) {
+ $name = $this->getPrefixedCookieName($name);
+ $this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30));
+ unset($_COOKIE[$name]);
+ }
+
+ /**
+ * Get the domain which cookies should be set on for this request, or null
+ * if the request does not correspond to a valid cookie domain.
+ *
+ * @return PhutilURI|null Domain URI, or null if no valid domain exists.
+ *
+ * @task cookie
+ */
+ private function getCookieDomainURI() {
+ if (PhabricatorEnv::getEnvConfig('security.require-https') &&
+ !$this->isHTTPS()) {
+ return null;
+ }
+
+ $host = $this->getHost();
+
+ // If there's no base domain configured, just use whatever the request
+ // domain is. This makes setup easier, and we'll tell administrators to
+ // configure a base domain during the setup process.
+ $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
+ if (!strlen($base_uri)) {
+ return new PhutilURI('http://'.$host.'/');
+ }
+
+ $alternates = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris');
+ $allowed_uris = array_merge(
+ array($base_uri),
+ $alternates);
+
+ foreach ($allowed_uris as $allowed_uri) {
+ $uri = new PhutilURI($allowed_uri);
+ if ($uri->getDomain() == $host) {
+ return $uri;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Determine if security policy rules will allow cookies to be set when
+ * responding to the request.
+ *
+ * @return bool True if setCookie() will succeed. If this method returns
+ * false, setCookie() will throw.
+ *
+ * @task cookie
+ */
+ final public function canSetCookies() {
+ return (bool)$this->getCookieDomainURI();
+ }
+
+
+ /**
+ * Set a cookie which does not expire for a long time.
+ *
+ * To set a temporary cookie, see @{method:setTemporaryCookie}.
+ *
+ * @param string Cookie name.
+ * @param string Cookie value.
+ * @return this
+ * @task cookie
+ */
+ final public function setCookie($name, $value) {
+ $far_future = time() + (60 * 60 * 24 * 365 * 5);
+ return $this->setCookieWithExpiration($name, $value, $far_future);
+ }
+
+
+ /**
+ * Set a cookie which expires soon.
+ *
+ * To set a durable cookie, see @{method:setCookie}.
+ *
+ * @param string Cookie name.
+ * @param string Cookie value.
+ * @return this
+ * @task cookie
+ */
+ final public function setTemporaryCookie($name, $value) {
+ return $this->setCookieWithExpiration($name, $value, 0);
+ }
+
+
+ /**
+ * Set a cookie with a given expiration policy.
+ *
+ * @param string Cookie name.
+ * @param string Cookie value.
+ * @param int Epoch timestamp for cookie expiration.
+ * @return this
+ * @task cookie
+ */
+ final private function setCookieWithExpiration(
+ $name,
+ $value,
+ $expire) {
+
+ $is_secure = false;
+
+ $base_domain_uri = $this->getCookieDomainURI();
+ if (!$base_domain_uri) {
+ $configured_as = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
+ $accessed_as = $this->getHost();
+
+ throw new Exception(
+ pht(
+ 'This Phabricator install is configured as "%s", but you are '.
+ 'using the domain name "%s" to access a page which is trying to '.
+ 'set a cookie. Acccess Phabricator on the configured primary '.
+ 'domain or a configured alternate domain. Phabricator will not '.
+ 'set cookies on other domains for security reasons.',
+ $configured_as,
+ $accessed_as));
+ }
+
+ $base_domain = $base_domain_uri->getDomain();
+ $is_secure = ($base_domain_uri->getProtocol() == 'https');
+
+ $name = $this->getPrefixedCookieName($name);
+
+ if (php_sapi_name() == 'cli') {
+ // Do nothing, to avoid triggering "Cannot modify header information"
+ // warnings.
+
+ // TODO: This is effectively a test for whether we're running in a unit
+ // test or not. Move this actual call to HTTPSink?
+ } else {
+ setcookie(
+ $name,
+ $value,
+ $expire,
+ $path = '/',
+ $base_domain,
+ $is_secure,
+ $http_only = true);
+ }
+
+ $_COOKIE[$name] = $value;
+
+ return $this;
+ }
+
+ final public function setUser($user) {
+ $this->user = $user;
+ return $this;
+ }
+
+ final public function getUser() {
+ return $this->user;
+ }
+
+ final public function getRequestURI() {
+ $get = $_GET;
+ unset($get['__path__']);
+ $path = phutil_escape_uri($this->getPath());
+ return id(new PhutilURI($path))->setQueryParams($get);
+ }
+
+ final public function isDialogFormPost() {
+ return $this->isFormPost() && $this->getStr('__dialog__');
+ }
+
+ final public function getRemoteAddr() {
+ return $_SERVER['REMOTE_ADDR'];
+ }
+
+ public function isHTTPS() {
+ if (empty($_SERVER['HTTPS'])) {
+ return false;
+ }
+ if (!strcasecmp($_SERVER['HTTPS'], 'off')) {
+ return false;
+ }
+ return true;
+ }
+
+ public function isContinueRequest() {
+ return $this->isFormPost() && $this->getStr('__continue__');
+ }
+
+ public function isPreviewRequest() {
+ return $this->isFormPost() && $this->getStr('__preview__');
+ }
+
+ /**
+ * Get application request parameters in a flattened form suitable for
+ * inclusion in an HTTP request, excluding parameters with special meanings.
+ * This is primarily useful if you want to ask the user for more input and
+ * then resubmit their request.
+ *
+ * @return dict<string, string> Original request parameters.
+ */
+ public function getPassthroughRequestParameters() {
+ return self::flattenData($this->getPassthroughRequestData());
+ }
+
+ /**
+ * Get request data other than "magic" parameters.
+ *
+ * @return dict<string, wild> Request data, with magic filtered out.
+ */
+ public function getPassthroughRequestData() {
+ $data = $this->getRequestData();
+
+ // Remove magic parameters like __dialog__ and __ajax__.
+ foreach ($data as $key => $value) {
+ if (!strncmp($key, '__', 2)) {
+ unset($data[$key]);
+ }
+ }
+
+ return $data;
+ }
+
+
+ /**
+ * Flatten an array of key-value pairs (possibly including arrays as values)
+ * into a list of key-value pairs suitable for submitting via HTTP request
+ * (with arrays flattened).
+ *
+ * @param dict<string, wild> Data to flatten.
+ * @return dict<string, string> Flat data suitable for inclusion in an HTTP
+ * request.
+ */
+ public static function flattenData(array $data) {
+ $result = array();
+ foreach ($data as $key => $value) {
+ if (is_array($value)) {
+ foreach (self::flattenData($value) as $fkey => $fvalue) {
+ $fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1);
+ $result[$key.$fkey] = $fvalue;
+ }
+ } else {
+ $result[$key] = (string)$value;
+ }
+ }
+
+ ksort($result);
+
+ return $result;
+ }
+
+
+ /**
+ * Read the value of an HTTP header from `$_SERVER`, or a similar datasource.
+ *
+ * This function accepts a canonical header name, like `"Accept-Encoding"`,
+ * and looks up the appropriate value in `$_SERVER` (in this case,
+ * `"HTTP_ACCEPT_ENCODING"`).
+ *
+ * @param string Canonical header name, like `"Accept-Encoding"`.
+ * @param wild Default value to return if header is not present.
+ * @param array? Read this instead of `$_SERVER`.
+ * @return string|wild Header value if present, or `$default` if not.
+ */
+ public static function getHTTPHeader($name, $default = null, $data = null) {
+ // PHP mangles HTTP headers by uppercasing them and replacing hyphens with
+ // underscores, then prepending 'HTTP_'.
+ $php_index = strtoupper($name);
+ $php_index = str_replace('-', '_', $php_index);
+
+ $try_names = array();
+
+ $try_names[] = 'HTTP_'.$php_index;
+ if ($php_index == 'CONTENT_TYPE' || $php_index == 'CONTENT_LENGTH') {
+ // These headers may be available under alternate names. See
+ // http://www.php.net/manual/en/reserved.variables.server.php#110763
+ $try_names[] = $php_index;
+ }
+
+ if ($data === null) {
+ $data = $_SERVER;
+ }
+
+ foreach ($try_names as $try_name) {
+ if (array_key_exists($try_name, $data)) {
+ return $data[$try_name];
+ }
+ }
+
+ return $default;
+ }
+
+}
diff --git a/src/aphront/AphrontURIMapper.php b/src/aphront/AphrontURIMapper.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/AphrontURIMapper.php
@@ -0,0 +1,50 @@
+<?php
+
+final class AphrontURIMapper {
+
+ private $map;
+
+ final public function __construct(array $map) {
+ $this->map = $map;
+ }
+
+ final public function mapPath($path) {
+ $map = $this->map;
+ foreach ($map as $rule => $value) {
+ list($controller, $data) = $this->tryRule($rule, $value, $path);
+ if ($controller) {
+ foreach ($data as $k => $v) {
+ if (is_numeric($k)) {
+ unset($data[$k]);
+ }
+ }
+ return array($controller, $data);
+ }
+ }
+
+ return array(null, null);
+ }
+
+ final private function tryRule($rule, $value, $path) {
+ $match = null;
+ $pattern = '#^'.$rule.(is_array($value) ? '' : '$').'#';
+ if (!preg_match($pattern, $path, $match)) {
+ return array(null, null);
+ }
+
+ if (!is_array($value)) {
+ return array($value, $match);
+ }
+
+ $path = substr($path, strlen($match[0]));
+ foreach ($value as $srule => $sval) {
+ list($controller, $data) = $this->tryRule($srule, $sval, $path);
+ if ($controller) {
+ return array($controller, $data + $match);
+ }
+ }
+
+ return array(null, null);
+ }
+
+}
diff --git a/src/aphront/__tests__/AphrontRequestTestCase.php b/src/aphront/__tests__/AphrontRequestTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/__tests__/AphrontRequestTestCase.php
@@ -0,0 +1,151 @@
+<?php
+
+final class AphrontRequestTestCase extends PhutilTestCase {
+
+ public function testRequestDataAccess() {
+ $r = new AphrontRequest('example.com', '/');
+ $r->setRequestData(
+ array(
+ 'str_empty' => '',
+ 'str' => 'derp',
+ 'str_true' => 'true',
+ 'str_false' => 'false',
+
+ 'zero' => '0',
+ 'one' => '1',
+
+ 'arr_empty' => array(),
+ 'arr_num' => array(1, 2, 3),
+
+ 'comma' => ',',
+ 'comma_1' => 'a, b',
+ 'comma_2' => ' ,a ,, b ,,,, ,, ',
+ 'comma_3' => '0',
+ 'comma_4' => 'a, a, b, a',
+ 'comma_5' => "a\nb, c\n\nd\n\n\n,\n",
+ ));
+
+ $this->assertEqual(1, $r->getInt('one'));
+ $this->assertEqual(0, $r->getInt('zero'));
+ $this->assertEqual(null, $r->getInt('does-not-exist'));
+ $this->assertEqual(0, $r->getInt('str_empty'));
+
+ $this->assertEqual(true, $r->getBool('one'));
+ $this->assertEqual(false, $r->getBool('zero'));
+ $this->assertEqual(true, $r->getBool('str_true'));
+ $this->assertEqual(false, $r->getBool('str_false'));
+ $this->assertEqual(true, $r->getBool('str'));
+ $this->assertEqual(null, $r->getBool('does-not-exist'));
+ $this->assertEqual(false, $r->getBool('str_empty'));
+
+ $this->assertEqual('derp', $r->getStr('str'));
+ $this->assertEqual('', $r->getStr('str_empty'));
+ $this->assertEqual(null, $r->getStr('does-not-exist'));
+
+ $this->assertEqual(array(), $r->getArr('arr_empty'));
+ $this->assertEqual(array(1, 2, 3), $r->getArr('arr_num'));
+ $this->assertEqual(null, $r->getArr('str_empty', null));
+ $this->assertEqual(null, $r->getArr('str_true', null));
+ $this->assertEqual(null, $r->getArr('does-not-exist', null));
+ $this->assertEqual(array(), $r->getArr('does-not-exist'));
+
+ $this->assertEqual(array(), $r->getStrList('comma'));
+ $this->assertEqual(array('a', 'b'), $r->getStrList('comma_1'));
+ $this->assertEqual(array('a', 'b'), $r->getStrList('comma_2'));
+ $this->assertEqual(array('0'), $r->getStrList('comma_3'));
+ $this->assertEqual(array('a', 'a', 'b', 'a'), $r->getStrList('comma_4'));
+ $this->assertEqual(array('a', 'b', 'c', 'd'), $r->getStrList('comma_5'));
+ $this->assertEqual(array(), $r->getStrList('does-not-exist'));
+ $this->assertEqual(null, $r->getStrList('does-not-exist', null));
+
+ $this->assertEqual(true, $r->getExists('str'));
+ $this->assertEqual(false, $r->getExists('does-not-exist'));
+ }
+
+ public function testHostAttacks() {
+ static $tests = array(
+ 'domain.com' => 'domain.com',
+ 'domain.com:80' => 'domain.com',
+ 'evil.com:evil.com@real.com' => 'real.com',
+ 'evil.com:evil.com@real.com:80' => 'real.com',
+ );
+
+ foreach ($tests as $input => $expect) {
+ $r = new AphrontRequest($input, '/');
+ $this->assertEqual(
+ $expect,
+ $r->getHost(),
+ 'Host: '.$input);
+ }
+ }
+
+ public function testFlattenRequestData() {
+ $test_cases = array(
+ array(
+ 'a' => 'a',
+ 'b' => '1',
+ 'c' => '',
+ ),
+ array(
+ 'a' => 'a',
+ 'b' => '1',
+ 'c' => '',
+ ),
+
+ array(
+ 'x' => array(
+ 0 => 'a',
+ 1 => 'b',
+ 2 => 'c',
+ ),
+ ),
+ array(
+ 'x[0]' => 'a',
+ 'x[1]' => 'b',
+ 'x[2]' => 'c',
+ ),
+
+ array(
+ 'x' => array(
+ 'y' => array(
+ 'z' => array(
+ 40 => 'A',
+ 50 => 'B',
+ 'C' => 60,
+ ),
+ ),
+ ),
+ ),
+ array(
+ 'x[y][z][40]' => 'A',
+ 'x[y][z][50]' => 'B',
+ 'x[y][z][C]' => '60',
+ ),
+ );
+
+ for ($ii = 0; $ii < count($test_cases); $ii += 2) {
+ $input = $test_cases[$ii];
+ $expect = $test_cases[$ii + 1];
+
+ $this->assertEqual($expect, AphrontRequest::flattenData($input));
+ }
+ }
+
+ public function testGetHTTPHeader() {
+ $server_data = array(
+ 'HTTP_ACCEPT_ENCODING' => 'duck/quack',
+ 'CONTENT_TYPE' => 'cow/moo',
+ );
+
+ $this->assertEqual(
+ 'duck/quack',
+ AphrontRequest::getHTTPHeader('AcCePt-EncOdING', null, $server_data));
+ $this->assertEqual(
+ 'cow/moo',
+ AphrontRequest::getHTTPHeader('cONTent-TyPE', null, $server_data));
+ $this->assertEqual(
+ null,
+ AphrontRequest::getHTTPHeader('Pie-Flavor', null, $server_data));
+ }
+
+}
diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/configuration/AphrontApplicationConfiguration.php
@@ -0,0 +1,231 @@
+<?php
+
+/**
+ * @task routing URI Routing
+ */
+abstract class AphrontApplicationConfiguration {
+
+ private $request;
+ private $host;
+ private $path;
+ private $console;
+
+ abstract public function getApplicationName();
+ abstract public function getURIMap();
+ abstract public function buildRequest();
+ abstract public function build404Controller();
+ abstract public function buildRedirectController($uri);
+
+ final public function setRequest(AphrontRequest $request) {
+ $this->request = $request;
+ return $this;
+ }
+
+ final public function getRequest() {
+ return $this->request;
+ }
+
+ final public function getConsole() {
+ return $this->console;
+ }
+
+ final public function setConsole($console) {
+ $this->console = $console;
+ return $this;
+ }
+
+ final public function setHost($host) {
+ $this->host = $host;
+ return $this;
+ }
+
+ final public function getHost() {
+ return $this->host;
+ }
+
+ final public function setPath($path) {
+ $this->path = $path;
+ return $this;
+ }
+
+ final public function getPath() {
+ return $this->path;
+ }
+
+ public function willBuildRequest() {
+ }
+
+
+/* -( URI Routing )-------------------------------------------------------- */
+
+
+ /**
+ * Using builtin and application routes, build the appropriate
+ * @{class:AphrontController} class for the request. To route a request, we
+ * first test if the HTTP_HOST is configured as a valid Phabricator URI. If
+ * it isn't, we do a special check to see if it's a custom domain for a blog
+ * in the Phame application and if that fails we error. Otherwise, we test
+ * the URI against all builtin routes from @{method:getURIMap}, then against
+ * all application routes from installed @{class:PhabricatorApplication}s.
+ *
+ * If we match a route, we construct the controller it points at, build it,
+ * and return it.
+ *
+ * If we fail to match a route, but the current path is missing a trailing
+ * "/", we try routing the same path with a trailing "/" and do a redirect
+ * if that has a valid route. The idea is to canoncalize URIs for consistency,
+ * but avoid breaking noncanonical URIs that we can easily salvage.
+ *
+ * NOTE: We only redirect on GET. On POST, we'd drop parameters and most
+ * likely mutate the request implicitly, and a bad POST usually indicates a
+ * programming error rather than a sloppy typist.
+ *
+ * If the failing path already has a trailing "/", or we can't route the
+ * version with a "/", we call @{method:build404Controller}, which build a
+ * fallback @{class:AphrontController}.
+ *
+ * @return pair<AphrontController,dict> Controller and dictionary of request
+ * parameters.
+ * @task routing
+ */
+ final public function buildController() {
+ $request = $this->getRequest();
+
+ if (PhabricatorEnv::getEnvConfig('security.require-https')) {
+ if (!$request->isHTTPS()) {
+ $https_uri = $request->getRequestURI();
+ $https_uri->setDomain($request->getHost());
+ $https_uri->setProtocol('https');
+ return $this->buildRedirectController($https_uri);
+ }
+ }
+
+ $path = $request->getPath();
+ $host = $request->getHost();
+ $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
+ $prod_uri = PhabricatorEnv::getEnvConfig('phabricator.production-uri');
+ $file_uri = PhabricatorEnv::getEnvConfig(
+ 'security.alternate-file-domain');
+ $conduit_uris = PhabricatorEnv::getEnvConfig('conduit.servers');
+ $allowed_uris = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris');
+
+ $uris = array_merge(
+ array(
+ $base_uri,
+ $prod_uri,
+ $file_uri,
+ ),
+ $conduit_uris,
+ $allowed_uris);
+
+ $host_match = false;
+ foreach ($uris as $uri) {
+ if ($host === id(new PhutilURI($uri))->getDomain()) {
+ $host_match = true;
+ break;
+ }
+ }
+
+ // NOTE: If the base URI isn't defined yet, don't activate alternate
+ // domains.
+ if ($base_uri && !$host_match) {
+
+ try {
+ $blog = id(new PhameBlogQuery())
+ ->setViewer(new PhabricatorUser())
+ ->withDomain($host)
+ ->executeOne();
+ } catch (PhabricatorPolicyException $ex) {
+ throw new Exception(
+ 'This blog is not visible to logged out users, so it can not be '.
+ 'visited from a custom domain.');
+ }
+
+ if (!$blog) {
+ if ($prod_uri && $prod_uri != $base_uri) {
+ $prod_str = ' or '.$prod_uri;
+ } else {
+ $prod_str = '';
+ }
+ throw new Exception(
+ 'Specified domain '.$host.' is not configured for Phabricator '.
+ 'requests. Please use '.$base_uri.$prod_str.' to visit this instance.'
+ );
+ }
+
+ // TODO: Make this more flexible and modular so any application can
+ // do crazy stuff here if it wants.
+
+ $path = '/phame/live/'.$blog->getID().'/'.$path;
+ }
+
+ list($controller, $uri_data) = $this->buildControllerForPath($path);
+ if (!$controller) {
+ if (!preg_match('@/$@', $path)) {
+ // If we failed to match anything but don't have a trailing slash, try
+ // to add a trailing slash and issue a redirect if that resolves.
+ list($controller, $uri_data) = $this->buildControllerForPath($path.'/');
+
+ // NOTE: For POST, just 404 instead of redirecting, since the redirect
+ // will be a GET without parameters.
+
+ if ($controller && !$request->isHTTPPost()) {
+ $slash_uri = $request->getRequestURI()->setPath($path.'/');
+ return $this->buildRedirectController($slash_uri);
+ }
+ }
+ return $this->build404Controller();
+ }
+
+ return array($controller, $uri_data);
+ }
+
+
+ /**
+ * Map a specific path to the corresponding controller. For a description
+ * of routing, see @{method:buildController}.
+ *
+ * @return pair<AphrontController,dict> Controller and dictionary of request
+ * parameters.
+ * @task routing
+ */
+ final public function buildControllerForPath($path) {
+ $maps = array();
+ $maps[] = array(null, $this->getURIMap());
+
+ $applications = PhabricatorApplication::getAllInstalledApplications();
+ foreach ($applications as $application) {
+ $maps[] = array($application, $application->getRoutes());
+ }
+
+ $current_application = null;
+ $controller_class = null;
+ foreach ($maps as $map_info) {
+ list($application, $map) = $map_info;
+
+ $mapper = new AphrontURIMapper($map);
+ list($controller_class, $uri_data) = $mapper->mapPath($path);
+
+ if ($controller_class) {
+ if ($application) {
+ $current_application = $application;
+ }
+ break;
+ }
+ }
+
+ if (!$controller_class) {
+ return array(null, null);
+ }
+
+ $request = $this->getRequest();
+
+ $controller = newv($controller_class, array($request));
+ if ($current_application) {
+ $controller->setCurrentApplication($current_application);
+ }
+
+ return array($controller, $uri_data);
+ }
+
+}
diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php
@@ -0,0 +1,315 @@
+<?php
+
+/**
+ * NOTE: Do not extend this!
+ *
+ * @concrete-extensible
+ */
+class AphrontDefaultApplicationConfiguration
+ extends AphrontApplicationConfiguration {
+
+ public function __construct() {
+
+ }
+
+ public function getApplicationName() {
+ return 'aphront-default';
+ }
+
+ public function getURIMap() {
+ return $this->getResourceURIMapRules() + array(
+ '/~/' => array(
+ '' => 'DarkConsoleController',
+ 'data/(?P<key>[^/]+)/' => 'DarkConsoleDataController',
+ ),
+ );
+ }
+
+ protected function getResourceURIMapRules() {
+ $extensions = CelerityResourceController::getSupportedResourceTypes();
+ $extensions = array_keys($extensions);
+ $extensions = implode('|', $extensions);
+
+ return array(
+ '/res/' => array(
+ '(?:(?P<mtime>[0-9]+)T/)?'.
+ '(?P<library>[^/]+)/'.
+ '(?P<hash>[a-f0-9]{8})/'.
+ '(?P<path>.+\.(?:'.$extensions.'))'
+ => 'CelerityPhabricatorResourceController',
+ ),
+ );
+ }
+
+ /**
+ * @phutil-external-symbol class PhabricatorStartup
+ */
+ public function buildRequest() {
+ $parser = new PhutilQueryStringParser();
+ $data = array();
+
+ // If the request has "multipart/form-data" content, we can't use
+ // PhutilQueryStringParser to parse it, and the raw data supposedly is not
+ // available anyway (according to the PHP documentation, "php://input" is
+ // not available for "multipart/form-data" requests). However, it is
+ // available at least some of the time (see T3673), so double check that
+ // we aren't trying to parse data we won't be able to parse correctly by
+ // examining the Content-Type header.
+ $content_type = idx($_SERVER, 'CONTENT_TYPE');
+ $is_form_data = preg_match('@^multipart/form-data@i', $content_type);
+
+ $raw_input = PhabricatorStartup::getRawInput();
+ if (strlen($raw_input) && !$is_form_data) {
+ $data += $parser->parseQueryString($raw_input);
+ } else if ($_POST) {
+ $data += $_POST;
+ }
+
+ $data += $parser->parseQueryString(idx($_SERVER, 'QUERY_STRING', ''));
+
+ $cookie_prefix = PhabricatorEnv::getEnvConfig('phabricator.cookie-prefix');
+
+ $request = new AphrontRequest($this->getHost(), $this->getPath());
+ $request->setRequestData($data);
+ $request->setApplicationConfiguration($this);
+ $request->setCookiePrefix($cookie_prefix);
+
+ return $request;
+ }
+
+ public function handleException(Exception $ex) {
+ $request = $this->getRequest();
+
+ // For Conduit requests, return a Conduit response.
+ if ($request->isConduit()) {
+ $response = new ConduitAPIResponse();
+ $response->setErrorCode(get_class($ex));
+ $response->setErrorInfo($ex->getMessage());
+
+ return id(new AphrontJSONResponse())
+ ->setAddJSONShield(false)
+ ->setContent($response->toDictionary());
+ }
+
+ // For non-workflow requests, return a Ajax response.
+ if ($request->isAjax() && !$request->isJavelinWorkflow()) {
+ // Log these; they don't get shown on the client and can be difficult
+ // to debug.
+ phlog($ex);
+
+ $response = new AphrontAjaxResponse();
+ $response->setError(
+ array(
+ 'code' => get_class($ex),
+ 'info' => $ex->getMessage(),
+ ));
+ return $response;
+ }
+
+ $user = $request->getUser();
+ if (!$user) {
+ // If we hit an exception very early, we won't have a user.
+ $user = new PhabricatorUser();
+ }
+
+ if ($ex instanceof PhabricatorSystemActionRateLimitException) {
+ $dialog = id(new AphrontDialogView())
+ ->setTitle(pht('Slow Down!'))
+ ->setUser($user)
+ ->setErrors(array(pht('You are being rate limited.')))
+ ->appendParagraph($ex->getMessage())
+ ->appendParagraph($ex->getRateExplanation())
+ ->addCancelButton('/', pht('Okaaaaaaaaaaaaaay...'));
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ return $response;
+ }
+
+ if ($ex instanceof PhabricatorAuthHighSecurityRequiredException) {
+
+ $form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm(
+ $ex->getFactors(),
+ $ex->getFactorValidationResults(),
+ $user,
+ $request);
+
+ $dialog = id(new AphrontDialogView())
+ ->setUser($user)
+ ->setTitle(pht('Entering High Security'))
+ ->setShortTitle(pht('Security Checkpoint'))
+ ->setWidth(AphrontDialogView::WIDTH_FORM)
+ ->addHiddenInput(AphrontRequest::TYPE_HISEC, true)
+ ->setErrors(
+ array(
+ pht(
+ 'You are taking an action which requires you to enter '.
+ 'high security.'),
+ ))
+ ->appendParagraph(
+ pht(
+ 'High security mode helps protect your account from security '.
+ 'threats, like session theft or someone messing with your stuff '.
+ 'while you\'re grabbing a coffee. To enter high security mode, '.
+ 'confirm your credentials.'))
+ ->appendChild($form->buildLayoutView())
+ ->appendParagraph(
+ pht(
+ 'Your account will remain in high security mode for a short '.
+ 'period of time. When you are finished taking sensitive '.
+ 'actions, you should leave high security.'))
+ ->setSubmitURI($request->getPath())
+ ->addCancelButton($ex->getCancelURI())
+ ->addSubmitButton(pht('Enter High Security'));
+
+ foreach ($request->getPassthroughRequestParameters() as $key => $value) {
+ $dialog->addHiddenInput($key, $value);
+ }
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ return $response;
+ }
+
+ if ($ex instanceof PhabricatorPolicyException) {
+
+ if (!$user->isLoggedIn()) {
+ // If the user isn't logged in, just give them a login form. This is
+ // probably a generally more useful response than a policy dialog that
+ // they have to click through to get a login form.
+ //
+ // Possibly we should add a header here like "you need to login to see
+ // the thing you are trying to look at".
+ $login_controller = new PhabricatorAuthStartController($request);
+
+ $auth_app_class = 'PhabricatorApplicationAuth';
+ $auth_app = PhabricatorApplication::getByClass($auth_app_class);
+ $login_controller->setCurrentApplication($auth_app);
+
+ return $login_controller->processRequest();
+ }
+
+ $list = $ex->getMoreInfo();
+ foreach ($list as $key => $item) {
+ $list[$key] = phutil_tag('li', array(), $item);
+ }
+ if ($list) {
+ $list = phutil_tag('ul', array(), $list);
+ }
+
+ $content = array(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-policy-rejection',
+ ),
+ $ex->getRejection()),
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-capability-details',
+ ),
+ pht('Users with the "%s" capability:', $ex->getCapabilityName())),
+ $list,
+ );
+
+ $dialog = new AphrontDialogView();
+ $dialog
+ ->setTitle($ex->getTitle())
+ ->setClass('aphront-access-dialog')
+ ->setUser($user)
+ ->appendChild($content);
+
+ if ($this->getRequest()->isAjax()) {
+ $dialog->addCancelButton('/', pht('Close'));
+ } else {
+ $dialog->addCancelButton('/', pht('OK'));
+ }
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ return $response;
+ }
+
+ if ($ex instanceof AphrontUsageException) {
+ $error = new AphrontErrorView();
+ $error->setTitle($ex->getTitle());
+ $error->appendChild($ex->getMessage());
+
+ $view = new PhabricatorStandardPageView();
+ $view->setRequest($this->getRequest());
+ $view->appendChild($error);
+
+ $response = new AphrontWebpageResponse();
+ $response->setContent($view->render());
+ $response->setHTTPResponseCode(500);
+
+ return $response;
+ }
+
+
+ // Always log the unhandled exception.
+ phlog($ex);
+
+ $class = get_class($ex);
+ $message = $ex->getMessage();
+
+ if ($ex instanceof AphrontQuerySchemaException) {
+ $message .=
+ "\n\n".
+ "NOTE: This usually indicates that the MySQL schema has not been ".
+ "properly upgraded. Run 'bin/storage upgrade' to ensure your ".
+ "schema is up to date.";
+ }
+
+ if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
+ $trace = id(new AphrontStackTraceView())
+ ->setUser($user)
+ ->setTrace($ex->getTrace());
+ } else {
+ $trace = null;
+ }
+
+ $content = phutil_tag(
+ 'div',
+ array('class' => 'aphront-unhandled-exception'),
+ array(
+ phutil_tag('div', array('class' => 'exception-message'), $message),
+ $trace,
+ ));
+
+ $dialog = new AphrontDialogView();
+ $dialog
+ ->setTitle('Unhandled Exception ("'.$class.'")')
+ ->setClass('aphront-exception-dialog')
+ ->setUser($user)
+ ->appendChild($content);
+
+ if ($this->getRequest()->isAjax()) {
+ $dialog->addCancelButton('/', 'Close');
+ }
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ $response->setHTTPResponseCode(500);
+
+ return $response;
+ }
+
+ public function willSendResponse(AphrontResponse $response) {
+ return $response;
+ }
+
+ public function build404Controller() {
+ return array(new Phabricator404Controller($this->getRequest()), array());
+ }
+
+ public function buildRedirectController($uri) {
+ return array(
+ new PhabricatorRedirectController($this->getRequest()),
+ array(
+ 'uri' => $uri,
+ ));
+ }
+
+}
diff --git a/src/aphront/exception/AphrontCSRFException.php b/src/aphront/exception/AphrontCSRFException.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/exception/AphrontCSRFException.php
@@ -0,0 +1,5 @@
+<?php
+
+final class AphrontCSRFException extends AphrontException {
+
+}
diff --git a/src/aphront/exception/AphrontException.php b/src/aphront/exception/AphrontException.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/exception/AphrontException.php
@@ -0,0 +1,5 @@
+<?php
+
+abstract class AphrontException extends Exception {
+
+}
diff --git a/src/aphront/exception/AphrontUsageException.php b/src/aphront/exception/AphrontUsageException.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/exception/AphrontUsageException.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * These exceptions represent user error, and are not logged.
+ *
+ * @concrete-extensible
+ */
+class AphrontUsageException extends AphrontException {
+
+ private $title;
+
+ public function __construct($title, $message) {
+ $this->title = $title;
+ parent::__construct($message);
+ }
+
+ public function getTitle() {
+ return $this->title;
+ }
+
+}
diff --git a/src/aphront/response/Aphront304Response.php b/src/aphront/response/Aphront304Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront304Response.php
@@ -0,0 +1,16 @@
+<?php
+
+final class Aphront304Response extends AphrontResponse {
+
+ public function getHTTPResponseCode() {
+ return 304;
+ }
+
+ public function buildResponseString() {
+ // IMPORTANT! According to the HTTP/1.1 spec (RFC 2616) a 304 response
+ // "MUST NOT" have any content. Apache + Safari strongly agree, and
+ // completely flip out and you start getting 304s for no-cache pages.
+ return null;
+ }
+
+}
diff --git a/src/aphront/response/Aphront400Response.php b/src/aphront/response/Aphront400Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront400Response.php
@@ -0,0 +1,13 @@
+<?php
+
+final class Aphront400Response extends AphrontResponse {
+
+ public function getHTTPResponseCode() {
+ return 400;
+ }
+
+ public function buildResponseString() {
+ return '400 Bad Request';
+ }
+
+}
diff --git a/src/aphront/response/Aphront403Response.php b/src/aphront/response/Aphront403Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront403Response.php
@@ -0,0 +1,36 @@
+<?php
+
+final class Aphront403Response extends AphrontHTMLResponse {
+
+ private $forbiddenText;
+ public function setForbiddenText($text) {
+ $this->forbiddenText = $text;
+ return $this;
+ }
+ private function getForbiddenText() {
+ return $this->forbiddenText;
+ }
+
+ public function getHTTPResponseCode() {
+ return 403;
+ }
+
+ public function buildResponseString() {
+ $forbidden_text = $this->getForbiddenText();
+ if (!$forbidden_text) {
+ $forbidden_text =
+ 'You do not have privileges to access the requested page.';
+ }
+ $failure = new AphrontRequestFailureView();
+ $failure->setHeader('403 Forbidden');
+ $failure->appendChild(phutil_tag('p', array(), $forbidden_text));
+
+ $view = new PhabricatorStandardPageView();
+ $view->setTitle('403 Forbidden');
+ $view->setRequest($this->getRequest());
+ $view->appendChild($failure);
+
+ return $view->render();
+ }
+
+}
diff --git a/src/aphront/response/Aphront404Response.php b/src/aphront/response/Aphront404Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront404Response.php
@@ -0,0 +1,23 @@
+<?php
+
+final class Aphront404Response extends AphrontHTMLResponse {
+
+ public function getHTTPResponseCode() {
+ return 404;
+ }
+
+ public function buildResponseString() {
+ $failure = new AphrontRequestFailureView();
+ $failure->setHeader('404 Not Found');
+ $failure->appendChild(phutil_tag('p', array(), pht(
+ 'The page you requested was not found.')));
+
+ $view = new PhabricatorStandardPageView();
+ $view->setTitle('404 Not Found');
+ $view->setRequest($this->getRequest());
+ $view->appendChild($failure);
+
+ return $view->render();
+ }
+
+}
diff --git a/src/aphront/response/AphrontAjaxResponse.php b/src/aphront/response/AphrontAjaxResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontAjaxResponse.php
@@ -0,0 +1,77 @@
+<?php
+
+final class AphrontAjaxResponse extends AphrontResponse {
+
+ private $content;
+ private $error;
+ private $disableConsole;
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function setError($error) {
+ $this->error = $error;
+ return $this;
+ }
+
+ public function setDisableConsole($disable) {
+ $this->disableConsole = $disable;
+ return $this;
+ }
+
+ private function getConsole() {
+ if ($this->disableConsole) {
+ $console = null;
+ } else {
+ $request = $this->getRequest();
+ $console = $request->getApplicationConfiguration()->getConsole();
+ }
+ return $console;
+ }
+
+ public function buildResponseString() {
+ $console = $this->getConsole();
+ if ($console) {
+ // NOTE: We're stripping query parameters here both for readability and
+ // to mitigate BREACH and similar attacks. The parameters are available
+ // in the "Request" tab, so this should not impact usability. See T3684.
+ $uri = $this->getRequest()->getRequestURI();
+ $uri = new PhutilURI($uri);
+ $uri->setQueryParams(array());
+
+ Javelin::initBehavior(
+ 'dark-console',
+ array(
+ 'uri' => (string)$uri,
+ 'key' => $console->getKey($this->getRequest()),
+ 'color' => $console->getColor(),
+ ));
+ }
+
+ // Flatten the response first, so we initialize any behaviors and metadata
+ // we need to.
+ $content = array(
+ 'payload' => $this->content,
+ );
+ $this->encodeJSONForHTTPResponse($content);
+
+ $response = CelerityAPI::getStaticResourceResponse();
+ $object = $response->buildAjaxResponse(
+ $content['payload'],
+ $this->error);
+
+ $response_json = $this->encodeJSONForHTTPResponse($object);
+ return $this->addJSONShield($response_json);
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'text/plain; charset=UTF-8'),
+ );
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontDialogResponse.php b/src/aphront/response/AphrontDialogResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontDialogResponse.php
@@ -0,0 +1,20 @@
+<?php
+
+final class AphrontDialogResponse extends AphrontResponse {
+
+ private $dialog;
+
+ public function setDialog(AphrontDialogView $dialog) {
+ $this->dialog = $dialog;
+ return $this;
+ }
+
+ public function getDialog() {
+ return $this->dialog;
+ }
+
+ public function buildResponseString() {
+ return $this->dialog->render();
+ }
+
+}
diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontFileResponse.php
@@ -0,0 +1,92 @@
+<?php
+
+final class AphrontFileResponse extends AphrontResponse {
+
+ private $content;
+ private $mimeType;
+ private $download;
+ private $rangeMin;
+ private $rangeMax;
+ private $allowOrigins = array();
+
+ public function addAllowOrigin($origin) {
+ $this->allowOrigins[] = $origin;
+ return $this;
+ }
+
+ public function setDownload($download) {
+ $download = preg_replace('/[^A-Za-z0-9_.-]/', '_', $download);
+ if (!strlen($download)) {
+ $download = 'untitled_document.txt';
+ }
+ $this->download = $download;
+ return $this;
+ }
+
+ public function getDownload() {
+ return $this->download;
+ }
+
+ public function setMimeType($mime_type) {
+ $this->mimeType = $mime_type;
+ return $this;
+ }
+
+ public function getMimeType() {
+ return $this->mimeType;
+ }
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function buildResponseString() {
+ if ($this->rangeMin || $this->rangeMax) {
+ $length = ($this->rangeMax - $this->rangeMin) + 1;
+ return substr($this->content, $this->rangeMin, $length);
+ } else {
+ return $this->content;
+ }
+ }
+
+ public function setRange($min, $max) {
+ $this->rangeMin = $min;
+ $this->rangeMax = $max;
+ return $this;
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', $this->getMimeType()),
+ array('Content-Length', strlen($this->buildResponseString())),
+ );
+
+ if ($this->rangeMin || $this->rangeMax) {
+ $len = strlen($this->content);
+ $min = $this->rangeMin;
+ $max = $this->rangeMax;
+ $headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}");
+ }
+
+ if (strlen($this->getDownload())) {
+ $headers[] = array('X-Download-Options', 'noopen');
+
+ $filename = $this->getDownload();
+ $headers[] = array(
+ 'Content-Disposition',
+ 'attachment; filename='.$filename,
+ );
+ }
+
+ if ($this->allowOrigins) {
+ $headers[] = array(
+ 'Access-Control-Allow-Origin',
+ implode(',', $this->allowOrigins));
+ }
+
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontHTMLResponse.php b/src/aphront/response/AphrontHTMLResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontHTMLResponse.php
@@ -0,0 +1,13 @@
+<?php
+
+abstract class AphrontHTMLResponse extends AphrontResponse {
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'text/html; charset=UTF-8'),
+ );
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontJSONResponse.php b/src/aphront/response/AphrontJSONResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontJSONResponse.php
@@ -0,0 +1,41 @@
+<?php
+
+final class AphrontJSONResponse extends AphrontResponse {
+
+ private $content;
+ private $addJSONShield;
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function setAddJSONShield($should_add) {
+ $this->addJSONShield = $should_add;
+ return $this;
+ }
+
+ public function shouldAddJSONShield() {
+ if ($this->addJSONShield === null) {
+ return true;
+ }
+ return (bool) $this->addJSONShield;
+ }
+
+ public function buildResponseString() {
+ $response = $this->encodeJSONForHTTPResponse($this->content);
+ if ($this->shouldAddJSONShield()) {
+ $response = $this->addJSONShield($response);
+ }
+ return $response;
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'application/json'),
+ );
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontPlainTextResponse.php b/src/aphront/response/AphrontPlainTextResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontPlainTextResponse.php
@@ -0,0 +1,22 @@
+<?php
+
+final class AphrontPlainTextResponse extends AphrontResponse {
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function buildResponseString() {
+ return $this->content;
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'text/plain; charset=utf-8'),
+ );
+
+ return array_merge(parent::getHeaders(), $headers);
+ }
+
+}
diff --git a/src/aphront/response/AphrontProxyResponse.php b/src/aphront/response/AphrontProxyResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontProxyResponse.php
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * Base class for responses which augment other types of responses. For example,
+ * a response might be substantially an Ajax response, but add structure to the
+ * response content. It can do this by extending @{class:AphrontProxyResponse},
+ * instantiating an @{class:AphrontAjaxResponse} in @{method:buildProxy}, and
+ * then constructing a real @{class:AphrontAjaxResponse} in
+ * @{method:reduceProxyResponse}.
+ */
+abstract class AphrontProxyResponse extends AphrontResponse {
+
+ private $proxy;
+
+ protected function getProxy() {
+ if (!$this->proxy) {
+ $this->proxy = $this->buildProxy();
+ }
+ return $this->proxy;
+ }
+
+ public function setRequest($request) {
+ $this->getProxy()->setRequest($request);
+ return $this;
+ }
+
+ public function getRequest() {
+ return $this->getProxy()->getRequest();
+ }
+
+ public function getHeaders() {
+ return $this->getProxy()->getHeaders();
+ }
+
+ public function setCacheDurationInSeconds($duration) {
+ $this->getProxy()->setCacheDurationInSeconds($duration);
+ return $this;
+ }
+
+ public function setLastModified($epoch_timestamp) {
+ $this->getProxy()->setLastModified($epoch_timestamp);
+ return $this;
+ }
+
+ public function setHTTPResponseCode($code) {
+ $this->getProxy()->setHTTPResponseCode($code);
+ return $this;
+ }
+
+ public function getHTTPResponseCode() {
+ return $this->getProxy()->getHTTPResponseCode();
+ }
+
+ public function setFrameable($frameable) {
+ $this->getProxy()->setFrameable($frameable);
+ return $this;
+ }
+
+ public function getCacheHeaders() {
+ return $this->getProxy()->getCacheHeaders();
+ }
+
+ abstract protected function buildProxy();
+ abstract public function reduceProxyResponse();
+
+ final public function buildResponseString() {
+ throw new Exception(
+ 'AphrontProxyResponse must implement reduceProxyResponse().');
+ }
+
+}
diff --git a/src/aphront/response/AphrontRedirectResponse.php b/src/aphront/response/AphrontRedirectResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontRedirectResponse.php
@@ -0,0 +1,88 @@
+<?php
+
+/**
+ * TODO: Should be final but isn't because of AphrontReloadResponse.
+ */
+class AphrontRedirectResponse extends AphrontResponse {
+
+ private $uri;
+ private $stackWhenCreated;
+
+ public function __construct() {
+ if ($this->shouldStopForDebugging()) {
+ // If we're going to stop, capture the stack so we can print it out.
+ $this->stackWhenCreated = id(new Exception())->getTrace();
+ }
+ }
+
+ public function setURI($uri) {
+ $this->uri = $uri;
+ return $this;
+ }
+
+ public function getURI() {
+ return (string)$this->uri;
+ }
+
+ public function shouldStopForDebugging() {
+ return PhabricatorEnv::getEnvConfig('debug.stop-on-redirect');
+ }
+
+ public function getHeaders() {
+ $headers = array();
+ if (!$this->shouldStopForDebugging()) {
+ $headers[] = array('Location', $this->uri);
+ }
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+ public function buildResponseString() {
+ if ($this->shouldStopForDebugging()) {
+ $request = $this->getRequest();
+ $viewer = $request->getUser();
+
+ $view = new PhabricatorStandardPageView();
+ $view->setRequest($this->getRequest());
+ $view->setApplicationName(pht('Debug'));
+ $view->setTitle(pht('Stopped on Redirect'));
+
+ $dialog = new AphrontDialogView();
+ $dialog->setUser($viewer);
+ $dialog->setTitle(pht('Stopped on Redirect'));
+
+ $dialog->appendParagraph(
+ pht(
+ 'You were stopped here because %s is set in your configuration.',
+ phutil_tag('tt', array(), 'debug.stop-on-redirect')));
+
+ $dialog->appendParagraph(
+ pht(
+ 'You are being redirected to: %s',
+ phutil_tag('tt', array(), $this->getURI())));
+
+ $dialog->addCancelButton($this->getURI(), pht('Continue'));
+
+ $dialog->appendChild(phutil_tag('br'));
+
+ $dialog->appendChild(
+ id(new AphrontStackTraceView())
+ ->setUser($viewer)
+ ->setTrace($this->stackWhenCreated));
+
+ $dialog->setIsStandalone(true);
+ $dialog->setWidth(AphrontDialogView::WIDTH_FULL);
+
+ $box = id(new PHUIBoxView())
+ ->addMargin(PHUI::MARGIN_LARGE)
+ ->appendChild($dialog);
+
+ $view->appendChild($box);
+
+ return $view->render();
+ }
+
+ return '';
+ }
+
+}
diff --git a/src/aphront/response/AphrontReloadResponse.php b/src/aphront/response/AphrontReloadResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontReloadResponse.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * When actions happen over a JX.Workflow, we may want to reload the page
+ * if the action is javascript-driven but redirect if it isn't. This preserves
+ * query parameters in the javascript case. A reload response behaves like
+ * a redirect response but causes a page reload when received via workflow.
+ */
+final class AphrontReloadResponse extends AphrontRedirectResponse {
+
+ public function getURI() {
+ if ($this->getRequest()->isAjax()) {
+ return null;
+ } else {
+ return parent::getURI();
+ }
+ }
+
+}
diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontResponse.php
@@ -0,0 +1,147 @@
+<?php
+
+abstract class AphrontResponse {
+
+ private $request;
+ private $cacheable = false;
+ private $responseCode = 200;
+ private $lastModified = null;
+
+ protected $frameable;
+
+ public function setRequest($request) {
+ $this->request = $request;
+ return $this;
+ }
+
+ public function getRequest() {
+ return $this->request;
+ }
+
+ public function getHeaders() {
+ $headers = array();
+ if (!$this->frameable) {
+ $headers[] = array('X-Frame-Options', 'Deny');
+ }
+
+ return $headers;
+ }
+
+ public function setCacheDurationInSeconds($duration) {
+ $this->cacheable = $duration;
+ return $this;
+ }
+
+ public function setLastModified($epoch_timestamp) {
+ $this->lastModified = $epoch_timestamp;
+ return $this;
+ }
+
+ public function setHTTPResponseCode($code) {
+ $this->responseCode = $code;
+ return $this;
+ }
+
+ public function getHTTPResponseCode() {
+ return $this->responseCode;
+ }
+
+ public function getHTTPResponseMessage() {
+ return '';
+ }
+
+ public function setFrameable($frameable) {
+ $this->frameable = $frameable;
+ return $this;
+ }
+
+ public static function processValueForJSONEncoding(&$value, $key) {
+ if ($value instanceof PhutilSafeHTMLProducerInterface) {
+ // This renders the producer down to PhutilSafeHTML, which will then
+ // be simplified into a string below.
+ $value = hsprintf('%s', $value);
+ }
+
+ if ($value instanceof PhutilSafeHTML) {
+ // TODO: Javelin supports implicity conversion of '__html' objects to
+ // JX.HTML, but only for Ajax responses, not behaviors. Just leave things
+ // as they are for now (where behaviors treat responses as HTML or plain
+ // text at their discretion).
+ $value = $value->getHTMLContent();
+ }
+ }
+
+ public static function encodeJSONForHTTPResponse(array $object) {
+
+ array_walk_recursive(
+ $object,
+ array('AphrontResponse', 'processValueForJSONEncoding'));
+
+ $response = json_encode($object);
+
+ // Prevent content sniffing attacks by encoding "<" and ">", so browsers
+ // won't try to execute the document as HTML even if they ignore
+ // Content-Type and X-Content-Type-Options. See T865.
+ $response = str_replace(
+ array('<', '>'),
+ array('\u003c', '\u003e'),
+ $response);
+
+ return $response;
+ }
+
+ protected function addJSONShield($json_response) {
+ // Add a shield to prevent "JSON Hijacking" attacks where an attacker
+ // requests a JSON response using a normal <script /> tag and then uses
+ // Object.prototype.__defineSetter__() or similar to read response data.
+ // This header causes the browser to loop infinitely instead of handing over
+ // sensitive data.
+
+ $shield = 'for (;;);';
+
+ $response = $shield.$json_response;
+
+ return $response;
+ }
+
+ public function getCacheHeaders() {
+ $headers = array();
+ if ($this->cacheable) {
+ $headers[] = array(
+ 'Expires',
+ $this->formatEpochTimestampForHTTPHeader(time() + $this->cacheable));
+ } else {
+ $headers[] = array(
+ 'Cache-Control',
+ 'private, no-cache, no-store, must-revalidate');
+ $headers[] = array(
+ 'Pragma',
+ 'no-cache');
+ $headers[] = array(
+ 'Expires',
+ 'Sat, 01 Jan 2000 00:00:00 GMT');
+ }
+
+ if ($this->lastModified) {
+ $headers[] = array(
+ 'Last-Modified',
+ $this->formatEpochTimestampForHTTPHeader($this->lastModified));
+ }
+
+ // IE has a feature where it may override an explicit Content-Type
+ // declaration by inferring a content type. This can be a security risk
+ // and we always explicitly transmit the correct Content-Type header, so
+ // prevent IE from using inferred content types. This only offers protection
+ // on recent versions of IE; IE6/7 and Opera currently ignore this header.
+ $headers[] = array('X-Content-Type-Options', 'nosniff');
+
+ return $headers;
+ }
+
+ private function formatEpochTimestampForHTTPHeader($epoch_timestamp) {
+ return gmdate('D, d M Y H:i:s', $epoch_timestamp).' GMT';
+ }
+
+ abstract public function buildResponseString();
+
+}
diff --git a/src/aphront/response/AphrontWebpageResponse.php b/src/aphront/response/AphrontWebpageResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontWebpageResponse.php
@@ -0,0 +1,16 @@
+<?php
+
+final class AphrontWebpageResponse extends AphrontHTMLResponse {
+
+ private $content;
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function buildResponseString() {
+ return hsprintf('%s', $this->content);
+ }
+
+}
diff --git a/src/aphront/sink/AphrontHTTPSink.php b/src/aphront/sink/AphrontHTTPSink.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/AphrontHTTPSink.php
@@ -0,0 +1,119 @@
+<?php
+
+/**
+ * Abstract class which wraps some sort of output mechanism for HTTP responses.
+ * Normally this is just @{class:AphrontPHPHTTPSink}, which uses "echo" and
+ * "header()" to emit responses.
+ *
+ * Mostly, this class allows us to do install security or metrics hooks in the
+ * output pipeline.
+ *
+ * @task write Writing Response Components
+ * @task emit Emitting the Response
+ */
+abstract class AphrontHTTPSink {
+
+
+/* -( Writing Response Components )---------------------------------------- */
+
+
+ /**
+ * Write an HTTP status code to the output.
+ *
+ * @param int Numeric HTTP status code.
+ * @return void
+ */
+ final public function writeHTTPStatus($code, $message = '') {
+ if (!preg_match('/^\d{3}$/', $code)) {
+ throw new Exception("Malformed HTTP status code '{$code}'!");
+ }
+
+ $code = (int)$code;
+ $this->emitHTTPStatus($code, $message);
+ }
+
+
+ /**
+ * Write HTTP headers to the output.
+ *
+ * @param list<pair> List of <name, value> pairs.
+ * @return void
+ */
+ final public function writeHeaders(array $headers) {
+ foreach ($headers as $header) {
+ if (!is_array($header) || count($header) !== 2) {
+ throw new Exception('Malformed header.');
+ }
+ list($name, $value) = $header;
+
+ if (strpos($name, ':') !== false) {
+ throw new Exception(
+ 'Declining to emit response with malformed HTTP header name: '.
+ $name);
+ }
+
+ // Attackers may perform an "HTTP response splitting" attack by making
+ // the application emit certain types of headers containing newlines:
+ //
+ // http://en.wikipedia.org/wiki/HTTP_response_splitting
+ //
+ // PHP has built-in protections against HTTP response-splitting, but they
+ // are of dubious trustworthiness:
+ //
+ // http://news.php.net/php.internals/57655
+
+ if (preg_match('/[\r\n\0]/', $name.$value)) {
+ throw new Exception(
+ "Declining to emit response with unsafe HTTP header: ".
+ "<'".$name."', '".$value."'>.");
+ }
+ }
+
+ foreach ($headers as $header) {
+ list($name, $value) = $header;
+ $this->emitHeader($name, $value);
+ }
+ }
+
+
+ /**
+ * Write HTTP body data to the output.
+ *
+ * @param string Body data.
+ * @return void
+ */
+ final public function writeData($data) {
+ $this->emitData($data);
+ }
+
+
+ /**
+ * Write an entire @{class:AphrontResponse} to the output.
+ *
+ * @param AphrontResponse The response object to write.
+ * @return void
+ */
+ final public function writeResponse(AphrontResponse $response) {
+ // Do this first, in case it throws.
+ $response_string = $response->buildResponseString();
+
+ $all_headers = array_merge(
+ $response->getHeaders(),
+ $response->getCacheHeaders());
+
+ $this->writeHTTPStatus(
+ $response->getHTTPResponseCode(),
+ $response->getHTTPResponseMessage());
+ $this->writeHeaders($all_headers);
+ $this->writeData($response_string);
+ }
+
+
+/* -( Emitting the Response )---------------------------------------------- */
+
+
+ abstract protected function emitHTTPStatus($code, $message = '');
+ abstract protected function emitHeader($name, $value);
+ abstract protected function emitData($data);
+
+}
diff --git a/src/aphront/sink/AphrontIsolatedHTTPSink.php b/src/aphront/sink/AphrontIsolatedHTTPSink.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/AphrontIsolatedHTTPSink.php
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * Isolated HTTP sink for testing.
+ */
+final class AphrontIsolatedHTTPSink extends AphrontHTTPSink {
+
+ private $status;
+ private $headers;
+ private $data;
+
+ protected function emitHTTPStatus($code, $message = '') {
+ $this->status = $code;
+ }
+
+ protected function emitHeader($name, $value) {
+ $this->headers[] = array($name, $value);
+ }
+
+ protected function emitData($data) {
+ $this->data .= $data;
+ }
+
+ public function getEmittedHTTPStatus() {
+ return $this->status;
+ }
+
+ public function getEmittedHeaders() {
+ return $this->headers;
+ }
+
+ public function getEmittedData() {
+ return $this->data;
+ }
+
+}
diff --git a/src/aphront/sink/AphrontPHPHTTPSink.php b/src/aphront/sink/AphrontPHPHTTPSink.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/AphrontPHPHTTPSink.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * Concrete HTTP sink which uses "echo" and "header()" to emit data.
+ */
+final class AphrontPHPHTTPSink extends AphrontHTTPSink {
+
+ protected function emitHTTPStatus($code, $message = '') {
+ if ($code != 200) {
+ $header = "HTTP/1.0 {$code}";
+ if (strlen($message)) {
+ $header .= " {$message}";
+ }
+ header($header);
+ }
+ }
+
+ protected function emitHeader($name, $value) {
+ header("{$name}: {$value}", $replace = false);
+ }
+
+ protected function emitData($data) {
+ echo $data;
+ }
+
+}
diff --git a/src/aphront/sink/__tests__/AphrontHTTPSinkTestCase.php b/src/aphront/sink/__tests__/AphrontHTTPSinkTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/__tests__/AphrontHTTPSinkTestCase.php
@@ -0,0 +1,82 @@
+<?php
+
+final class AphrontHTTPSinkTestCase extends PhutilTestCase {
+
+ public function testHTTPSinkBasics() {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHTTPStatus(200);
+ $sink->writeHeaders(array(array('X-Test', 'test')));
+ $sink->writeData('test');
+
+ $this->assertEqual(200, $sink->getEmittedHTTPStatus());
+ $this->assertEqual(
+ array(array('X-Test', 'test')),
+ $sink->getEmittedHeaders());
+ $this->assertEqual('test', $sink->getEmittedData());
+ }
+
+ public function testHTTPSinkStatusCode() {
+ $input = $this->tryTestCaseMap(
+ array(
+ 200 => true,
+ '201' => true,
+ 1 => false,
+ 1000 => false,
+ 'apple' => false,
+ '' => false,
+ ),
+ array($this, 'tryHTTPSinkStatusCode'));
+ }
+
+ protected function tryHTTPSinkStatusCode($input) {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHTTPStatus($input);
+ }
+
+ public function testHTTPSinkResponseSplitting() {
+ $input = $this->tryTestCaseMap(
+ array(
+ 'test' => true,
+ "test\nx" => false,
+ "test\rx" => false,
+ "test\0x" => false,
+ ),
+ array($this, 'tryHTTPSinkResponseSplitting'));
+ }
+
+ protected function tryHTTPSinkResponseSplitting($input) {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHeaders(array(array('X-Test', $input)));
+ }
+
+ public function testHTTPHeaderNames() {
+ $input = $this->tryTestCaseMap(
+ array(
+ 'test' => true,
+ 'test:' => false,
+ ),
+ array($this, 'tryHTTPHeaderNames'));
+ }
+
+ protected function tryHTTPHeaderNames($input) {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHeaders(array(array($input, 'value')));
+ }
+
+ public function testJSONContentSniff() {
+ $response = id(new AphrontJSONResponse())
+ ->setContent(
+ array(
+ 'x' => '<iframe>',
+ ));
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeResponse($response);
+
+ $this->assertEqual(
+ 'for (;;);{"x":"\u003ciframe\u003e"}',
+ $sink->getEmittedData(),
+ 'JSONResponse should prevent content-sniffing attacks.');
+ }
+
+
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Aug 8, 3:38 PM (11 h, 59 m)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/ma/sz/fokvgqa53mf6mjsa
Default Alt Text
D9941.id23851.diff (78 KB)

Event Timeline