diff --git a/resources/sql/autopatches/20140731.cancdn.php b/resources/sql/autopatches/20140731.cancdn.php new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20140731.cancdn.php @@ -0,0 +1,20 @@ +establishConnection('w'); +foreach (new LiskMigrationIterator($table) as $file) { + $id = $file->getID(); + echo "Updating flags for file {$id}...\n"; + $meta = $file->getMetadata(); + if (!idx($meta, 'canCDN')) { + + $meta['canCDN'] = true; + + queryfx( + $conn_w, + 'UPDATE %T SET metadata = %s WHERE id = %d', + $table->getTableName(), + json_encode($meta), + $id); + } +} diff --git a/src/applications/files/application/PhabricatorFilesApplication.php b/src/applications/files/application/PhabricatorFilesApplication.php --- a/src/applications/files/application/PhabricatorFilesApplication.php +++ b/src/applications/files/application/PhabricatorFilesApplication.php @@ -51,6 +51,8 @@ 'comment/(?P[1-9]\d*)/' => 'PhabricatorFileCommentController', 'delete/(?P[1-9]\d*)/' => 'PhabricatorFileDeleteController', 'info/(?P[^/]+)/' => 'PhabricatorFileInfoController', + 'data/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/.*' + => 'PhabricatorFileDataController', 'data/(?P[^/]+)/(?P[^/]+)/.*' => 'PhabricatorFileDataController', 'proxy/' => 'PhabricatorFileProxyController', diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php --- a/src/applications/files/controller/PhabricatorFileDataController.php +++ b/src/applications/files/controller/PhabricatorFileDataController.php @@ -4,10 +4,12 @@ private $phid; private $key; + private $token; public function willProcessRequest(array $data) { $this->phid = $data['phid']; $this->key = $data['key']; + $this->token = idx($data, 'token'); } public function shouldRequireLogin() { @@ -17,22 +19,79 @@ public function processRequest() { $request = $this->getRequest(); - $alt = PhabricatorEnv::getEnvConfig('security.alternate-file-domain'); - $uri = new PhutilURI($alt); - $alt_domain = $uri->getDomain(); - if ($alt_domain && ($alt_domain != $request->getHost())) { - return id(new AphrontRedirectResponse()) - ->setURI($uri->setPath($request->getPath())); - } - // NOTE: This endpoint will ideally be accessed via CDN or otherwise on // a non-credentialed domain. Knowing the file's secret key gives you // access, regardless of authentication on the request itself. - $file = id(new PhabricatorFileQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->withPHIDs(array($this->phid)) - ->executeOne(); + $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 + + // load the file with permissions checks; + $file = id(new PhabricatorFileQuery()) + ->setViewer($request->getUser()) + ->withPHIDs(array($this->phid)) + ->executeOne(); + // when the file is not CDNable, don't allow cache + $cache_response = $file && $file->getCanCDN(); + } else if ($req_domain != $alt_domain) { + // Alternate domain is configured but this request isn't using it + + // load the file with permissions checks; + $file = id(new PhabricatorFileQuery()) + ->setViewer($request->getUser()) + ->withPHIDs(array($this->phid)) + ->executeOne(); + + if (!$file) { + return new Aphront404Response(); + } + + // if the user can see the file, generate a token; + // redirect to the alt domain with the token; + return id(new AphrontRedirectResponse()) + ->setURI($file->getCDNURIWithToken()); + + } else { + // We are using the alternate domain + + // load the file, bypassing permission checks; + $file = id(new PhabricatorFileQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(array($this->phid)) + ->executeOne(); + + if (!$file) { + return new Aphront404Response(); + } + + if ($this->token) { + // validate the token, if it is valid, continue + if (! $validated_token = $file->validateOneTimeToken($this->token)) { + return new Aphront403Response(); + } + // 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 + $file_uri = id(new PhutilURI($file->getViewURI())) + ->setDomain($main_domain); + + return id(new AphrontRedirectResponse()) + ->setURI($file_uri); + } + } + if (!$file) { return new Aphront404Response(); } @@ -44,7 +103,9 @@ $data = $file->loadFileData(); $response = new AphrontFileResponse(); $response->setContent($data); - $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); + if ($cache_response) { + $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); + } // NOTE: It's important to accept "Range" requests when playing audio. // If we don't, Safari has difficulty figuring out how long sounds are @@ -58,6 +119,8 @@ $response->setHTTPResponseCode(206); $response->setRange((int)$matches[1], (int)$matches[2]); } + } else if (isset($validated_token)) { + $validated_token->delete(); } $is_viewable = $file->isViewableInBrowser(); diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php --- a/src/applications/files/storage/PhabricatorFile.php +++ b/src/applications/files/storage/PhabricatorFile.php @@ -7,10 +7,12 @@ PhabricatorFlaggableInterface, PhabricatorPolicyInterface { + const ONETIME_TEMPORARY_TOKEN_TYPE = 'file:onetime'; const STORAGE_FORMAT_RAW = 'raw'; const METADATA_IMAGE_WIDTH = 'width'; const METADATA_IMAGE_HEIGHT = 'height'; + const METADATA_CAN_CDN = 'cancdn'; protected $name; protected $mimeType; @@ -198,7 +200,6 @@ } private static function buildFromFileData($data, array $params = array()) { - $selector = PhabricatorEnv::newObjectFromConfig('storage.engine-selector'); if (isset($params['storageEngines'])) { $engines = $params['storageEngines']; @@ -848,6 +849,58 @@ return idx($this->metadata, self::METADATA_IMAGE_WIDTH); } + public function getCanCDN() { + if (!$this->isViewableImage()) { + return false; + } + return idx($this->metadata, self::METADATA_CAN_CDN); + } + + protected function generateOneTimeToken() { + $key = Filesystem::readRandomCharacters(16); + + // Save the new secret. + return id(new PhabricatorAuthTemporaryToken()) + ->setObjectPHID($this->getPHID()) + ->setTokenType(self::ONETIME_TEMPORARY_TOKEN_TYPE) + ->setTokenExpires(time() + phutil_units('1 hour in seconds')) + ->setTokenCode(PhabricatorHash::digest($key)) + ->save(); + } + + public function validateOneTimeToken($token_code) { + $token = id(new PhabricatorAuthTemporaryTokenQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withObjectPHIDs(array($this->getPHID())) + ->withTokenTypes(array(self::ONETIME_TEMPORARY_TOKEN_TYPE)) + ->withExpired(false) + ->withTokenCodes(array($token_code)) + ->executeOne(); + + return $token; + } + + /** Get the CDN uri for this file + * This will generate a one-time-use token if + * security.alternate_file_domain is set in the config. + */ + public function getCDNURIWithToken() { + if (!$this->getPHID()) { + throw new Exception( + 'You must save a file before you can generate a CDN URI.'); + } + $name = phutil_escape_uri($this->getName()); + + $path = '/file/data' + .'/'.$this->getSecretKey() + .'/'.$this->getPHID() + .'/'.$this->generateOneTimeToken() + .'/'.$name; + return PhabricatorEnv::getCDNURI($path); + } + + + /** * Write the policy edge between this file and some object. *