Passed
Push — master ( 370d98...7a26bd )
by Thomas
03:15
created

FilePondField::getLinkParameters()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 11
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 17
rs 9.9
1
<?php
2
3
namespace LeKoala\FilePond;
4
5
use Exception;
6
use LogicException;
7
use RuntimeException;
8
use SilverStripe\Assets\File;
9
use SilverStripe\ORM\ArrayList;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\Security\Member;
12
use SilverStripe\Control\Director;
13
use SilverStripe\View\Requirements;
14
use SilverStripe\Control\HTTPRequest;
15
use SilverStripe\Versioned\Versioned;
16
use SilverStripe\Control\HTTPResponse;
17
use SilverStripe\ORM\DataObjectInterface;
18
use SilverStripe\ORM\ValidationException;
19
use SilverStripe\Security\Security;
20
21
/**
22
 * A FilePond field
23
 */
24
class FilePondField extends AbstractUploadField
25
{
26
27
    /**
28
     * @config
29
     * @var array
30
     */
31
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
32
        'upload'
33
    ];
34
35
    /**
36
     * @config
37
     * @var boolean
38
     */
39
    private static $enable_requirements = true;
40
41
    /**
42
     * @config
43
     * @var boolean
44
     */
45
    private static $enable_validation = true;
46
47
    /**
48
     * @config
49
     * @var boolean
50
     */
51
    private static $enable_poster = false;
52
53
    /**
54
     * @config
55
     * @var boolean
56
     */
57
    private static $enable_image = false;
58
59
    /**
60
     * @config
61
     * @var boolean
62
     */
63
    private static $enable_polyfill = true;
64
65
    /**
66
     * @config
67
     * @var boolean
68
     */
69
    private static $enable_ajax_init = true;
70
71
    /**
72
     * @config
73
     * @var boolean
74
     */
75
    private static $enable_default_description = true;
0 ignored issues
show
introduced by
The private property $enable_default_description is not used, and could be removed.
Loading history...
76
77
    /**
78
     * @config
79
     * @var boolean
80
     */
81
    private static $auto_clear_temp_folder = true;
82
83
    /**
84
     * @config
85
     * @var int
86
     */
87
    private static $auto_clear_threshold = true;
88
89
    /**
90
     * @var array
91
     */
92
    protected $filePondConfig = [];
93
94
    /**
95
     * Set a custom config value for this field
96
     *
97
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
98
     * @param string $k
99
     * @param string $v
100
     * @return $this
101
     */
102
    public function addFilePondConfig($k, $v)
103
    {
104
        $this->filePondConfig[$k] = $v;
105
        return $this;
106
    }
107
108
    /**
109
     * Custom configuration applied to this field
110
     *
111
     * @return array
112
     */
113
    public function getCustomFilePondConfig()
114
    {
115
        return $this->filePondConfig;
116
    }
117
118
    /**
119
     * Return the config applied for this field
120
     *
121
     * Typically converted to json and set in a data attribute
122
     *
123
     * @return array
124
     */
125
    public function getFilePondConfig()
126
    {
127
        $name = $this->getName();
128
        $multiple = $this->getIsMultiUpload();
129
130
        // Multi uploads need []
131
        if ($multiple && strpos($name, '[]') === false) {
132
            $name .= '[]';
133
            $this->setName($name);
134
        }
135
136
        $i18nConfig = [
137
            'labelIdle' => _t('FilePondField.labelIdle', 'Drag & Drop your files or <span class="filepond--label-action"> Browse </span>'),
138
            'labelFileProcessing' => _t('FilePondField.labelFileProcessing', 'Uploading'),
139
            'labelFileProcessingComplete' => _t('FilePondField.labelFileProcessingComplete', 'Upload complete'),
140
            'labelFileProcessingAborted' => _t('FilePondField.labelFileProcessingAborted', 'Upload cancelled'),
141
            'labelTapToCancel' => _t('FilePondField.labelTapToCancel', 'tap to cancel'),
142
            'labelTapToRetry' => _t('FilePondField.labelTapToCancel', 'tap to retry'),
143
            'labelTapToUndo' => _t('FilePondField.labelTapToCancel', 'tap to undo'),
144
        ];
145
        $config = [
146
            'name' => $name, // This will also apply to the hidden fields
147
            'allowMultiple' => $multiple,
148
            'maxFiles' => $this->getAllowedMaxFileNumber(),
149
            'maxFileSize' => $this->getMaxFileSize(),
150
            'server' => $this->getServerOptions(),
151
            'files' => $this->getExistingUploadsData(),
152
        ];
153
154
        $acceptedFileTypes = $this->getAcceptedFileTypes();
155
        if (!empty($acceptedFileTypes)) {
156
            $config['acceptedFileTypes'] = array_values($acceptedFileTypes);
157
        }
158
159
        // image validation
160
        $record = $this->getForm()->getRecord();
161
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
162
            $sizes = $record->config()->image_sizes;
163
            $name = $this->getSafeName();
164
            if ($sizes && isset($sizes[$name])) {
165
                if (isset($sizes[$name][2]) && $sizes[$name][2] == 'max') {
166
                    $config['imageValidateSizeMaxWidth'] = $sizes[$name][0];
167
                    $config['imageValidateSizeMaxHeight'] = $sizes[$name][1];
168
                } else {
169
                    $config['imageValidateSizeMinWidth'] = $sizes[$name][0];
170
                    $config['imageValidateSizeMinHeight'] = $sizes[$name][1];
171
                }
172
            }
173
        }
174
175
        $config = array_merge($config, $i18nConfig, $this->filePondConfig);
176
177
        return $config;
178
    }
179
180
    /**
181
     * @inheritDoc
182
     */
183
    public function setValue($value, $record = null)
184
    {
185
        // Normalize values to something similar to UploadField usage
186
        if (is_numeric($value)) {
187
            $value = ['Files' => [$value]];
188
        } elseif (is_array($value) && empty($value['Files'])) {
189
            $value = ['Files' => $value];
190
        }
191
        // Track existing record data
192
        if ($record) {
193
            $name = $this->name;
194
            if ($record instanceof DataObject && $record->hasMethod($name)) {
195
                $data = $record->$name();
196
                // Wrap
197
                if ($data instanceof DataObject) {
198
                    $data = new ArrayList([$data]);
199
                }
200
                foreach ($data as $uploadedItem) {
201
                    $this->trackFileID($uploadedItem->ID);
202
                }
203
            }
204
        }
205
        return parent::setValue($value, $record);
206
    }
207
208
209
    /**
210
     * Configure our endpoint
211
     *
212
     * @link https://pqina.nl/filepond/docs/patterns/api/server/
213
     * @return array
214
     */
215
    public function getServerOptions()
216
    {
217
        if (!$this->getForm()) {
218
            throw new LogicException(
219
                'Field must be associated with a form to call getServerOptions(). Please use $field->setForm($form);'
220
            );
221
        }
222
        return [
223
            'process' => $this->getUploadEnabled() ? $this->getLinkParameters('upload') : null,
224
            'fetch' => null,
225
            'revert' => null,
226
        ];
227
    }
228
229
    /**
230
     * Configure the following parameters:
231
     *
232
     * url : Path to the end point
233
     * method : Request method to use
234
     * withCredentials : Toggles the XMLHttpRequest withCredentials on or off
235
     * headers : An object containing additional headers to send
236
     * timeout : Timeout for this action
237
     * onload : Called when server response is received, useful for getting the unique file id from the server response
238
     * onerror : Called when server error is received, receis the response body, useful to select the relevant error data
239
     *
240
     * @param string $action
241
     * @return array
242
     */
243
    protected function getLinkParameters($action)
244
    {
245
        $form = $this->getForm();
246
        $token = $form->getSecurityToken()->getValue();
247
        $record = $form->getRecord();
248
249
        $headers = [
250
            'X-SecurityID' => $token
251
        ];
252
        // Allow us to track the record instance
253
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
254
            $headers['X-RecordClassName'] = get_class($record);
255
            $headers['X-RecordID'] = $record->ID;
256
        }
257
        return [
258
            'url' => $this->Link($action),
259
            'headers' => $headers,
260
        ];
261
    }
262
263
    /**
264
     * The maximum size of a file, for instance 5MB or 750KB
265
     * Suitable for JS usage
266
     *
267
     * @return string
268
     */
269
    public function getMaxFileSize()
270
    {
271
        return str_replace(' ', '', File::format_size($this->getValidator()->getAllowedMaxFileSize()));
272
    }
273
274
    /**
275
     * Set initial values to FilePondField
276
     * See: https://pqina.nl/filepond/docs/patterns/api/filepond-object/#setting-initial-files
277
     *
278
     * @return array
279
     */
280
    public function getExistingUploadsData()
281
    {
282
        // Both Value() & dataValue() seem to return an array eg: ['Files' => [258, 259, 257]]
283
        $fileIDarray = $this->Value() ?: ['Files' => []];
284
        if (!isset($fileIDarray['Files']) || !count($fileIDarray['Files'])) {
285
            return [];
286
        }
287
288
        $existingUploads = [];
289
        foreach ($fileIDarray['Files'] as $fileID) {
290
            /* @var $file File */
291
            $file = File::get()->byID($fileID);
292
            if (!$file) {
293
                continue;
294
            }
295
            // $poster = null;
296
            // if ($file instanceof Image) {
297
            //     $w = self::config()->get('thumbnail_width');
298
            //     $h = self::config()->get('thumbnail_height');
299
            //     $poster = $file->Fill($w, $h)->getAbsoluteURL();
300
            // }
301
            $existingUploads[] = [
302
                // the server file reference
303
                'source' => (int) $fileID,
304
                // set type to local to indicate an already uploaded file
305
                'options' => [
306
                    'type' => 'local',
307
                    // file information
308
                    'file' => [
309
                        'name' => $file->Name,
310
                        'size' => (int) $file->getAbsoluteSize(),
311
                        'type' => $file->getMimeType(),
312
                    ],
313
                    // poster
314
                    // 'metadata' => [
315
                    //     'poster' => $poster
316
                    // ]
317
                ],
318
319
            ];
320
        }
321
        return $existingUploads;
322
    }
323
324
    /**
325
     * Requirements are NOT versioned since filepond is regularly updated
326
     *
327
     * @return void
328
     */
329
    public static function Requirements()
330
    {
331
        // Polyfill to ensure max compatibility
332
        if (self::config()->enable_polyfill) {
333
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-polyfill/dist/filepond-polyfill.min.js");
334
        }
335
336
        // File/image validation plugins
337
        if (self::config()->enable_validation) {
338
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.min.js");
339
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.min.js");
340
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-image-validate-size/dist/filepond-plugin-image-validate-size.js");
341
        }
342
343
        // Poster plugins
344
        if (self::config()->enable_poster) {
345
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-file-metadata/dist/filepond-plugin-file-metadata.min.js");
346
            Requirements::css("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.css");
347
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.js");
348
        }
349
350
        // Image plugins
351
        if (self::config()->enable_image) {
352
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-image-exif-orientation/dist/filepond-plugin-image-exif-orientation.js");
353
            Requirements::css("https://cdn.jsdelivr.net/gh/pqina/unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css");
354
            Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js");
355
        }
356
357
        // Base elements
358
        Requirements::css("https://cdn.jsdelivr.net/gh/pqina/filepond/dist/filepond.css");
359
        Requirements::javascript("https://cdn.jsdelivr.net/gh/pqina/filepond/dist/filepond.js");
360
361
        // Our custom init
362
        Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField.js');
363
364
        // In the cms, init will not be triggered
365
        if (self::config()->enable_ajax_init && Director::is_ajax()) {
366
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField-init.js?t=' . time());
367
        }
368
    }
369
370
    public function FieldHolder($properties = array())
371
    {
372
        $config = $this->getFilePondConfig();
373
374
        $this->setAttribute('data-config', json_encode($config));
375
376
        if (self::config()->enable_requirements) {
377
            self::Requirements();
378
        }
379
380
        return parent::FieldHolder($properties);
381
    }
382
383
    /**
384
     * Check the incoming request
385
     *
386
     * @param HTTPRequest $request
387
     * @return array
388
     */
389
    public function prepareUpload(HTTPRequest $request)
390
    {
391
        if ($this->isDisabled() || $this->isReadonly()) {
392
            throw new RuntimeException("Field is disabled or readonly");
393
        }
394
395
        // CSRF check
396
        $token = $this->getForm()->getSecurityToken();
397
        if (!$token->checkRequest($request)) {
398
            throw new RuntimeException("Invalid token");
399
        }
400
401
        $name = $this->getName();
402
        $tmpFile = $request->postVar($name);
403
        if (!$tmpFile) {
404
            throw new RuntimeException("No file");
405
        }
406
        $tmpFile = $this->normalizeTempFile($tmpFile);
407
408
        // Update $tmpFile with a better name
409
        if ($this->renamePattern) {
410
            $tmpFile['name'] = $this->changeFilenameWithPattern(
411
                $tmpFile['name'],
412
                $this->renamePattern
413
            );
414
        }
415
416
        return $tmpFile;
417
    }
418
419
    /**
420
     * Creates a single file based on a form-urlencoded upload.
421
     *
422
     * 1 client uploads file my-file.jpg as multipart/form-data using a POST request
423
     * 2 server saves file to unique location tmp/12345/my-file.jpg
424
     * 3 server returns unique location id 12345 in text/plain response
425
     * 4 client stores unique id 12345 in a hidden input field
426
     * 5 client submits the FilePond parent form containing the hidden input field with the unique id
427
     * 6 server uses the unique id to move tmp/12345/my-file.jpg to its final location and remove the tmp/12345 folder
428
     *
429
     * Along with the file object, FilePond also sends the file metadata to the server, both these objects are given the same name.
430
     *
431
     * @param HTTPRequest $request
432
     * @return HTTPResponse
433
     */
434
    public function upload(HTTPRequest $request)
435
    {
436
        try {
437
            $tmpFile = $this->prepareUpload($request);
438
        } catch (Exception $ex) {
439
            return $this->httpError(400, $ex->getMessage());
440
        }
441
442
        $file = $this->saveTemporaryFile($tmpFile, $error);
443
444
        // Handle upload errors
445
        if ($error) {
446
            $this->getUpload()->clearErrors();
447
            return $this->httpError(400, json_encode($error));
448
        }
449
450
        // File can be an AssetContainer and not a DataObject
451
        if ($file instanceof DataObject) {
452
            // Mark as temporary until properly associated with a record
453
            // Files will be unmarked later on by saveInto method
454
            $file->IsTemporary = true;
455
456
            // We can also track the record
457
            $RecordID = $request->getHeader('X-RecordID');
458
            $RecordClassName = $request->getHeader('X-RecordClassName');
459
            if (!$file->ObjectID) {
460
                $file->ObjectID = $RecordID;
461
            }
462
            if (!$file->ObjectClass) {
463
                $file->ObjectClass = $RecordClassName;
464
            }
465
466
            if ($file->isChanged()) {
467
                // If possible, prevent creating a version for no reason
468
                // @link https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#writing-changes-to-a-versioned-dataobject
469
                if ($file->hasExtension(Versioned::class)) {
470
                    $file->writeWithoutVersion();
471
                } else {
472
                    $file->write();
473
                }
474
            }
475
        }
476
477
        $this->getUpload()->clearErrors();
478
        $fileId = $file->ID;
0 ignored issues
show
Bug introduced by
Accessing ID on the interface SilverStripe\Assets\Storage\AssetContainer suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
479
        $this->trackFileID($fileId);
480
481
        if (self::config()->auto_clear_temp_folder) {
482
            // Set a limit of 100 because otherwise it would be really slow
483
            self::clearTemporaryUploads(true, 100);
484
        }
485
486
        // server returns unique location id 12345 in text/plain response
487
        $response = new HTTPResponse($fileId);
488
        $response->addHeader('Content-Type', 'text/plain');
489
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type SilverStripe\Control\HTTPResponse which is incompatible with the return type mandated by LeKoala\FilePond\AbstractUploadField::upload() of LeKoala\FilePond\HTTPResponse.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
490
    }
491
492
    /**
493
     * Clear temp folder that should not contain any file other than temporary
494
     *
495
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
496
     * @param int $limit
497
     * @return File[] List of files removed
498
     */
499
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
500
    {
501
        $tempFiles = File::get()->filter('IsTemporary', true);
502
        if ($limit) {
503
            $tempFiles = $tempFiles->limit($limit);
504
        }
505
506
        $threshold = self::config()->auto_clear_threshold;
507
508
        // Set a default threshold if none set
509
        if (!$threshold) {
510
            if (Director::isDev()) {
511
                $threshold = '-10 minutes';
512
            } else {
513
                $threshold = '-1 day';
514
            }
515
        }
516
        if (is_int($threshold)) {
517
            $thresholdTime = time() - $threshold;
518
        } else {
519
            $thresholdTime = strtotime($threshold);
520
        }
521
522
        // Update query to avoid fetching unecessary records
523
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
524
525
        $filesDeleted = [];
526
        foreach ($tempFiles as $tempFile) {
527
            $createdTime = strtotime($tempFile->Created);
528
            if ($createdTime < $thresholdTime) {
529
                $filesDeleted[] = $tempFile;
530
                if ($doDelete) {
531
                    if ($tempFile->hasExtension(Versioned::class)) {
532
                        $tempFile->deleteFromStage(Versioned::LIVE);
533
                        $tempFile->deleteFromStage(Versioned::DRAFT);
534
                    } else {
535
                        $tempFile->delete();
536
                    }
537
                }
538
            }
539
        }
540
        return $filesDeleted;
541
    }
542
543
    /**
544
     * Allows tracking uploaded ids to prevent unauthorized attachements
545
     *
546
     * @param int $fileId
547
     * @return void
548
     */
549
    public function trackFileID($fileId)
550
    {
551
        $session = $this->getRequest()->getSession();
552
        $uploadedIDs = $this->getTrackedIDs();
553
        if (!in_array($fileId, $uploadedIDs)) {
554
            $uploadedIDs[] = $fileId;
555
        }
556
        $session->set('FilePond', $uploadedIDs);
557
    }
558
559
    /**
560
     * Get all authorized tracked ids
561
     * @return array
562
     */
563
    public function getTrackedIDs()
564
    {
565
        $session = $this->getRequest()->getSession();
566
        $uploadedIDs = $session->get('FilePond');
567
        if ($uploadedIDs) {
568
            return $uploadedIDs;
569
        }
570
        return [];
571
    }
572
573
    public function saveInto(DataObjectInterface $record)
574
    {
575
        // Note that the list of IDs is based on the value sent by the user
576
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
577
        $IDs = $this->getItemIDs();
578
579
        $Member = Security::getCurrentUser();
580
581
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
582
        $trackedIDs = $this->getTrackedIDs();
583
        foreach ($IDs as $ID) {
584
            if (!in_array($ID, $trackedIDs)) {
585
                throw new ValidationException("Invalid file ID : $ID");
586
            }
587
        }
588
589
        // Move files out of temporary folder
590
        foreach ($IDs as $ID) {
591
            $file = $this->getFileByID($ID);
592
            if ($file && $file->IsTemporary) {
593
                // The record does not have an ID which is a bad idea to attach the file to it
594
                if (!$record->ID) {
595
                    $record->write();
596
                }
597
                // Check if the member is owner
598
                if ($Member && $Member->ID != $file->OwnerID) {
599
                    throw new ValidationException("Failed to authenticate owner");
600
                }
601
                $file->IsTemporary = false;
602
                $file->ObjectID = $record->ID;
603
                $file->ObjectClass = get_class($record);
604
                $file->write();
605
            } else {
606
                // File was uploaded earlier, no need to do anything
607
            }
608
        }
609
610
        // Proceed
611
        return parent::saveInto($record);
612
    }
613
614
    public function Type()
615
    {
616
        return 'filepond';
617
    }
618
}
619