@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
1<?php
2
3/**
4 * Parameters
5 * ==========
6 *
7 * When creating a new file using a method like @{method:newFromFileData}, these
8 * parameters are supported:
9 *
10 * | name | Human readable filename.
11 * | authorPHID | User PHID of uploader.
12 * | ttl.absolute | Temporary file lifetime as an epoch timestamp.
13 * | ttl.relative | Temporary file lifetime, relative to now, in seconds.
14 * | viewPolicy | File visibility policy.
15 * | isExplicitUpload | Used to show users files they explicitly uploaded.
16 * | canCDN | Allows the file to be cached and delivered over a CDN.
17 * | profile | Marks the file as a profile image.
18 * | format | Internal encoding format.
19 * | mime-type | Optional, explicit file MIME type.
20 * | builtin | Optional filename, identifies this as a builtin.
21 *
22 */
23final class PhabricatorFile extends PhabricatorFileDAO
24 implements
25 PhabricatorApplicationTransactionInterface,
26 PhabricatorTokenReceiverInterface,
27 PhabricatorSubscribableInterface,
28 PhabricatorFlaggableInterface,
29 PhabricatorPolicyInterface,
30 PhabricatorDestructibleInterface,
31 PhabricatorConduitResultInterface,
32 PhabricatorIndexableInterface,
33 PhabricatorNgramsInterface {
34
35 const METADATA_IMAGE_WIDTH = 'width';
36 const METADATA_IMAGE_HEIGHT = 'height';
37 const METADATA_CAN_CDN = 'canCDN';
38 const METADATA_BUILTIN = 'builtin';
39 const METADATA_PARTIAL = 'partial';
40 const METADATA_PROFILE = 'profile';
41 const METADATA_STORAGE = 'storage';
42 const METADATA_INTEGRITY = 'integrity';
43 const METADATA_CHUNK = 'chunk';
44 const METADATA_ALT_TEXT = 'alt';
45
46 const STATUS_ACTIVE = 'active';
47 const STATUS_DELETED = 'deleted';
48
49 protected $name;
50 protected $mimeType;
51 protected $byteSize;
52 protected $authorPHID;
53 protected $secretKey;
54 protected $contentHash;
55 protected $metadata = array();
56 protected $mailKey;
57 protected $builtinKey;
58
59 protected $storageEngine;
60 protected $storageFormat;
61 protected $storageHandle;
62
63 protected $ttl;
64 protected $isExplicitUpload = 1;
65 protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
66 protected $isPartial = 0;
67 protected $isDeleted = 0;
68
69 private $objects = self::ATTACHABLE;
70 private $objectPHIDs = self::ATTACHABLE;
71 private $originalFile = self::ATTACHABLE;
72 private $transforms = self::ATTACHABLE;
73
74 public static function initializeNewFile() {
75 $app = id(new PhabricatorApplicationQuery())
76 ->setViewer(PhabricatorUser::getOmnipotentUser())
77 ->withClasses(array(PhabricatorFilesApplication::class))
78 ->executeOne();
79
80 $view_policy = $app->getPolicy(
81 FilesDefaultViewCapability::CAPABILITY);
82
83 return id(new self())
84 ->setViewPolicy($view_policy)
85 ->setIsPartial(0)
86 ->attachOriginalFile(null)
87 ->attachObjects(array())
88 ->attachObjectPHIDs(array());
89 }
90
91 protected function getConfiguration() {
92 return array(
93 self::CONFIG_AUX_PHID => true,
94 self::CONFIG_SERIALIZATION => array(
95 'metadata' => self::SERIALIZATION_JSON,
96 ),
97 self::CONFIG_COLUMN_SCHEMA => array(
98 'name' => 'sort255?',
99 'mimeType' => 'text255?',
100 'byteSize' => 'uint64',
101 'storageEngine' => 'text32',
102 'storageFormat' => 'text32',
103 'storageHandle' => 'text255',
104 'authorPHID' => 'phid?',
105 'secretKey' => 'bytes20?',
106 'contentHash' => 'bytes64?',
107 'ttl' => 'epoch?',
108 'isExplicitUpload' => 'bool?',
109 'mailKey' => 'bytes20',
110 'isPartial' => 'bool',
111 'builtinKey' => 'text64?',
112 'isDeleted' => 'bool',
113 ),
114 self::CONFIG_KEY_SCHEMA => array(
115 'key_phid' => null,
116 'phid' => array(
117 'columns' => array('phid'),
118 'unique' => true,
119 ),
120 'authorPHID' => array(
121 'columns' => array('authorPHID'),
122 ),
123 'contentHash' => array(
124 'columns' => array('contentHash'),
125 ),
126 'key_ttl' => array(
127 'columns' => array('ttl'),
128 ),
129 'key_dateCreated' => array(
130 'columns' => array('dateCreated'),
131 ),
132 'key_partial' => array(
133 'columns' => array('authorPHID', 'isPartial'),
134 ),
135 'key_builtin' => array(
136 'columns' => array('builtinKey'),
137 'unique' => true,
138 ),
139 'key_engine' => array(
140 'columns' => array('storageEngine', 'storageHandle(64)'),
141 ),
142 ),
143 ) + parent::getConfiguration();
144 }
145
146 public function generatePHID() {
147 return PhabricatorPHID::generateNewPHID(
148 PhabricatorFileFilePHIDType::TYPECONST);
149 }
150
151 public function save() {
152 if (!$this->getSecretKey()) {
153 $this->setSecretKey($this->generateSecretKey());
154 }
155 if (!$this->getMailKey()) {
156 $this->setMailKey(Filesystem::readRandomCharacters(20));
157 }
158 return parent::save();
159 }
160
161 public function saveAndIndex() {
162 $this->save();
163
164 if ($this->isIndexableFile()) {
165 PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
166 }
167
168 return $this;
169 }
170
171 private function isIndexableFile() {
172 if ($this->getIsChunk()) {
173 return false;
174 }
175
176 return true;
177 }
178
179 /**
180 * Get file monogram in the format of "F123"
181 * @return string
182 */
183 public function getMonogram() {
184 return 'F'.$this->getID();
185 }
186
187 public function scrambleSecret() {
188 return $this->setSecretKey($this->generateSecretKey());
189 }
190
191 public static function readUploadedFileData($spec) {
192 if (!$spec) {
193 throw new Exception(pht('No file was uploaded!'));
194 }
195
196 $err = idx($spec, 'error');
197 if ($err) {
198 throw new PhabricatorFileUploadException($err);
199 }
200
201 $tmp_name = idx($spec, 'tmp_name');
202
203 // NOTE: If we parsed the request body ourselves, the files we wrote will
204 // not be registered in the `is_uploaded_file()` list. It's fine to skip
205 // this check: it just protects against sloppy code from the long ago era
206 // of "register_globals".
207
208 if (ini_get('enable_post_data_reading')) {
209 $is_valid = @is_uploaded_file($tmp_name);
210 if (!$is_valid) {
211 throw new Exception(pht('File is not an uploaded file.'));
212 }
213 }
214
215 $file_data = Filesystem::readFile($tmp_name);
216 $file_size = idx($spec, 'size');
217
218 if (strlen($file_data) != $file_size) {
219 throw new Exception(pht('File size disagrees with uploaded size.'));
220 }
221
222 return $file_data;
223 }
224
225 public static function newFromPHPUpload($spec, array $params = array()) {
226 $file_data = self::readUploadedFileData($spec);
227
228 $file_name = nonempty(
229 idx($params, 'name'),
230 idx($spec, 'name'));
231 $params = array(
232 'name' => $file_name,
233 ) + $params;
234
235 return self::newFromFileData($file_data, $params);
236 }
237
238 public static function newFromXHRUpload($data, array $params = array()) {
239 return self::newFromFileData($data, $params);
240 }
241
242
243 public static function newFileFromContentHash($hash, array $params) {
244 if ($hash === null) {
245 return null;
246 }
247
248 // Check to see if a file with same hash already exists.
249 $file = id(new PhabricatorFile())->loadOneWhere(
250 'contentHash = %s LIMIT 1',
251 $hash);
252 if (!$file) {
253 return null;
254 }
255
256 $copy_of_storage_engine = $file->getStorageEngine();
257 $copy_of_storage_handle = $file->getStorageHandle();
258 $copy_of_storage_format = $file->getStorageFormat();
259 $copy_of_storage_properties = $file->getStorageProperties();
260 $copy_of_byte_size = $file->getByteSize();
261 $copy_of_mime_type = $file->getMimeType();
262
263 $new_file = self::initializeNewFile();
264
265 $new_file->setByteSize($copy_of_byte_size);
266
267 $new_file->setContentHash($hash);
268 $new_file->setStorageEngine($copy_of_storage_engine);
269 $new_file->setStorageHandle($copy_of_storage_handle);
270 $new_file->setStorageFormat($copy_of_storage_format);
271 $new_file->setStorageProperties($copy_of_storage_properties);
272 $new_file->setMimeType($copy_of_mime_type);
273 $new_file->copyDimensions($file);
274
275 $new_file->readPropertiesFromParameters($params);
276
277 $new_file->saveAndIndex();
278
279 return $new_file;
280 }
281
282 public static function newChunkedFile(
283 PhabricatorFileStorageEngine $engine,
284 $length,
285 array $params) {
286
287 $file = self::initializeNewFile();
288
289 $file->setByteSize($length);
290
291 // NOTE: Once we receive the first chunk, we'll detect its MIME type and
292 // update the parent file if a MIME type hasn't been provided. This matters
293 // for large media files like video.
294 $mime_type = idx($params, 'mime-type', '');
295 if (!strlen($mime_type)) {
296 $file->setMimeType('application/octet-stream');
297 }
298
299 $chunked_hash = idx($params, 'chunkedHash');
300
301 // Get rid of this parameter now; we aren't passing it any further down
302 // the stack.
303 unset($params['chunkedHash']);
304
305 if ($chunked_hash) {
306 $file->setContentHash($chunked_hash);
307 } else {
308 // See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some
309 // discussion of this.
310 $seed = Filesystem::readRandomBytes(64);
311 $hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput(
312 $seed);
313 $file->setContentHash($hash);
314 }
315
316 $file->setStorageEngine($engine->getEngineIdentifier());
317 $file->setStorageHandle(PhabricatorFileChunk::newChunkHandle());
318
319 // Chunked files are always stored raw because they do not actually store
320 // data. The chunks do, and can be individually formatted.
321 $file->setStorageFormat(PhabricatorFileRawStorageFormat::FORMATKEY);
322
323 $file->setIsPartial(1);
324
325 $file->readPropertiesFromParameters($params);
326
327 return $file;
328 }
329
330 private static function buildFromFileData($data, array $params = array()) {
331
332 if (isset($params['storageEngines'])) {
333 $engines = $params['storageEngines'];
334 } else {
335 $size = strlen($data);
336 $engines = PhabricatorFileStorageEngine::loadStorageEngines($size);
337
338 if (!$engines) {
339 throw new Exception(
340 pht(
341 'No configured storage engine can store this file. See '.
342 '"Configuring File Storage" in the documentation for '.
343 'information on configuring storage engines. '.
344 'This is likely because the file is too large.'));
345 }
346 }
347
348 assert_instances_of($engines, PhabricatorFileStorageEngine::class);
349 if (!$engines) {
350 throw new Exception(pht('No valid storage engines are available!'));
351 }
352
353 $file = self::initializeNewFile();
354
355 $aes_type = PhabricatorFileAES256StorageFormat::FORMATKEY;
356 $has_aes = PhabricatorKeyring::getDefaultKeyName($aes_type);
357 if ($has_aes !== null) {
358 $default_key = PhabricatorFileAES256StorageFormat::FORMATKEY;
359 } else {
360 $default_key = PhabricatorFileRawStorageFormat::FORMATKEY;
361 }
362 $key = idx($params, 'format', $default_key);
363
364 // Callers can pass in an object explicitly instead of a key. This is
365 // primarily useful for unit tests.
366 if ($key instanceof PhabricatorFileStorageFormat) {
367 $format = clone $key;
368 } else {
369 $format = clone PhabricatorFileStorageFormat::requireFormat($key);
370 }
371
372 $format->setFile($file);
373
374 $properties = $format->newStorageProperties();
375 $file->setStorageFormat($format->getStorageFormatKey());
376 $file->setStorageProperties($properties);
377
378 $data_handle = null;
379 $engine_identifier = null;
380 $integrity_hash = null;
381 $exceptions = array();
382 foreach ($engines as $engine) {
383 $engine_class = get_class($engine);
384 try {
385 $result = $file->writeToEngine(
386 $engine,
387 $data,
388 $params);
389
390 list($engine_identifier, $data_handle, $integrity_hash) = $result;
391
392 // We stored the file somewhere so stop trying to write it to other
393 // places.
394 break;
395 } catch (PhabricatorFileStorageConfigurationException $ex) {
396 // If an engine is outright misconfigured (or misimplemented), raise
397 // that immediately since it probably needs attention.
398 throw $ex;
399 } catch (Throwable $ex) {
400 phlog($ex);
401
402 // If an engine doesn't work, keep trying all the other valid engines
403 // in case something else works.
404 $exceptions[$engine_class] = $ex;
405 }
406 }
407
408 if (!$data_handle) {
409 throw new PhutilAggregateException(
410 pht('All storage engines failed to write file:'),
411 $exceptions);
412 }
413
414 $file->setByteSize(strlen($data));
415
416 $hash = self::hashFileContent($data);
417 $file->setContentHash($hash);
418
419 $file->setStorageEngine($engine_identifier);
420 $file->setStorageHandle($data_handle);
421
422 $file->setIntegrityHash($integrity_hash);
423
424 $file->readPropertiesFromParameters($params);
425
426 if (!$file->getMimeType()) {
427 $tmp = new TempFile();
428 Filesystem::writeFile($tmp, $data);
429 $file->setMimeType(Filesystem::getMimeType($tmp));
430 unset($tmp);
431 }
432
433 try {
434 $file->updateDimensions(false);
435 } catch (Throwable $ex) {
436 // Do nothing.
437 }
438
439 $file->saveAndIndex();
440
441 return $file;
442 }
443
444 public static function newFromFileData($data, array $params = array()) {
445 $hash = self::hashFileContent($data);
446
447 if ($hash !== null) {
448 $file = self::newFileFromContentHash($hash, $params);
449 if ($file) {
450 return $file;
451 }
452 }
453
454 return self::buildFromFileData($data, $params);
455 }
456
457 public function migrateToEngine(
458 PhabricatorFileStorageEngine $engine,
459 $make_copy) {
460
461 if (!$this->getID() || !$this->getStorageHandle()) {
462 throw new Exception(
463 pht("You can not migrate a file which hasn't yet been saved."));
464 }
465
466 $data = $this->loadFileData();
467 $params = array(
468 'name' => $this->getName(),
469 );
470
471 list($new_identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
472 $engine,
473 $data,
474 $params);
475
476 $old_engine = $this->instantiateStorageEngine();
477 $old_identifier = $this->getStorageEngine();
478 $old_handle = $this->getStorageHandle();
479
480 $this->setStorageEngine($new_identifier);
481 $this->setStorageHandle($new_handle);
482 $this->setIntegrityHash($integrity_hash);
483 $this->save();
484
485 if (!$make_copy) {
486 $this->deleteFileDataIfUnused(
487 $old_engine,
488 $old_identifier,
489 $old_handle);
490 }
491
492 return $this;
493 }
494
495 public function migrateToStorageFormat(PhabricatorFileStorageFormat $format) {
496 if (!$this->getID() || !$this->getStorageHandle()) {
497 throw new Exception(
498 pht("You can not migrate a file which hasn't yet been saved."));
499 }
500
501 $data = $this->loadFileData();
502 $params = array(
503 'name' => $this->getName(),
504 );
505
506 $engine = $this->instantiateStorageEngine();
507 $old_handle = $this->getStorageHandle();
508
509 $properties = $format->newStorageProperties();
510 $this->setStorageFormat($format->getStorageFormatKey());
511 $this->setStorageProperties($properties);
512
513 list($identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
514 $engine,
515 $data,
516 $params);
517
518 $this->setStorageHandle($new_handle);
519 $this->setIntegrityHash($integrity_hash);
520 $this->save();
521
522 $this->deleteFileDataIfUnused(
523 $engine,
524 $identifier,
525 $old_handle);
526
527 return $this;
528 }
529
530 public function cycleMasterStorageKey(PhabricatorFileStorageFormat $format) {
531 if (!$this->getID() || !$this->getStorageHandle()) {
532 throw new Exception(
533 pht("You can not cycle keys for a file which hasn't yet been saved."));
534 }
535
536 $properties = $format->cycleStorageProperties();
537 $this->setStorageProperties($properties);
538 $this->save();
539
540 return $this;
541 }
542
543 private function writeToEngine(
544 PhabricatorFileStorageEngine $engine,
545 $data,
546 array $params) {
547
548 $engine_class = get_class($engine);
549
550 $format = $this->newStorageFormat();
551
552 $data_iterator = array($data);
553 $formatted_iterator = $format->newWriteIterator($data_iterator);
554 $formatted_data = $this->loadDataFromIterator($formatted_iterator);
555
556 $integrity_hash = $engine->newIntegrityHash($formatted_data, $format);
557
558 $data_handle = $engine->writeFile($formatted_data, $params);
559
560 if (!$data_handle || strlen($data_handle) > 255) {
561 // This indicates an improperly implemented storage engine.
562 throw new PhabricatorFileStorageConfigurationException(
563 pht(
564 "Storage engine '%s' executed %s but did not return a valid ".
565 "handle ('%s') to the data: it must be nonempty and no longer ".
566 "than 255 characters.",
567 $engine_class,
568 'writeFile()',
569 $data_handle));
570 }
571
572 $engine_identifier = $engine->getEngineIdentifier();
573 if (!$engine_identifier || strlen($engine_identifier) > 32) {
574 throw new PhabricatorFileStorageConfigurationException(
575 pht(
576 "Storage engine '%s' returned an improper engine identifier '{%s}': ".
577 "it must be nonempty and no longer than 32 characters.",
578 $engine_class,
579 $engine_identifier));
580 }
581
582 return array($engine_identifier, $data_handle, $integrity_hash);
583 }
584
585
586 /**
587 * Download a remote resource over HTTP and save the response body as a file.
588 *
589 * This method respects `security.outbound-blacklist`, and protects against
590 * HTTP redirection (by manually following "Location" headers and verifying
591 * each destination). It does not protect against DNS rebinding. See
592 * discussion in T6755.
593 */
594 public static function newFromFileDownload($uri, array $params = array()) {
595 $timeout = 5;
596
597 $redirects = array();
598 $current = $uri;
599 while (true) {
600 try {
601 if (count($redirects) > 10) {
602 throw new Exception(
603 pht('Too many redirects trying to fetch remote URI.'));
604 }
605
606 $resolved = PhabricatorEnv::requireValidRemoteURIForFetch(
607 $current,
608 array(
609 'http',
610 'https',
611 ));
612
613 list($resolved_uri, $resolved_domain) = $resolved;
614
615 $current = new PhutilURI($current);
616 if ($current->getProtocol() == 'http') {
617 // For HTTP, we can use a pre-resolved URI to defuse DNS rebinding.
618 $fetch_uri = $resolved_uri;
619 $fetch_host = $resolved_domain;
620 } else {
621 // For HTTPS, we can't: cURL won't verify the SSL certificate if
622 // the domain has been replaced with an IP. But internal services
623 // presumably will not have valid certificates for rebindable
624 // domain names on attacker-controlled domains, so the DNS rebinding
625 // attack should generally not be possible anyway.
626 $fetch_uri = $current;
627 $fetch_host = null;
628 }
629
630 $future = id(new HTTPSFuture($fetch_uri))
631 ->setFollowLocation(false)
632 ->setTimeout($timeout);
633
634 if ($fetch_host !== null) {
635 $future->addHeader('Host', $fetch_host);
636 }
637
638 list($status, $body, $headers) = $future->resolve();
639
640 if ($status->isRedirect()) {
641 // This is an HTTP 3XX status, so look for a "Location" header.
642 $location = null;
643 foreach ($headers as $header) {
644 list($name, $value) = $header;
645 if (phutil_utf8_strtolower($name) == 'location') {
646 $location = $value;
647 break;
648 }
649 }
650
651 // HTTP 3XX status with no "Location" header, just treat this like
652 // a normal HTTP error.
653 if ($location === null) {
654 throw $status;
655 }
656
657 if (isset($redirects[$location])) {
658 throw new Exception(
659 pht('Encountered loop while following redirects.'));
660 }
661
662 $redirects[$location] = $location;
663 $current = $location;
664 // We'll fall off the bottom and go try this URI now.
665 } else if ($status->isError()) {
666 // This is something other than an HTTP 2XX or HTTP 3XX status, so
667 // just bail out.
668 throw $status;
669 } else {
670 // This is HTTP 2XX, so use the response body to save the file data.
671 // Provide a default name based on the URI, truncating it if the URI
672 // is exceptionally long.
673
674 $default_name = basename($uri);
675 $default_name = id(new PhutilUTF8StringTruncator())
676 ->setMaximumBytes(64)
677 ->truncateString($default_name);
678
679 $params = $params + array(
680 'name' => $default_name,
681 );
682
683 return self::newFromFileData($body, $params);
684 }
685 } catch (Throwable $ex) {
686 if ($redirects) {
687 throw new Exception(
688 pht(
689 'Failed to fetch remote URI "%s" after following %s redirect(s) '.
690 '(%s): %s',
691 $uri,
692 phutil_count($redirects),
693 implode(' > ', array_keys($redirects)),
694 $ex->getMessage()),
695 0,
696 $ex);
697 } else {
698 throw $ex;
699 }
700 }
701 }
702 }
703
704 public static function normalizeFileName($file_name) {
705 $pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@";
706 $file_name = preg_replace($pattern, '_', $file_name);
707 $file_name = preg_replace('@_+@', '_', $file_name);
708 $file_name = trim($file_name, '_');
709
710 $disallowed_filenames = array(
711 '.' => 'dot',
712 '..' => 'dotdot',
713 '' => 'file',
714 );
715 $file_name = idx($disallowed_filenames, $file_name, $file_name);
716
717 return $file_name;
718 }
719
720 public function delete() {
721 // We want to delete all the rows which mark this file as the transformation
722 // of some other file (since we're getting rid of it). We also delete all
723 // the transformations of this file, so that a user who deletes an image
724 // doesn't need to separately hunt down and delete a bunch of thumbnails and
725 // resizes of it.
726
727 $outbound_xforms = id(new PhabricatorFileQuery())
728 ->setViewer(PhabricatorUser::getOmnipotentUser())
729 ->withTransforms(
730 array(
731 array(
732 'originalPHID' => $this->getPHID(),
733 'transform' => true,
734 ),
735 ))
736 ->execute();
737
738 foreach ($outbound_xforms as $outbound_xform) {
739 $outbound_xform->delete();
740 }
741
742 $inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
743 'transformedPHID = %s',
744 $this->getPHID());
745
746 $this->openTransaction();
747 foreach ($inbound_xforms as $inbound_xform) {
748 $inbound_xform->delete();
749 }
750 $ret = parent::delete();
751 $this->saveTransaction();
752
753 $this->deleteFileDataIfUnused(
754 $this->instantiateStorageEngine(),
755 $this->getStorageEngine(),
756 $this->getStorageHandle());
757
758 return $ret;
759 }
760
761
762 /**
763 * Destroy stored file data if there are no remaining files which reference
764 * it.
765 */
766 public function deleteFileDataIfUnused(
767 PhabricatorFileStorageEngine $engine,
768 $engine_identifier,
769 $handle) {
770
771 // Check to see if any files are using storage.
772 $usage = id(new self())->loadAllWhere(
773 'storageEngine = %s AND storageHandle = %s LIMIT 1',
774 $engine_identifier,
775 $handle);
776
777 // If there are no files using the storage, destroy the actual storage.
778 if (!$usage) {
779 try {
780 $engine->deleteFile($handle);
781 } catch (Throwable $ex) {
782 // In the worst case, we're leaving some data stranded in a storage
783 // engine, which is not a big deal.
784 phlog($ex);
785 }
786 }
787 }
788
789 public static function hashFileContent($data) {
790 // NOTE: Hashing can fail if the algorithm isn't available in the current
791 // build of PHP. It's fine if we're unable to generate a content hash:
792 // it just means we'll store extra data when users upload duplicate files
793 // instead of being able to deduplicate it.
794
795 $hash = hash('sha256', $data, $raw_output = false);
796 if ($hash === false) {
797 return null;
798 }
799
800 return $hash;
801 }
802
803 public function loadFileData() {
804 $iterator = $this->getFileDataIterator();
805 return $this->loadDataFromIterator($iterator);
806 }
807
808
809 /**
810 * Return an iterable which emits file content bytes.
811 *
812 * @param int $begin (optional) Offset for the start of data.
813 * @param int $end (optional) Offset for the end of data.
814 * @return Iterable Iterable object which emits requested data.
815 */
816 public function getFileDataIterator($begin = null, $end = null) {
817 $engine = $this->instantiateStorageEngine();
818
819 $format = $this->newStorageFormat();
820
821 $iterator = $engine->getRawFileDataIterator(
822 $this,
823 $begin,
824 $end,
825 $format);
826
827 return $iterator;
828 }
829
830 /**
831 * Get file URI in the format of "/F123"
832 * @return string
833 */
834 public function getURI() {
835 return $this->getInfoURI();
836 }
837
838 /**
839 * Get file view URI in the format of
840 * https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename
841 * @return string
842 */
843 public function getViewURI() {
844 if (!$this->getPHID()) {
845 throw new Exception(
846 pht('You must save a file before you can generate a view URI.'));
847 }
848
849 return $this->getCDNURI('data');
850 }
851
852 /**
853 * Get file view URI in the format of
854 * https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename or
855 * https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
856 * @return string
857 */
858 public function getCDNURI($request_kind) {
859 if (($request_kind !== 'data') &&
860 ($request_kind !== 'download')) {
861 throw new Exception(
862 pht(
863 'Unknown file content request kind "%s".',
864 $request_kind));
865 }
866
867 $name = self::normalizeFileName($this->getName());
868 $name = phutil_escape_uri($name);
869
870 $parts = array();
871 $parts[] = 'file';
872 $parts[] = $request_kind;
873
874 // If this is an instanced install, add the instance identifier to the URI.
875 // Instanced configurations behind a CDN may not be able to control the
876 // request domain used by the CDN (as with AWS CloudFront). Embedding the
877 // instance identity in the path allows us to distinguish between requests
878 // originating from different instances but served through the same CDN.
879 $instance = PhabricatorEnv::getEnvConfig('cluster.instance');
880 if (phutil_nonempty_string($instance)) {
881 $parts[] = '@'.$instance;
882 }
883
884 $parts[] = $this->getSecretKey();
885 $parts[] = $this->getPHID();
886 $parts[] = $name;
887
888 $path = '/'.implode('/', $parts);
889
890 // If this file is only partially uploaded, we're just going to return a
891 // local URI to make sure that Ajax works, since the page is inevitably
892 // going to give us an error back.
893 if ($this->getIsPartial()) {
894 return PhabricatorEnv::getURI($path);
895 } else {
896 return PhabricatorEnv::getCDNURI($path);
897 }
898 }
899
900 /**
901 * Get file info URI in the format of "/F123"
902 * @return string
903 */
904 public function getInfoURI() {
905 return '/'.$this->getMonogram();
906 }
907
908 public function getBestURI() {
909 if ($this->isViewableInBrowser()) {
910 return $this->getViewURI();
911 } else {
912 return $this->getInfoURI();
913 }
914 }
915
916 /**
917 * Get file view URI in the format of
918 * https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
919 * @return string
920 */
921 public function getDownloadURI() {
922 return $this->getCDNURI('download');
923 }
924
925 public function getURIForTransform(PhabricatorFileTransform $transform) {
926 return $this->getTransformedURI($transform->getTransformKey());
927 }
928
929 private function getTransformedURI($transform) {
930 $parts = array();
931 $parts[] = 'file';
932 $parts[] = 'xform';
933
934 $instance = PhabricatorEnv::getEnvConfig('cluster.instance');
935 if (phutil_nonempty_string($instance)) {
936 $parts[] = '@'.$instance;
937 }
938
939 $parts[] = $transform;
940 $parts[] = $this->getPHID();
941 $parts[] = $this->getSecretKey();
942
943 $path = implode('/', $parts);
944 $path = $path.'/';
945
946 return PhabricatorEnv::getCDNURI($path);
947 }
948
949 /**
950 * Whether the file can be viewed in a browser
951 * @return bool True if MIME type of the file is listed in the
952 * files.viewable-mime-types setting
953 */
954 public function isViewableInBrowser() {
955 return ($this->getViewableMimeType() !== null);
956 }
957
958 /**
959 * Whether the file is an image viewable in the browser
960 * @return bool True if MIME type of the file is listed in the
961 * files.image-mime-types setting and file is viewable in the browser
962 */
963 public function isViewableImage() {
964 if (!$this->isViewableInBrowser()) {
965 return false;
966 }
967
968 $mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types');
969 $mime_type = $this->getMimeType();
970 return idx($mime_map, $mime_type);
971 }
972
973 /**
974 * Whether the file is an audio file
975 * @return bool True if MIME type of the file is listed in the
976 * files.audio-mime-types setting and file is viewable in the browser
977 */
978 public function isAudio() {
979 if (!$this->isViewableInBrowser()) {
980 return false;
981 }
982
983 $mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types');
984 $mime_type = $this->getMimeType();
985 return idx($mime_map, $mime_type);
986 }
987
988 /**
989 * Whether the file is a video file
990 * @return bool True if MIME type of the file is listed in the
991 * files.video-mime-types setting and file is viewable in the browser
992 */
993 public function isVideo() {
994 if (!$this->isViewableInBrowser()) {
995 return false;
996 }
997
998 $mime_map = PhabricatorEnv::getEnvConfig('files.video-mime-types');
999 $mime_type = $this->getMimeType();
1000 return idx($mime_map, $mime_type);
1001 }
1002
1003 /**
1004 * Whether the file is a PDF file
1005 * @return bool True if MIME type of the file is application/pdf and file is
1006 * viewable in the browser
1007 */
1008 public function isPDF() {
1009 if (!$this->isViewableInBrowser()) {
1010 return false;
1011 }
1012
1013 $mime_map = array(
1014 'application/pdf' => 'application/pdf',
1015 );
1016
1017 $mime_type = $this->getMimeType();
1018 return idx($mime_map, $mime_type);
1019 }
1020
1021 public function isTransformableImage() {
1022 // NOTE: The way the 'gd' extension works in PHP is that you can install it
1023 // with support for only some file types, so it might be able to handle
1024 // PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup
1025 // warns you if you don't have complete support.
1026
1027 $matches = null;
1028 $ok = false;
1029 if ($this->getViewableMimeType() !== null) {
1030 $ok = preg_match(
1031 '@^image/(gif|png|jpe?g|webp)@',
1032 $this->getViewableMimeType(),
1033 $matches);
1034 }
1035 if (!$ok) {
1036 return false;
1037 }
1038
1039 switch ($matches[1]) {
1040 case 'jpg':
1041 case 'jpeg':
1042 return function_exists('imagejpeg');
1043 case 'png':
1044 return function_exists('imagepng');
1045 case 'gif':
1046 return function_exists('imagegif');
1047 case 'webp':
1048 return function_exists('imagewebp');
1049 default:
1050 throw new Exception(pht('Unknown type matched as image MIME type.'));
1051 }
1052 }
1053
1054 public static function getTransformableImageFormats() {
1055 $supported = array();
1056
1057 if (function_exists('imagejpeg')) {
1058 $supported[] = 'jpg';
1059 }
1060
1061 if (function_exists('imagepng')) {
1062 $supported[] = 'png';
1063 }
1064
1065 if (function_exists('imagegif')) {
1066 $supported[] = 'gif';
1067 }
1068
1069 if (function_exists('imagewebp')) {
1070 $supported[] = 'webp';
1071 }
1072
1073 return $supported;
1074 }
1075
1076 public function getDragAndDropDictionary() {
1077 return array(
1078 'id' => $this->getID(),
1079 'phid' => $this->getPHID(),
1080 'uri' => $this->getBestURI(),
1081 );
1082 }
1083
1084 public function instantiateStorageEngine() {
1085 return self::buildEngine($this->getStorageEngine());
1086 }
1087
1088 public static function buildEngine($engine_identifier) {
1089 $engines = self::buildAllEngines();
1090 foreach ($engines as $engine) {
1091 if ($engine->getEngineIdentifier() == $engine_identifier) {
1092 return $engine;
1093 }
1094 }
1095
1096 throw new Exception(
1097 pht(
1098 "Storage engine '%s' could not be located!",
1099 $engine_identifier));
1100 }
1101
1102 public static function buildAllEngines() {
1103 return id(new PhutilClassMapQuery())
1104 ->setAncestorClass(PhabricatorFileStorageEngine::class)
1105 ->execute();
1106 }
1107
1108 /**
1109 * Whether the file is listed as a viewable MIME type
1110 * @return bool True if MIME type of the file is listed in the
1111 * files.viewable-mime-types setting
1112 */
1113 public function getViewableMimeType() {
1114 $mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types');
1115
1116 $mime_type = $this->getMimeType();
1117 $mime_parts = explode(';', $mime_type);
1118 $mime_type = trim(reset($mime_parts));
1119
1120 return idx($mime_map, $mime_type);
1121 }
1122
1123 public function getDisplayIconForMimeType() {
1124 $mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types');
1125 $mime_type = $this->getMimeType();
1126 return idx($mime_map, $mime_type, 'fa-file-o');
1127 }
1128
1129 public function validateSecretKey($key) {
1130 return ($key == $this->getSecretKey());
1131 }
1132
1133 public function generateSecretKey() {
1134 return Filesystem::readRandomCharacters(20);
1135 }
1136
1137 public function setStorageProperties(array $properties) {
1138 $this->metadata[self::METADATA_STORAGE] = $properties;
1139 return $this;
1140 }
1141
1142 public function getStorageProperties() {
1143 return idx($this->metadata, self::METADATA_STORAGE, array());
1144 }
1145
1146 public function getStorageProperty($key, $default = null) {
1147 $properties = $this->getStorageProperties();
1148 return idx($properties, $key, $default);
1149 }
1150
1151 public function loadDataFromIterator($iterator) {
1152 $result = '';
1153
1154 foreach ($iterator as $chunk) {
1155 $result .= $chunk;
1156 }
1157
1158 return $result;
1159 }
1160
1161 public function updateDimensions($save = true) {
1162 if (!$this->isViewableImage()) {
1163 throw new Exception(pht('This file is not a viewable image.'));
1164 }
1165
1166 if (!function_exists('imagecreatefromstring')) {
1167 throw new Exception(pht('Cannot retrieve image information.'));
1168 }
1169
1170 if ($this->getIsChunk()) {
1171 throw new Exception(
1172 pht('Refusing to assess image dimensions of file chunk.'));
1173 }
1174
1175 $engine = $this->instantiateStorageEngine();
1176 if ($engine->isChunkEngine()) {
1177 throw new Exception(
1178 pht('Refusing to assess image dimensions of chunked file.'));
1179 }
1180
1181 $data = $this->loadFileData();
1182
1183 $img = @imagecreatefromstring($data);
1184 if ($img === false) {
1185 throw new Exception(pht('Error when decoding image.'));
1186 }
1187
1188 $this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img);
1189 $this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img);
1190
1191 if ($save) {
1192 $this->save();
1193 }
1194
1195 return $this;
1196 }
1197
1198 public function copyDimensions(PhabricatorFile $file) {
1199 $metadata = $file->getMetadata();
1200 $width = idx($metadata, self::METADATA_IMAGE_WIDTH);
1201 if ($width) {
1202 $this->metadata[self::METADATA_IMAGE_WIDTH] = $width;
1203 }
1204 $height = idx($metadata, self::METADATA_IMAGE_HEIGHT);
1205 if ($height) {
1206 $this->metadata[self::METADATA_IMAGE_HEIGHT] = $height;
1207 }
1208
1209 return $this;
1210 }
1211
1212
1213 /**
1214 * Load (or build) the @{class:PhabricatorFile} objects for builtin file
1215 * resources. The builtin mechanism allows files shipped with Phabricator
1216 * to be treated like normal files so that APIs do not need to special case
1217 * things like default images or deleted files.
1218 *
1219 * Builtins are located in `resources/builtin/` and identified by their
1220 * name.
1221 *
1222 * @param PhabricatorUser $user Viewing user.
1223 * @param list<PhabricatorFilesBuiltinFile> $builtins List of builtin file
1224 * specs.
1225 * @return array<string, PhabricatorFile> Dictionary of named builtins.
1226 */
1227 public static function loadBuiltins(PhabricatorUser $user, array $builtins) {
1228 $builtins = mpull($builtins, null, 'getBuiltinFileKey');
1229
1230 // NOTE: Anyone is allowed to access builtin files.
1231
1232 $files = id(new PhabricatorFileQuery())
1233 ->setViewer(PhabricatorUser::getOmnipotentUser())
1234 ->withBuiltinKeys(array_keys($builtins))
1235 ->execute();
1236
1237 $results = array();
1238 foreach ($files as $file) {
1239 $builtin_key = $file->getBuiltinName();
1240 if ($builtin_key !== null) {
1241 $results[$builtin_key] = $file;
1242 }
1243 }
1244
1245 $build = array();
1246 foreach ($builtins as $key => $builtin) {
1247 if (isset($results[$key])) {
1248 continue;
1249 }
1250
1251 $data = $builtin->loadBuiltinFileData();
1252
1253 $params = array(
1254 'name' => $builtin->getBuiltinDisplayName(),
1255 'canCDN' => true,
1256 'builtin' => $key,
1257 );
1258
1259 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
1260 try {
1261 $file = self::newFromFileData($data, $params);
1262 } catch (AphrontDuplicateKeyQueryException $ex) {
1263 $file = id(new PhabricatorFileQuery())
1264 ->setViewer(PhabricatorUser::getOmnipotentUser())
1265 ->withBuiltinKeys(array($key))
1266 ->executeOne();
1267 if (!$file) {
1268 throw new Exception(
1269 pht(
1270 'Collided mid-air when generating builtin file "%s", but '.
1271 'then failed to load the object we collided with.',
1272 $key));
1273 }
1274 }
1275 unset($unguarded);
1276
1277 $file->attachObjectPHIDs(array());
1278 $file->attachObjects(array());
1279
1280 $results[$key] = $file;
1281 }
1282
1283 return $results;
1284 }
1285
1286
1287 /**
1288 * Convenience wrapper for @{method:loadBuiltins}.
1289 *
1290 * @param PhabricatorUser $user Viewing user.
1291 * @param string $name Single builtin name to load.
1292 * @return PhabricatorFile Corresponding builtin file.
1293 */
1294 public static function loadBuiltin(PhabricatorUser $user, $name) {
1295 $builtin = id(new PhabricatorFilesOnDiskBuiltinFile())
1296 ->setName($name);
1297
1298 $key = $builtin->getBuiltinFileKey();
1299
1300 return idx(self::loadBuiltins($user, array($builtin)), $key);
1301 }
1302
1303 public function getObjects() {
1304 return $this->assertAttached($this->objects);
1305 }
1306
1307 public function attachObjects(array $objects) {
1308 $this->objects = $objects;
1309 return $this;
1310 }
1311
1312 public function getObjectPHIDs() {
1313 return $this->assertAttached($this->objectPHIDs);
1314 }
1315
1316 public function attachObjectPHIDs(array $object_phids) {
1317 $this->objectPHIDs = $object_phids;
1318 return $this;
1319 }
1320
1321 public function getOriginalFile() {
1322 return $this->assertAttached($this->originalFile);
1323 }
1324
1325 public function attachOriginalFile(?PhabricatorFile $file = null) {
1326 $this->originalFile = $file;
1327 return $this;
1328 }
1329
1330 public function getImageHeight() {
1331 if (!$this->isViewableImage()) {
1332 return null;
1333 }
1334 return idx($this->metadata, self::METADATA_IMAGE_HEIGHT);
1335 }
1336
1337 public function getImageWidth() {
1338 if (!$this->isViewableImage()) {
1339 return null;
1340 }
1341 return idx($this->metadata, self::METADATA_IMAGE_WIDTH);
1342 }
1343
1344 public function getAltText() {
1345 $alt = $this->getCustomAltText();
1346
1347 if (phutil_nonempty_string($alt)) {
1348 return $alt;
1349 }
1350
1351 return $this->getDefaultAltText();
1352 }
1353
1354 public function getCustomAltText() {
1355 return idx($this->metadata, self::METADATA_ALT_TEXT);
1356 }
1357
1358 public function setCustomAltText($value) {
1359 $value = phutil_string_cast($value);
1360
1361 if (!strlen($value)) {
1362 $value = null;
1363 }
1364
1365 if ($value === null) {
1366 unset($this->metadata[self::METADATA_ALT_TEXT]);
1367 } else {
1368 $this->metadata[self::METADATA_ALT_TEXT] = $value;
1369 }
1370
1371 return $this;
1372 }
1373
1374 public function getDefaultAltText() {
1375 $parts = array();
1376
1377 $name = $this->getName();
1378 if (strlen($name)) {
1379 $parts[] = $name;
1380 }
1381
1382 $stats = array();
1383
1384 $image_x = $this->getImageWidth();
1385 $image_y = $this->getImageHeight();
1386
1387 if ($image_x && $image_y) {
1388 $stats[] = pht(
1389 "%s\xC3\x97%s px",
1390 new PhutilNumber($image_x),
1391 new PhutilNumber($image_y));
1392 }
1393
1394 $bytes = $this->getByteSize();
1395 if ($bytes) {
1396 $stats[] = phutil_format_bytes($bytes);
1397 }
1398
1399 if ($stats) {
1400 $parts[] = pht('(%s)', implode(', ', $stats));
1401 }
1402
1403 if (!$parts) {
1404 return null;
1405 }
1406
1407 return implode(' ', $parts);
1408 }
1409
1410 public function getCanCDN() {
1411 if (!$this->isViewableImage()) {
1412 return false;
1413 }
1414
1415 return idx($this->metadata, self::METADATA_CAN_CDN);
1416 }
1417
1418 public function setCanCDN($can_cdn) {
1419 $this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0;
1420 return $this;
1421 }
1422
1423 public function isBuiltin() {
1424 return ($this->getBuiltinName() !== null);
1425 }
1426
1427 public function getBuiltinName() {
1428 return idx($this->metadata, self::METADATA_BUILTIN);
1429 }
1430
1431 public function setBuiltinName($name) {
1432 $this->metadata[self::METADATA_BUILTIN] = $name;
1433 return $this;
1434 }
1435
1436 public function getIsProfileImage() {
1437 return idx($this->metadata, self::METADATA_PROFILE);
1438 }
1439
1440 public function setIsProfileImage($value) {
1441 $this->metadata[self::METADATA_PROFILE] = $value;
1442 return $this;
1443 }
1444
1445 public function getIsChunk() {
1446 return idx($this->metadata, self::METADATA_CHUNK);
1447 }
1448
1449 public function setIsChunk($value) {
1450 $this->metadata[self::METADATA_CHUNK] = $value;
1451 return $this;
1452 }
1453
1454 public function setIntegrityHash($integrity_hash) {
1455 $this->metadata[self::METADATA_INTEGRITY] = $integrity_hash;
1456 return $this;
1457 }
1458
1459 public function getIntegrityHash() {
1460 return idx($this->metadata, self::METADATA_INTEGRITY);
1461 }
1462
1463 public function newIntegrityHash() {
1464 $engine = $this->instantiateStorageEngine();
1465
1466 if ($engine->isChunkEngine()) {
1467 return null;
1468 }
1469
1470 $format = $this->newStorageFormat();
1471
1472 $storage_handle = $this->getStorageHandle();
1473 $data = $engine->readFile($storage_handle);
1474
1475 return $engine->newIntegrityHash($data, $format);
1476 }
1477
1478 /**
1479 * Write the policy edge between this file and some object.
1480 * This method is successful even if the file is already attached.
1481 *
1482 * @param string $phid Object PHID to attach to.
1483 * @return $this
1484 */
1485 public function attachToObject($phid) {
1486 self::attachFileToObject($this->getPHID(), $phid);
1487 return $this;
1488 }
1489
1490 /**
1491 * Write the policy edge between a file and some object.
1492 * This method is successful even if the file is already attached.
1493 * NOTE: Please avoid to use this static method directly.
1494 * Instead, use PhabricatorFile#attachToObject(phid).
1495 *
1496 * @param string $file_phid File PHID to attach from.
1497 * @param string $object_phid Object PHID to attach to.
1498 * @return void
1499 */
1500 public static function attachFileToObject($file_phid, $object_phid) {
1501
1502 // It can be easy to confuse the two arguments. Be strict.
1503 if (phid_get_type($file_phid) !== PhabricatorFileFilePHIDType::TYPECONST) {
1504 throw new Exception(pht('The first argument must be a phid of a file.'));
1505 }
1506
1507 $attachment_table = new PhabricatorFileAttachment();
1508 $attachment_conn = $attachment_table->establishConnection('w');
1509
1510 queryfx(
1511 $attachment_conn,
1512 'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
1513 attacherPHID, dateCreated, dateModified)
1514 VALUES (%s, %s, %s, %ns, %d, %d)
1515 ON DUPLICATE KEY UPDATE
1516 attachmentMode = VALUES(attachmentMode),
1517 attacherPHID = VALUES(attacherPHID),
1518 dateModified = VALUES(dateModified)',
1519 $attachment_table,
1520 $object_phid,
1521 $file_phid,
1522 PhabricatorFileAttachment::MODE_ATTACH,
1523 null,
1524 PhabricatorTime::getNow(),
1525 PhabricatorTime::getNow());
1526 }
1527
1528
1529 /**
1530 * Configure a newly created file object according to specified parameters.
1531 *
1532 * This method is called both when creating a file from fresh data, and
1533 * when creating a new file which reuses existing storage.
1534 *
1535 * @param map<string, mixed> $params Bag of parameters, see
1536 * @{class:PhabricatorFile} for documentation.
1537 * @return $this
1538 */
1539 private function readPropertiesFromParameters(array $params) {
1540 PhutilTypeSpec::checkMap(
1541 $params,
1542 array(
1543 'name' => 'optional string',
1544 'authorPHID' => 'optional string',
1545 'ttl.relative' => 'optional int',
1546 'ttl.absolute' => 'optional int',
1547 'viewPolicy' => 'optional string',
1548 'isExplicitUpload' => 'optional bool',
1549 'canCDN' => 'optional bool',
1550 'profile' => 'optional bool',
1551 'format' => 'optional string|PhabricatorFileStorageFormat',
1552 'mime-type' => 'optional string',
1553 'builtin' => 'optional string',
1554 'storageEngines' => 'optional list<PhabricatorFileStorageEngine>',
1555 'chunk' => 'optional bool',
1556 ));
1557
1558 $file_name = idx($params, 'name');
1559 $this->setName($file_name);
1560
1561 $author_phid = idx($params, 'authorPHID');
1562 $this->setAuthorPHID($author_phid);
1563
1564 $absolute_ttl = idx($params, 'ttl.absolute');
1565 $relative_ttl = idx($params, 'ttl.relative');
1566 if ($absolute_ttl !== null && $relative_ttl !== null) {
1567 throw new Exception(
1568 pht(
1569 'Specify an absolute TTL or a relative TTL, but not both.'));
1570 } else if ($absolute_ttl !== null) {
1571 if ($absolute_ttl < PhabricatorTime::getNow()) {
1572 throw new Exception(
1573 pht(
1574 'Absolute TTL must be in the present or future, but TTL "%s" '.
1575 'is in the past.',
1576 $absolute_ttl));
1577 }
1578
1579 $this->setTtl($absolute_ttl);
1580 } else if ($relative_ttl !== null) {
1581 if ($relative_ttl < 0) {
1582 throw new Exception(
1583 pht(
1584 'Relative TTL must be zero or more seconds, but "%s" is '.
1585 'negative.',
1586 $relative_ttl));
1587 }
1588
1589 $max_relative = phutil_units('365 days in seconds');
1590 if ($relative_ttl > $max_relative) {
1591 throw new Exception(
1592 pht(
1593 'Relative TTL must not be more than "%s" seconds, but TTL '.
1594 '"%s" was specified.',
1595 $max_relative,
1596 $relative_ttl));
1597 }
1598
1599 $absolute_ttl = PhabricatorTime::getNow() + $relative_ttl;
1600
1601 $this->setTtl($absolute_ttl);
1602 }
1603
1604 $view_policy = idx($params, 'viewPolicy');
1605 if ($view_policy) {
1606 $this->setViewPolicy($params['viewPolicy']);
1607 }
1608
1609 $is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0);
1610 $this->setIsExplicitUpload($is_explicit);
1611
1612 $can_cdn = idx($params, 'canCDN');
1613 if ($can_cdn) {
1614 $this->setCanCDN(true);
1615 }
1616
1617 $builtin = idx($params, 'builtin');
1618 if ($builtin) {
1619 $this->setBuiltinName($builtin);
1620 $this->setBuiltinKey($builtin);
1621 }
1622
1623 $profile = idx($params, 'profile');
1624 if ($profile) {
1625 $this->setIsProfileImage(true);
1626 }
1627
1628 $mime_type = idx($params, 'mime-type');
1629 if ($mime_type) {
1630 $this->setMimeType($mime_type);
1631 }
1632
1633 $is_chunk = idx($params, 'chunk');
1634 if ($is_chunk) {
1635 $this->setIsChunk(true);
1636 }
1637
1638 return $this;
1639 }
1640
1641 public function getRedirectResponse() {
1642 $uri = $this->getBestURI();
1643
1644 // TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI
1645 // (if the file is a viewable image) and sometimes a local URI (if not).
1646 // For now, just detect which one we got and configure the response
1647 // appropriately. In the long run, if this endpoint is served from a CDN
1648 // domain, we can't issue a local redirect to an info URI (which is not
1649 // present on the CDN domain). We probably never actually issue local
1650 // redirects here anyway, since we only ever transform viewable images
1651 // right now.
1652
1653 $is_external = strlen(id(new PhutilURI($uri))->getDomain());
1654
1655 return id(new AphrontRedirectResponse())
1656 ->setIsExternal($is_external)
1657 ->setURI($uri);
1658 }
1659
1660 public function newDownloadResponse() {
1661 // We're cheating a little bit here and relying on the fact that
1662 // getDownloadURI() always returns a fully qualified URI with a complete
1663 // domain.
1664 return id(new AphrontRedirectResponse())
1665 ->setIsExternal(true)
1666 ->setCloseDialogBeforeRedirect(true)
1667 ->setURI($this->getDownloadURI());
1668 }
1669
1670 public function attachTransforms(array $map) {
1671 $this->transforms = $map;
1672 return $this;
1673 }
1674
1675 public function getTransform($key) {
1676 return $this->assertAttachedKey($this->transforms, $key);
1677 }
1678
1679 public function newStorageFormat() {
1680 $key = $this->getStorageFormat();
1681 $template = PhabricatorFileStorageFormat::requireFormat($key);
1682
1683 $format = id(clone $template)
1684 ->setFile($this);
1685
1686 return $format;
1687 }
1688
1689
1690/* -( PhabricatorApplicationTransactionInterface )------------------------- */
1691
1692
1693 public function getApplicationTransactionEditor() {
1694 return new PhabricatorFileEditor();
1695 }
1696
1697 public function getApplicationTransactionTemplate() {
1698 return new PhabricatorFileTransaction();
1699 }
1700
1701
1702/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
1703
1704
1705 public function getCapabilities() {
1706 return array(
1707 PhabricatorPolicyCapability::CAN_VIEW,
1708 PhabricatorPolicyCapability::CAN_EDIT,
1709 );
1710 }
1711
1712 public function getPolicy($capability) {
1713 switch ($capability) {
1714 case PhabricatorPolicyCapability::CAN_VIEW:
1715 if ($this->isBuiltin()) {
1716 return PhabricatorPolicies::getMostOpenPolicy();
1717 }
1718 if ($this->getIsProfileImage()) {
1719 return PhabricatorPolicies::getMostOpenPolicy();
1720 }
1721 return $this->getViewPolicy();
1722 case PhabricatorPolicyCapability::CAN_EDIT:
1723 return PhabricatorPolicies::POLICY_NOONE;
1724 }
1725 }
1726
1727 public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
1728 $viewer_phid = $viewer->getPHID();
1729 if ($viewer_phid) {
1730 if ($this->getAuthorPHID() == $viewer_phid) {
1731 return true;
1732 }
1733 }
1734
1735 switch ($capability) {
1736 case PhabricatorPolicyCapability::CAN_VIEW:
1737 // If you can see the file this file is a transform of, you can see
1738 // this file.
1739 if ($this->getOriginalFile()) {
1740 return true;
1741 }
1742
1743 // If you can see any object this file is attached to, you can see
1744 // the file.
1745 return (count($this->getObjects()) > 0);
1746 }
1747
1748 return false;
1749 }
1750
1751 public function describeAutomaticCapability($capability) {
1752 $out = array();
1753 $out[] = pht('The user who uploaded a file can always view and edit it.');
1754 switch ($capability) {
1755 case PhabricatorPolicyCapability::CAN_VIEW:
1756 $out[] = pht(
1757 'Files attached to objects are visible to users who can view '.
1758 'those objects.');
1759 $out[] = pht(
1760 'Thumbnails are visible only to users who can view the original '.
1761 'file.');
1762 break;
1763 }
1764
1765 return $out;
1766 }
1767
1768
1769/* -( PhabricatorSubscribableInterface Implementation )-------------------- */
1770
1771
1772 public function isAutomaticallySubscribed($phid) {
1773 return ($this->authorPHID == $phid);
1774 }
1775
1776
1777/* -( PhabricatorTokenReceiverInterface )---------------------------------- */
1778
1779
1780 public function getUsersToNotifyOfTokenGiven() {
1781 return array(
1782 $this->getAuthorPHID(),
1783 );
1784 }
1785
1786
1787/* -( PhabricatorDestructibleInterface )----------------------------------- */
1788
1789
1790 public function destroyObjectPermanently(
1791 PhabricatorDestructionEngine $engine) {
1792
1793 $this->openTransaction();
1794
1795 $attachments = id(new PhabricatorFileAttachment())->loadAllWhere(
1796 'filePHID = %s',
1797 $this->getPHID());
1798 foreach ($attachments as $attachment) {
1799 $attachment->delete();
1800 }
1801
1802 $this->delete();
1803 $this->saveTransaction();
1804 }
1805
1806
1807/* -( PhabricatorConduitResultInterface )---------------------------------- */
1808
1809
1810 public function getFieldSpecificationsForConduit() {
1811 return array(
1812 id(new PhabricatorConduitSearchFieldSpecification())
1813 ->setKey('name')
1814 ->setType('string')
1815 ->setDescription(pht('The name of the file.')),
1816 id(new PhabricatorConduitSearchFieldSpecification())
1817 ->setKey('uri')
1818 ->setType('uri')
1819 ->setDescription(pht('View URI for the file.')),
1820 id(new PhabricatorConduitSearchFieldSpecification())
1821 ->setKey('dataURI')
1822 ->setType('uri')
1823 ->setDescription(pht('Download URI for the file data.')),
1824 id(new PhabricatorConduitSearchFieldSpecification())
1825 ->setKey('size')
1826 ->setType('int')
1827 ->setDescription(pht('File size, in bytes.')),
1828 );
1829 }
1830
1831 public function getFieldValuesForConduit() {
1832 return array(
1833 'name' => $this->getName(),
1834 'uri' => PhabricatorEnv::getURI($this->getURI()),
1835 'dataURI' => $this->getCDNURI('data'),
1836 'size' => (int)$this->getByteSize(),
1837 'alt' => array(
1838 'custom' => $this->getCustomAltText(),
1839 'default' => $this->getDefaultAltText(),
1840 ),
1841 );
1842 }
1843
1844 public function getConduitSearchAttachments() {
1845 return array();
1846 }
1847
1848/* -( PhabricatorNgramInterface )------------------------------------------ */
1849
1850
1851 public function newNgrams() {
1852 return array(
1853 id(new PhabricatorFileNameNgrams())
1854 ->setValue($this->getName()),
1855 );
1856 }
1857
1858}