1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\Core\Resource; 17 18use TYPO3\CMS\Core\Resource\Processing\TaskTypeRegistry; 19use TYPO3\CMS\Core\Utility\GeneralUtility; 20use TYPO3\CMS\Core\Utility\MathUtility; 21 22/** 23 * Representation of a specific processed version of a file. These are created by the FileProcessingService, 24 * which in turn uses helper classes for doing the actual file processing. See there for a detailed description. 25 * 26 * Objects of this class may be freshly created during runtime or being fetched from the database. The latter 27 * indicates that the file has been processed earlier and was then cached. 28 * 29 * Each processed file—besides belonging to one file—has been created for a certain task (context) and 30 * configuration. All these won't change during the lifetime of a processed file; the only thing 31 * that can change is the original file, or rather it's contents. In that case, the processed file has to 32 * be processed again. Detecting this is done via comparing the current SHA1 hash of the original file against 33 * the one it had at the time the file was processed. 34 * The configuration of a processed file indicates what should be done to the original file to create the 35 * processed version. This may include things like cropping, scaling, rotating, flipping or using some special 36 * magic. 37 * A file may also meet the expectations set in the configuration without any processing. In that case, the 38 * ProcessedFile object still exists, but there is no physical file directly linked to it. Instead, it then 39 * redirects most method calls to the original file object. The data of these objects are also stored in the 40 * database, to indicate that no processing is required. With such files, the identifier and name fields in the 41 * database are empty to show this. 42 */ 43class ProcessedFile extends AbstractFile 44{ 45 /********************************************* 46 * FILE PROCESSING CONTEXTS 47 *********************************************/ 48 /** 49 * Basic processing context to get a processed image with smaller 50 * width/height to render a preview 51 */ 52 const CONTEXT_IMAGEPREVIEW = 'Image.Preview'; 53 /** 54 * Standard processing context for the frontend, that was previously 55 * in \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::getImgResource which only takes cropping, masking and scaling 56 * into account 57 */ 58 const CONTEXT_IMAGECROPSCALEMASK = 'Image.CropScaleMask'; 59 60 /** 61 * Processing context, i.e. the type of processing done 62 * 63 * @var string 64 */ 65 protected $taskType; 66 67 /** 68 * @var Processing\TaskInterface 69 */ 70 protected $task; 71 72 /** 73 * @var Processing\TaskTypeRegistry 74 */ 75 protected $taskTypeRegistry; 76 77 /** 78 * Processing configuration 79 * 80 * @var array 81 */ 82 protected $processingConfiguration; 83 84 /** 85 * Reference to the original file this processed file has been created from. 86 * 87 * @var File 88 */ 89 protected $originalFile; 90 91 /** 92 * The SHA1 hash of the original file this processed version has been created for. 93 * Is used for detecting changes if the original file has been changed and thus 94 * we have to recreate this processed file. 95 * 96 * @var string 97 */ 98 protected $originalFileSha1; 99 100 /** 101 * A flag that shows if this object has been updated during its lifetime, i.e. the file has been 102 * replaced with a new one. 103 * 104 * @var bool 105 */ 106 protected $updated = false; 107 108 /** 109 * If this is set, this URL is used as public URL 110 * This MUST be a fully qualified URL including host 111 * 112 * @var string 113 */ 114 protected $processingUrl = ''; 115 116 /** 117 * Constructor for a processed file object. Should normally not be used 118 * directly, use the corresponding factory methods instead. 119 * 120 * @param File $originalFile 121 * @param string $taskType 122 * @param array $processingConfiguration 123 * @param array $databaseRow 124 */ 125 public function __construct(File $originalFile, $taskType, array $processingConfiguration, array $databaseRow = null) 126 { 127 $this->originalFile = $originalFile; 128 $this->originalFileSha1 = $this->originalFile->getSha1(); 129 $this->storage = $originalFile->getStorage()->getProcessingFolder()->getStorage(); 130 $this->taskType = $taskType; 131 $this->processingConfiguration = $processingConfiguration; 132 if (is_array($databaseRow)) { 133 $this->reconstituteFromDatabaseRecord($databaseRow); 134 } 135 $this->taskTypeRegistry = GeneralUtility::makeInstance(TaskTypeRegistry::class); 136 } 137 138 /** 139 * Creates a ProcessedFile object from a database record. 140 * 141 * @param array $databaseRow 142 */ 143 protected function reconstituteFromDatabaseRecord(array $databaseRow) 144 { 145 $this->taskType = $this->taskType ?: $databaseRow['task_type']; 146 $this->processingConfiguration = $this->processingConfiguration ?: unserialize($databaseRow['configuration']); 147 148 $this->originalFileSha1 = $databaseRow['originalfilesha1']; 149 $this->identifier = $databaseRow['identifier']; 150 $this->name = $databaseRow['name']; 151 $this->properties = $databaseRow; 152 $this->processingUrl = $databaseRow['processing_url'] ?? ''; 153 154 if (!empty($databaseRow['storage']) && (int)$this->storage->getUid() !== (int)$databaseRow['storage']) { 155 $this->storage = GeneralUtility::makeInstance(ResourceFactory::class)->getStorageObject($databaseRow['storage']); 156 } 157 } 158 159 /******************************** 160 * VARIOUS FILE PROPERTY GETTERS 161 ********************************/ 162 163 /** 164 * Returns a unique checksum for this file's processing configuration and original file. 165 * 166 * @return string 167 */ 168 // @todo replace these usages with direct calls to the task object 169 public function calculateChecksum() 170 { 171 return $this->getTask()->getConfigurationChecksum(); 172 } 173 174 /******************* 175 * CONTENTS RELATED 176 *******************/ 177 /** 178 * Replace the current file contents with the given string 179 * 180 * @param string $contents The contents to write to the file. 181 * @throws \BadMethodCallException 182 */ 183 public function setContents($contents) 184 { 185 throw new \BadMethodCallException('Setting contents not possible for processed file.', 1305438528); 186 } 187 188 /** 189 * Injects a local file, which is a processing result into the object. 190 * 191 * @param string $filePath 192 * @throws \RuntimeException 193 */ 194 public function updateWithLocalFile($filePath) 195 { 196 if (empty($this->identifier)) { 197 throw new \RuntimeException('Cannot update original file!', 1350582054); 198 } 199 $processingFolder = $this->originalFile->getStorage()->getProcessingFolder($this->originalFile); 200 $addedFile = $this->storage->updateProcessedFile($filePath, $this, $processingFolder); 201 202 // Update some related properties 203 $this->identifier = $addedFile->getIdentifier(); 204 $this->originalFileSha1 = $this->originalFile->getSha1(); 205 if ($addedFile instanceof AbstractFile) { 206 $this->updateProperties($addedFile->getProperties()); 207 } 208 $this->deleted = false; 209 $this->updated = true; 210 } 211 212 /***************************************** 213 * STORAGE AND MANAGEMENT RELATED METHODS 214 *****************************************/ 215 /** 216 * Returns TRUE if this file is indexed 217 * 218 * @return bool 219 */ 220 public function isIndexed() 221 { 222 // Processed files are never indexed; instead you might be looking for isPersisted() 223 return false; 224 } 225 226 /** 227 * Checks whether the ProcessedFile already has an entry in sys_file_processedfile table 228 * 229 * @return bool 230 */ 231 public function isPersisted() 232 { 233 return is_array($this->properties) && array_key_exists('uid', $this->properties) && $this->properties['uid'] > 0; 234 } 235 236 /** 237 * Checks whether the ProcessedFile Object is newly created 238 * 239 * @return bool 240 */ 241 public function isNew() 242 { 243 return !$this->isPersisted(); 244 } 245 246 /** 247 * Checks whether the object since last reconstitution, and therefore 248 * needs persistence again 249 * 250 * @return bool 251 */ 252 public function isUpdated() 253 { 254 return $this->updated; 255 } 256 257 /** 258 * Sets a new file name 259 * 260 * @param string $name 261 */ 262 public function setName($name) 263 { 264 // Remove the existing file, but only we actually have a name or the name has changed 265 if (!empty($this->name) && $this->name !== $name && $this->exists()) { 266 $this->delete(); 267 } 268 269 $this->name = $name; 270 // @todo this is a *weird* hack that will fail if the storage is non-hierarchical! 271 $this->identifier = $this->storage->getProcessingFolder($this->originalFile)->getIdentifier() . $this->name; 272 273 $this->updated = true; 274 } 275 276 /** 277 * Checks if this file exists. 278 * Since the original file may reside in a different storage 279 * we ask the original file if it exists in case the processed is representing it 280 * 281 * @return bool TRUE if this file physically exists 282 */ 283 public function exists() 284 { 285 if ($this->usesOriginalFile()) { 286 return $this->originalFile->exists(); 287 } 288 289 return parent::exists(); 290 } 291 292 /****************** 293 * SPECIAL METHODS 294 ******************/ 295 296 /** 297 * Returns TRUE if this file is already processed. 298 * 299 * @return bool 300 */ 301 public function isProcessed() 302 { 303 return $this->updated || ($this->isPersisted() && !$this->needsReprocessing()); 304 } 305 306 /** 307 * Getter for the Original, unprocessed File 308 * 309 * @return File 310 */ 311 public function getOriginalFile() 312 { 313 return $this->originalFile; 314 } 315 316 /** 317 * Get the identifier of the file 318 * 319 * If there is no processed file in the file system (as the original file did not have to be modified e.g. 320 * when the original image is in the boundaries of the maxW/maxH stuff), then just return the identifier of 321 * the original file 322 * 323 * @return string 324 */ 325 public function getIdentifier() 326 { 327 return (!$this->usesOriginalFile()) ? $this->identifier : $this->getOriginalFile()->getIdentifier(); 328 } 329 330 /** 331 * Get the name of the file 332 * 333 * If there is no processed file in the file system (as the original file did not have to be modified e.g. 334 * when the original image is in the boundaries of the maxW/maxH stuff) 335 * then just return the name of the original file 336 * 337 * @return string 338 */ 339 public function getName() 340 { 341 if ($this->usesOriginalFile()) { 342 return $this->originalFile->getName(); 343 } 344 return $this->name; 345 } 346 347 /** 348 * Updates properties of this object. Do not use this to reconstitute an object from the database; use 349 * reconstituteFromDatabaseRecord() instead! 350 * 351 * @param array $properties 352 */ 353 public function updateProperties(array $properties) 354 { 355 if (!is_array($this->properties)) { 356 $this->properties = []; 357 } 358 359 if (array_key_exists('uid', $properties) && MathUtility::canBeInterpretedAsInteger($properties['uid'])) { 360 $this->properties['uid'] = $properties['uid']; 361 } 362 if (isset($properties['processing_url'])) { 363 $this->processingUrl = $properties['processing_url']; 364 } 365 366 // @todo we should have a blacklist of properties that might not be updated 367 $this->properties = array_merge($this->properties, $properties); 368 369 // @todo when should this update be done? 370 if (!$this->isUnchanged() && $this->exists()) { 371 $storage = $this->storage; 372 if ($this->usesOriginalFile()) { 373 $storage = $this->originalFile->getStorage(); 374 } 375 $this->properties = array_merge($this->properties, $storage->getFileInfo($this)); 376 } 377 } 378 379 /** 380 * Basic array function for the DB update 381 * 382 * @return array 383 */ 384 public function toArray() 385 { 386 if ($this->usesOriginalFile()) { 387 $properties = $this->originalFile->getProperties(); 388 unset($properties['uid']); 389 $properties['identifier'] = ''; 390 $properties['name'] = null; 391 $properties['processing_url'] = ''; 392 393 // Use width + height set in processed file 394 $properties['width'] = $this->properties['width']; 395 $properties['height'] = $this->properties['height']; 396 } else { 397 $properties = $this->properties; 398 $properties['identifier'] = $this->getIdentifier(); 399 $properties['name'] = $this->getName(); 400 } 401 402 $properties['configuration'] = serialize($this->processingConfiguration); 403 404 return array_merge($properties, [ 405 'storage' => $this->getStorage()->getUid(), 406 'checksum' => $this->calculateChecksum(), 407 'task_type' => $this->taskType, 408 'configurationsha1' => sha1($properties['configuration']), 409 'original' => $this->originalFile->getUid(), 410 'originalfilesha1' => $this->originalFileSha1 411 ]); 412 } 413 414 /** 415 * Returns TRUE if this file has not been changed during processing (i.e., we just deliver the original file) 416 * 417 * @return bool 418 */ 419 protected function isUnchanged() 420 { 421 return !$this->properties['width'] && $this->usesOriginalFile(); 422 } 423 424 /** 425 * Defines that the original file should be used. 426 */ 427 public function setUsesOriginalFile() 428 { 429 // @todo check if some of these properties can/should be set in a generic update method 430 $this->identifier = $this->originalFile->getIdentifier(); 431 $this->updated = true; 432 $this->processingUrl = ''; 433 $this->originalFileSha1 = $this->originalFile->getSha1(); 434 } 435 436 public function updateProcessingUrl(string $url): void 437 { 438 $this->updated = true; 439 $this->processingUrl = $url; 440 } 441 442 /** 443 * @return bool 444 */ 445 public function usesOriginalFile() 446 { 447 return empty($this->identifier) || $this->identifier === $this->originalFile->getIdentifier(); 448 } 449 450 /** 451 * Returns TRUE if the original file of this file changed and the file should be processed again. 452 * 453 * @return bool 454 */ 455 public function isOutdated() 456 { 457 return $this->needsReprocessing(); 458 } 459 460 /** 461 * Delete processed file 462 * 463 * @param bool $force 464 * @return bool 465 */ 466 public function delete($force = false) 467 { 468 if (!$force && $this->isUnchanged()) { 469 return false; 470 } 471 // Only delete file when original isn't used 472 if (!$this->usesOriginalFile()) { 473 return parent::delete(); 474 } 475 return true; 476 } 477 478 /** 479 * Getter for file-properties 480 * 481 * @param string $key 482 * 483 * @return mixed 484 */ 485 public function getProperty($key) 486 { 487 // The uid always (!) has to come from this file and never the original file (see getOriginalFile() to get this) 488 if ($this->isUnchanged() && $key !== 'uid') { 489 return $this->originalFile->getProperty($key); 490 } 491 return $this->properties[$key]; 492 } 493 494 /** 495 * Returns the uid of this file 496 * 497 * @return int 498 */ 499 public function getUid() 500 { 501 return $this->properties['uid']; 502 } 503 504 /** 505 * Checks if the ProcessedFile needs reprocessing 506 * 507 * @return bool 508 */ 509 public function needsReprocessing() 510 { 511 $fileMustBeRecreated = false; 512 513 // if original is missing we can not reprocess the file 514 if ($this->originalFile->isMissing()) { 515 return false; 516 } 517 518 // processedFile does not exist 519 if (!$this->usesOriginalFile() && !$this->exists()) { 520 $fileMustBeRecreated = true; 521 } 522 523 // hash does not match 524 if (array_key_exists('checksum', $this->properties) && $this->calculateChecksum() !== $this->properties['checksum']) { 525 $fileMustBeRecreated = true; 526 } 527 528 // original file changed 529 if ($this->originalFile->getSha1() !== $this->originalFileSha1) { 530 $fileMustBeRecreated = true; 531 } 532 533 if (!array_key_exists('uid', $this->properties)) { 534 $fileMustBeRecreated = true; 535 } 536 537 // remove outdated file 538 if ($fileMustBeRecreated && $this->exists()) { 539 $this->delete(); 540 } 541 return $fileMustBeRecreated; 542 } 543 544 /** 545 * Returns the processing information 546 * 547 * @return array 548 */ 549 public function getProcessingConfiguration() 550 { 551 return $this->processingConfiguration; 552 } 553 554 /** 555 * Getter for the task identifier. 556 * 557 * @return string 558 */ 559 public function getTaskIdentifier() 560 { 561 return $this->taskType; 562 } 563 564 /** 565 * Returns the task object associated with this processed file. 566 * 567 * @return Processing\TaskInterface 568 * @throws \RuntimeException 569 */ 570 public function getTask(): Processing\TaskInterface 571 { 572 if ($this->task === null) { 573 $this->task = $this->taskTypeRegistry->getTaskForType($this->taskType, $this, $this->processingConfiguration); 574 } 575 576 return $this->task; 577 } 578 579 /** 580 * Generate the name of of the new File 581 * 582 * @return string 583 */ 584 public function generateProcessedFileNameWithoutExtension() 585 { 586 $name = $this->originalFile->getNameWithoutExtension(); 587 $name .= '_' . $this->originalFile->getUid(); 588 $name .= '_' . $this->calculateChecksum(); 589 590 return $name; 591 } 592 593 /** 594 * Returns a publicly accessible URL for this file 595 * 596 * @param bool $relativeToCurrentScript Determines whether the URL returned should be relative to the current script, in case it is relative at all 597 * @return string|null NULL if file is deleted, the generated URL otherwise 598 */ 599 public function getPublicUrl($relativeToCurrentScript = false) 600 { 601 if ($this->processingUrl) { 602 return $this->processingUrl; 603 } 604 if ($this->deleted) { 605 return null; 606 } 607 if ($this->usesOriginalFile()) { 608 return $this->getOriginalFile()->getPublicUrl($relativeToCurrentScript); 609 } 610 return $this->getStorage()->getPublicUrl($this, $relativeToCurrentScript); 611 } 612} 613