Completed
Pull Request — master (#6325)
by Damian
08:47
created

FileUploadReceiver::extractUploadedFileData()   C

Complexity

Conditions 8
Paths 3

Size

Total Lines 27
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 15
nc 3
nop 1
dl 0
loc 27
rs 5.3846
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Forms;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Assets\File;
8
use SilverStripe\Assets\Storage\AssetContainer;
9
use SilverStripe\Core\Object;
10
use SilverStripe\ORM\ArrayList;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\ORM\DataObjectInterface;
13
use SilverStripe\ORM\RelationList;
14
use SilverStripe\ORM\SS_List;
15
use SilverStripe\ORM\UnsavedRelationList;
16
use SilverStripe\ORM\ValidationException;
17
18
/**
19
 * Provides operations for reading and writing uploaded files to/from
20
 * {@see File} dataobject instances.
21
 * Allows writing to a parent record with the following relation types:
22
 *   - has_one
23
 *   - has_many
24
 *   - many_many
25
 * Additionally supports writing directly to the File table not attached
26
 * to any parent record.
27
 *
28
 * Note that this trait expects to be applied to a {@see FormField} class
29
 *
30
 * @mixin FormField
31
 */
32
trait FileUploadReceiver
33
{
34
    use UploadReceiver;
35
36
    /**
37
     * Flag to automatically determine and save a has_one-relationship
38
     * on the saved record (e.g. a "Player" has_one "PlayerImage" would
39
     * trigger saving the ID of newly created file into "PlayerImageID"
40
     * on the record).
41
     *
42
     * @var boolean
43
     */
44
    public $relationAutoSetting = true;
45
46
    /**
47
     * Parent data record. Will be infered from parent form or controller if blank.
48
     *
49
     * @var DataObject
50
     */
51
    protected $record;
52
53
    /**
54
     * Items loaded into this field. May be a RelationList, or any other SS_List
55
     *
56
     * @var SS_List
57
     */
58
    protected $items;
59
60
    protected function constructFileUploadReceiver()
61
    {
62
        $this->constructUploadReceiver();
63
    }
64
65
66
    /**
67
     * Force a record to be used as "Parent" for uploaded Files (eg a Page with a has_one to File)
68
     *
69
     * @param DataObject $record
70
     * @return $this
71
     */
72
    public function setRecord($record)
73
    {
74
        $this->record = $record;
75
        return $this;
76
    }
77
    /**
78
     * Get the record to use as "Parent" for uploaded Files (eg a Page with a has_one to File) If none is set, it will
79
     * use Form->getRecord() or Form->Controller()->data()
80
     *
81
     * @return DataObject
82
     */
83
    public function getRecord()
84
    {
85
        if ($this->record) {
86
            return $this->record;
87
        }
88
        if (!$this->getForm()) {
0 ignored issues
show
Bug introduced by
It seems like getForm() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
89
            return null;
90
        }
91
92
        // Get record from form
93
        $record = $this->getForm()->getRecord();
0 ignored issues
show
Bug introduced by
It seems like getForm() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
94
        if ($record && ($record instanceof DataObject)) {
95
            $this->record = $record;
96
            return $record;
97
        }
98
99
        // Get record from controller
100
        $controller = $this->getForm()->getController();
0 ignored issues
show
Bug introduced by
It seems like getForm() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
101
        if ($controller
102
            && $controller->hasMethod('data')
103
            && ($record = $controller->data())
104
            && ($record instanceof DataObject)
105
        ) {
106
            $this->record = $record;
107
            return $record;
108
        }
109
110
        return null;
111
    }
112
113
114
    /**
115
     * Loads the related record values into this field. This can be uploaded
116
     * in one of three ways:
117
     *
118
     *  - By passing in a list of file IDs in the $value parameter (an array with a single
119
     *    key 'Files', with the value being the actual array of IDs).
120
     *  - By passing in an explicit list of File objects in the $record parameter, and
121
     *    leaving $value blank.
122
     *  - By passing in a dataobject in the $record parameter, from which file objects
123
     *    will be extracting using the field name as the relation field.
124
     *
125
     * Each of these methods will update both the items (list of File objects) and the
126
     * field value (list of file ID values).
127
     *
128
     * @param array $value Array of submitted form data, if submitting from a form
129
     * @param array|DataObject|SS_List $record Full source record, either as a DataObject,
130
     * SS_List of items, or an array of submitted form data
131
     * @return $this Self reference
132
     * @throws ValidationException
133
     */
134
    public function setValue($value, $record = null)
135
    {
136
137
        // If we're not passed a value directly, we can attempt to infer the field
138
        // value from the second parameter by inspecting its relations
139
        $items = new ArrayList();
140
141
        // Determine format of presented data
142
        if (empty($value) && $record) {
143
            // If a record is given as a second parameter, but no submitted values,
144
            // then we should inspect this instead for the form values
145
146
            if (($record instanceof DataObject) && $record->hasMethod($this->getName())) {
0 ignored issues
show
Bug introduced by
It seems like getName() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
147
                // If given a dataobject use reflection to extract details
148
149
                $data = $record->{$this->getName()}();
0 ignored issues
show
Bug introduced by
It seems like getName() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
150
                if ($data instanceof DataObject) {
151
                    // If has_one, add sole item to default list
152
                    $items->push($data);
153
                } elseif ($data instanceof SS_List) {
154
                    // For many_many and has_many relations we can use the relation list directly
155
                    $items = $data;
156
                }
157
            } elseif ($record instanceof SS_List) {
158
                // If directly passing a list then save the items directly
159
                $items = $record;
160
            }
161
        } elseif (!empty($value['Files'])) {
162
            // If value is given as an array (such as a posted form), extract File IDs from this
163
            $class = $this->getRelationAutosetClass();
164
            $items = DataObject::get($class)->byIDs($value['Files']);
165
        }
166
167
        // If javascript is disabled, direct file upload (non-html5 style) can
168
        // trigger a single or multiple file submission. Note that this may be
169
        // included in addition to re-submitted File IDs as above, so these
170
        // should be added to the list instead of operated on independently.
171
        if ($uploadedFiles = $this->extractUploadedFileData($value)) {
172
            foreach ($uploadedFiles as $tempFile) {
173
                $file = $this->saveTemporaryFile($tempFile, $error);
174
                if ($file) {
175
                    $items->add($file);
176
                } else {
177
                    throw new ValidationException($error);
178
                }
179
            }
180
        }
181
182
        // Filter items by what's allowed to be viewed
183
        $filteredItems = new ArrayList();
184
        $fileIDs = array();
185
        foreach ($items as $file) {
186
            if ($file->exists() && $file->canView()) {
187
                $filteredItems->push($file);
188
                $fileIDs[] = $file->ID;
189
            }
190
        }
191
192
        // Filter and cache updated item list
193
        $this->items = $filteredItems;
194
        // Same format as posted form values for this field. Also ensures that
195
        // $this->setValue($this->getValue()); is non-destructive
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
196
        $value = $fileIDs ? array('Files' => $fileIDs) : null;
197
198
        // Set value using parent
199
        parent::setValue($value, $record);
200
        return $this;
201
    }
202
203
    /**
204
     * Sets the items assigned to this field as an SS_List of File objects.
205
     * Calling setItems will also update the value of this field, as well as
206
     * updating the internal list of File items.
207
     *
208
     * @param SS_List $items
209
     * @return $this self reference
210
     */
211
    public function setItems(SS_List $items)
212
    {
213
        return $this->setValue(null, $items);
214
    }
215
216
    /**
217
     * Retrieves the current list of files
218
     *
219
     * @return SS_List|File[]
220
     */
221
    public function getItems()
222
    {
223
        return $this->items ? $this->items : new ArrayList();
224
    }
225
226
    /**
227
     * Retrieves the list of selected file IDs
228
     *
229
     * @return array
230
     */
231
    public function getItemIDs()
232
    {
233
        $value = $this->Value();
234
        return empty($value['Files']) ? array() : $value['Files'];
235
    }
236
237
    public function Value()
238
    {
239
        // Re-override FileField Value to use data value
240
        return $this->dataValue();
0 ignored issues
show
Bug introduced by
It seems like dataValue() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
241
    }
242
243
    /**
244
     * @param DataObject|DataObjectInterface $record
245
     * @return $this
246
     */
247
    public function saveInto(DataObjectInterface $record)
248
    {
249
        // Check required relation details are available
250
        $fieldname = $this->getName();
0 ignored issues
show
Bug introduced by
It seems like getName() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
251
        if (!$fieldname) {
252
            return $this;
253
        }
254
255
        // Get details to save
256
        $idList = $this->getItemIDs();
257
258
        // Check type of relation
259
        $relation = $record->hasMethod($fieldname) ? $record->$fieldname() : null;
260
        if ($relation && ($relation instanceof RelationList || $relation instanceof UnsavedRelationList)) {
261
            // has_many or many_many
262
            $relation->setByIDList($idList);
263
        } elseif ($class = DataObject::getSchema()->hasOneComponent(get_class($record), $fieldname)) {
264
            // Assign has_one ID
265
            $id = $idList ? reset($idList) : 0;
266
            $record->{"{$fieldname}ID"} = $id;
267
268
            // Polymorphic asignment
269
            if ($class === DataObject::class) {
270
                $file = $id ? File::get()->byID($id) : null;
271
                $fileClass = $file ? get_class($file) : File::class;
272
                $record->{"{$fieldname}Class"} = $id ? $fileClass : null;
273
            }
274
        }
275
        return $this;
276
    }
277
278
    /**
279
     * Loads the temporary file data into a File object
280
     *
281
     * @param array $tmpFile Temporary file data
282
     * @param string $error Error message
283
     * @return AssetContainer File object, or null if error
284
     */
285
    protected function saveTemporaryFile($tmpFile, &$error = null)
286
    {
287
        // Determine container object
288
        $error = null;
289
        $fileObject = null;
290
291
        if (empty($tmpFile)) {
292
            $error = _t('UploadField.FIELDNOTSET', 'File information not found');
293
            return null;
294
        }
295
296
        if ($tmpFile['error']) {
297
            $error = $tmpFile['error'];
298
            return null;
299
        }
300
301
        // Search for relations that can hold the uploaded files, but don't fallback
302
        // to default if there is no automatic relation
303
        if ($relationClass = $this->getRelationAutosetClass(null)) {
304
            // Allow File to be subclassed
305
            if ($relationClass === File::class && isset($tmpFile['name'])) {
306
                $relationClass = File::get_class_for_file_extension(
307
                    File::get_file_extension($tmpFile['name'])
308
                );
309
            }
310
            // Create new object explicitly. Otherwise rely on Upload::load to choose the class.
311
            $fileObject = Object::create($relationClass);
312
            if (! ($fileObject instanceof DataObject) || !($fileObject instanceof AssetContainer)) {
313
                throw new InvalidArgumentException("Invalid asset container $relationClass");
314
            }
315
        }
316
317
        // Get the uploaded file into a new file object.
318
        try {
319
            $this->getUpload()->loadIntoFile($tmpFile, $fileObject, $this->getFolderName());
320
        } catch (Exception $e) {
321
            // we shouldn't get an error here, but just in case
322
            $error = $e->getMessage();
323
            return null;
324
        }
325
326
        // Check if upload field has an error
327
        if ($this->getUpload()->isError()) {
328
            $error = implode(' ' . PHP_EOL, $this->getUpload()->getErrors());
329
            return null;
330
        }
331
332
        // return file
333
        return $this->getUpload()->getFile();
334
    }
335
336
    /**
337
     * Gets the foreign class that needs to be created, or 'File' as default if there
338
     * is no relationship, or it cannot be determined.
339
     *
340
     * @param string $default Default value to return if no value could be calculated
341
     * @return string Foreign class name.
342
     */
343
    public function getRelationAutosetClass($default = File::class)
344
    {
345
        // Don't autodetermine relation if no relationship between parent record
346
        if (!$this->getRelationAutoSetting()) {
347
            return $default;
348
        }
349
350
        // Check record and name
351
        $name = $this->getName();
0 ignored issues
show
Bug introduced by
It seems like getName() must be provided by classes using this trait. How about adding it as abstract method to this trait?

This check looks for methods that are used by a trait but not required by it.

To illustrate, let’s look at the following code example

trait Idable {
    public function equalIds(Idable $other) {
        return $this->getId() === $other->getId();
    }
}

The trait Idable provides a method equalsId that in turn relies on the method getId(). If this method does not exist on a class mixing in this trait, the method will fail.

Adding the getId() as an abstract method to the trait will make sure it is available.

Loading history...
352
        $record = $this->getRecord();
353
        if (empty($name) || empty($record)) {
354
            return $default;
355
        } else {
356
            $class = $record->getRelationClass($name);
357
            return empty($class) ? $default : $class;
358
        }
359
    }
360
361
    /**
362
     * Set if relation can be automatically assigned to the underlying dataobject
363
     *
364
     * @param bool $auto
365
     * @return $this
366
     */
367
    public function setRelationAutoSetting($auto)
368
    {
369
        $this->relationAutoSetting = $auto;
370
        return $this;
371
    }
372
373
    /**
374
     * Check if relation can be automatically assigned to the underlying dataobject
375
     *
376
     * @return bool
377
     */
378
    public function getRelationAutoSetting()
379
    {
380
        return $this->relationAutoSetting;
381
    }
382
383
    /**
384
     * Given an array of post variables, extract all temporary file data into an array
385
     *
386
     * @param array $postVars Array of posted form data
387
     * @return array List of temporary file data
388
     */
389
    protected function extractUploadedFileData($postVars)
390
    {
391
        // Note: Format of posted file parameters in php is a feature of using
392
        // <input name='{$Name}[Uploads][]' /> for multiple file uploads
393
        $tmpFiles = array();
394
        if (!empty($postVars['tmp_name'])
395
            && is_array($postVars['tmp_name'])
396
            && !empty($postVars['tmp_name']['Uploads'])
397
        ) {
398
            for ($i = 0; $i < count($postVars['tmp_name']['Uploads']); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
399
                // Skip if "empty" file
400
                if (empty($postVars['tmp_name']['Uploads'][$i])) {
401
                    continue;
402
                }
403
                $tmpFile = array();
404
                foreach (array('name', 'type', 'tmp_name', 'error', 'size') as $field) {
405
                    $tmpFile[$field] = $postVars[$field]['Uploads'][$i];
406
                }
407
                $tmpFiles[] = $tmpFile;
408
            }
409
        } elseif (!empty($postVars['tmp_name'])) {
410
            // Fallback to allow single file uploads (method used by AssetUploadField)
411
            $tmpFiles[] = $postVars;
412
        }
413
414
        return $tmpFiles;
415
    }
416
}
417