FilePondField   F
last analyzed

Complexity

Total Complexity 158

Size/Duplication

Total Lines 1164
Duplicated Lines 0 %

Importance

Changes 28
Bugs 7 Features 4
Metric Value
eloc 449
c 28
b 7
f 4
dl 0
loc 1164
rs 2
wmc 158

41 Methods

Rating   Name   Duplication   Size   Complexity  
A addFilePondConfig() 0 4 1
A __construct() 0 6 2
A getCustomFilePondConfig() 0 3 1
A getChunkUploads() 0 6 2
A setCustomServerConfig() 0 4 1
A setChunkUploads() 0 9 2
A getCustomServerConfig() 0 3 1
A setRenameFile() 0 4 1
A getPosterWidth() 0 6 2
A getLinkParameters() 0 18 2
A getForm() 0 3 1
A getPosterHeight() 0 6 2
C getExistingUploadsData() 0 53 12
A getMaxFileSize() 0 15 3
B setValue() 0 28 10
B getFilePondConfig() 0 58 7
A adjustPosterSize() 0 11 2
A getImageSizeConfigFromArray() 0 7 2
A getCustomConfigValue() 0 6 2
A getDefaultPosterHeight() 0 3 1
A Requirements() 0 4 1
B getServerOptions() 0 21 8
A setImageSize() 0 11 2
A computeMaxChunkSize() 0 13 3
B getImageSizeConfig() 0 41 7
A getDefaultPosterWidth() 0 3 1
B clearTemporaryUploads() 0 42 9
A getTrackedIDs() 0 8 2
A revert() 0 28 6
B upload() 0 37 6
A fixName() 0 9 3
A prepareUpload() 0 18 3
A FieldHolder() 0 6 2
D chunk() 0 154 24
A trackFileID() 0 9 3
A getAttributes() 0 18 1
A Field() 0 10 1
B saveInto() 0 42 9
A Type() 0 3 1
A securityChecks() 0 10 4
A setFileDetails() 0 23 5

How to fix   Complexity   

Complex Class

Complex classes like FilePondField often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FilePondField, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace LeKoala\FilePond;
4
5
use Exception;
6
use LogicException;
7
use RuntimeException;
8
use SilverStripe\Forms\Form;
9
use SilverStripe\Assets\File;
10
use SilverStripe\ORM\SS_List;
11
use SilverStripe\Assets\Image;
12
use SilverStripe\Core\Convert;
13
use SilverStripe\ORM\ArrayList;
14
use SilverStripe\ORM\DataObject;
15
use SilverStripe\Control\Director;
16
use SilverStripe\Security\Security;
17
use SilverStripe\View\Requirements;
18
use SilverStripe\Control\HTTPRequest;
19
use SilverStripe\Versioned\Versioned;
20
use SilverStripe\Control\HTTPResponse;
21
use SilverStripe\ORM\DataObjectInterface;
22
use SilverStripe\ORM\ValidationException;
23
use SilverStripe\ORM\FieldType\DBHTMLText;
24
25
/**
26
 * A FilePond field
27
 */
28
class FilePondField extends AbstractUploadField
29
{
30
    const IMAGE_MODE_MIN = "min";
31
    const IMAGE_MODE_MAX = "max";
32
    const IMAGE_MODE_CROP = "crop";
33
    const IMAGE_MODE_RESIZE = "resize";
34
    const IMAGE_MODE_CROP_RESIZE = "crop_resize";
35
    const DEFAULT_POSTER_HEIGHT = 264;
36
    const DEFAULT_POSTER_WIDTH = 352;
37
38
    /**
39
     * @config
40
     * @var array<string>
41
     */
42
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
43
        'upload',
44
        'chunk',
45
        'revert',
46
    ];
47
48
    /**
49
     * @config
50
     * @var boolean
51
     */
52
    private static $enable_requirements = true;
53
54
    /**
55
     * @config
56
     * @var boolean
57
     */
58
    private static $enable_poster = false;
59
60
    /**
61
     * @config
62
     * @var boolean
63
     */
64
    private static $chunk_by_default = false;
65
66
    /**
67
     * @config
68
     * @var boolean
69
     */
70
    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...
71
72
    /**
73
     * @config
74
     * @var boolean
75
     */
76
    private static $auto_clear_temp_folder = true;
77
78
    /**
79
     * @config
80
     * @var bool
81
     */
82
    private static $auto_clear_threshold = true;
83
84
    /**
85
     * @config
86
     * @var boolean
87
     */
88
    private static $enable_auto_thumbnails = false;
0 ignored issues
show
introduced by
The private property $enable_auto_thumbnails is not used, and could be removed.
Loading history...
89
90
    /**
91
     * @config
92
     * @var int
93
     */
94
    private static $poster_width = 352;
95
96
    /**
97
     * @config
98
     * @var int
99
     */
100
    private static $poster_height = 264;
101
102
    /**
103
     * @var array<string|int,mixed|null>
104
     */
105
    protected $filePondConfig = [];
106
107
    /**
108
     * @var array<string,mixed>|null
109
     */
110
    protected $customServerConfig = null;
111
112
    /**
113
     * @var ?int
114
     */
115
    protected $posterHeight = null;
116
117
    /**
118
     * @var ?int
119
     */
120
    protected $posterWidth = null;
121
122
    /**
123
     * Create a new file field.
124
     *
125
     * @param string $name The internal field name, passed to forms.
126
     * @param string $title The field label.
127
     * @param SS_List $items Items assigned to this field
128
     */
129
    public function __construct($name, $title = null, SS_List $items = null)
130
    {
131
        parent::__construct($name, $title, $items);
132
133
        if (self::config()->chunk_by_default) {
134
            $this->setChunkUploads(true);
135
        }
136
    }
137
138
    /**
139
     * Set a custom config value for this field
140
     *
141
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
142
     * @param string $k
143
     * @param string|bool|float|int|array<mixed> $v
144
     * @return $this
145
     */
146
    public function addFilePondConfig($k, $v)
147
    {
148
        $this->filePondConfig[$k] = $v;
149
        return $this;
150
    }
151
152
    /**
153
     * @param string $k
154
     * @param mixed $default
155
     * @return mixed
156
     */
157
    public function getCustomConfigValue($k, $default = null)
158
    {
159
        if (isset($this->filePondConfig[$k])) {
160
            return $this->filePondConfig[$k];
161
        }
162
        return $default;
163
    }
164
165
    /**
166
     * Custom configuration applied to this field
167
     *
168
     * @return array<mixed>
169
     */
170
    public function getCustomFilePondConfig()
171
    {
172
        return $this->filePondConfig;
173
    }
174
175
    /**
176
     * Get the value of chunkUploads
177
     * @return bool
178
     */
179
    public function getChunkUploads()
180
    {
181
        if (!isset($this->filePondConfig['chunkUploads'])) {
182
            return false;
183
        }
184
        return $this->filePondConfig['chunkUploads'];
185
    }
186
187
    /**
188
     * Get the value of customServerConfig
189
     * @return array<mixed>
190
     */
191
    public function getCustomServerConfig()
192
    {
193
        return $this->customServerConfig;
194
    }
195
196
    /**
197
     * Set the value of customServerConfig
198
     *
199
     * @param array<mixed> $customServerConfig
200
     * @return $this
201
     */
202
    public function setCustomServerConfig(array $customServerConfig)
203
    {
204
        $this->customServerConfig = $customServerConfig;
205
        return $this;
206
    }
207
208
    /**
209
     * Set the value of chunkUploads
210
     *
211
     * Note: please set max file upload first if you want
212
     * to see the size limit in the description
213
     *
214
     * @param bool $chunkUploads
215
     * @return $this
216
     */
217
    public function setChunkUploads($chunkUploads)
218
    {
219
        $this->addFilePondConfig('chunkUploads', true);
220
        $this->addFilePondConfig('chunkForce', true);
221
        $this->addFilePondConfig('chunkSize', $this->computeMaxChunkSize());
222
        if ($this->isDefaultMaxFileSize()) {
223
            $this->showDescriptionSize = false;
224
        }
225
        return $this;
226
    }
227
228
    /**
229
     * @param array<mixed> $sizes
230
     * @return array<mixed>
231
     */
232
    public function getImageSizeConfigFromArray($sizes)
233
    {
234
        $mode = null;
235
        if (isset($sizes[2])) {
236
            $mode = $sizes[2];
237
        }
238
        return $this->getImageSizeConfig($sizes[0], $sizes[1], $mode);
239
    }
240
241
    /**
242
     * @param int $width
243
     * @param int $height
244
     * @param string $mode min|max|crop|resize|crop_resize
245
     * @return array<mixed>
246
     */
247
    public function getImageSizeConfig($width, $height, $mode = null)
248
    {
249
        if ($mode === null) {
250
            $mode = self::IMAGE_MODE_MIN;
251
        }
252
        $config = [];
253
        switch ($mode) {
254
            case self::IMAGE_MODE_MIN:
255
                $config['imageValidateSizeMinWidth'] = $width;
256
                $config['imageValidateSizeMinHeight'] = $height;
257
                break;
258
            case self::IMAGE_MODE_MAX:
259
                $config['imageValidateSizeMaxWidth'] = $width;
260
                $config['imageValidateSizeMaxHeight'] = $height;
261
                break;
262
            case self::IMAGE_MODE_CROP:
263
                // It crops only to given ratio and tries to keep the largest image
264
                $config['allowImageCrop'] = true;
265
                $config['imageCropAspectRatio'] = "{$width}:{$height}";
266
                break;
267
            case self::IMAGE_MODE_RESIZE:
268
                //  Cover will respect the aspect ratio and will scale to fill the target dimensions
269
                $config['allowImageResize'] = true;
270
                $config['imageResizeTargetWidth'] = $width;
271
                $config['imageResizeTargetHeight'] = $height;
272
273
                // Don't use these settings and keep api simple
274
                // $config['imageResizeMode'] = 'cover';
275
                // $config['imageResizeUpscale'] = true;
276
                break;
277
            case self::IMAGE_MODE_CROP_RESIZE:
278
                $config['allowImageResize'] = true;
279
                $config['imageResizeTargetWidth'] = $width;
280
                $config['imageResizeTargetHeight'] = $height;
281
                $config['allowImageCrop'] = true;
282
                $config['imageCropAspectRatio'] = "{$width}:{$height}";
283
                break;
284
            default:
285
                throw new Exception("Unsupported '$mode' mode");
286
        }
287
        return $config;
288
    }
289
290
    /**
291
     * @link https://pqina.nl/filepond/docs/api/plugins/image-crop/
292
     * @link https://pqina.nl/filepond/docs/api/plugins/image-resize/
293
     * @link https://pqina.nl/filepond/docs/api/plugins/image-validate-size/
294
     * @param int $width
295
     * @param int $height
296
     * @param string $mode min|max|crop|resize|crop_resize
297
     * @return $this
298
     */
299
    public function setImageSize($width, $height, $mode = null)
300
    {
301
        $config = $this->getImageSizeConfig($width, $height, $mode);
302
        foreach ($config as $k => $v) {
303
            $this->addFilePondConfig($k, $v);
304
        }
305
306
        // We need a custom poster size
307
        $this->adjustPosterSize($width, $height);
308
309
        return $this;
310
    }
311
312
    /**
313
     * This is a frontend alternative to setRenamePattern
314
     *
315
     * @link https://pqina.nl/filepond/docs/api/plugins/file-rename/
316
     * @param string $name The name (extension is added automatically)
317
     * @return $this
318
     */
319
    public function setRenameFile($name)
320
    {
321
        $this->addFilePondConfig('fileRenameFunction', $name);
322
        return $this;
323
    }
324
325
    /**
326
     * @param int $width
327
     * @param int $height
328
     * @return void
329
     */
330
    protected function adjustPosterSize($width, $height)
331
    {
332
        // If the height is smaller than our default, make smaller
333
        if ($height < self::getDefaultPosterHeight()) {
334
            $this->posterHeight = $height;
335
            $this->posterWidth = $width;
336
        } else {
337
            // Adjust width to keep aspect ratio with our default height
338
            $ratio = $height / self::getDefaultPosterHeight();
339
            //@phpstan-ignore-next-line
340
            $this->posterWidth = round($width / $ratio);
341
        }
342
    }
343
344
    /**
345
     * @return int
346
     */
347
    public function getPosterWidth()
348
    {
349
        if ($this->posterWidth) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->posterWidth of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
350
            return $this->posterWidth;
351
        }
352
        return self::getDefaultPosterWidth();
353
    }
354
355
    /**
356
     * @return int
357
     */
358
    public function getPosterHeight()
359
    {
360
        if ($this->posterHeight) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->posterHeight of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
361
            return $this->posterHeight;
362
        }
363
        return self::getDefaultPosterHeight();
364
    }
365
366
    /**
367
     * Return the config applied for this field
368
     *
369
     * Typically converted to json and set in a data attribute
370
     *
371
     * @return array<string,mixed>
372
     */
373
    public function getFilePondConfig()
374
    {
375
        $this->fixName();
376
        $name = $this->getName();
377
        $multiple = $this->getIsMultiUpload();
378
379
        $i18nConfig = [
380
            'labelIdle' => _t('FilePondField.labelIdle', 'Drag & Drop your files or <span class="filepond--label-action"> Browse </span>'),
381
            'labelFileProcessing' => _t('FilePondField.labelFileProcessing', 'Uploading'),
382
            'labelFileProcessingComplete' => _t('FilePondField.labelFileProcessingComplete', 'Upload complete'),
383
            'labelFileProcessingAborted' => _t('FilePondField.labelFileProcessingAborted', 'Upload cancelled'),
384
            'labelTapToCancel' => _t('FilePondField.labelTapToCancel', 'tap to cancel'),
385
            'labelTapToRetry' => _t('FilePondField.labelTapToCancel', 'tap to retry'),
386
            'labelTapToUndo' => _t('FilePondField.labelTapToCancel', 'tap to undo'),
387
        ];
388
389
        // Base config
390
        $config = [
391
            'name' => $name, // This will also apply to the hidden fields
392
            'allowMultiple' => $multiple,
393
            'maxFiles' => $this->getAllowedMaxFileNumber(),
394
            'server' => $this->getServerOptions(),
395
            'files' => $this->getExistingUploadsData(),
396
        ];
397
        $maxFileSize = $this->getMaxFileSize();
398
        if ($maxFileSize) {
399
            $config['maxFileSize'] = $maxFileSize;
400
        }
401
402
        $acceptedFileTypes = $this->getAcceptedFileTypes();
403
        if (!empty($acceptedFileTypes)) {
404
            $config['acceptedFileTypes'] = array_values($acceptedFileTypes);
405
        }
406
407
        // image poster
408
        // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
409
        if (self::config()->enable_poster) {
410
            $config['filePosterHeight'] = self::config()->poster_height ?? self::DEFAULT_POSTER_HEIGHT;
411
        }
412
413
        // image validation/crop based on record
414
        /** @var DataObject|null $record */
415
        $record = $this->getForm()->getRecord();
416
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
417
            $sizes = $record->config()->image_sizes;
418
            $name = $this->getSafeName();
419
            if ($sizes && isset($sizes[$name])) {
420
                $newConfig = $this->getImageSizeConfigFromArray($sizes[$name]);
421
                $config = array_merge($config, $newConfig);
422
                $this->adjustPosterSize($sizes[$name][0], $sizes[$name][1]);
423
            }
424
        }
425
426
427
        // Any custom setting will override the base ones
428
        $config = array_merge($config, $i18nConfig, $this->filePondConfig);
429
430
        return $config;
431
    }
432
433
    /**
434
     * Compute best size for chunks based on server settings
435
     *
436
     * @return float
437
     */
438
    protected function computeMaxChunkSize()
439
    {
440
        $upload_max_filesize = ini_get('upload_max_filesize');
441
        $post_max_size = ini_get('post_max_size');
442
443
        $upload_max_filesize = $upload_max_filesize ? $upload_max_filesize : "2MB";
444
        $post_max_size = $post_max_size ? $post_max_size : "2MB";
445
446
        $maxUpload = Convert::memstring2bytes($upload_max_filesize);
447
        $maxPost = Convert::memstring2bytes($post_max_size);
448
449
        // ~90%, allow some overhead
450
        return round(min($maxUpload, $maxPost) * 0.9);
451
    }
452
453
    /**
454
     * @param array<mixed>|int|string $value
455
     * @param DataObject|array<string,mixed> $record
456
     * @return $this
457
     */
458
    public function setValue($value, $record = null)
459
    {
460
        // Normalize values to something similar to UploadField usage
461
        if (is_numeric($value)) {
462
            $value = ['Files' => [$value]];
463
        } elseif (is_array($value) && empty($value['Files'])) {
464
            // make sure we don't assign {"name":"","full_path":"","type":"","tmp_name":"","error":4,"size":0}
465
            // if $_FILES is not empty
466
            if (isset($value['tmp_name'])) {
467
                $value = null;
468
            }
469
            $value = ['Files' => $value];
470
        }
471
        if ($record) {
472
            $name = $this->name;
473
            if ($record instanceof DataObject && $record->hasMethod($name)) {
474
                $data = $record->$name();
475
                // Wrap
476
                if ($data instanceof DataObject) {
477
                    $data = new ArrayList([$data]);
478
                }
479
                foreach ($data as $uploadedItem) {
480
                    $this->trackFileID($uploadedItem->ID);
481
                }
482
            }
483
        }
484
        //@phpstan-ignore-next-line
485
        return parent::setValue($value, $record);
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type string; however, parameter $value of SilverStripe\Forms\FileUploadReceiver::setValue() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

485
        return parent::setValue(/** @scrutinizer ignore-type */ $value, $record);
Loading history...
486
    }
487
488
    /**
489
     * Get the currently used form.
490
     *
491
     * @return Form|null
492
     */
493
    public function getForm()
494
    {
495
        return $this->form;
496
    }
497
498
    /**
499
     * Configure our endpoint
500
     *
501
     * @link https://pqina.nl/filepond/docs/patterns/api/server/
502
     * @return array<mixed>
503
     */
504
    public function getServerOptions()
505
    {
506
        if (!empty($this->customServerConfig)) {
507
            return $this->customServerConfig;
508
        }
509
        if (!$this->getForm()) {
510
            throw new LogicException(
511
                'Field must be associated with a form to call getServerOptions(). Please use $field->setForm($form);'
512
            );
513
        }
514
        $endpoint = $this->getChunkUploads() ? 'chunk' : 'upload';
515
        $server = [
516
            'process' => $this->getUploadEnabled() ? $this->getLinkParameters($endpoint) : null,
517
            'fetch' => null,
518
            'revert' => $this->getUploadEnabled() ? $this->getLinkParameters('revert') : null,
519
        ];
520
        if ($this->getUploadEnabled() && $this->getChunkUploads()) {
521
            $server['fetch'] =  $this->getLinkParameters($endpoint . "?fetch=");
522
            $server['patch'] =  $this->getLinkParameters($endpoint . "?patch=");
523
        }
524
        return $server;
525
    }
526
527
    /**
528
     * Configure the following parameters:
529
     *
530
     * url : Path to the end point
531
     * method : Request method to use
532
     * withCredentials : Toggles the XMLHttpRequest withCredentials on or off
533
     * headers : An object containing additional headers to send
534
     * timeout : Timeout for this action
535
     * onload : Called when server response is received, useful for getting the unique file id from the server response
536
     * onerror : Called when server error is received, receis the response body, useful to select the relevant error data
537
     *
538
     * @param string $action
539
     * @return array<mixed>
540
     */
541
    protected function getLinkParameters($action)
542
    {
543
        $form = $this->getForm();
544
        $token = $form->getSecurityToken()->getValue();
545
        /** @var DataObject|null $record */
546
        $record = $form->getRecord();
547
548
        $headers = [
549
            'X-SecurityID' => $token
550
        ];
551
        // Allow us to track the record instance
552
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
553
            $headers['X-RecordClassName'] = get_class($record);
554
            $headers['X-RecordID'] = $record->ID;
555
        }
556
        return [
557
            'url' => $this->Link($action),
558
            'headers' => $headers,
559
        ];
560
    }
561
562
    /**
563
     * The maximum size of a file, for instance 5MB or 750KB
564
     * Suitable for JS usage
565
     *
566
     * @return string
567
     */
568
    public function getMaxFileSize()
569
    {
570
        $size = $this->getValidator()->getAllowedMaxFileSize();
571
        if (!$size) {
572
            return '';
573
        }
574
575
        // Only supports KB and MB
576
        if ($size < 1024 * 1024) {
577
            $size = round($size / 1024) . ' KB';
578
        } else {
579
            $size = round($size / (1024 * 1024)) . ' MB';
580
        }
581
582
        return str_replace(' ', '', $size);
583
    }
584
585
    /**
586
     * Set initial values to FilePondField
587
     * See: https://pqina.nl/filepond/docs/patterns/api/filepond-object/#setting-initial-files
588
     *
589
     * @return array<mixed>
590
     */
591
    public function getExistingUploadsData()
592
    {
593
        // Both Value() & dataValue() seem to return an array eg: ['Files' => [258, 259, 257]]
594
        $fileIDarray = $this->Value() ?: ['Files' => []];
595
        if (!isset($fileIDarray['Files']) || !count($fileIDarray['Files'])) {
596
            return [];
597
        }
598
599
        $existingUploads = [];
600
        foreach ($fileIDarray['Files'] as $fileID) {
601
            /** @var File|null $file */
602
            $file = File::get()->byID($fileID);
603
            if (!$file) {
604
                continue;
605
            }
606
            $existingUpload = [
607
                // the server file reference
608
                'source' => (int) $fileID,
609
                // set type to local to indicate an already uploaded file
610
                'options' => [
611
                    'type' => 'local',
612
                    // file information
613
                    'file' => [
614
                        'name' => $file->Name,
615
                        'size' => (int) $file->getAbsoluteSize(),
616
                        'type' => $file->getMimeType(),
617
                    ],
618
                ],
619
                'metadata' => []
620
            ];
621
622
            // Show poster
623
            // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
624
            if (self::config()->enable_poster && $file instanceof Image && $file->ID) {
625
                // Size matches the one from asset admin or from or set size
626
                $w = self::getDefaultPosterWidth();
627
                if ($this->posterWidth) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->posterWidth of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
628
                    $w = $this->posterWidth;
629
                }
630
                $h = self::getDefaultPosterHeight();
631
                if ($this->posterHeight) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->posterHeight of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
632
                    $h = $this->posterHeight;
633
                }
634
                /** @var Image|null $resizedImage */
635
                $resizedImage = $file->Fill($w, $h);
636
                if ($resizedImage) {
637
                    $poster = $resizedImage->getAbsoluteURL();
638
                    $existingUpload['options']['metadata']['poster'] = $poster;
639
                }
640
            }
641
            $existingUploads[] = $existingUpload;
642
        }
643
        return $existingUploads;
644
    }
645
646
    /**
647
     * @return int
648
     */
649
    public static function getDefaultPosterWidth()
650
    {
651
        return self::config()->poster_width ?? self::DEFAULT_POSTER_WIDTH;
652
    }
653
654
    /**
655
     * @return int
656
     */
657
    public static function getDefaultPosterHeight()
658
    {
659
        return self::config()->poster_height ?? self::DEFAULT_POSTER_HEIGHT;
660
    }
661
662
    /**
663
     * @return void
664
     */
665
    public static function Requirements()
666
    {
667
        // It includes css styles already
668
        Requirements::javascript('lekoala/silverstripe-filepond: javascript/filepond-input.min.js');
669
    }
670
671
    public function getAttributes()
672
    {
673
        // don't use parent as it will include data-schema that we don'tt need
674
        $attributes = array(
675
            'class' => $this->extraClass(),
676
            'type' => 'file',
677
            'multiple' => $this->getIsMultiUpload(),
678
            'id' => $this->ID(),
679
        );
680
681
        $attributes = array_merge($attributes, $this->attributes);
682
683
        $this->fixName();
684
        $attributes['name'] = $this->getName();
685
686
        $this->extend('updateAttributes', $attributes);
687
688
        return $attributes;
689
    }
690
691
    /**
692
     * Make sure the name is correct
693
     * @return void
694
     */
695
    protected function fixName()
696
    {
697
        $name = $this->getName();
698
        $multiple = $this->getIsMultiUpload();
699
700
        // Multi uploads need []
701
        if ($multiple && strpos($name, '[]') === false) {
702
            $name .= '[]';
703
            $this->setName($name);
704
        }
705
    }
706
707
    /**
708
     * @param array<string,mixed> $properties
709
     * @return DBHTMLText
710
     */
711
    public function FieldHolder($properties = array())
712
    {
713
        if (self::config()->enable_requirements) {
714
            self::Requirements();
715
        }
716
        return parent::FieldHolder($properties);
717
    }
718
719
    /**
720
     * @param array<mixed> $properties
721
     * @return DBHTMLText|string
722
     */
723
    public function Field($properties = array())
724
    {
725
        $html = parent::Field($properties);
726
727
        $config = $this->getFilePondConfig();
728
729
        // Simply wrap with custom element and set config
730
        $html = "<filepond-input data-config='" . json_encode($config) . "'>" . $html . '</filepond-input>';
731
732
        return $html;
733
    }
734
735
    /**
736
     * Check the incoming request
737
     *
738
     * @param HTTPRequest $request
739
     * @return array<mixed>
740
     */
741
    public function prepareUpload(HTTPRequest $request)
742
    {
743
        $name = $this->getName();
744
        $tmpFile = $request->postVar($name);
745
        if (!$tmpFile) {
746
            throw new RuntimeException("No file");
747
        }
748
        $tmpFile = $this->normalizeTempFile($tmpFile);
749
750
        // Update $tmpFile with a better name
751
        if ($this->renamePattern) {
752
            $tmpFile['name'] = $this->changeFilenameWithPattern(
753
                $tmpFile['name'],
754
                $this->renamePattern
755
            );
756
        }
757
758
        return $tmpFile;
759
    }
760
761
    /**
762
     * @param HTTPRequest $request
763
     * @return void
764
     */
765
    protected function securityChecks(HTTPRequest $request)
766
    {
767
        if ($this->isDisabled() || $this->isReadonly()) {
768
            throw new RuntimeException("Field is disabled or readonly");
769
        }
770
771
        // CSRF check
772
        $token = $this->getForm()->getSecurityToken();
773
        if (!$token->checkRequest($request)) {
774
            throw new RuntimeException("Invalid token");
775
        }
776
    }
777
778
    /**
779
     * @param File $file
780
     * @param HTTPRequest $request
781
     * @return void
782
     */
783
    protected function setFileDetails(File $file, HTTPRequest $request)
784
    {
785
        // Mark as temporary until properly associated with a record
786
        // Files will be unmarked later on by saveInto method
787
        $file->IsTemporary = true; //@phpstan-ignore-line
788
789
        // We can also track the record
790
        $RecordID = $request->getHeader('X-RecordID');
791
        $RecordClassName = $request->getHeader('X-RecordClassName');
792
        if (!$file->ObjectID) { //@phpstan-ignore-line
793
            $file->ObjectID = $RecordID;
794
        }
795
        if (!$file->ObjectClass) { //@phpstan-ignore-line
796
            $file->ObjectClass = $RecordClassName;
797
        }
798
799
        if ($file->isChanged()) {
800
            // If possible, prevent creating a version for no reason
801
            // @link https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#writing-changes-to-a-versioned-dataobject
802
            if ($file->hasExtension(Versioned::class)) {
803
                $file->writeWithoutVersion();
804
            } else {
805
                $file->write();
806
            }
807
        }
808
    }
809
810
    /**
811
     * Creates a single file based on a form-urlencoded upload.
812
     *
813
     * 1 client uploads file my-file.jpg as multipart/form-data using a POST request
814
     * 2 server saves file to unique location tmp/12345/my-file.jpg
815
     * 3 server returns unique location id 12345 in text/plain response
816
     * 4 client stores unique id 12345 in a hidden input field
817
     * 5 client submits the FilePond parent form containing the hidden input field with the unique id
818
     * 6 server uses the unique id to move tmp/12345/my-file.jpg to its final location and remove the tmp/12345 folder
819
     *
820
     * Along with the file object, FilePond also sends the file metadata to the server, both these objects are given the same name.
821
     *
822
     * @param HTTPRequest $request
823
     * @return HTTPResponse
824
     */
825
    public function upload(HTTPRequest $request)
826
    {
827
        try {
828
            $this->securityChecks($request);
829
            $tmpFile = $this->prepareUpload($request);
830
        } catch (Exception $ex) {
831
            return $this->httpError(400, $ex->getMessage());
832
        }
833
834
        $file = $this->saveTemporaryFile($tmpFile, $error);
835
836
        // Handle upload errors
837
        if ($error) {
838
            $this->getUpload()->clearErrors();
839
            $jsonError = json_encode($error);
840
            $jsonError = $jsonError ? $jsonError : json_last_error_msg();
841
            return $this->httpError(400, $jsonError);
842
        }
843
844
        // File can be an AssetContainer and not a DataObject
845
        if ($file instanceof DataObject) {
846
            $this->setFileDetails($file, $request); //@phpstan-ignore-line
847
        }
848
849
        $this->getUpload()->clearErrors();
850
        $fileId = $file->ID; //@phpstan-ignore-line
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...
851
        $this->trackFileID($fileId);
852
853
        if (self::config()->auto_clear_temp_folder) {
854
            // Set a limit of 100 because otherwise it would be really slow
855
            self::clearTemporaryUploads(true, 100);
856
        }
857
858
        // server returns unique location id 12345 in text/plain response
859
        $response = new HTTPResponse($fileId);
860
        $response->addHeader('Content-Type', 'text/plain');
861
        return $response;
862
    }
863
864
    /**
865
     * @link https://pqina.nl/filepond/docs/api/server/#process-chunks
866
     * @param HTTPRequest $request
867
     * @return HTTPResponse
868
     */
869
    public function chunk(HTTPRequest $request)
870
    {
871
        try {
872
            $this->securityChecks($request);
873
        } catch (Exception $ex) {
874
            return $this->httpError(400, $ex->getMessage());
875
        }
876
877
        $method = $request->httpMethod();
878
879
        // The random token is returned as a query string
880
        $id = $request->getVar('patch');
881
882
        // FilePond will send a POST request (without file) to start a chunked transfer,
883
        // expecting to receive a unique transfer id in the response body, it'll add the Upload-Length header to this request.
884
        if ($method == "POST") {
885
            // Initial post payload doesn't contain name
886
            // It would be better to return some kind of random token instead
887
            // But FilePond stores the id upon the first request :-(
888
            $file = new File();
889
            $this->setFileDetails($file, $request);
890
            $fileId = $file->ID;
891
            $this->trackFileID($fileId);
892
            $response = new HTTPResponse((string)$fileId, 200);
893
            $response->addHeader('Content-Type', 'text/plain');
894
            return $response;
895
        }
896
897
        // location of patch files
898
        $filePath = TEMP_PATH . "/filepond-" . $id;
899
900
        // FilePond will send a HEAD request to determine which chunks have already been uploaded,
901
        // expecting the file offset of the next expected chunk in the Upload-Offset response header.
902
        if ($method == "HEAD") {
903
            $nextOffset = 0;
904
            while (is_file($filePath . '.patch.' . $nextOffset)) {
905
                $nextOffset++;
906
            }
907
908
            $response = new HTTPResponse((string)$nextOffset, 200);
909
            $response->addHeader('Content-Type', 'text/plain');
910
            $response->addHeader('Upload-Offset', (string)$nextOffset);
911
            return $response;
912
        }
913
914
        // FilePond will send a PATCH request to push a chunk to the server.
915
        // Each of these requests is accompanied by a Content-Type, Upload-Offset, Upload-Name, and Upload-Length header.
916
        if ($method != "PATCH") {
917
            return $this->httpError(400, "Invalid method");
918
        }
919
920
        // The name of the file being transferred
921
        $uploadName = $request->getHeader('Upload-Name');
922
        // The offset of the chunk being transferred (starts with 0)
923
        $offset = $request->getHeader('Upload-Offset');
924
        // The total size of the file being transferred (in bytes)
925
        $length = (int) $request->getHeader('Upload-Length');
926
927
        // should be numeric values, else exit
928
        if (!is_numeric($offset) || !is_numeric($length)) {
929
            return $this->httpError(400, "Invalid offset or length");
930
        }
931
932
        // write patch file for this request
933
        file_put_contents($filePath . '.patch.' . $offset, $request->getBody());
934
935
        // calculate total size of patches
936
        $size = 0;
937
        $patch = glob($filePath . '.patch.*');
938
        if ($patch) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $patch of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
939
            foreach ($patch as $filename) {
940
                $size += filesize($filename);
941
            }
942
        }
943
944
        // check if we are above our size limit
945
        $maxAllowedSize = $this->getValidator()->getAllowedMaxFileSize();
946
        if ($maxAllowedSize && $size > $maxAllowedSize) {
947
            return $this->httpError(400, "File must not be larger than " . $this->getMaxFileSize());
948
        }
949
950
        // if total size equals length of file we have gathered all patch files
951
        if ($size >= $length) {
952
            // create output file
953
            $outputFile = fopen($filePath, 'wb');
954
            if ($patch && $outputFile) {
0 ignored issues
show
introduced by
$outputFile is of type resource, thus it always evaluated to false.
Loading history...
Bug Best Practice introduced by
The expression $patch of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
955
                // write patches to file
956
                foreach ($patch as $filename) {
957
                    // get offset from filename
958
                    list($dir, $offset) = explode('.patch.', $filename, 2);
959
                    // read patch and close
960
                    $patchFile = fopen($filename, 'rb');
961
                    $patchFileSize = filesize($filename);
962
                    if ($patchFile && $patchFileSize) {
963
                        $patchContent = fread($patchFile, $patchFileSize);
964
                        if ($patchContent) {
965
                            fclose($patchFile);
966
967
                            // apply patch
968
                            fseek($outputFile, (int) $offset);
969
                            fwrite($outputFile, $patchContent);
970
                        }
971
                    }
972
                }
973
                // remove patches
974
                foreach ($patch as $filename) {
975
                    unlink($filename);
976
                }
977
                // done with file
978
                fclose($outputFile);
979
            }
980
981
            // Finalize real filename
982
983
            // We need to class this as it mutates the state and set the record if any
984
            $relationClass = $this->getRelationAutosetClass(File::class);
0 ignored issues
show
Unused Code introduced by
The assignment to $relationClass is dead and can be removed.
Loading history...
985
            $realFilename = $this->getFolderName() . "/" . $uploadName;
986
            if ($this->renamePattern) {
987
                $realFilename = $this->changeFilenameWithPattern(
988
                    $realFilename,
989
                    $this->renamePattern
990
                );
991
            }
992
993
            // write output file to asset store
994
            $file = $this->getFileByID($id);
995
            if (!$file) {
996
                return $this->httpError(400, "File $id not found");
997
            }
998
            $file->setFromLocalFile($filePath);
999
            $file->setFilename($realFilename);
1000
            $file->Title = $uploadName;
1001
            // Set proper class
1002
            $relationClass = File::get_class_for_file_extension(
1003
                File::get_file_extension($realFilename)
1004
            );
1005
            $file->setClassName($relationClass);
1006
            $file->write();
1007
            // Reload file instance to get the right class
1008
            // it is not cached so we should get a fresh record
1009
            $file = $this->getFileByID($id);
1010
            // since we don't go through our upload object, call extension manually
1011
            $file->extend('onAfterUpload');
1012
1013
            // Cleanup temp files
1014
            $patch = glob($filePath . '.patch.*');
1015
            if ($patch) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $patch of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1016
                foreach ($patch as $filename) {
1017
                    unlink($filename);
1018
                }
1019
            }
1020
        }
1021
        $response = new HTTPResponse('', 204);
1022
        return $response;
1023
    }
1024
1025
    /**
1026
     * @link https://pqina.nl/filepond/docs/api/server/#revert
1027
     * @param HTTPRequest $request
1028
     * @return HTTPResponse
1029
     */
1030
    public function revert(HTTPRequest $request)
1031
    {
1032
        try {
1033
            $this->securityChecks($request);
1034
        } catch (Exception $ex) {
1035
            return $this->httpError(400, $ex->getMessage());
1036
        }
1037
1038
        $method = $request->httpMethod();
1039
1040
        if ($method != "DELETE") {
1041
            return $this->httpError(400, "Invalid method");
1042
        }
1043
1044
        $fileID = (int) $request->getBody();
1045
        if (!in_array($fileID, $this->getTrackedIDs())) {
1046
            return $this->httpError(400, "Invalid ID");
1047
        }
1048
        $file = File::get()->byID($fileID);
1049
        if (!$file->IsTemporary) {
1050
            return $this->httpError(400, "Invalid file");
1051
        }
1052
        if (!$file->canDelete()) {
1053
            return $this->httpError(400, "Cannot delete file");
1054
        }
1055
        $file->delete();
1056
        $response = new HTTPResponse('', 200);
1057
        return $response;
1058
    }
1059
1060
    /**
1061
     * Clear temp folder that should not contain any file other than temporary
1062
     *
1063
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
1064
     * @param int $limit
1065
     * @return File[] List of files removed
1066
     */
1067
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
1068
    {
1069
        $tempFiles = File::get()->filter('IsTemporary', true);
1070
        if ($limit) {
1071
            $tempFiles = $tempFiles->limit($limit);
1072
        }
1073
1074
        $threshold = self::config()->auto_clear_threshold;
1075
1076
        // Set a default threshold if none set
1077
        if (!$threshold) {
1078
            if (Director::isDev()) {
1079
                $threshold = '-10 minutes';
1080
            } else {
1081
                $threshold = '-1 day';
1082
            }
1083
        }
1084
        if (is_int($threshold)) {
1085
            $thresholdTime = time() - $threshold;
1086
        } else {
1087
            $thresholdTime = strtotime($threshold);
1088
        }
1089
1090
        // Update query to avoid fetching unecessary records
1091
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
1092
1093
        $filesDeleted = [];
1094
        foreach ($tempFiles as $tempFile) {
1095
            $createdTime = strtotime($tempFile->Created);
1096
            if ($createdTime < $thresholdTime) {
1097
                $filesDeleted[] = $tempFile;
1098
                if ($doDelete) {
1099
                    if ($tempFile->hasExtension(Versioned::class)) {
1100
                        $tempFile->deleteFromStage(Versioned::LIVE);
1101
                        $tempFile->deleteFromStage(Versioned::DRAFT);
1102
                    } else {
1103
                        $tempFile->delete();
1104
                    }
1105
                }
1106
            }
1107
        }
1108
        return $filesDeleted;
1109
    }
1110
1111
    /**
1112
     * Allows tracking uploaded ids to prevent unauthorized attachements
1113
     *
1114
     * @param int|string $fileId
1115
     * @return void
1116
     */
1117
    public function trackFileID($fileId)
1118
    {
1119
        $fileId = is_string($fileId) ? intval($fileId) : $fileId;
1120
        $session = $this->getRequest()->getSession();
1121
        $uploadedIDs = $this->getTrackedIDs();
1122
        if (!in_array($fileId, $uploadedIDs)) {
1123
            $uploadedIDs[] = $fileId;
1124
        }
1125
        $session->set('FilePond', $uploadedIDs);
1126
    }
1127
1128
    /**
1129
     * Get all authorized tracked ids
1130
     * @return array<mixed>
1131
     */
1132
    public function getTrackedIDs()
1133
    {
1134
        $session = $this->getRequest()->getSession();
1135
        $uploadedIDs = $session->get('FilePond');
1136
        if ($uploadedIDs) {
1137
            return $uploadedIDs;
1138
        }
1139
        return [];
1140
    }
1141
1142
    public function saveInto(DataObjectInterface $record)
1143
    {
1144
        // Note that the list of IDs is based on the value sent by the user
1145
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
1146
        $IDs = $this->getItemIDs();
1147
1148
        $Member = Security::getCurrentUser();
1149
1150
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
1151
        $trackedIDs = $this->getTrackedIDs();
1152
        foreach ($IDs as $ID) {
1153
            if (!in_array($ID, $trackedIDs)) {
1154
                throw new ValidationException("Invalid file ID : $ID");
1155
            }
1156
        }
1157
1158
        // Move files out of temporary folder
1159
        foreach ($IDs as $ID) {
1160
            $file = $this->getFileByID($ID);
1161
            //@phpstan-ignore-next-line
1162
            if ($file && $file->IsTemporary) {
1163
                // The record does not have an ID which is a bad idea to attach the file to it
1164
                if (!$record->ID) {
1165
                    $record->write();
1166
                }
1167
                // Check if the member is owner
1168
                if ($Member && $Member->ID != $file->OwnerID) {
1169
                    throw new ValidationException("Failed to authenticate owner");
1170
                }
1171
                $file->IsTemporary = false;
1172
                $file->ObjectID = $record->ID; //@phpstan-ignore-line
1173
                $file->ObjectClass = get_class($record); //@phpstan-ignore-line
1174
                $file->write();
1175
            } else {
1176
                // File was uploaded earlier, no need to do anything
1177
            }
1178
        }
1179
1180
        // Proceed
1181
        parent::saveInto($record);
1182
1183
        return $this;
1184
    }
1185
1186
    /**
1187
     * @return string
1188
     */
1189
    public function Type()
1190
    {
1191
        return 'filepond';
1192
    }
1193
}
1194