diff --git a/conf/default.conf.php b/conf/default.conf.php index 3261b22c6a..5c9ff4ba91 100644 --- a/conf/default.conf.php +++ b/conf/default.conf.php @@ -1,237 +1,257 @@ <?php /* * Copyright 2011 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ return array( // The root URI which Phabricator is installed on. // Example: "http://phabricator.example.com/" 'phabricator.base-uri' => null, // The Conduit URI for API access to this install. Normally this is just // the 'base-uri' plus "/api/" (e.g. "http://phabricator.example.com/api/"), // but make sure you specify 'https' if you have HTTPS configured. 'phabricator.conduit-uri' => null, // The default PHID for users who haven't uploaded a profile image. It should // be 50x50px. 'user.default-profile-image-phid' => 'PHID-FILE-f57aaefce707fc4060ef', // -- Access Control -------------------------------------------------------- // // Phabricator users have one of three access levels: "anyone", "verified", // or "admin". "anyone" means every user, including users who do not have // accounts or are not logged into the system. "verified" is users who have // accounts, are logged in, and have satisfied whatever verification steps // the configuration requires (e.g., email verification and/or manual // approval). "admin" is verified users with the "administrator" flag set. // These configuration options control which access level is required to read // data from Phabricator (e.g., view revisions and comments in Differential) // and write data to Phabricator (e.g., upload files and create diffs). By // default they are both set to "verified", meaning only verified user // accounts can interact with the system in any meaningful way. // If you are configuring an install for an open source project, you may // want to reduce the "phabricator.read-access" requirement to "anyone". This // will allow anyone to browse Phabricator content, even without logging in. // Alternatively, you could raise the "phabricator.write-access" requirement // to "admin", effectively creating a read-only install. // Controls the minimum access level required to read data from Phabricator // (e.g., view revisions in Differential). Allowed values are "anyone", // "verified", or "admin". Note that "anyone" includes users who are not // logged in! You should leave this at 'verified' unless you want your data // to be publicly readable (e.g., you are developing open source software). 'phabricator.read-access' => 'verified', // Controls the minimum access level required to write data to Phabricator // (e.g., create new revisions in Differential). Allowed values are // "verified" or "admin". Setting this to "admin" will effectively create a // read-only install. 'phabricator.write-access' => 'verified', // -- DarkConsole ----------------------------------------------------------- // // DarkConsole is a administrative debugging/profiling tool built into // Phabricator. You can leave it disabled unless you're developing against // Phabricator. // Determines whether or not DarkConsole is available. DarkConsole exposes // some data like queries and stack traces, so you should be careful about // turning it on in production (although users can not normally see it, even // if the deployment configuration enables it). 'darkconsole.enabled' => true, // Always enable DarkConsole, even for logged out users. This potentially // exposes sensitive information to users, so make sure untrusted users can // not access an install running in this mode. You should definitely leave // this off in production. It is only really useful for using DarkConsole // utilties to debug or profile logged-out pages. You must set // 'darkconsole.enabled' to use this option. 'darkconsole.always-on' => false, // Allows you to mask certain configuration values from appearing in the // "Config" tab of DarkConsole. 'darkconsole.config-mask' => array( 'mysql.pass', 'amazon-ses.secret-key', 'recaptcha.private-key', 'phabricator.csrf-key', 'facebook.application-secret', 'github.secret', ), // -- MySQL --------------------------------------------------------------- // // The username to use when connecting to MySQL. 'mysql.user' => 'root', // The password to use when connecting to MySQL. 'mysql.pass' => '', // The MySQL server to connect to. 'mysql.host' => 'localhost', // -- Email ----------------------------------------------------------------- // // Some Phabricator tools send email notifications, e.g. when Differential // revisions are updated or Maniphest tasks are changed. These options allow // you to configure how email is delivered. // You can test your mail setup by going to "MetaMTA" in the web interface, // clicking "Send New Message", and then composing a message. // Default address to send mail "From". 'metamta.default-address' => 'noreply@example.com', // When a user takes an action which generates an email notification (like // commenting on a Differential revision), Phabricator can either send that // mail "From" the user's email address (like "alincoln@logcabin.com") or // "From" the 'metamta.default-address' address. The user experience is // generally better if Phabricator uses the user's real address as the "From" // since the messages are easier to organize when they appear in mail clients, // but this will only work if the server is authorized to send email on behalf // of the "From" domain. Practically, this means: // - If you are doing an install for Example Corp and all the users will // have corporate @corp.example.com addresses and any hosts Phabricator // is running on are authorized to send email from corp.example.com, // you can enable this to make the user experience a little better. // - If you are doing an install for an open source project and your // users will be registering via Facebook and using personal email // addresses, you MUST NOT enable this or virtually all of your outgoing // email will vanish into SFP blackholes. // - If your install is anything else, you're much safer leaving this // off since the risk in turning it on is that your outgoing mail will // mostly never arrive. 'metamta.can-send-as-user' => false, // Adapter class to use to transmit mail to the MTA. The default uses // PHPMailerLite, which will invoke PHP's mail() function. This is appropriate // if mail() actually works on your host, but if you haven't configured mail // it may not be so great. You can also use Amazon SES, by changing this to // 'PhabricatorMailImplementationAmazonSESAdapter', signing up for SES, and // filling in your 'amazon-ses.access-key' and 'amazon-ses.secret-key' below. 'metamta.mail-adapter' => 'PhabricatorMailImplementationPHPMailerLiteAdapter', // When email is sent, try to hand it off to the MTA immediately. This may // be worth disabling if your MTA infrastructure is slow or unreliable. If you // disable this option, you must run the 'metamta_mta.php' daemon or mail // won't be handed off to the MTA. If you're using Amazon SES it can be a // little slugish sometimes so it may be worth disabling this and moving to // the daemon after you've got your install up and running. If you have a // properly configured local MTA it should not be necessary to disable this. 'metamta.send-immediately' => true, // If you're using Amazon SES to send email, provide your AWS access key // and AWS secret key here. To set up Amazon SES with Phabricator, you need // to: // - Make sure 'metamta.mail-adapter' is set to: // "PhabricatorMailImplementationAmazonSESAdapter" // - Make sure 'metamta.can-send-as-user' is false. // - Make sure 'metamta.default-address' is configured to something sensible. // - Make sure 'metamta.default-address' is a validated SES "From" address. 'amazon-ses.access-key' => null, 'amazon-ses.secret-key' => null, // -- Facebook ------------------------------------------------------------ // // Can users use Facebook credentials to login to Phabricator? 'facebook.auth-enabled' => false, // The Facebook "Application ID" to use for Facebook API access. 'facebook.application-id' => null, // The Facebook "Application Secret" to use for Facebook API access. 'facebook.application-secret' => null, // -- Github ---------------------------------------------------------------- // // Can users use Github credentials to login to Phabricator? 'github.auth-enabled' => false, // The Github "Client ID" to use for Github API access. 'github.application-id' => null, // The Github "Secret" to use for Github API access. 'github.application-secret' => null, // Github Authorize URI. You don't need to change this unless Github changes // its API in the future (this is unlikely). 'github.authorize-uri' => 'https://github.com/login/oauth/authorize', // Github Access Token URI. You don't need to change this unless Github // changes its API in the future (this is unlikely). 'github.access-token-uri' => 'https://github.com/login/oauth/access_token', // -- Recaptcha ------------------------------------------------------------- // // Is Recaptcha enabled? If disabled, captchas will not appear. 'recaptcha.enabled' => false, // Your Recaptcha public key, obtained from Recaptcha. 'recaptcha.public-key' => null, // Your Recaptcha private key, obtained from Recaptcha. 'recaptcha.private-key' => null, // -- Misc ------------------------------------------------------------------ // // This is hashed with other inputs to generate CSRF tokens. If you want, you // can change it to some other string which is unique to your install. This // will make your install more secure in a vague, mostly theoretical way. But // it will take you like 3 seconds of mashing on your keyboard to set it up so // you might as well. 'phabricator.csrf-key' => '0b7ec0592e0a2829d8b71df2fa269b2c6172eca3', // Version string displayed in the footer. You probably should leave this // alone. 'phabricator.version' => 'UNSTABLE', + + +// -- Files ----------------------------------------------------------------- // + + // Lists which uploaded file types may be viewed in the browser. If a file + // has a mime type which does not appear in this list, it will always be + // downloaded instead of displayed. This is a security consideration: if a + // user uploads a file of type "text/html" and it is displayed as + // "text/html", they can eaily execute XSS attacks. This is also a usability + // consideration, since browsers tend to freak out when viewing enormous + // binary files. + // + // The keys in this array are viewable mime types; the values are the mime + // types they will be delivered as when they are viewed in the browser. + 'files.viewable-mime-types' => array( + 'image/jpeg' => 'image/jpeg', + 'image/jpg' => 'image/jpg', + 'image/png' => 'image/png', + 'text/plain' => 'text/plain; charset=utf-8', + ), ); diff --git a/src/aphront/response/file/AphrontFileResponse.php b/src/aphront/response/file/AphrontFileResponse.php index 0e60435951..4bd2831afd 100644 --- a/src/aphront/response/file/AphrontFileResponse.php +++ b/src/aphront/response/file/AphrontFileResponse.php @@ -1,70 +1,82 @@ <?php /* * Copyright 2011 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @group aphront */ class AphrontFileResponse extends AphrontResponse { private $content; private $mimeType; private $download; public function setDownload($download) { + $download = preg_replace('/[^A-Za-z0-9_.-]/', '_', $download); + if (!strlen($download)) { + $download = 'untitled_document.txt'; + } $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->content = $content; return $this; } public function buildResponseString() { return $this->content; } public function getHeaders() { $headers = array( array('Content-Type', $this->getMimeType()), + // Without this, IE can decide that we surely meant "text/html" when + // delivering another content type since, you know, it looks like it's + // probably an HTML document. This closes the security hole that policy + // creates. + array('X-Content-Type-Options', 'nosniff'), ); - if ($this->getDownload()) { + if (strlen($this->getDownload())) { + $headers[] = array('X-Download-Options', 'noopen'); + + $filename = $this->getDownload(); $headers[] = array( 'Content-Disposition', - 'attachment; filename='.$this->getDownload(), + 'attachment; filename='.$filename, ); } return $headers; } } diff --git a/src/applications/files/controller/list/PhabricatorFileListController.php b/src/applications/files/controller/list/PhabricatorFileListController.php index ac2edab897..8e4435baf4 100644 --- a/src/applications/files/controller/list/PhabricatorFileListController.php +++ b/src/applications/files/controller/list/PhabricatorFileListController.php @@ -1,85 +1,90 @@ <?php /* * Copyright 2011 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ class PhabricatorFileListController extends PhabricatorFileController { public function processRequest() { $files = id(new PhabricatorFile())->loadAllWhere( '1 = 1 ORDER BY id DESC LIMIT 100'); $rows = array(); foreach ($files as $file) { + if ($file->isViewableInBrowser()) { + $view_button = phutil_render_tag( + 'a', + array( + 'class' => 'small button grey', + 'href' => '/file/view/'.$file->getPHID().'/', + ), + 'View'); + } else { + $view_button = null; + } $rows[] = array( phutil_escape_html($file->getPHID()), phutil_escape_html($file->getName()), phutil_escape_html($file->getByteSize()), phutil_render_tag( 'a', array( 'class' => 'small button grey', 'href' => '/file/info/'.$file->getPHID().'/', ), 'Info'), - phutil_render_tag( - 'a', - array( - 'class' => 'small button grey', - 'href' => '/file/view/'.$file->getPHID().'/', - ), - 'View'), + $view_button, phutil_render_tag( 'a', array( 'class' => 'small button grey', 'href' => '/file/download/'.$file->getPHID().'/', ), 'Download'), ); } $table = new AphrontTableView($rows); $table->setHeaders( array( 'PHID', 'Name', 'Size', '', '', '', )); $table->setColumnClasses( array( null, 'wide', null, 'action', 'action', 'action', )); $panel = new AphrontPanelView(); $panel->appendChild($table); $panel->setHeader('Files'); $panel->setCreateButton('Upload File', '/file/upload/'); return $this->buildStandardPageResponse($panel, array( 'title' => 'Files', 'tab' => 'files', )); } } diff --git a/src/applications/files/controller/view/PhabricatorFileViewController.php b/src/applications/files/controller/view/PhabricatorFileViewController.php index 38e9b92654..3dd93248c1 100644 --- a/src/applications/files/controller/view/PhabricatorFileViewController.php +++ b/src/applications/files/controller/view/PhabricatorFileViewController.php @@ -1,113 +1,137 @@ <?php /* * Copyright 2011 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ class PhabricatorFileViewController extends PhabricatorFileController { private $phid; private $view; public function willProcessRequest(array $data) { $this->phid = $data['phid']; $this->view = $data['view']; } public function processRequest() { $file = id(new PhabricatorFile())->loadOneWhere( 'phid = %s', $this->phid); if (!$file) { return new Aphront404Response(); } - + switch ($this->view) { case 'download': case 'view': $data = $file->loadFileData(); $response = new AphrontFileResponse(); $response->setContent($data); - $response->setMimeType($file->getMimeType()); - if ($this->view == 'download') { + + if ($this->view == 'view') { + if (!$file->isViewableInBrowser()) { + return new Aphront400Response(); + } + $download = false; + } else { + $download = true; + } + + if ($download) { + $mime_type = $file->getMimeType(); + } else { + $mime_type = $file->getViewableMimeType(); + } + + $response->setMimeType($mime_type); + + if ($download) { $response->setDownload($file->getName()); } return $response; default: break; } $form = new AphrontFormView(); - $form->setAction('/file/view/'.$file->getPHID().'/'); + + if ($file->isViewableInBrowser()) { + $form->setAction('/file/view/'.$file->getPHID().'/'); + $button_name = 'View File'; + } else { + $form->setAction('/file/download/'.$file->getPHID().'/'); + $button_name = 'Download File'; + } $form->setUser($this->getRequest()->getUser()); $form ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Name') ->setName('name') ->setValue($file->getName())) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('PHID') ->setName('phid') ->setValue($file->getPHID())) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Created') ->setName('created') ->setValue(date('Y-m-d g:i:s A', $file->getDateCreated()))) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Mime Type') ->setName('mime') ->setValue($file->getMimeType())) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Size') ->setName('size') ->setValue($file->getByteSize().' bytes')) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Engine') ->setName('storageEngine') ->setValue($file->getStorageEngine())) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Format') ->setName('storageFormat') ->setValue($file->getStorageFormat())) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel('Handle') ->setName('storageHandle') ->setValue($file->getStorageHandle())) ->appendChild( id(new AphrontFormSubmitControl()) - ->setValue('View File')); + ->setValue($button_name)); $panel = new AphrontPanelView(); $panel->setHeader('File Info - '.$file->getName()); $panel->appendChild($form); $panel->setWidth(AphrontPanelView::WIDTH_FORM); return $this->buildStandardPageResponse( array($panel), array( 'title' => 'File Info - '.$file->getName(), )); } } diff --git a/src/applications/files/storage/file/PhabricatorFile.php b/src/applications/files/storage/file/PhabricatorFile.php index d2ab45826e..e365706304 100644 --- a/src/applications/files/storage/file/PhabricatorFile.php +++ b/src/applications/files/storage/file/PhabricatorFile.php @@ -1,178 +1,192 @@ <?php /* * Copyright 2011 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ class PhabricatorFile extends PhabricatorFileDAO { const STORAGE_ENGINE_BLOB = 'blob'; const STORAGE_FORMAT_RAW = 'raw'; const PHID_TYPE = 'FILE'; // TODO: We need to reconcile this with MySQL packet size. const FILE_SIZE_BYTE_LIMIT = 12582912; protected $phid; protected $name; protected $mimeType; protected $byteSize; protected $storageEngine; protected $storageFormat; protected $storageHandle; public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID(self::PHID_TYPE); } public static function newFromPHPUpload($spec, array $params = array()) { if (!$spec) { throw new Exception("No file was uploaded!"); } $err = idx($spec, 'error'); if ($err) { throw new Exception("File upload failed with error '{$err}'."); } $tmp_name = idx($spec, 'tmp_name'); $is_valid = @is_uploaded_file($tmp_name); if (!$is_valid) { throw new Exception("File is not an uploaded file."); } $file_data = Filesystem::readFile($tmp_name); $file_size = idx($spec, 'size'); if (strlen($file_data) != $file_size) { throw new Exception("File size disagrees with uploaded size."); } $file_name = nonempty( idx($params, 'name'), idx($spec, 'name')); $params = array( 'name' => $file_name, ) + $params; return self::newFromFileData($file_data, $params); } public static function newFromFileData($data, array $params = array()) { $file_size = strlen($data); if ($file_size > self::FILE_SIZE_BYTE_LIMIT) { throw new Exception("File is too large to store."); } $file_name = idx($params, 'name'); $file_name = self::normalizeFileName($file_name); $file = new PhabricatorFile(); $file->setName($file_name); $file->setByteSize(strlen($data)); $blob = new PhabricatorFileStorageBlob(); $blob->setData($data); $blob->save(); // TODO: This stuff is almost certainly YAGNI, but we could imagine having // an alternate disk store and gzipping or encrypting things or something // crazy like that and this isn't toooo much extra code. $file->setStorageEngine(self::STORAGE_ENGINE_BLOB); $file->setStorageFormat(self::STORAGE_FORMAT_RAW); $file->setStorageHandle($blob->getID()); if (isset($params['mime-type'])) { $file->setMimeType($params['mime-type']); } else { try { $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); list($stdout) = execx('file -b --mime %s', $tmp); $file->setMimeType($stdout); } catch (Exception $ex) { // Be robust here since we don't really care that much about mime types. } } $file->save(); return $file; } public static function normalizeFileName($file_name) { return preg_replace('/[^a-zA-Z0-9.~_-]/', '_', $file_name); } public function delete() { $this->openTransaction(); switch ($this->getStorageEngine()) { case self::STORAGE_ENGINE_BLOB: $handle = $this->getStorageHandle(); $blob = id(new PhabricatorFileStorageBlob())->load($handle); $blob->delete(); break; default: throw new Exception("Unknown storage engine!"); } $ret = parent::delete(); $this->saveTransaction(); return $ret; } public function loadFileData() { $handle = $this->getStorageHandle(); $data = null; switch ($this->getStorageEngine()) { case self::STORAGE_ENGINE_BLOB: $blob = id(new PhabricatorFileStorageBlob())->load($handle); if (!$blob) { throw new Exception("Failed to load file blob data."); } $data = $blob->getData(); break; default: throw new Exception("Unknown storage engine."); } switch ($this->getStorageFormat()) { case self::STORAGE_FORMAT_RAW: $data = $data; break; default: throw new Exception("Unknown storage format."); } return $data; } public function getViewURI() { return PhabricatorFileURI::getViewURIForPHID($this->getPHID()); } + + public function isViewableInBrowser() { + return ($this->getViewableMimeType() !== null); + } + + public function getViewableMimeType() { + $mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types'); + + $mime_type = $this->getMimeType(); + $mime_parts = explode(';', $mime_type); + $mime_type = reset($mime_parts); + + return idx($mime_map, $mime_type); + } }