Path: blob/master/src/applications/files/storage/PhabricatorFile.php
12242 views
<?php12/**3* Parameters4* ==========5*6* When creating a new file using a method like @{method:newFromFileData}, these7* parameters are supported:8*9* | name | Human readable filename.10* | authorPHID | User PHID of uploader.11* | ttl.absolute | Temporary file lifetime as an epoch timestamp.12* | ttl.relative | Temporary file lifetime, relative to now, in seconds.13* | viewPolicy | File visibility policy.14* | isExplicitUpload | Used to show users files they explicitly uploaded.15* | canCDN | Allows the file to be cached and delivered over a CDN.16* | profile | Marks the file as a profile image.17* | format | Internal encoding format.18* | mime-type | Optional, explicit file MIME type.19* | builtin | Optional filename, identifies this as a builtin.20*21*/22final class PhabricatorFile extends PhabricatorFileDAO23implements24PhabricatorApplicationTransactionInterface,25PhabricatorTokenReceiverInterface,26PhabricatorSubscribableInterface,27PhabricatorFlaggableInterface,28PhabricatorPolicyInterface,29PhabricatorDestructibleInterface,30PhabricatorConduitResultInterface,31PhabricatorIndexableInterface,32PhabricatorNgramsInterface {3334const METADATA_IMAGE_WIDTH = 'width';35const METADATA_IMAGE_HEIGHT = 'height';36const METADATA_CAN_CDN = 'canCDN';37const METADATA_BUILTIN = 'builtin';38const METADATA_PARTIAL = 'partial';39const METADATA_PROFILE = 'profile';40const METADATA_STORAGE = 'storage';41const METADATA_INTEGRITY = 'integrity';42const METADATA_CHUNK = 'chunk';43const METADATA_ALT_TEXT = 'alt';4445const STATUS_ACTIVE = 'active';46const STATUS_DELETED = 'deleted';4748protected $name;49protected $mimeType;50protected $byteSize;51protected $authorPHID;52protected $secretKey;53protected $contentHash;54protected $metadata = array();55protected $mailKey;56protected $builtinKey;5758protected $storageEngine;59protected $storageFormat;60protected $storageHandle;6162protected $ttl;63protected $isExplicitUpload = 1;64protected $viewPolicy = PhabricatorPolicies::POLICY_USER;65protected $isPartial = 0;66protected $isDeleted = 0;6768private $objects = self::ATTACHABLE;69private $objectPHIDs = self::ATTACHABLE;70private $originalFile = self::ATTACHABLE;71private $transforms = self::ATTACHABLE;7273public static function initializeNewFile() {74$app = id(new PhabricatorApplicationQuery())75->setViewer(PhabricatorUser::getOmnipotentUser())76->withClasses(array('PhabricatorFilesApplication'))77->executeOne();7879$view_policy = $app->getPolicy(80FilesDefaultViewCapability::CAPABILITY);8182return id(new PhabricatorFile())83->setViewPolicy($view_policy)84->setIsPartial(0)85->attachOriginalFile(null)86->attachObjects(array())87->attachObjectPHIDs(array());88}8990protected function getConfiguration() {91return array(92self::CONFIG_AUX_PHID => true,93self::CONFIG_SERIALIZATION => array(94'metadata' => self::SERIALIZATION_JSON,95),96self::CONFIG_COLUMN_SCHEMA => array(97'name' => 'sort255?',98'mimeType' => 'text255?',99'byteSize' => 'uint64',100'storageEngine' => 'text32',101'storageFormat' => 'text32',102'storageHandle' => 'text255',103'authorPHID' => 'phid?',104'secretKey' => 'bytes20?',105'contentHash' => 'bytes64?',106'ttl' => 'epoch?',107'isExplicitUpload' => 'bool?',108'mailKey' => 'bytes20',109'isPartial' => 'bool',110'builtinKey' => 'text64?',111'isDeleted' => 'bool',112),113self::CONFIG_KEY_SCHEMA => array(114'key_phid' => null,115'phid' => array(116'columns' => array('phid'),117'unique' => true,118),119'authorPHID' => array(120'columns' => array('authorPHID'),121),122'contentHash' => array(123'columns' => array('contentHash'),124),125'key_ttl' => array(126'columns' => array('ttl'),127),128'key_dateCreated' => array(129'columns' => array('dateCreated'),130),131'key_partial' => array(132'columns' => array('authorPHID', 'isPartial'),133),134'key_builtin' => array(135'columns' => array('builtinKey'),136'unique' => true,137),138'key_engine' => array(139'columns' => array('storageEngine', 'storageHandle(64)'),140),141),142) + parent::getConfiguration();143}144145public function generatePHID() {146return PhabricatorPHID::generateNewPHID(147PhabricatorFileFilePHIDType::TYPECONST);148}149150public function save() {151if (!$this->getSecretKey()) {152$this->setSecretKey($this->generateSecretKey());153}154if (!$this->getMailKey()) {155$this->setMailKey(Filesystem::readRandomCharacters(20));156}157return parent::save();158}159160public function saveAndIndex() {161$this->save();162163if ($this->isIndexableFile()) {164PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());165}166167return $this;168}169170private function isIndexableFile() {171if ($this->getIsChunk()) {172return false;173}174175return true;176}177178public function getMonogram() {179return 'F'.$this->getID();180}181182public function scrambleSecret() {183return $this->setSecretKey($this->generateSecretKey());184}185186public static function readUploadedFileData($spec) {187if (!$spec) {188throw new Exception(pht('No file was uploaded!'));189}190191$err = idx($spec, 'error');192if ($err) {193throw new PhabricatorFileUploadException($err);194}195196$tmp_name = idx($spec, 'tmp_name');197198// NOTE: If we parsed the request body ourselves, the files we wrote will199// not be registered in the `is_uploaded_file()` list. It's fine to skip200// this check: it just protects against sloppy code from the long ago era201// of "register_globals".202203if (ini_get('enable_post_data_reading')) {204$is_valid = @is_uploaded_file($tmp_name);205if (!$is_valid) {206throw new Exception(pht('File is not an uploaded file.'));207}208}209210$file_data = Filesystem::readFile($tmp_name);211$file_size = idx($spec, 'size');212213if (strlen($file_data) != $file_size) {214throw new Exception(pht('File size disagrees with uploaded size.'));215}216217return $file_data;218}219220public static function newFromPHPUpload($spec, array $params = array()) {221$file_data = self::readUploadedFileData($spec);222223$file_name = nonempty(224idx($params, 'name'),225idx($spec, 'name'));226$params = array(227'name' => $file_name,228) + $params;229230return self::newFromFileData($file_data, $params);231}232233public static function newFromXHRUpload($data, array $params = array()) {234return self::newFromFileData($data, $params);235}236237238public static function newFileFromContentHash($hash, array $params) {239if ($hash === null) {240return null;241}242243// Check to see if a file with same hash already exists.244$file = id(new PhabricatorFile())->loadOneWhere(245'contentHash = %s LIMIT 1',246$hash);247if (!$file) {248return null;249}250251$copy_of_storage_engine = $file->getStorageEngine();252$copy_of_storage_handle = $file->getStorageHandle();253$copy_of_storage_format = $file->getStorageFormat();254$copy_of_storage_properties = $file->getStorageProperties();255$copy_of_byte_size = $file->getByteSize();256$copy_of_mime_type = $file->getMimeType();257258$new_file = self::initializeNewFile();259260$new_file->setByteSize($copy_of_byte_size);261262$new_file->setContentHash($hash);263$new_file->setStorageEngine($copy_of_storage_engine);264$new_file->setStorageHandle($copy_of_storage_handle);265$new_file->setStorageFormat($copy_of_storage_format);266$new_file->setStorageProperties($copy_of_storage_properties);267$new_file->setMimeType($copy_of_mime_type);268$new_file->copyDimensions($file);269270$new_file->readPropertiesFromParameters($params);271272$new_file->saveAndIndex();273274return $new_file;275}276277public static function newChunkedFile(278PhabricatorFileStorageEngine $engine,279$length,280array $params) {281282$file = self::initializeNewFile();283284$file->setByteSize($length);285286// NOTE: Once we receive the first chunk, we'll detect its MIME type and287// update the parent file if a MIME type hasn't been provided. This matters288// for large media files like video.289$mime_type = idx($params, 'mime-type');290if ($mime_type === null || !strlen($mime_type)) {291$file->setMimeType('application/octet-stream');292}293294$chunked_hash = idx($params, 'chunkedHash');295296// Get rid of this parameter now; we aren't passing it any further down297// the stack.298unset($params['chunkedHash']);299300if ($chunked_hash) {301$file->setContentHash($chunked_hash);302} else {303// See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some304// discussion of this.305$seed = Filesystem::readRandomBytes(64);306$hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput(307$seed);308$file->setContentHash($hash);309}310311$file->setStorageEngine($engine->getEngineIdentifier());312$file->setStorageHandle(PhabricatorFileChunk::newChunkHandle());313314// Chunked files are always stored raw because they do not actually store315// data. The chunks do, and can be individually formatted.316$file->setStorageFormat(PhabricatorFileRawStorageFormat::FORMATKEY);317318$file->setIsPartial(1);319320$file->readPropertiesFromParameters($params);321322return $file;323}324325private static function buildFromFileData($data, array $params = array()) {326327if (isset($params['storageEngines'])) {328$engines = $params['storageEngines'];329} else {330$size = strlen($data);331$engines = PhabricatorFileStorageEngine::loadStorageEngines($size);332333if (!$engines) {334throw new Exception(335pht(336'No configured storage engine can store this file. See '.337'"Configuring File Storage" in the documentation for '.338'information on configuring storage engines.'));339}340}341342assert_instances_of($engines, 'PhabricatorFileStorageEngine');343if (!$engines) {344throw new Exception(pht('No valid storage engines are available!'));345}346347$file = self::initializeNewFile();348349$aes_type = PhabricatorFileAES256StorageFormat::FORMATKEY;350$has_aes = PhabricatorKeyring::getDefaultKeyName($aes_type);351if ($has_aes !== null) {352$default_key = PhabricatorFileAES256StorageFormat::FORMATKEY;353} else {354$default_key = PhabricatorFileRawStorageFormat::FORMATKEY;355}356$key = idx($params, 'format', $default_key);357358// Callers can pass in an object explicitly instead of a key. This is359// primarily useful for unit tests.360if ($key instanceof PhabricatorFileStorageFormat) {361$format = clone $key;362} else {363$format = clone PhabricatorFileStorageFormat::requireFormat($key);364}365366$format->setFile($file);367368$properties = $format->newStorageProperties();369$file->setStorageFormat($format->getStorageFormatKey());370$file->setStorageProperties($properties);371372$data_handle = null;373$engine_identifier = null;374$integrity_hash = null;375$exceptions = array();376foreach ($engines as $engine) {377$engine_class = get_class($engine);378try {379$result = $file->writeToEngine(380$engine,381$data,382$params);383384list($engine_identifier, $data_handle, $integrity_hash) = $result;385386// We stored the file somewhere so stop trying to write it to other387// places.388break;389} catch (PhabricatorFileStorageConfigurationException $ex) {390// If an engine is outright misconfigured (or misimplemented), raise391// that immediately since it probably needs attention.392throw $ex;393} catch (Exception $ex) {394phlog($ex);395396// If an engine doesn't work, keep trying all the other valid engines397// in case something else works.398$exceptions[$engine_class] = $ex;399}400}401402if (!$data_handle) {403throw new PhutilAggregateException(404pht('All storage engines failed to write file:'),405$exceptions);406}407408$file->setByteSize(strlen($data));409410$hash = self::hashFileContent($data);411$file->setContentHash($hash);412413$file->setStorageEngine($engine_identifier);414$file->setStorageHandle($data_handle);415416$file->setIntegrityHash($integrity_hash);417418$file->readPropertiesFromParameters($params);419420if (!$file->getMimeType()) {421$tmp = new TempFile();422Filesystem::writeFile($tmp, $data);423$file->setMimeType(Filesystem::getMimeType($tmp));424unset($tmp);425}426427try {428$file->updateDimensions(false);429} catch (Exception $ex) {430// Do nothing.431}432433$file->saveAndIndex();434435return $file;436}437438public static function newFromFileData($data, array $params = array()) {439$hash = self::hashFileContent($data);440441if ($hash !== null) {442$file = self::newFileFromContentHash($hash, $params);443if ($file) {444return $file;445}446}447448return self::buildFromFileData($data, $params);449}450451public function migrateToEngine(452PhabricatorFileStorageEngine $engine,453$make_copy) {454455if (!$this->getID() || !$this->getStorageHandle()) {456throw new Exception(457pht("You can not migrate a file which hasn't yet been saved."));458}459460$data = $this->loadFileData();461$params = array(462'name' => $this->getName(),463);464465list($new_identifier, $new_handle, $integrity_hash) = $this->writeToEngine(466$engine,467$data,468$params);469470$old_engine = $this->instantiateStorageEngine();471$old_identifier = $this->getStorageEngine();472$old_handle = $this->getStorageHandle();473474$this->setStorageEngine($new_identifier);475$this->setStorageHandle($new_handle);476$this->setIntegrityHash($integrity_hash);477$this->save();478479if (!$make_copy) {480$this->deleteFileDataIfUnused(481$old_engine,482$old_identifier,483$old_handle);484}485486return $this;487}488489public function migrateToStorageFormat(PhabricatorFileStorageFormat $format) {490if (!$this->getID() || !$this->getStorageHandle()) {491throw new Exception(492pht("You can not migrate a file which hasn't yet been saved."));493}494495$data = $this->loadFileData();496$params = array(497'name' => $this->getName(),498);499500$engine = $this->instantiateStorageEngine();501$old_handle = $this->getStorageHandle();502503$properties = $format->newStorageProperties();504$this->setStorageFormat($format->getStorageFormatKey());505$this->setStorageProperties($properties);506507list($identifier, $new_handle, $integrity_hash) = $this->writeToEngine(508$engine,509$data,510$params);511512$this->setStorageHandle($new_handle);513$this->setIntegrityHash($integrity_hash);514$this->save();515516$this->deleteFileDataIfUnused(517$engine,518$identifier,519$old_handle);520521return $this;522}523524public function cycleMasterStorageKey(PhabricatorFileStorageFormat $format) {525if (!$this->getID() || !$this->getStorageHandle()) {526throw new Exception(527pht("You can not cycle keys for a file which hasn't yet been saved."));528}529530$properties = $format->cycleStorageProperties();531$this->setStorageProperties($properties);532$this->save();533534return $this;535}536537private function writeToEngine(538PhabricatorFileStorageEngine $engine,539$data,540array $params) {541542$engine_class = get_class($engine);543544$format = $this->newStorageFormat();545546$data_iterator = array($data);547$formatted_iterator = $format->newWriteIterator($data_iterator);548$formatted_data = $this->loadDataFromIterator($formatted_iterator);549550$integrity_hash = $engine->newIntegrityHash($formatted_data, $format);551552$data_handle = $engine->writeFile($formatted_data, $params);553554if (!$data_handle || strlen($data_handle) > 255) {555// This indicates an improperly implemented storage engine.556throw new PhabricatorFileStorageConfigurationException(557pht(558"Storage engine '%s' executed %s but did not return a valid ".559"handle ('%s') to the data: it must be nonempty and no longer ".560"than 255 characters.",561$engine_class,562'writeFile()',563$data_handle));564}565566$engine_identifier = $engine->getEngineIdentifier();567if (!$engine_identifier || strlen($engine_identifier) > 32) {568throw new PhabricatorFileStorageConfigurationException(569pht(570"Storage engine '%s' returned an improper engine identifier '{%s}': ".571"it must be nonempty and no longer than 32 characters.",572$engine_class,573$engine_identifier));574}575576return array($engine_identifier, $data_handle, $integrity_hash);577}578579580/**581* Download a remote resource over HTTP and save the response body as a file.582*583* This method respects `security.outbound-blacklist`, and protects against584* HTTP redirection (by manually following "Location" headers and verifying585* each destination). It does not protect against DNS rebinding. See586* discussion in T6755.587*/588public static function newFromFileDownload($uri, array $params = array()) {589$timeout = 5;590591$redirects = array();592$current = $uri;593while (true) {594try {595if (count($redirects) > 10) {596throw new Exception(597pht('Too many redirects trying to fetch remote URI.'));598}599600$resolved = PhabricatorEnv::requireValidRemoteURIForFetch(601$current,602array(603'http',604'https',605));606607list($resolved_uri, $resolved_domain) = $resolved;608609$current = new PhutilURI($current);610if ($current->getProtocol() == 'http') {611// For HTTP, we can use a pre-resolved URI to defuse DNS rebinding.612$fetch_uri = $resolved_uri;613$fetch_host = $resolved_domain;614} else {615// For HTTPS, we can't: cURL won't verify the SSL certificate if616// the domain has been replaced with an IP. But internal services617// presumably will not have valid certificates for rebindable618// domain names on attacker-controlled domains, so the DNS rebinding619// attack should generally not be possible anyway.620$fetch_uri = $current;621$fetch_host = null;622}623624$future = id(new HTTPSFuture($fetch_uri))625->setFollowLocation(false)626->setTimeout($timeout);627628if ($fetch_host !== null) {629$future->addHeader('Host', $fetch_host);630}631632list($status, $body, $headers) = $future->resolve();633634if ($status->isRedirect()) {635// This is an HTTP 3XX status, so look for a "Location" header.636$location = null;637foreach ($headers as $header) {638list($name, $value) = $header;639if (phutil_utf8_strtolower($name) == 'location') {640$location = $value;641break;642}643}644645// HTTP 3XX status with no "Location" header, just treat this like646// a normal HTTP error.647if ($location === null) {648throw $status;649}650651if (isset($redirects[$location])) {652throw new Exception(653pht('Encountered loop while following redirects.'));654}655656$redirects[$location] = $location;657$current = $location;658// We'll fall off the bottom and go try this URI now.659} else if ($status->isError()) {660// This is something other than an HTTP 2XX or HTTP 3XX status, so661// just bail out.662throw $status;663} else {664// This is HTTP 2XX, so use the response body to save the file data.665// Provide a default name based on the URI, truncating it if the URI666// is exceptionally long.667668$default_name = basename($uri);669$default_name = id(new PhutilUTF8StringTruncator())670->setMaximumBytes(64)671->truncateString($default_name);672673$params = $params + array(674'name' => $default_name,675);676677return self::newFromFileData($body, $params);678}679} catch (Exception $ex) {680if ($redirects) {681throw new PhutilProxyException(682pht(683'Failed to fetch remote URI "%s" after following %s redirect(s) '.684'(%s): %s',685$uri,686phutil_count($redirects),687implode(' > ', array_keys($redirects)),688$ex->getMessage()),689$ex);690} else {691throw $ex;692}693}694}695}696697public static function normalizeFileName($file_name) {698$pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@";699$file_name = preg_replace($pattern, '_', $file_name);700$file_name = preg_replace('@_+@', '_', $file_name);701$file_name = trim($file_name, '_');702703$disallowed_filenames = array(704'.' => 'dot',705'..' => 'dotdot',706'' => 'file',707);708$file_name = idx($disallowed_filenames, $file_name, $file_name);709710return $file_name;711}712713public function delete() {714// We want to delete all the rows which mark this file as the transformation715// of some other file (since we're getting rid of it). We also delete all716// the transformations of this file, so that a user who deletes an image717// doesn't need to separately hunt down and delete a bunch of thumbnails and718// resizes of it.719720$outbound_xforms = id(new PhabricatorFileQuery())721->setViewer(PhabricatorUser::getOmnipotentUser())722->withTransforms(723array(724array(725'originalPHID' => $this->getPHID(),726'transform' => true,727),728))729->execute();730731foreach ($outbound_xforms as $outbound_xform) {732$outbound_xform->delete();733}734735$inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere(736'transformedPHID = %s',737$this->getPHID());738739$this->openTransaction();740foreach ($inbound_xforms as $inbound_xform) {741$inbound_xform->delete();742}743$ret = parent::delete();744$this->saveTransaction();745746$this->deleteFileDataIfUnused(747$this->instantiateStorageEngine(),748$this->getStorageEngine(),749$this->getStorageHandle());750751return $ret;752}753754755/**756* Destroy stored file data if there are no remaining files which reference757* it.758*/759public function deleteFileDataIfUnused(760PhabricatorFileStorageEngine $engine,761$engine_identifier,762$handle) {763764// Check to see if any files are using storage.765$usage = id(new PhabricatorFile())->loadAllWhere(766'storageEngine = %s AND storageHandle = %s LIMIT 1',767$engine_identifier,768$handle);769770// If there are no files using the storage, destroy the actual storage.771if (!$usage) {772try {773$engine->deleteFile($handle);774} catch (Exception $ex) {775// In the worst case, we're leaving some data stranded in a storage776// engine, which is not a big deal.777phlog($ex);778}779}780}781782public static function hashFileContent($data) {783// NOTE: Hashing can fail if the algorithm isn't available in the current784// build of PHP. It's fine if we're unable to generate a content hash:785// it just means we'll store extra data when users upload duplicate files786// instead of being able to deduplicate it.787788$hash = hash('sha256', $data, $raw_output = false);789if ($hash === false) {790return null;791}792793return $hash;794}795796public function loadFileData() {797$iterator = $this->getFileDataIterator();798return $this->loadDataFromIterator($iterator);799}800801802/**803* Return an iterable which emits file content bytes.804*805* @param int Offset for the start of data.806* @param int Offset for the end of data.807* @return Iterable Iterable object which emits requested data.808*/809public function getFileDataIterator($begin = null, $end = null) {810$engine = $this->instantiateStorageEngine();811812$format = $this->newStorageFormat();813814$iterator = $engine->getRawFileDataIterator(815$this,816$begin,817$end,818$format);819820return $iterator;821}822823public function getURI() {824return $this->getInfoURI();825}826827public function getViewURI() {828if (!$this->getPHID()) {829throw new Exception(830pht('You must save a file before you can generate a view URI.'));831}832833return $this->getCDNURI('data');834}835836public function getCDNURI($request_kind) {837if (($request_kind !== 'data') &&838($request_kind !== 'download')) {839throw new Exception(840pht(841'Unknown file content request kind "%s".',842$request_kind));843}844845$name = self::normalizeFileName($this->getName());846$name = phutil_escape_uri($name);847848$parts = array();849$parts[] = 'file';850$parts[] = $request_kind;851852// If this is an instanced install, add the instance identifier to the URI.853// Instanced configurations behind a CDN may not be able to control the854// request domain used by the CDN (as with AWS CloudFront). Embedding the855// instance identity in the path allows us to distinguish between requests856// originating from different instances but served through the same CDN.857$instance = PhabricatorEnv::getEnvConfig('cluster.instance');858if ($instance !== null && strlen($instance)) {859$parts[] = '@'.$instance;860}861862$parts[] = $this->getSecretKey();863$parts[] = $this->getPHID();864$parts[] = $name;865866$path = '/'.implode('/', $parts);867868// If this file is only partially uploaded, we're just going to return a869// local URI to make sure that Ajax works, since the page is inevitably870// going to give us an error back.871if ($this->getIsPartial()) {872return PhabricatorEnv::getURI($path);873} else {874return PhabricatorEnv::getCDNURI($path);875}876}877878879public function getInfoURI() {880return '/'.$this->getMonogram();881}882883public function getBestURI() {884if ($this->isViewableInBrowser()) {885return $this->getViewURI();886} else {887return $this->getInfoURI();888}889}890891public function getDownloadURI() {892return $this->getCDNURI('download');893}894895public function getURIForTransform(PhabricatorFileTransform $transform) {896return $this->getTransformedURI($transform->getTransformKey());897}898899private function getTransformedURI($transform) {900$parts = array();901$parts[] = 'file';902$parts[] = 'xform';903904$instance = PhabricatorEnv::getEnvConfig('cluster.instance');905if ($instance !== null && strlen($instance)) {906$parts[] = '@'.$instance;907}908909$parts[] = $transform;910$parts[] = $this->getPHID();911$parts[] = $this->getSecretKey();912913$path = implode('/', $parts);914$path = $path.'/';915916return PhabricatorEnv::getCDNURI($path);917}918919public function isViewableInBrowser() {920return ($this->getViewableMimeType() !== null);921}922923public function isViewableImage() {924if (!$this->isViewableInBrowser()) {925return false;926}927928$mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types');929$mime_type = $this->getMimeType();930return idx($mime_map, $mime_type);931}932933public function isAudio() {934if (!$this->isViewableInBrowser()) {935return false;936}937938$mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types');939$mime_type = $this->getMimeType();940return idx($mime_map, $mime_type);941}942943public function isVideo() {944if (!$this->isViewableInBrowser()) {945return false;946}947948$mime_map = PhabricatorEnv::getEnvConfig('files.video-mime-types');949$mime_type = $this->getMimeType();950return idx($mime_map, $mime_type);951}952953public function isPDF() {954if (!$this->isViewableInBrowser()) {955return false;956}957958$mime_map = array(959'application/pdf' => 'application/pdf',960);961962$mime_type = $this->getMimeType();963return idx($mime_map, $mime_type);964}965966public function isTransformableImage() {967// NOTE: The way the 'gd' extension works in PHP is that you can install it968// with support for only some file types, so it might be able to handle969// PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup970// warns you if you don't have complete support.971972$matches = null;973$ok = preg_match(974'@^image/(gif|png|jpe?g)@',975$this->getViewableMimeType(),976$matches);977if (!$ok) {978return false;979}980981switch ($matches[1]) {982case 'jpg';983case 'jpeg':984return function_exists('imagejpeg');985break;986case 'png':987return function_exists('imagepng');988break;989case 'gif':990return function_exists('imagegif');991break;992default:993throw new Exception(pht('Unknown type matched as image MIME type.'));994}995}996997public static function getTransformableImageFormats() {998$supported = array();9991000if (function_exists('imagejpeg')) {1001$supported[] = 'jpg';1002}10031004if (function_exists('imagepng')) {1005$supported[] = 'png';1006}10071008if (function_exists('imagegif')) {1009$supported[] = 'gif';1010}10111012return $supported;1013}10141015public function getDragAndDropDictionary() {1016return array(1017'id' => $this->getID(),1018'phid' => $this->getPHID(),1019'uri' => $this->getBestURI(),1020);1021}10221023public function instantiateStorageEngine() {1024return self::buildEngine($this->getStorageEngine());1025}10261027public static function buildEngine($engine_identifier) {1028$engines = self::buildAllEngines();1029foreach ($engines as $engine) {1030if ($engine->getEngineIdentifier() == $engine_identifier) {1031return $engine;1032}1033}10341035throw new Exception(1036pht(1037"Storage engine '%s' could not be located!",1038$engine_identifier));1039}10401041public static function buildAllEngines() {1042return id(new PhutilClassMapQuery())1043->setAncestorClass('PhabricatorFileStorageEngine')1044->execute();1045}10461047public function getViewableMimeType() {1048$mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types');10491050$mime_type = $this->getMimeType();1051$mime_parts = explode(';', $mime_type);1052$mime_type = trim(reset($mime_parts));10531054return idx($mime_map, $mime_type);1055}10561057public function getDisplayIconForMimeType() {1058$mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types');1059$mime_type = $this->getMimeType();1060return idx($mime_map, $mime_type, 'fa-file-o');1061}10621063public function validateSecretKey($key) {1064return ($key == $this->getSecretKey());1065}10661067public function generateSecretKey() {1068return Filesystem::readRandomCharacters(20);1069}10701071public function setStorageProperties(array $properties) {1072$this->metadata[self::METADATA_STORAGE] = $properties;1073return $this;1074}10751076public function getStorageProperties() {1077return idx($this->metadata, self::METADATA_STORAGE, array());1078}10791080public function getStorageProperty($key, $default = null) {1081$properties = $this->getStorageProperties();1082return idx($properties, $key, $default);1083}10841085public function loadDataFromIterator($iterator) {1086$result = '';10871088foreach ($iterator as $chunk) {1089$result .= $chunk;1090}10911092return $result;1093}10941095public function updateDimensions($save = true) {1096if (!$this->isViewableImage()) {1097throw new Exception(pht('This file is not a viewable image.'));1098}10991100if (!function_exists('imagecreatefromstring')) {1101throw new Exception(pht('Cannot retrieve image information.'));1102}11031104if ($this->getIsChunk()) {1105throw new Exception(1106pht('Refusing to assess image dimensions of file chunk.'));1107}11081109$engine = $this->instantiateStorageEngine();1110if ($engine->isChunkEngine()) {1111throw new Exception(1112pht('Refusing to assess image dimensions of chunked file.'));1113}11141115$data = $this->loadFileData();11161117$img = @imagecreatefromstring($data);1118if ($img === false) {1119throw new Exception(pht('Error when decoding image.'));1120}11211122$this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img);1123$this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img);11241125if ($save) {1126$this->save();1127}11281129return $this;1130}11311132public function copyDimensions(PhabricatorFile $file) {1133$metadata = $file->getMetadata();1134$width = idx($metadata, self::METADATA_IMAGE_WIDTH);1135if ($width) {1136$this->metadata[self::METADATA_IMAGE_WIDTH] = $width;1137}1138$height = idx($metadata, self::METADATA_IMAGE_HEIGHT);1139if ($height) {1140$this->metadata[self::METADATA_IMAGE_HEIGHT] = $height;1141}11421143return $this;1144}114511461147/**1148* Load (or build) the {@class:PhabricatorFile} objects for builtin file1149* resources. The builtin mechanism allows files shipped with Phabricator1150* to be treated like normal files so that APIs do not need to special case1151* things like default images or deleted files.1152*1153* Builtins are located in `resources/builtin/` and identified by their1154* name.1155*1156* @param PhabricatorUser Viewing user.1157* @param list<PhabricatorFilesBuiltinFile> List of builtin file specs.1158* @return dict<string, PhabricatorFile> Dictionary of named builtins.1159*/1160public static function loadBuiltins(PhabricatorUser $user, array $builtins) {1161$builtins = mpull($builtins, null, 'getBuiltinFileKey');11621163// NOTE: Anyone is allowed to access builtin files.11641165$files = id(new PhabricatorFileQuery())1166->setViewer(PhabricatorUser::getOmnipotentUser())1167->withBuiltinKeys(array_keys($builtins))1168->execute();11691170$results = array();1171foreach ($files as $file) {1172$builtin_key = $file->getBuiltinName();1173if ($builtin_key !== null) {1174$results[$builtin_key] = $file;1175}1176}11771178$build = array();1179foreach ($builtins as $key => $builtin) {1180if (isset($results[$key])) {1181continue;1182}11831184$data = $builtin->loadBuiltinFileData();11851186$params = array(1187'name' => $builtin->getBuiltinDisplayName(),1188'canCDN' => true,1189'builtin' => $key,1190);11911192$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();1193try {1194$file = self::newFromFileData($data, $params);1195} catch (AphrontDuplicateKeyQueryException $ex) {1196$file = id(new PhabricatorFileQuery())1197->setViewer(PhabricatorUser::getOmnipotentUser())1198->withBuiltinKeys(array($key))1199->executeOne();1200if (!$file) {1201throw new Exception(1202pht(1203'Collided mid-air when generating builtin file "%s", but '.1204'then failed to load the object we collided with.',1205$key));1206}1207}1208unset($unguarded);12091210$file->attachObjectPHIDs(array());1211$file->attachObjects(array());12121213$results[$key] = $file;1214}12151216return $results;1217}121812191220/**1221* Convenience wrapper for @{method:loadBuiltins}.1222*1223* @param PhabricatorUser Viewing user.1224* @param string Single builtin name to load.1225* @return PhabricatorFile Corresponding builtin file.1226*/1227public static function loadBuiltin(PhabricatorUser $user, $name) {1228$builtin = id(new PhabricatorFilesOnDiskBuiltinFile())1229->setName($name);12301231$key = $builtin->getBuiltinFileKey();12321233return idx(self::loadBuiltins($user, array($builtin)), $key);1234}12351236public function getObjects() {1237return $this->assertAttached($this->objects);1238}12391240public function attachObjects(array $objects) {1241$this->objects = $objects;1242return $this;1243}12441245public function getObjectPHIDs() {1246return $this->assertAttached($this->objectPHIDs);1247}12481249public function attachObjectPHIDs(array $object_phids) {1250$this->objectPHIDs = $object_phids;1251return $this;1252}12531254public function getOriginalFile() {1255return $this->assertAttached($this->originalFile);1256}12571258public function attachOriginalFile(PhabricatorFile $file = null) {1259$this->originalFile = $file;1260return $this;1261}12621263public function getImageHeight() {1264if (!$this->isViewableImage()) {1265return null;1266}1267return idx($this->metadata, self::METADATA_IMAGE_HEIGHT);1268}12691270public function getImageWidth() {1271if (!$this->isViewableImage()) {1272return null;1273}1274return idx($this->metadata, self::METADATA_IMAGE_WIDTH);1275}12761277public function getAltText() {1278$alt = $this->getCustomAltText();12791280if ($alt !== null && strlen($alt)) {1281return $alt;1282}12831284return $this->getDefaultAltText();1285}12861287public function getCustomAltText() {1288return idx($this->metadata, self::METADATA_ALT_TEXT);1289}12901291public function setCustomAltText($value) {1292$value = phutil_string_cast($value);12931294if (!strlen($value)) {1295$value = null;1296}12971298if ($value === null) {1299unset($this->metadata[self::METADATA_ALT_TEXT]);1300} else {1301$this->metadata[self::METADATA_ALT_TEXT] = $value;1302}13031304return $this;1305}13061307public function getDefaultAltText() {1308$parts = array();13091310$name = $this->getName();1311if ($name !== null && strlen($name)) {1312$parts[] = $name;1313}13141315$stats = array();13161317$image_x = $this->getImageHeight();1318$image_y = $this->getImageWidth();13191320if ($image_x && $image_y) {1321$stats[] = pht(1322"%d\xC3\x97%d px",1323new PhutilNumber($image_x),1324new PhutilNumber($image_y));1325}13261327$bytes = $this->getByteSize();1328if ($bytes) {1329$stats[] = phutil_format_bytes($bytes);1330}13311332if ($stats) {1333$parts[] = pht('(%s)', implode(', ', $stats));1334}13351336if (!$parts) {1337return null;1338}13391340return implode(' ', $parts);1341}13421343public function getCanCDN() {1344if (!$this->isViewableImage()) {1345return false;1346}13471348return idx($this->metadata, self::METADATA_CAN_CDN);1349}13501351public function setCanCDN($can_cdn) {1352$this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0;1353return $this;1354}13551356public function isBuiltin() {1357return ($this->getBuiltinName() !== null);1358}13591360public function getBuiltinName() {1361return idx($this->metadata, self::METADATA_BUILTIN);1362}13631364public function setBuiltinName($name) {1365$this->metadata[self::METADATA_BUILTIN] = $name;1366return $this;1367}13681369public function getIsProfileImage() {1370return idx($this->metadata, self::METADATA_PROFILE);1371}13721373public function setIsProfileImage($value) {1374$this->metadata[self::METADATA_PROFILE] = $value;1375return $this;1376}13771378public function getIsChunk() {1379return idx($this->metadata, self::METADATA_CHUNK);1380}13811382public function setIsChunk($value) {1383$this->metadata[self::METADATA_CHUNK] = $value;1384return $this;1385}13861387public function setIntegrityHash($integrity_hash) {1388$this->metadata[self::METADATA_INTEGRITY] = $integrity_hash;1389return $this;1390}13911392public function getIntegrityHash() {1393return idx($this->metadata, self::METADATA_INTEGRITY);1394}13951396public function newIntegrityHash() {1397$engine = $this->instantiateStorageEngine();13981399if ($engine->isChunkEngine()) {1400return null;1401}14021403$format = $this->newStorageFormat();14041405$storage_handle = $this->getStorageHandle();1406$data = $engine->readFile($storage_handle);14071408return $engine->newIntegrityHash($data, $format);1409}14101411/**1412* Write the policy edge between this file and some object.1413*1414* @param phid Object PHID to attach to.1415* @return this1416*/1417public function attachToObject($phid) {1418$attachment_table = new PhabricatorFileAttachment();1419$attachment_conn = $attachment_table->establishConnection('w');14201421queryfx(1422$attachment_conn,1423'INSERT INTO %R (objectPHID, filePHID, attachmentMode,1424attacherPHID, dateCreated, dateModified)1425VALUES (%s, %s, %s, %ns, %d, %d)1426ON DUPLICATE KEY UPDATE1427attachmentMode = VALUES(attachmentMode),1428attacherPHID = VALUES(attacherPHID),1429dateModified = VALUES(dateModified)',1430$attachment_table,1431$phid,1432$this->getPHID(),1433PhabricatorFileAttachment::MODE_ATTACH,1434null,1435PhabricatorTime::getNow(),1436PhabricatorTime::getNow());14371438return $this;1439}144014411442/**1443* Configure a newly created file object according to specified parameters.1444*1445* This method is called both when creating a file from fresh data, and1446* when creating a new file which reuses existing storage.1447*1448* @param map<string, wild> Bag of parameters, see @{class:PhabricatorFile}1449* for documentation.1450* @return this1451*/1452private function readPropertiesFromParameters(array $params) {1453PhutilTypeSpec::checkMap(1454$params,1455array(1456'name' => 'optional string',1457'authorPHID' => 'optional string',1458'ttl.relative' => 'optional int',1459'ttl.absolute' => 'optional int',1460'viewPolicy' => 'optional string',1461'isExplicitUpload' => 'optional bool',1462'canCDN' => 'optional bool',1463'profile' => 'optional bool',1464'format' => 'optional string|PhabricatorFileStorageFormat',1465'mime-type' => 'optional string',1466'builtin' => 'optional string',1467'storageEngines' => 'optional list<PhabricatorFileStorageEngine>',1468'chunk' => 'optional bool',1469));14701471$file_name = idx($params, 'name');1472$this->setName($file_name);14731474$author_phid = idx($params, 'authorPHID');1475$this->setAuthorPHID($author_phid);14761477$absolute_ttl = idx($params, 'ttl.absolute');1478$relative_ttl = idx($params, 'ttl.relative');1479if ($absolute_ttl !== null && $relative_ttl !== null) {1480throw new Exception(1481pht(1482'Specify an absolute TTL or a relative TTL, but not both.'));1483} else if ($absolute_ttl !== null) {1484if ($absolute_ttl < PhabricatorTime::getNow()) {1485throw new Exception(1486pht(1487'Absolute TTL must be in the present or future, but TTL "%s" '.1488'is in the past.',1489$absolute_ttl));1490}14911492$this->setTtl($absolute_ttl);1493} else if ($relative_ttl !== null) {1494if ($relative_ttl < 0) {1495throw new Exception(1496pht(1497'Relative TTL must be zero or more seconds, but "%s" is '.1498'negative.',1499$relative_ttl));1500}15011502$max_relative = phutil_units('365 days in seconds');1503if ($relative_ttl > $max_relative) {1504throw new Exception(1505pht(1506'Relative TTL must not be more than "%s" seconds, but TTL '.1507'"%s" was specified.',1508$max_relative,1509$relative_ttl));1510}15111512$absolute_ttl = PhabricatorTime::getNow() + $relative_ttl;15131514$this->setTtl($absolute_ttl);1515}15161517$view_policy = idx($params, 'viewPolicy');1518if ($view_policy) {1519$this->setViewPolicy($params['viewPolicy']);1520}15211522$is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0);1523$this->setIsExplicitUpload($is_explicit);15241525$can_cdn = idx($params, 'canCDN');1526if ($can_cdn) {1527$this->setCanCDN(true);1528}15291530$builtin = idx($params, 'builtin');1531if ($builtin) {1532$this->setBuiltinName($builtin);1533$this->setBuiltinKey($builtin);1534}15351536$profile = idx($params, 'profile');1537if ($profile) {1538$this->setIsProfileImage(true);1539}15401541$mime_type = idx($params, 'mime-type');1542if ($mime_type) {1543$this->setMimeType($mime_type);1544}15451546$is_chunk = idx($params, 'chunk');1547if ($is_chunk) {1548$this->setIsChunk(true);1549}15501551return $this;1552}15531554public function getRedirectResponse() {1555$uri = $this->getBestURI();15561557// TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI1558// (if the file is a viewable image) and sometimes a local URI (if not).1559// For now, just detect which one we got and configure the response1560// appropriately. In the long run, if this endpoint is served from a CDN1561// domain, we can't issue a local redirect to an info URI (which is not1562// present on the CDN domain). We probably never actually issue local1563// redirects here anyway, since we only ever transform viewable images1564// right now.15651566$is_external = strlen(id(new PhutilURI($uri))->getDomain());15671568return id(new AphrontRedirectResponse())1569->setIsExternal($is_external)1570->setURI($uri);1571}15721573public function newDownloadResponse() {1574// We're cheating a little bit here and relying on the fact that1575// getDownloadURI() always returns a fully qualified URI with a complete1576// domain.1577return id(new AphrontRedirectResponse())1578->setIsExternal(true)1579->setCloseDialogBeforeRedirect(true)1580->setURI($this->getDownloadURI());1581}15821583public function attachTransforms(array $map) {1584$this->transforms = $map;1585return $this;1586}15871588public function getTransform($key) {1589return $this->assertAttachedKey($this->transforms, $key);1590}15911592public function newStorageFormat() {1593$key = $this->getStorageFormat();1594$template = PhabricatorFileStorageFormat::requireFormat($key);15951596$format = id(clone $template)1597->setFile($this);15981599return $format;1600}160116021603/* -( PhabricatorApplicationTransactionInterface )------------------------- */160416051606public function getApplicationTransactionEditor() {1607return new PhabricatorFileEditor();1608}16091610public function getApplicationTransactionTemplate() {1611return new PhabricatorFileTransaction();1612}161316141615/* -( PhabricatorPolicyInterface Implementation )-------------------------- */161616171618public function getCapabilities() {1619return array(1620PhabricatorPolicyCapability::CAN_VIEW,1621PhabricatorPolicyCapability::CAN_EDIT,1622);1623}16241625public function getPolicy($capability) {1626switch ($capability) {1627case PhabricatorPolicyCapability::CAN_VIEW:1628if ($this->isBuiltin()) {1629return PhabricatorPolicies::getMostOpenPolicy();1630}1631if ($this->getIsProfileImage()) {1632return PhabricatorPolicies::getMostOpenPolicy();1633}1634return $this->getViewPolicy();1635case PhabricatorPolicyCapability::CAN_EDIT:1636return PhabricatorPolicies::POLICY_NOONE;1637}1638}16391640public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {1641$viewer_phid = $viewer->getPHID();1642if ($viewer_phid) {1643if ($this->getAuthorPHID() == $viewer_phid) {1644return true;1645}1646}16471648switch ($capability) {1649case PhabricatorPolicyCapability::CAN_VIEW:1650// If you can see the file this file is a transform of, you can see1651// this file.1652if ($this->getOriginalFile()) {1653return true;1654}16551656// If you can see any object this file is attached to, you can see1657// the file.1658return (count($this->getObjects()) > 0);1659}16601661return false;1662}16631664public function describeAutomaticCapability($capability) {1665$out = array();1666$out[] = pht('The user who uploaded a file can always view and edit it.');1667switch ($capability) {1668case PhabricatorPolicyCapability::CAN_VIEW:1669$out[] = pht(1670'Files attached to objects are visible to users who can view '.1671'those objects.');1672$out[] = pht(1673'Thumbnails are visible only to users who can view the original '.1674'file.');1675break;1676}16771678return $out;1679}168016811682/* -( PhabricatorSubscribableInterface Implementation )-------------------- */168316841685public function isAutomaticallySubscribed($phid) {1686return ($this->authorPHID == $phid);1687}168816891690/* -( PhabricatorTokenReceiverInterface )---------------------------------- */169116921693public function getUsersToNotifyOfTokenGiven() {1694return array(1695$this->getAuthorPHID(),1696);1697}169816991700/* -( PhabricatorDestructibleInterface )----------------------------------- */170117021703public function destroyObjectPermanently(1704PhabricatorDestructionEngine $engine) {17051706$this->openTransaction();1707$this->delete();1708$this->saveTransaction();1709}171017111712/* -( PhabricatorConduitResultInterface )---------------------------------- */171317141715public function getFieldSpecificationsForConduit() {1716return array(1717id(new PhabricatorConduitSearchFieldSpecification())1718->setKey('name')1719->setType('string')1720->setDescription(pht('The name of the file.')),1721id(new PhabricatorConduitSearchFieldSpecification())1722->setKey('uri')1723->setType('uri')1724->setDescription(pht('View URI for the file.')),1725id(new PhabricatorConduitSearchFieldSpecification())1726->setKey('dataURI')1727->setType('uri')1728->setDescription(pht('Download URI for the file data.')),1729id(new PhabricatorConduitSearchFieldSpecification())1730->setKey('size')1731->setType('int')1732->setDescription(pht('File size, in bytes.')),1733);1734}17351736public function getFieldValuesForConduit() {1737return array(1738'name' => $this->getName(),1739'uri' => PhabricatorEnv::getURI($this->getURI()),1740'dataURI' => $this->getCDNURI('data'),1741'size' => (int)$this->getByteSize(),1742'alt' => array(1743'custom' => $this->getCustomAltText(),1744'default' => $this->getDefaultAltText(),1745),1746);1747}17481749public function getConduitSearchAttachments() {1750return array();1751}17521753/* -( PhabricatorNgramInterface )------------------------------------------ */175417551756public function newNgrams() {1757return array(1758id(new PhabricatorFileNameNgrams())1759->setValue($this->getName()),1760);1761}17621763}176417651766