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