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