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