diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php index 9699c49ad4..6bae4c808f 100644 --- a/src/aphront/response/AphrontFileResponse.php +++ b/src/aphront/response/AphrontFileResponse.php @@ -1,142 +1,167 @@ 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() { 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 setCompressResponse($compress_response) { $this->compressResponse = $compress_response; return $this; } public function getCompressResponse() { return $this->compressResponse; } public function setRange($min, $max) { $this->rangeMin = $min; $this->rangeMax = $max; return $this; } public function getHeaders() { $headers = array( array('Content-Type', $this->getMimeType()), // 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 !== null || $this->rangeMax !== null) { $len = $this->getContentLength(); $min = $this->rangeMin; $max = $this->rangeMax; if ($max === null) { $max = ($len - 1); } $headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}"); $content_len = ($max - $min) + 1; } else { $content_len = $this->getContentLength(); } if (!$this->shouldCompressResponse()) { $headers[] = array('Content-Length', $content_len); } 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; } protected function shouldCompressResponse() { return $this->getCompressResponse(); } + public function parseHTTPRange($range) { + $begin = null; + $end = null; + + $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]; + + // The "Range" may be "200-299" or "200-", meaning "until end of file". + if (strlen($matches[2])) { + $range_end = (int)$matches[2]; + $end = $range_end + 1; + } else { + $range_end = null; + } + + $this->setHTTPResponseCode(206); + $this->setRange($begin, $range_end); + } + + return array($begin, $end); + } + } diff --git a/src/applications/celerity/controller/CelerityResourceController.php b/src/applications/celerity/controller/CelerityResourceController.php index 0f1478ec5c..730e5ddc90 100644 --- a/src/applications/celerity/controller/CelerityResourceController.php +++ b/src/applications/celerity/controller/CelerityResourceController.php @@ -1,186 +1,207 @@ getCelerityResourceMap(); $expect_hash = $map->getHashForName($path); // Test if the URI hash is correct for our current resource map. If it // is not, refuse to cache this resource. This avoids poisoning caches // and CDNs if we're getting a request for a new resource to an old node // shortly after a push. $is_cacheable = ($hash === $expect_hash); $is_locally_cacheable = $this->isLocallyCacheableResourceType($type); if (AphrontRequest::getHTTPHeader('If-Modified-Since') && $is_cacheable) { // Return a "304 Not Modified". We don't care about the value of this // field since we never change what resource is served by a given URI. return $this->makeResponseCacheable(new Aphront304Response()); } $cache = null; $data = null; if ($is_cacheable && $is_locally_cacheable && !$dev_mode) { $cache = PhabricatorCaches::getImmutableCache(); $request_path = $this->getRequest()->getPath(); $cache_key = $this->getCacheKey($request_path); $data = $cache->getKey($cache_key); } if ($data === null) { if ($map->isPackageResource($path)) { $resource_names = $map->getResourceNamesForPackageName($path); if (!$resource_names) { return new Aphront404Response(); } try { $data = array(); foreach ($resource_names as $resource_name) { $data[] = $map->getResourceDataForName($resource_name); } $data = implode("\n\n", $data); } catch (Exception $ex) { return new Aphront404Response(); } } else { try { $data = $map->getResourceDataForName($path); } catch (Exception $ex) { return new Aphront404Response(); } } $xformer = $this->buildResourceTransformer(); if ($xformer) { $data = $xformer->transformResource($path, $data); } if ($cache) { $cache->setKey($cache_key, $data); } } $response = id(new AphrontFileResponse()) - ->setContent($data) - ->setMimeType($type_map[$type]) - ->setCompressResponse(true); + ->setMimeType($type_map[$type]); + + $range = AphrontRequest::getHTTPHeader('Range'); + + if (strlen($range)) { + $response->setContentLength(strlen($data)); + + list($range_begin, $range_end) = $response->parseHTTPRange($range); + + if ($range_begin !== null) { + if ($range_end !== null) { + $data = substr($data, $range_begin, ($range_end - $range_begin)); + } else { + $data = substr($data, $range_begin); + } + } + + $response->setContentIterator(array($data)); + } else { + $response + ->setContent($data) + ->setCompressResponse(true); + } + // NOTE: This is a piece of magic required to make WOFF fonts work in // Firefox and IE. Possibly we should generalize this more. $cross_origin_types = array( 'woff' => true, 'woff2' => true, 'eot' => true, ); if (isset($cross_origin_types[$type])) { // We could be more tailored here, but it's not currently trivial to // generate a comprehensive list of valid origins (an install may have // arbitrarily many Phame blogs, for example), and we lose nothing by // allowing access from anywhere. $response->addAllowOrigin('*'); } if ($is_cacheable) { $response = $this->makeResponseCacheable($response); } return $response; } public static function getSupportedResourceTypes() { return array( 'css' => 'text/css; charset=utf-8', 'js' => 'text/javascript; charset=utf-8', 'png' => 'image/png', 'svg' => 'image/svg+xml', 'gif' => 'image/gif', 'jpg' => 'image/jpeg', 'swf' => 'application/x-shockwave-flash', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'eot' => 'font/eot', 'ttf' => 'font/ttf', 'mp3' => 'audio/mpeg', 'ico' => 'image/x-icon', ); } private function makeResponseCacheable(AphrontResponse $response) { $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); $response->setLastModified(time()); $response->setCanCDN(true); return $response; } /** * Is it appropriate to cache the data for this resource type in the fast * immutable cache? * * Generally, text resources (which are small, and expensive to process) * are cached, while other types of resources (which are large, and cheap * to process) are not. * * @param string Resource type. * @return bool True to enable caching. */ private function isLocallyCacheableResourceType($type) { $types = array( 'js' => true, 'css' => true, ); return isset($types[$type]); } protected function getCacheKey($path) { return 'celerity:'.PhabricatorHash::digestToLength($path, 64); } } diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php index c98c05e27b..c8bfcc488a 100644 --- a/src/applications/files/controller/PhabricatorFileDataController.php +++ b/src/applications/files/controller/PhabricatorFileDataController.php @@ -1,207 +1,191 @@ getViewer(); $this->phid = $request->getURIData('phid'); $this->key = $request->getURIData('key'); $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(); if (!strlen($alt) || $main_domain == $alt_domain) { // No alternate domain. $should_redirect = false; $is_alternate_domain = false; } else if ($req_domain != $alt_domain) { // Alternate domain, but this request is on the main domain. $should_redirect = true; $is_alternate_domain = false; } else { // Alternate domain, and on the alternate domain. $should_redirect = false; $is_alternate_domain = true; } $response = $this->loadFile(); if ($response) { return $response; } $file = $this->getFile(); if ($should_redirect) { return id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($file->getCDNURI()); } $response = new AphrontFileResponse(); $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); $response->setCanCDN($file->getCanCDN()); $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]; - - // The "Range" may be "200-299" or "200-", meaning "until end of file". - if (strlen($matches[2])) { - $range_end = (int)$matches[2]; - $end = $range_end + 1; - } else { - $range_end = null; - } - - $response->setHTTPResponseCode(206); - $response->setRange($begin, $range_end); - } + if (strlen($range)) { + list($begin, $end) = $response->parseHTTPRange($range); } $is_viewable = $file->isViewableInBrowser(); $force_download = $request->getExists('download'); $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); $is_lfs = ($request_type == 'git-lfs'); if ($is_viewable && !$force_download) { $response->setMimeType($file->getViewableMimeType()); } else { $is_public = !$viewer->isLoggedIn(); $is_post = $request->isHTTPPost(); // NOTE: Require POST to download files from the primary domain if the // request includes credentials. The "Download File" links we generate // in the web UI are forms which use POST to satisfy this requirement. // The intent is to make attacks based on tags like "