diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php index 8ceb1e7907..8189b30a21 100644 --- a/src/applications/files/controller/PhabricatorFileDataController.php +++ b/src/applications/files/controller/PhabricatorFileDataController.php @@ -1,227 +1,278 @@ 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(); $request_kind = $request->getURIData('kind'); $is_download = ($request_kind === 'download'); 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($request_kind)); } $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 (strlen($range)) { list($begin, $end) = $response->parseHTTPRange($range); } if (!$file->isViewableInBrowser()) { $is_download = true; } $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); $is_lfs = ($request_type == 'git-lfs'); if (!$is_download) { $response->setMimeType($file->getViewableMimeType()); } else { $is_post = $request->isHTTPPost(); $is_public = !$viewer->isLoggedIn(); // NOTE: Require POST to download files from the primary domain. If the // request is not a POST request but arrives on the primary domain, we // render a confirmation dialog. For discussion, see T13094. // There are two exceptions to this rule: // Git LFS requests can download with GET. This is safe (Git LFS won't // execute files it downloads) and necessary to support Git LFS. // Requests with no credentials may also download with GET. This // primarily supports downloading files with `arc download` or other // API clients. This is only "mostly" safe: if you aren't logged in, you // are likely immune to XSS and CSRF. However, an attacker may still be // able to set cookies on this domain (for example, to fixate your // session). For now, we accept these risks because users running // Phabricator in this mode are knowingly accepting a security risk // against setup advice, and there's significant value in having // API development against test and production installs work the same // way. $is_safe = ($is_alternate_domain || $is_post || $is_lfs || $is_public); if (!$is_safe) { return $this->newDialog() ->setSubmitURI($file->getDownloadURI()) ->setTitle(pht('Download File')) ->appendParagraph( pht( 'Download file %s (%s)?', phutil_tag('strong', array(), $file->getName()), phutil_format_bytes($file->getByteSize()))) ->addCancelButton($file->getURI()) ->addSubmitButton(pht('Download File')); } $response->setMimeType($file->getMimeType()); $response->setDownload($file->getName()); } $iterator = $file->getFileDataIterator($begin, $end); $response->setContentLength($file->getByteSize()); $response->setContentIterator($iterator); // In Chrome, we must permit this domain in "object-src" CSP when serving a // PDF or the browser will refuse to render it. if (!$is_download && $file->isPDF()) { $request_uri = id(clone $request->getAbsoluteRequestURI()) ->setPath(null) ->setFragment(null) ->removeAllQueryParams(); $response->addContentSecurityPolicyURI( 'object-src', (string)$request_uri); } + if ($this->shouldCompressFileDataResponse($file)) { + $response->setCompressResponse(true); + } + return $response; } private function loadFile() { // Access to files is provided by knowledge of a per-file secret key in // the URI. Knowledge of this secret is sufficient to retrieve the file. // For some requests, we also have a valid viewer. However, for many // requests (like alternate domain requests or Git LFS requests) we will // not. Even if we do have a valid viewer, use the omnipotent viewer to // make this logic simpler and more consistent. // Beyond making the policy check itself more consistent, this also makes // sure we're consistent about returning HTTP 404 on bad requests instead // of serving HTTP 200 with a login page, which can mislead some clients. $viewer = PhabricatorUser::getOmnipotentUser(); $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($this->phid)) ->withIsDeleted(false) ->executeOne(); if (!$file) { return new Aphront404Response(); } // 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()); if (!$file->validateSecretKey($this->key)) { $dialog = $this->newDialog() ->setTitle(pht('Invalid Authorization')) ->appendParagraph( pht( 'The link you followed to access this file is no longer '. 'valid. The visibility of the file may have changed after '. 'the link was generated.')) ->appendParagraph( pht( 'You can continue to the file detail page to get more '. 'information and attempt to access the file.')) ->addCancelButton($info_uri, pht('Continue')); return id(new AphrontDialogResponse()) ->setDialog($dialog) ->setHTTPResponseCode(404); } if ($file->getIsPartial()) { $dialog = $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.')) ->appendParagraph( pht( 'You can continue to the file detail page to monitor the '. 'upload progress of the file.')) ->addCancelButton($info_uri, pht('Continue')); return id(new AphrontDialogResponse()) ->setDialog($dialog) ->setHTTPResponseCode(404); } $this->file = $file; return null; } private function getFile() { if (!$this->file) { throw new PhutilInvalidStateException('loadFile'); } return $this->file; } + private function shouldCompressFileDataResponse(PhabricatorFile $file) { + // If the client sends "Accept-Encoding: gzip", we have the option of + // compressing the response. + + // We generally expect this to be a good idea if the file compresses well, + // but maybe not such a great idea if the file is already compressed (like + // an image or video) or compresses poorly: the CPU cost of compressing and + // decompressing the stream may exceed the bandwidth savings during + // transfer. + + // Ideally, we'd probably make this decision by compressing files when + // they are uploaded, storing the compressed size, and then doing a test + // here using the compression savings and estimated transfer speed. + + // For now, just guess that we shouldn't compress images or videos or + // files that look like they are already compressed, and should compress + // everything else. + + if ($file->isViewableImage()) { + return false; + } + + if ($file->isAudio()) { + return false; + } + + if ($file->isVideo()) { + return false; + } + + $compressed_types = array( + 'application/x-gzip', + 'application/x-compress', + 'application/x-compressed', + 'application/x-zip-compressed', + 'application/zip', + ); + $compressed_types = array_fuse($compressed_types); + + $mime_type = $file->getMimeType(); + if (isset($compressed_types[$mime_type])) { + return false; + } + + return true; + } + }