@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 278 lines 9.4 kB view raw
1<?php 2 3final class PhabricatorFileDataController extends PhabricatorFileController { 4 5 private $phid; 6 private $key; 7 private $file; 8 9 public function shouldRequireLogin() { 10 return false; 11 } 12 13 public function shouldAllowPartialSessions() { 14 return true; 15 } 16 17 public function handleRequest(AphrontRequest $request) { 18 $viewer = $request->getViewer(); 19 $this->phid = $request->getURIData('phid'); 20 $this->key = $request->getURIData('key'); 21 22 $alt = PhabricatorEnv::getEnvConfig('security.alternate-file-domain'); 23 $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); 24 $alt_uri = new PhutilURI($alt); 25 $alt_domain = $alt_uri->getDomain(); 26 $req_domain = $request->getHost(); 27 $main_domain = id(new PhutilURI($base_uri))->getDomain(); 28 29 $request_kind = $request->getURIData('kind'); 30 $is_download = ($request_kind === 'download'); 31 32 if (!phutil_nonempty_string($alt) || $main_domain == $alt_domain) { 33 // No alternate domain. 34 $should_redirect = false; 35 $is_alternate_domain = false; 36 } else if ($req_domain != $alt_domain) { 37 // Alternate domain, but this request is on the main domain. 38 $should_redirect = true; 39 $is_alternate_domain = false; 40 } else { 41 // Alternate domain, and on the alternate domain. 42 $should_redirect = false; 43 $is_alternate_domain = true; 44 } 45 46 $response = $this->loadFile(); 47 if ($response) { 48 return $response; 49 } 50 51 $file = $this->getFile(); 52 53 if ($should_redirect) { 54 return id(new AphrontRedirectResponse()) 55 ->setIsExternal(true) 56 ->setURI($file->getCDNURI($request_kind)); 57 } 58 59 $response = new AphrontFileResponse(); 60 $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); 61 $response->setCanCDN($file->getCanCDN()); 62 63 $begin = null; 64 $end = null; 65 66 // NOTE: It's important to accept "Range" requests when playing audio. 67 // If we don't, Safari has difficulty figuring out how long sounds are 68 // and glitches when trying to loop them. In particular, Safari sends 69 // an initial request for bytes 0-1 of the audio file, and things go south 70 // if we can't respond with a 206 Partial Content. 71 $range = $request->getHTTPHeader('range'); 72 if (phutil_nonempty_string($range)) { 73 list($begin, $end) = $response->parseHTTPRange($range); 74 } 75 76 if (!$file->isViewableInBrowser()) { 77 $is_download = true; 78 } 79 80 $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); 81 $is_lfs = ($request_type == 'git-lfs'); 82 83 if (!$is_download) { 84 $response->setMimeType($file->getViewableMimeType()); 85 } else { 86 $is_post = $request->isHTTPPost(); 87 $is_public = !$viewer->isLoggedIn(); 88 89 // NOTE: Require POST to download files from the primary domain. If the 90 // request is not a POST request but arrives on the primary domain, we 91 // render a confirmation dialog. For discussion, see T13094. 92 93 // There are two exceptions to this rule: 94 95 // Git LFS requests can download with GET. This is safe (Git LFS won't 96 // execute files it downloads) and necessary to support Git LFS. 97 98 // Requests with no credentials may also download with GET. This 99 // primarily supports downloading files with `arc download` or other 100 // API clients. This is only "mostly" safe: if you aren't logged in, you 101 // are likely immune to XSS and CSRF. However, an attacker may still be 102 // able to set cookies on this domain (for example, to fixate your 103 // session). For now, we accept these risks because users running 104 // Phabricator in this mode are knowingly accepting a security risk 105 // against setup advice, and there's significant value in having 106 // API development against test and production installs work the same 107 // way. 108 109 $is_safe = ($is_alternate_domain || $is_post || $is_lfs || $is_public); 110 if (!$is_safe) { 111 return $this->newDialog() 112 ->setSubmitURI($file->getDownloadURI()) 113 ->setTitle(pht('Download File')) 114 ->appendParagraph( 115 pht( 116 'Download file %s (%s)?', 117 phutil_tag('strong', array(), $file->getName()), 118 phutil_format_bytes($file->getByteSize()))) 119 ->addCancelButton($file->getURI()) 120 ->addSubmitButton(pht('Download File')); 121 } 122 123 $response->setMimeType($file->getMimeType()); 124 $response->setDownload($file->getName()); 125 } 126 127 $iterator = $file->getFileDataIterator($begin, $end); 128 129 $response->setContentLength($file->getByteSize()); 130 $response->setContentIterator($iterator); 131 132 // In Chrome, we must permit this domain in "object-src" CSP when serving a 133 // PDF or the browser will refuse to render it. 134 if (!$is_download && $file->isPDF()) { 135 $request_uri = id(clone $request->getAbsoluteRequestURI()) 136 ->setPath(null) 137 ->setFragment(null) 138 ->removeAllQueryParams(); 139 140 $response->addContentSecurityPolicyURI( 141 'object-src', 142 (string)$request_uri); 143 } 144 145 if ($this->shouldCompressFileDataResponse($file)) { 146 $response->setCompressResponse(true); 147 } 148 149 return $response; 150 } 151 152 private function loadFile() { 153 // Access to files is provided by knowledge of a per-file secret key in 154 // the URI. Knowledge of this secret is sufficient to retrieve the file. 155 156 // For some requests, we also have a valid viewer. However, for many 157 // requests (like alternate domain requests or Git LFS requests) we will 158 // not. Even if we do have a valid viewer, use the omnipotent viewer to 159 // make this logic simpler and more consistent. 160 161 // Beyond making the policy check itself more consistent, this also makes 162 // sure we're consistent about returning HTTP 404 on bad requests instead 163 // of serving HTTP 200 with a login page, which can mislead some clients. 164 165 $viewer = PhabricatorUser::getOmnipotentUser(); 166 167 $file = id(new PhabricatorFileQuery()) 168 ->setViewer($viewer) 169 ->withPHIDs(array($this->phid)) 170 ->withIsDeleted(false) 171 ->executeOne(); 172 173 if (!$file) { 174 return new Aphront404Response(); 175 } 176 177 // We may be on the CDN domain, so we need to use a fully-qualified URI 178 // here to make sure we end up back on the main domain. 179 $info_uri = PhabricatorEnv::getURI($file->getInfoURI()); 180 181 182 if (!$file->validateSecretKey($this->key)) { 183 $dialog = $this->newDialog() 184 ->setTitle(pht('Invalid Authorization')) 185 ->appendParagraph( 186 pht( 187 'The link you followed to access this file is no longer '. 188 'valid. The visibility of the file may have changed after '. 189 'the link was generated.')) 190 ->appendParagraph( 191 pht( 192 'You can continue to the file detail page to get more '. 193 'information and attempt to access the file.')) 194 ->addCancelButton($info_uri, pht('Continue')); 195 196 return id(new AphrontDialogResponse()) 197 ->setDialog($dialog) 198 ->setHTTPResponseCode(404); 199 } 200 201 if ($file->getIsPartial()) { 202 $dialog = $this->newDialog() 203 ->setTitle(pht('Partial Upload')) 204 ->appendParagraph( 205 pht( 206 'This file has only been partially uploaded. It must be '. 207 'uploaded completely before you can download it.')) 208 ->appendParagraph( 209 pht( 210 'You can continue to the file detail page to monitor the '. 211 'upload progress of the file.')) 212 ->addCancelButton($info_uri, pht('Continue')); 213 214 return id(new AphrontDialogResponse()) 215 ->setDialog($dialog) 216 ->setHTTPResponseCode(404); 217 } 218 219 $this->file = $file; 220 221 return null; 222 } 223 224 private function getFile() { 225 if (!$this->file) { 226 throw new PhutilInvalidStateException('loadFile'); 227 } 228 return $this->file; 229 } 230 231 private function shouldCompressFileDataResponse(PhabricatorFile $file) { 232 // If the client sends "Accept-Encoding: gzip", we have the option of 233 // compressing the response. 234 235 // We generally expect this to be a good idea if the file compresses well, 236 // but maybe not such a great idea if the file is already compressed (like 237 // an image or video) or compresses poorly: the CPU cost of compressing and 238 // decompressing the stream may exceed the bandwidth savings during 239 // transfer. 240 241 // Ideally, we'd probably make this decision by compressing files when 242 // they are uploaded, storing the compressed size, and then doing a test 243 // here using the compression savings and estimated transfer speed. 244 245 // For now, just guess that we shouldn't compress images or videos or 246 // files that look like they are already compressed, and should compress 247 // everything else. 248 249 if ($file->isViewableImage()) { 250 return false; 251 } 252 253 if ($file->isAudio()) { 254 return false; 255 } 256 257 if ($file->isVideo()) { 258 return false; 259 } 260 261 $compressed_types = array( 262 'application/x-gzip', 263 'application/x-compress', 264 'application/x-compressed', 265 'application/x-zip-compressed', 266 'application/zip', 267 ); 268 $compressed_types = array_fuse($compressed_types); 269 270 $mime_type = $file->getMimeType(); 271 if (isset($compressed_types[$mime_type])) { 272 return false; 273 } 274 275 return true; 276 } 277 278}