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 @@ -224,6 +224,8 @@ 'AphrontFormView' => 'view/form/AphrontFormView.php', 'AphrontGlyphBarView' => 'view/widget/bars/AphrontGlyphBarView.php', 'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php', + 'AphrontHTTPHeaderParser' => 'aphront/headerparser/AphrontHTTPHeaderParser.php', + 'AphrontHTTPHeaderParserTestCase' => 'aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php', 'AphrontHTTPParameterType' => 'aphront/httpparametertype/AphrontHTTPParameterType.php', 'AphrontHTTPProxyResponse' => 'aphront/response/AphrontHTTPProxyResponse.php', 'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php', @@ -242,6 +244,9 @@ 'AphrontMalformedRequestException' => 'aphront/exception/AphrontMalformedRequestException.php', 'AphrontMoreView' => 'view/layout/AphrontMoreView.php', 'AphrontMultiColumnView' => 'view/layout/AphrontMultiColumnView.php', + 'AphrontMultipartParser' => 'aphront/multipartparser/AphrontMultipartParser.php', + 'AphrontMultipartParserTestCase' => 'aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php', + 'AphrontMultipartPart' => 'aphront/multipartparser/AphrontMultipartPart.php', 'AphrontMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php', 'AphrontMySQLDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontMySQLDatabaseConnectionTestCase.php', 'AphrontMySQLiDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php', @@ -265,12 +270,14 @@ 'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php', 'AphrontRequest' => 'aphront/AphrontRequest.php', 'AphrontRequestExceptionHandler' => 'aphront/handler/AphrontRequestExceptionHandler.php', + 'AphrontRequestStream' => 'aphront/requeststream/AphrontRequestStream.php', 'AphrontRequestTestCase' => 'aphront/__tests__/AphrontRequestTestCase.php', 'AphrontResponse' => 'aphront/response/AphrontResponse.php', 'AphrontResponseProducerInterface' => 'aphront/interface/AphrontResponseProducerInterface.php', 'AphrontRoutingMap' => 'aphront/site/AphrontRoutingMap.php', 'AphrontRoutingResult' => 'aphront/site/AphrontRoutingResult.php', 'AphrontSchemaQueryException' => 'infrastructure/storage/exception/AphrontSchemaQueryException.php', + 'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php', 'AphrontSelectHTTPParameterType' => 'aphront/httpparametertype/AphrontSelectHTTPParameterType.php', 'AphrontSideNavFilterView' => 'view/layout/AphrontSideNavFilterView.php', 'AphrontSite' => 'aphront/site/AphrontSite.php', @@ -286,6 +293,7 @@ 'AphrontUserListHTTPParameterType' => 'aphront/httpparametertype/AphrontUserListHTTPParameterType.php', 'AphrontView' => 'view/AphrontView.php', 'AphrontWebpageResponse' => 'aphront/response/AphrontWebpageResponse.php', + 'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php', 'ArcanistConduitAPIMethod' => 'applications/arcanist/conduit/ArcanistConduitAPIMethod.php', 'AuditConduitAPIMethod' => 'applications/audit/conduit/AuditConduitAPIMethod.php', 'AuditQueryConduitAPIMethod' => 'applications/audit/conduit/AuditQueryConduitAPIMethod.php', @@ -6170,6 +6178,8 @@ 'AphrontFormView' => 'AphrontView', 'AphrontGlyphBarView' => 'AphrontBarView', 'AphrontHTMLResponse' => 'AphrontResponse', + 'AphrontHTTPHeaderParser' => 'Phobject', + 'AphrontHTTPHeaderParserTestCase' => 'PhutilTestCase', 'AphrontHTTPParameterType' => 'Phobject', 'AphrontHTTPProxyResponse' => 'AphrontResponse', 'AphrontHTTPSink' => 'Phobject', @@ -6188,6 +6198,9 @@ 'AphrontMalformedRequestException' => 'AphrontException', 'AphrontMoreView' => 'AphrontView', 'AphrontMultiColumnView' => 'AphrontView', + 'AphrontMultipartParser' => 'Phobject', + 'AphrontMultipartParserTestCase' => 'PhutilTestCase', + 'AphrontMultipartPart' => 'Phobject', 'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 'AphrontMySQLDatabaseConnectionTestCase' => 'PhabricatorTestCase', 'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', @@ -6214,11 +6227,13 @@ 'AphrontReloadResponse' => 'AphrontRedirectResponse', 'AphrontRequest' => 'Phobject', 'AphrontRequestExceptionHandler' => 'Phobject', + 'AphrontRequestStream' => 'Phobject', 'AphrontRequestTestCase' => 'PhabricatorTestCase', 'AphrontResponse' => 'Phobject', 'AphrontRoutingMap' => 'Phobject', 'AphrontRoutingResult' => 'Phobject', 'AphrontSchemaQueryException' => 'AphrontQueryException', + 'AphrontScopedUnguardedWriteCapability' => 'Phobject', 'AphrontSelectHTTPParameterType' => 'AphrontHTTPParameterType', 'AphrontSideNavFilterView' => 'AphrontView', 'AphrontSite' => 'Phobject', @@ -6237,6 +6252,7 @@ 'PhutilSafeHTMLProducerInterface', ), 'AphrontWebpageResponse' => 'AphrontHTMLResponse', + 'AphrontWriteGuard' => 'Phobject', 'ArcanistConduitAPIMethod' => 'ConduitAPIMethod', 'AuditConduitAPIMethod' => 'ConduitAPIMethod', 'AuditQueryConduitAPIMethod' => 'AuditConduitAPIMethod', diff --git a/src/aphront/headerparser/AphrontHTTPHeaderParser.php b/src/aphront/headerparser/AphrontHTTPHeaderParser.php new file mode 100644 --- /dev/null +++ b/src/aphront/headerparser/AphrontHTTPHeaderParser.php @@ -0,0 +1,150 @@ +name = null; + $this->content = null; + + $parts = explode(':', $raw_header, 2); + $this->name = trim($parts[0]); + if (count($parts) > 1) { + $this->content = trim($parts[1]); + } + + $this->pairs = null; + + return $this; + } + + public function getHeaderName() { + $this->requireParse(); + return $this->name; + } + + public function getHeaderContent() { + $this->requireParse(); + return $this->content; + } + + public function getHeaderContentAsPairs() { + $content = $this->getHeaderContent(); + + + $state = 'prekey'; + $length = strlen($content); + + $pair_name = null; + $pair_value = null; + + $pairs = array(); + $ii = 0; + while ($ii < $length) { + $c = $content[$ii]; + + switch ($state) { + case 'prekey'; + // We're eating space in front of a key. + if ($c == ' ') { + $ii++; + break; + } + $pair_name = ''; + $state = 'key'; + break; + case 'key'; + // We're parsing a key name until we find "=" or ";". + if ($c == ';') { + $state = 'done'; + break; + } + + if ($c == '=') { + $ii++; + $state = 'value'; + break; + } + + $ii++; + $pair_name .= $c; + break; + case 'value': + // We found an "=", so now figure out if the value is quoted + // or not. + if ($c == '"') { + $ii++; + $state = 'quoted'; + break; + } + $state = 'unquoted'; + break; + case 'quoted': + // We're in a quoted string, parse until we find the closing quote. + if ($c == '"') { + $ii++; + $state = 'done'; + break; + } + + $ii++; + $pair_value .= $c; + break; + case 'unquoted': + // We're in an unquoted string, parse until we find a space or a + // semicolon. + if ($c == ' ' || $c == ';') { + $state = 'done'; + break; + } + $ii++; + $pair_value .= $c; + break; + case 'done': + // We parsed something, so eat any trailing whitespace and semicolons + // and look for a new value. + if ($c == ' ' || $c == ';') { + $ii++; + break; + } + + $pairs[] = array( + $pair_name, + $pair_value, + ); + + $pair_name = null; + $pair_value = null; + + $state = 'prekey'; + break; + } + } + + if ($state == 'quoted') { + throw new Exception( + pht( + 'Header has unterminated double quote for key "%s".', + $pair_name)); + } + + if ($pair_name !== null) { + $pairs[] = array( + $pair_name, + $pair_value, + ); + } + + return $pairs; + } + + private function requireParse() { + if ($this->name === null) { + throw new PhutilInvalidStateException('parseRawHeader'); + } + } + +} diff --git a/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php b/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php new file mode 100644 --- /dev/null +++ b/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php @@ -0,0 +1,108 @@ +parseRawHeader($input); + + $actual_name = $parser->getHeaderName(); + $actual_content = $parser->getHeaderContent(); + + $this->assertEqual( + $expect_name, + $actual_name, + pht('Header name for: %s', $input)); + + $this->assertEqual( + $expect_content, + $actual_content, + pht('Header content for: %s', $input)); + + if (isset($case[3])) { + $expect_pairs = $case[3]; + + $caught = null; + try { + $actual_pairs = $parser->getHeaderContentAsPairs(); + } catch (Exception $ex) { + $caught = $ex; + } + + if ($expect_pairs === false) { + $this->assertEqual( + true, + ($caught instanceof Exception), + pht('Expect exception for header pairs of: %s', $input)); + } else { + $this->assertEqual( + $expect_pairs, + $actual_pairs, + pht('Header pairs for: %s', $input)); + } + } + } + } + + +} diff --git a/src/aphront/multipartparser/AphrontMultipartParser.php b/src/aphront/multipartparser/AphrontMultipartParser.php new file mode 100644 --- /dev/null +++ b/src/aphront/multipartparser/AphrontMultipartParser.php @@ -0,0 +1,249 @@ +contentType = $content_type; + return $this; + } + + public function getContentType() { + return $this->contentType; + } + + public function beginParse() { + $content_type = $this->getContentType(); + if ($content_type === null) { + throw new PhutilInvalidStateException('setContentType'); + } + + if (!preg_match('(^multipart/form-data)', $content_type)) { + throw new Exception( + pht( + 'Expected "multipart/form-data" content type when executing a '. + 'multipart body read.')); + } + + $type_parts = preg_split('(\s*;\s*)', $content_type); + $boundary = null; + foreach ($type_parts as $type_part) { + $matches = null; + if (preg_match('(^boundary=(.*))', $type_part, $matches)) { + $boundary = $matches[1]; + break; + } + } + + if ($boundary === null) { + throw new Exception( + pht('Received "multipart/form-data" request with no "boundary".')); + } + + $this->parts = array(); + $this->part = null; + + $this->buffer = ''; + $this->boundary = $boundary; + + // We're looking for a (usually empty) body before the first boundary. + $this->state = 'bodynewline'; + } + + public function continueParse($bytes) { + $this->buffer .= $bytes; + + $continue = true; + while ($continue) { + switch ($this->state) { + case 'endboundary': + // We've just parsed a boundary. Next, we expect either "--" (which + // indicates we've reached the end of the parts) or "\r\n" (which + // indicates we should read the headers for the next part). + + if (strlen($this->buffer) < 2) { + // We don't have enough bytes yet, so wait for more. + $continue = false; + break; + } + + if (!strncmp($this->buffer, '--', 2)) { + // This is "--" after a boundary, so we're done. We'll read the + // rest of the body (the "epilogue") and discard it. + $this->buffer = substr($this->buffer, 2); + $this->state = 'epilogue'; + + $this->part = null; + break; + } + + if (!strncmp($this->buffer, "\r\n", 2)) { + // This is "\r\n" after a boundary, so we're going to going to + // read the headers for a part. + $this->buffer = substr($this->buffer, 2); + $this->state = 'header'; + + // Create the object to hold the part we're about to read. + $part = new AphrontMultipartPart(); + $this->parts[] = $part; + $this->part = $part; + break; + } + + throw new Exception( + pht('Expected "\r\n" or "--" after multipart data boundary.')); + case 'header': + // We've just parsed a boundary, followed by "\r\n". We are going + // to read the headers for this part. They are in the form of HTTP + // headers and terminated by "\r\n". The section is terminated by + // a line with no header on it. + + if (strlen($this->buffer) < 2) { + // We don't have enough data to find a "\r\n", so wait for more. + $continue = false; + break; + } + + if (!strncmp("\r\n", $this->buffer, 2)) { + // This line immediately began "\r\n", so we're done with parsing + // headers. Start parsing the body. + $this->buffer = substr($this->buffer, 2); + $this->state = 'body'; + break; + } + + // This is an actual header, so look for the end of it. + $header_len = strpos($this->buffer, "\r\n"); + if ($header_len === false) { + // We don't have a full header yet, so wait for more data. + $continue = false; + break; + } + + $header_buf = substr($this->buffer, 0, $header_len); + $this->part->appendRawHeader($header_buf); + + $this->buffer = substr($this->buffer, $header_len + 2); + break; + case 'body': + // We've parsed a boundary and headers, and are parsing the data for + // this part. The data is terminated by "\r\n--", then the boundary. + + // We'll look for "\r\n", then switch to the "bodynewline" state if + // we find it. + + $marker = "\r"; + $marker_pos = strpos($this->buffer, $marker); + + if ($marker_pos === false) { + // There's no "\r" anywhere in the buffer, so we can just read it + // as provided. Then, since we read all the data, we're done until + // we get more. + + // Note that if we're in the preamble, we won't have a "part" + // object and will just discard the data. + if ($this->part) { + $this->part->appendData($this->buffer); + } + $this->buffer = ''; + $continue = false; + break; + } + + if ($marker_pos > 0) { + // If there are bytes before the "\r", + if ($this->part) { + $this->part->appendData(substr($this->buffer, 0, $marker_pos)); + } + $this->buffer = substr($this->buffer, $marker_pos); + } + + $expect = "\r\n"; + $expect_len = strlen($expect); + if (strlen($this->buffer) < $expect_len) { + // We don't have enough bytes yet to know if this is "\r\n" + // or not. + $continue = false; + break; + } + + if (strncmp($this->buffer, $expect, $expect_len)) { + // The next two bytes aren't "\r\n", so eat them and go looking + // for more newlines. + if ($this->part) { + $this->part->appendData(substr($this->buffer, 0, $expect_len)); + } + $this->buffer = substr($this->buffer, $expect_len); + break; + } + + // Eat the "\r\n". + $this->buffer = substr($this->buffer, $expect_len); + $this->state = 'bodynewline'; + break; + case 'bodynewline': + // We've parsed a newline in a body, or we just started parsing the + // request. In either case, we're looking for "--", then the boundary. + // If we find it, this section is done. If we don't, we consume the + // bytes and move on. + + $expect = '--'.$this->boundary; + $expect_len = strlen($expect); + + if (strlen($this->buffer) < $expect_len) { + // We don't have enough bytes yet, so wait for more. + $continue = false; + break; + } + + if (strncmp($this->buffer, $expect, $expect_len)) { + // This wasn't the boundary, so return to the "body" state and + // consume it. (But first, we need to append the "\r\n" which we + // ate earlier.) + if ($this->part) { + $this->part->appendData("\r\n"); + } + $this->state = 'body'; + break; + } + + // This is the boundary, so toss it and move on. + $this->buffer = substr($this->buffer, $expect_len); + $this->state = 'endboundary'; + break; + case 'epilogue': + // We just discard any epilogue. + $this->buffer = ''; + $continue = false; + break; + default: + throw new Exception( + pht( + 'Unknown parser state "%s".\n', + $this->state)); + } + } + } + + public function endParse() { + if ($this->state !== 'epilogue') { + throw new Exception( + pht( + 'Expected "multipart/form-data" parse to end '. + 'in state "epilogue".')); + } + + return $this->parts; + } + + +} diff --git a/src/aphront/multipartparser/AphrontMultipartPart.php b/src/aphront/multipartparser/AphrontMultipartPart.php new file mode 100644 --- /dev/null +++ b/src/aphront/multipartparser/AphrontMultipartPart.php @@ -0,0 +1,96 @@ +parseRawHeader($bytes); + + $header_name = $parser->getHeaderName(); + + $this->headers[] = array( + $header_name, + $parser->getHeaderContent(), + ); + + if (strtolower($header_name) === 'content-disposition') { + $pairs = $parser->getHeaderContentAsPairs(); + foreach ($pairs as $pair) { + list($key, $value) = $pair; + switch ($key) { + case 'filename': + $this->filename = $value; + break; + case 'name': + $this->name = $value; + break; + } + } + } + + return $this; + } + + public function appendData($bytes) { + $this->byteSize += strlen($bytes); + + if ($this->isVariable()) { + $this->value .= $bytes; + } else { + if (!$this->tempFile) { + $this->tempFile = new TempFile(getmypid().'.upload'); + } + Filesystem::appendFile($this->tempFile, $bytes); + } + + return $this; + } + + public function isVariable() { + return ($this->filename === null); + } + + public function getName() { + return $this->name; + } + + public function getVariableValue() { + if (!$this->isVariable()) { + throw new Exception(pht('This part is not a variable!')); + } + + return $this->value; + } + + public function getPHPFileDictionary() { + if (!$this->tempFile) { + $this->appendData(''); + } + + $mime_type = 'application/octet-stream'; + foreach ($this->headers as $header) { + list($name, $value) = $header; + if (strtolower($name) == 'content-type') { + $mime_type = $value; + break; + } + } + + return array( + 'name' => $this->filename, + 'type' => $mime_type, + 'tmp_name' => (string)$this->tempFile, + 'error' => 0, + 'size' => $this->byteSize, + ); + } + +} diff --git a/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php b/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php new file mode 100644 --- /dev/null +++ b/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php @@ -0,0 +1,45 @@ + 'simple.txt', + 'variables' => array( + array('a', 'b'), + ), + ), + ); + + $data_dir = dirname(__FILE__).'/data/'; + foreach ($map as $test_case) { + $data = Filesystem::readFile($data_dir.$test_case['data']); + $data = str_replace("\n", "\r\n", $data); + + $parser = id(new AphrontMultipartParser()) + ->setContentType('multipart/form-data; boundary=ABCDEFG'); + $parser->beginParse(); + $parser->continueParse($data); + $parts = $parser->endParse(); + + $variables = array(); + foreach ($parts as $part) { + if (!$part->isVariable()) { + continue; + } + + $variables[] = array( + $part->getName(), + $part->getVariableValue(), + ); + } + + $expect_variables = idx($test_case, 'variables', array()); + $this->assertEqual($expect_variables, $variables); + } + } + + + +} diff --git a/src/aphront/multipartparser/__tests__/data/simple.txt b/src/aphront/multipartparser/__tests__/data/simple.txt new file mode 100644 --- /dev/null +++ b/src/aphront/multipartparser/__tests__/data/simple.txt @@ -0,0 +1,5 @@ +--ABCDEFG +Content-Disposition: form-data; name="a" + +b +--ABCDEFG-- diff --git a/src/aphront/requeststream/AphrontRequestStream.php b/src/aphront/requeststream/AphrontRequestStream.php new file mode 100644 --- /dev/null +++ b/src/aphront/requeststream/AphrontRequestStream.php @@ -0,0 +1,92 @@ +encoding = $encoding; + return $this; + } + + public function getEncoding() { + return $this->encoding; + } + + public function getIterator() { + if (!$this->iterator) { + $this->iterator = new PhutilStreamIterator($this->getStream()); + } + return $this->iterator; + } + + public function readData() { + if (!$this->iterator) { + $iterator = $this->getIterator(); + $iterator->rewind(); + } else { + $iterator = $this->getIterator(); + } + + if (!$iterator->valid()) { + return null; + } + + $data = $iterator->current(); + $iterator->next(); + + return $data; + } + + private function getStream() { + if (!$this->stream) { + $this->stream = $this->newStream(); + } + + return $this->stream; + } + + private function newStream() { + $stream = fopen('php://input', 'rb'); + if (!$stream) { + throw new Exception( + pht( + 'Failed to open stream "%s" for reading.', + 'php://input')); + } + + $encoding = $this->getEncoding(); + if ($encoding === 'gzip') { + // This parameter is magic. Values 0-15 express a time/memory tradeoff, + // but the largest value (15) corresponds to only 32KB of memory and + // data encoded with a smaller window size than the one we pass can not + // be decompressed. Always pass the maximum window size. + + // Additionally, you can add 16 (to enable gzip) or 32 (to enable both + // gzip and zlib). Add 32 to support both. + $zlib_window = 15 + 32; + + $ok = stream_filter_append( + $stream, + 'zlib.inflate', + STREAM_FILTER_READ, + array( + 'window' => $zlib_window, + )); + if (!$ok) { + throw new Exception( + pht( + 'Failed to append filter "%s" to input stream while processing '. + 'a request with "%s" encoding.', + 'zlib.inflate', + $encoding)); + } + } + + return $stream; + } + +} diff --git a/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php b/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php new file mode 100644 --- /dev/null +++ b/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php @@ -0,0 +1,9 @@ +dispose(); + * + * Normally, you do not need to manage guards yourself -- the Aphront stack + * handles it for you. + * + * This class accepts a callback, which will be invoked when a write is + * attempted. The callback should validate the presence of a CSRF token in + * the request, or abort the request (e.g., by throwing an exception) if a + * valid token isn't present. + * + * @param callable CSRF callback. + * @return this + * @task manage + */ + public function __construct($callback) { + if (self::$instance) { + throw new Exception( + pht( + 'An %s already exists. Dispose of the previous guard '. + 'before creating a new one.', + __CLASS__)); + } + if (self::$allowUnguardedWrites) { + throw new Exception( + pht( + 'An %s is being created in a context which permits '. + 'unguarded writes unconditionally. This is not allowed and '. + 'indicates a serious error.', + __CLASS__)); + } + $this->callback = $callback; + self::$instance = $this; + } + + + /** + * Dispose of the active write guard. You must call this method when you are + * done with a write guard. You do not normally need to call this yourself. + * + * @return void + * @task manage + */ + public function dispose() { + if (!self::$instance) { + throw new Exception(pht( + 'Attempting to dispose of write guard, but no write guard is active!')); + } + + if ($this->allowDepth > 0) { + throw new Exception( + pht( + 'Imbalanced %s: more %s calls than %s calls.', + __CLASS__, + 'beginUnguardedWrites()', + 'endUnguardedWrites()')); + } + self::$instance = null; + } + + + /** + * Determine if there is an active write guard. + * + * @return bool + * @task manage + */ + public static function isGuardActive() { + return (bool)self::$instance; + } + + /** + * Return on instance of AphrontWriteGuard if it's active, or null + * + * @return AphrontWriteGuard|null + */ + public static function getInstance() { + return self::$instance; + } + + +/* -( Protecting Writes )-------------------------------------------------- */ + + + /** + * Declare intention to perform a write, validating that writes are allowed. + * You should call this method before executing a write whenever you implement + * a new storage engine where information can be permanently kept. + * + * Writes are permitted if: + * + * - The request has valid CSRF tokens. + * - Unguarded writes have been temporarily enabled by a call to + * @{method:beginUnguardedWrites}. + * - All write guarding has been disabled with + * @{method:allowDangerousUnguardedWrites}. + * + * If none of these conditions are true, this method will throw and prevent + * the write. + * + * @return void + * @task protect + */ + public static function willWrite() { + if (!self::$instance) { + if (!self::$allowUnguardedWrites) { + throw new Exception( + pht( + 'Unguarded write! There must be an active %s to perform writes.', + __CLASS__)); + } else { + // Unguarded writes are being allowed unconditionally. + return; + } + } + + $instance = self::$instance; + if ($instance->allowDepth == 0) { + call_user_func($instance->callback); + } + } + + +/* -( Disabling Write Protection )----------------------------------------- */ + + + /** + * Enter a scope which permits unguarded writes. This works like + * @{method:beginUnguardedWrites} but returns an object which will end + * the unguarded write scope when its __destruct() method is called. This + * is useful to more easily handle exceptions correctly in unguarded write + * blocks: + * + * // Restores the guard even if do_logging() throws. + * function unguarded_scope() { + * $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + * do_logging(); + * } + * + * @return AphrontScopedUnguardedWriteCapability Object which ends unguarded + * writes when it leaves scope. + * @task disable + */ + public static function beginScopedUnguardedWrites() { + self::beginUnguardedWrites(); + return new AphrontScopedUnguardedWriteCapability(); + } + + + /** + * Begin a block which permits unguarded writes. You should use this very + * sparingly, and only for things like logging where CSRF is not a concern. + * + * You must pair every call to @{method:beginUnguardedWrites} with a call to + * @{method:endUnguardedWrites}: + * + * AphrontWriteGuard::beginUnguardedWrites(); + * do_logging(); + * AphrontWriteGuard::endUnguardedWrites(); + * + * @return void + * @task disable + */ + public static function beginUnguardedWrites() { + if (!self::$instance) { + return; + } + self::$instance->allowDepth++; + } + + /** + * Declare that you have finished performing unguarded writes. You must + * call this exactly once for each call to @{method:beginUnguardedWrites}. + * + * @return void + * @task disable + */ + public static function endUnguardedWrites() { + if (!self::$instance) { + return; + } + if (self::$instance->allowDepth <= 0) { + throw new Exception( + pht( + 'Imbalanced %s: more %s calls than %s calls.', + __CLASS__, + 'endUnguardedWrites()', + 'beginUnguardedWrites()')); + } + self::$instance->allowDepth--; + } + + + /** + * Allow execution of unguarded writes. This is ONLY appropriate for use in + * script contexts or other contexts where you are guaranteed to never be + * vulnerable to CSRF concerns. Calling this method is EXTREMELY DANGEROUS + * if you do not understand the consequences. + * + * If you need to perform unguarded writes on an otherwise guarded workflow + * which is vulnerable to CSRF, use @{method:beginUnguardedWrites}. + * + * @return void + * @task disable + */ + public static function allowDangerousUnguardedWrites($allow) { + if (self::$instance) { + throw new Exception( + pht( + 'You can not unconditionally disable %s by calling %s while a write '. + 'guard is active. Use %s to temporarily allow unguarded writes.', + __CLASS__, + __FUNCTION__.'()', + 'beginUnguardedWrites()')); + } + self::$allowUnguardedWrites = true; + } + +}