diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php
index 9e5bc50b48..bf58421c68 100644
--- a/src/aphront/response/AphrontFileResponse.php
+++ b/src/aphront/response/AphrontFileResponse.php
@@ -1,93 +1,142 @@
allowOrigins[] = $origin;
return $this;
}
public function setDownload($download) {
if (!strlen($download)) {
$download = 'untitled';
}
$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->setContentLength(strlen($content));
$this->content = $content;
return $this;
}
+ public function setContentIterator($iterator) {
+ $this->contentIterator = $iterator;
+ 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;
+ return $this->content;
+ }
+
+ public function getContentIterator() {
+ if ($this->contentIterator) {
+ return $this->contentIterator;
}
+ return parent::getContentIterator();
+ }
+
+ public function setContentLength($length) {
+ $this->contentLength = $length;
+ return $this;
+ }
+
+ public function getContentLength() {
+ return $this->contentLength;
}
public function setRange($min, $max) {
$this->rangeMin = $min;
$this->rangeMax = $max;
return $this;
}
+ public function setTemporaryFileToken(PhabricatorAuthTemporaryToken $token) {
+ $this->fileToken = $token;
+ return $this;
+ }
+
+ public function getTemporaryFileToken() {
+ return $this->fileToken;
+ }
+
public function getHeaders() {
$headers = array(
array('Content-Type', $this->getMimeType()),
- array('Content-Length', strlen($this->buildResponseString())),
+ // This tells clients that we can support requests with a "Range" header,
+ // which allows downloads to be resumed, in some browsers, some of the
+ // time, if the stars align.
+ array('Accept-Ranges', 'bytes'),
);
if ($this->rangeMin || $this->rangeMax) {
- $len = strlen($this->content);
+ $len = $this->getContentLength();
$min = $this->rangeMin;
$max = $this->rangeMax;
$headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}");
+ $content_len = ($max - $min) + 1;
+ } else {
+ $content_len = $this->getContentLength();
}
+ $headers[] = array('Content-Length', $this->getContentLength());
+
if (strlen($this->getDownload())) {
$headers[] = array('X-Download-Options', 'noopen');
$filename = $this->getDownload();
$filename = addcslashes($filename, '"\\');
$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;
}
+ public function didCompleteWrite($aborted) {
+ if (!$aborted) {
+ $token = $this->getTemporaryFileToken();
+ if ($token) {
+ $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
+ $token->delete();
+ unset($unguarded);
+ }
+ }
+ }
+
}
diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php
index 27c603b091..4cf420f066 100644
--- a/src/aphront/response/AphrontResponse.php
+++ b/src/aphront/response/AphrontResponse.php
@@ -1,170 +1,188 @@
request = $request;
return $this;
}
public function getRequest() {
return $this->request;
}
+
+/* -( Content )------------------------------------------------------------ */
+
+
+ public function getContentIterator() {
+ return array($this->buildResponseString());
+ }
+
+ public function buildResponseString() {
+ throw new PhutilMethodNotImplementedException();
+ }
+
+
+/* -( Metadata )----------------------------------------------------------- */
+
+
public function getHeaders() {
$headers = array();
if (!$this->frameable) {
$headers[] = array('X-Frame-Options', 'Deny');
}
if ($this->getRequest() && $this->getRequest()->isHTTPS()) {
$hsts_key = 'security.strict-transport-security';
$use_hsts = PhabricatorEnv::getEnvConfig($hsts_key);
if ($use_hsts) {
$duration = phutil_units('365 days in seconds');
} else {
// If HSTS has been disabled, tell browsers to turn it off. This may
// not be effective because we can only disable it over a valid HTTPS
// connection, but it best represents the configured intent.
$duration = 0;
}
$headers[] = array(
'Strict-Transport-Security',
"max-age={$duration}; includeSubdomains; preload",
);
}
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 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();
+ public function didCompleteWrite($aborted) {
+ return;
+ }
}
diff --git a/src/aphront/sink/AphrontHTTPSink.php b/src/aphront/sink/AphrontHTTPSink.php
index 9c6efca4f6..72bf4244f9 100644
--- a/src/aphront/sink/AphrontHTTPSink.php
+++ b/src/aphront/sink/AphrontHTTPSink.php
@@ -1,119 +1,132 @@
emitHTTPStatus($code, $message);
}
/**
* Write HTTP headers to the output.
*
* @param list List of 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();
+ // Build the content iterator first, in case it throws. Ideally, we'd
+ // prefer to handle exceptions before we emit the response status or any
+ // HTTP headers.
+ $data = $response->getContentIterator();
$all_headers = array_merge(
$response->getHeaders(),
$response->getCacheHeaders());
$this->writeHTTPStatus(
$response->getHTTPResponseCode(),
$response->getHTTPResponseMessage());
$this->writeHeaders($all_headers);
- $this->writeData($response_string);
+
+ $abort = false;
+ foreach ($data as $block) {
+ if (!$this->isWritable()) {
+ $abort = true;
+ break;
+ }
+ $this->writeData($block);
+ }
+
+ $response->didCompleteWrite($abort);
}
/* -( Emitting the Response )---------------------------------------------- */
abstract protected function emitHTTPStatus($code, $message = '');
abstract protected function emitHeader($name, $value);
abstract protected function emitData($data);
+ abstract protected function isWritable();
}
diff --git a/src/aphront/sink/AphrontIsolatedHTTPSink.php b/src/aphront/sink/AphrontIsolatedHTTPSink.php
index ac49b1f9bc..0b0c2b1685 100644
--- a/src/aphront/sink/AphrontIsolatedHTTPSink.php
+++ b/src/aphront/sink/AphrontIsolatedHTTPSink.php
@@ -1,36 +1,40 @@
status = $code;
}
protected function emitHeader($name, $value) {
$this->headers[] = array($name, $value);
}
protected function emitData($data) {
$this->data .= $data;
}
+ protected function isWritable() {
+ return true;
+ }
+
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
index f028340fa9..2504aea037 100644
--- a/src/aphront/sink/AphrontPHPHTTPSink.php
+++ b/src/aphront/sink/AphrontPHPHTTPSink.php
@@ -1,26 +1,35 @@
phid = $data['phid'];
$this->key = $data['key'];
$this->token = idx($data, 'token');
}
public function shouldRequireLogin() {
return false;
}
public function processRequest() {
$request = $this->getRequest();
$viewer = $this->getViewer();
$alt = PhabricatorEnv::getEnvConfig('security.alternate-file-domain');
$base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
$alt_uri = new PhutilURI($alt);
$alt_domain = $alt_uri->getDomain();
$req_domain = $request->getHost();
$main_domain = id(new PhutilURI($base_uri))->getDomain();
$cache_response = true;
if (empty($alt) || $main_domain == $alt_domain) {
// Alternate files domain isn't configured or it's set
// to the same as the default domain
$response = $this->loadFile($viewer);
if ($response) {
return $response;
}
$file = $this->getFile();
// when the file is not CDNable, don't allow cache
$cache_response = $file->getCanCDN();
} else if ($req_domain != $alt_domain) {
// Alternate domain is configured but this request isn't using it
$response = $this->loadFile($viewer);
if ($response) {
return $response;
}
$file = $this->getFile();
// if the user can see the file, generate a token;
// redirect to the alt domain with the token;
$token_uri = $file->getCDNURIWithToken();
$token_uri = new PhutilURI($token_uri);
$token_uri = $this->addURIParameters($token_uri);
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI($token_uri);
} else {
// We are using the alternate domain. We don't have authentication
// on this domain, so we bypass policy checks when loading the file.
$bypass_policies = PhabricatorUser::getOmnipotentUser();
$response = $this->loadFile($bypass_policies);
if ($response) {
return $response;
}
$file = $this->getFile();
$acquire_token_uri = id(new PhutilURI($file->getViewURI()))
->setDomain($main_domain);
$acquire_token_uri = $this->addURIParameters($acquire_token_uri);
if ($this->token) {
// validate the token, if it is valid, continue
$validated_token = $file->validateOneTimeToken($this->token);
if (!$validated_token) {
$dialog = $this->newDialog()
->setShortTitle(pht('Expired File'))
->setTitle(pht('File Link Has Expired'))
->appendParagraph(
pht(
'The link you followed to view this file is invalid or '.
'expired.'))
->appendParagraph(
pht(
'Continue to generate a new link to the file. You may be '.
'required to log in.'))
->addCancelButton(
$acquire_token_uri,
pht('Continue'));
// Build an explicit response so we can respond with HTTP/403 instead
// of HTTP/200.
$response = id(new AphrontDialogResponse())
->setDialog($dialog)
->setHTTPResponseCode(403);
return $response;
}
// return the file data without cache headers
$cache_response = false;
} else if (!$file->getCanCDN()) {
// file cannot be served via cdn, and no token given
// redirect to the main domain to aquire a token
// This is marked as an "external" URI because it is fully qualified.
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI($acquire_token_uri);
}
}
- $data = $file->loadFileData();
$response = new AphrontFileResponse();
- $response->setContent($data);
if ($cache_response) {
$response->setCacheDurationInSeconds(60 * 60 * 24 * 30);
}
+ $begin = null;
+ $end = null;
+
// NOTE: It's important to accept "Range" requests when playing audio.
// If we don't, Safari has difficulty figuring out how long sounds are
// and glitches when trying to loop them. In particular, Safari sends
// an initial request for bytes 0-1 of the audio file, and things go south
// if we can't respond with a 206 Partial Content.
$range = $request->getHTTPHeader('range');
if ($range) {
$matches = null;
if (preg_match('/^bytes=(\d+)-(\d+)$/', $range, $matches)) {
+ // Note that the "Range" header specifies bytes differently than
+ // we do internally: the range 0-1 has 2 bytes (byte 0 and byte 1).
+ $begin = (int)$matches[1];
+ $end = (int)$matches[2] + 1;
+
$response->setHTTPResponseCode(206);
- $response->setRange((int)$matches[1], (int)$matches[2]);
+ $response->setRange($begin, ($end - 1));
}
} else if (isset($validated_token)) {
- // consume the one-time token if we have one.
- $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
- $validated_token->delete();
- unset($unguarded);
+ // We set this on the response, and the response deletes it after the
+ // transfer completes. This allows transfers to be resumed, in theory.
+ $response->setTemporaryFileToken($validated_token);
}
$is_viewable = $file->isViewableInBrowser();
$force_download = $request->getExists('download');
if ($is_viewable && !$force_download) {
$response->setMimeType($file->getViewableMimeType());
} else {
if (!$request->isHTTPPost() && !$alt_domain) {
// NOTE: Require POST to download files from the primary domain. We'd
// rather go full-bore and do a real CSRF check, but can't currently
// authenticate users on the file domain. This should blunt any
// attacks based on iframes, script tags, applet tags, etc., at least.
// Send the user to the "info" page if they're using some other method.
// This is marked as "external" because it is fully qualified.
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI(PhabricatorEnv::getProductionURI($file->getBestURI()));
}
$response->setMimeType($file->getMimeType());
$response->setDownload($file->getName());
}
+ $iterator = $file->getFileDataIterator($begin, $end);
+
+ $response->setContentLength($file->getByteSize());
+ $response->setContentIterator($iterator);
+
return $response;
}
/**
* Add passthrough parameters to the URI so they aren't lost when we
* redirect to acquire tokens.
*/
private function addURIParameters(PhutilURI $uri) {
$request = $this->getRequest();
if ($request->getBool('download')) {
$uri->setQueryParam('download', 1);
}
return $uri;
}
private function loadFile(PhabricatorUser $viewer) {
$file = id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs(array($this->phid))
->executeOne();
if (!$file) {
return new Aphront404Response();
}
if (!$file->validateSecretKey($this->key)) {
return new Aphront403Response();
}
if ($file->getIsPartial()) {
// We may be on the CDN domain, so we need to use a fully-qualified URI
// here to make sure we end up back on the main domain.
$info_uri = PhabricatorEnv::getURI($file->getInfoURI());
return $this->newDialog()
->setTitle(pht('Partial Upload'))
->appendParagraph(
pht(
'This file has only been partially uploaded. It must be '.
'uploaded completely before you can download it.'))
->addCancelButton($info_uri);
}
$this->file = $file;
return null;
}
private function getFile() {
if (!$this->file) {
throw new Exception(pht('Call loadFile() before getFile()!'));
}
return $this->file;
}
}
diff --git a/src/applications/files/query/PhabricatorFileSearchEngine.php b/src/applications/files/query/PhabricatorFileSearchEngine.php
index fa9d2332c7..0eaa711297 100644
--- a/src/applications/files/query/PhabricatorFileSearchEngine.php
+++ b/src/applications/files/query/PhabricatorFileSearchEngine.php
@@ -1,188 +1,192 @@
setParameter(
'authorPHIDs',
$this->readUsersFromRequest($request, 'authors'));
$saved->setParameter('explicit', $request->getBool('explicit'));
$saved->setParameter('createdStart', $request->getStr('createdStart'));
$saved->setParameter('createdEnd', $request->getStr('createdEnd'));
return $saved;
}
public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) {
- $query = id(new PhabricatorFileQuery())
- ->withAuthorPHIDs($saved->getParameter('authorPHIDs', array()));
+ $query = id(new PhabricatorFileQuery());
+
+ $author_phids = $saved->getParameter('authorPHIDs', array());
+ if ($author_phids) {
+ $query->withAuthorPHIDs($author_phids);
+ }
if ($saved->getParameter('explicit')) {
$query->showOnlyExplicitUploads(true);
}
$start = $this->parseDateTime($saved->getParameter('createdStart'));
$end = $this->parseDateTime($saved->getParameter('createdEnd'));
if ($start) {
$query->withDateCreatedAfter($start);
}
if ($end) {
$query->withDateCreatedBefore($end);
}
return $query;
}
public function buildSearchForm(
AphrontFormView $form,
PhabricatorSavedQuery $saved_query) {
$phids = $saved_query->getParameter('authorPHIDs', array());
$author_handles = id(new PhabricatorHandleQuery())
->setViewer($this->requireViewer())
->withPHIDs($phids)
->execute();
$explicit = $saved_query->getParameter('explicit');
$form
->appendChild(
id(new AphrontFormTokenizerControl())
->setDatasource(new PhabricatorPeopleDatasource())
->setName('authors')
->setLabel(pht('Authors'))
->setValue($author_handles))
->appendChild(
id(new AphrontFormCheckboxControl())
->addCheckbox(
'explicit',
1,
pht('Show only manually uploaded files.'),
$explicit));
$this->buildDateRange(
$form,
$saved_query,
'createdStart',
pht('Created After'),
'createdEnd',
pht('Created Before'));
}
protected function getURI($path) {
return '/file/'.$path;
}
protected function getBuiltinQueryNames() {
$names = array();
if ($this->requireViewer()->isLoggedIn()) {
$names['authored'] = pht('Authored');
}
$names += array(
'all' => pht('All'),
);
return $names;
}
public function buildSavedQueryFromBuiltin($query_key) {
$query = $this->newSavedQuery();
$query->setQueryKey($query_key);
switch ($query_key) {
case 'all':
return $query;
case 'authored':
$author_phid = array($this->requireViewer()->getPHID());
return $query
->setParameter('authorPHIDs', $author_phid)
->setParameter('explicit', true);
}
return parent::buildSavedQueryFromBuiltin($query_key);
}
protected function getRequiredHandlePHIDsForResultList(
array $files,
PhabricatorSavedQuery $query) {
return mpull($files, 'getAuthorPHID');
}
protected function renderResultList(
array $files,
PhabricatorSavedQuery $query,
array $handles) {
assert_instances_of($files, 'PhabricatorFile');
$request = $this->getRequest();
if ($request) {
$highlighted_ids = $request->getStrList('h');
} else {
$highlighted_ids = array();
}
$viewer = $this->requireViewer();
$highlighted_ids = array_fill_keys($highlighted_ids, true);
$list_view = id(new PHUIObjectItemListView())
->setUser($viewer);
foreach ($files as $file) {
$id = $file->getID();
$phid = $file->getPHID();
$name = $file->getName();
$file_uri = $this->getApplicationURI("/info/{$phid}/");
$date_created = phabricator_date($file->getDateCreated(), $viewer);
$author_phid = $file->getAuthorPHID();
if ($author_phid) {
$author_link = $handles[$author_phid]->renderLink();
$uploaded = pht('Uploaded by %s on %s', $author_link, $date_created);
} else {
$uploaded = pht('Uploaded on %s', $date_created);
}
$item = id(new PHUIObjectItemView())
->setObject($file)
->setObjectName("F{$id}")
->setHeader($name)
->setHref($file_uri)
->addAttribute($uploaded)
->addIcon('none', phutil_format_bytes($file->getByteSize()));
$ttl = $file->getTTL();
if ($ttl !== null) {
$item->addIcon('blame', pht('Temporary'));
}
if (isset($highlighted_ids[$id])) {
$item->setEffect('highlighted');
}
$list_view->addItem($item);
}
$list_view->appendChild(id(new PhabricatorGlobalUploadTargetView())
->setUser($viewer));
return $list_view;
}
}