Passed
Push — master ( 7fe716...9cab19 )
by Thomas
02:01
created

FilePondField::getPosterHeight()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
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\SS_List;
10
use SilverStripe\Assets\Image;
11
use SilverStripe\Core\Convert;
12
use SilverStripe\ORM\ArrayList;
13
use SilverStripe\ORM\DataObject;
14
use SilverStripe\Control\Director;
15
use SilverStripe\Security\Security;
16
use SilverStripe\View\Requirements;
17
use SilverStripe\Control\HTTPRequest;
18
use SilverStripe\Versioned\Versioned;
19
use SilverStripe\Control\HTTPResponse;
20
use SilverStripe\ORM\DataObjectInterface;
21
use SilverStripe\ORM\ValidationException;
22
use SilverStripe\Core\Manifest\ModuleResourceLoader;
23
24
/**
25
 * A FilePond field
26
 */
27
class FilePondField extends AbstractUploadField
28
{
29
    const BASE_CDN = "https://cdn.jsdelivr.net/gh/pqina";
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
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_validation = true;
59
60
    /**
61
     * @config
62
     * @var boolean
63
     */
64
    private static $enable_poster = false;
65
66
    /**
67
     * @config
68
     * @var boolean
69
     */
70
    private static $enable_image = false;
71
72
    /**
73
     * @config
74
     * @var boolean
75
     */
76
    private static $enable_polyfill = true;
77
78
    /**
79
     * @config
80
     * @var boolean
81
     */
82
    private static $enable_ajax_init = true;
83
84
    /**
85
     * @config
86
     * @var boolean
87
     */
88
    private static $chunk_by_default = false;
89
90
    /**
91
     * @config
92
     * @var boolean
93
     */
94
    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...
95
96
    /**
97
     * @config
98
     * @var boolean
99
     */
100
    private static $auto_clear_temp_folder = true;
101
102
    /**
103
     * @config
104
     * @var int
105
     */
106
    private static $auto_clear_threshold = true;
107
108
    /**
109
     * @config
110
     * @var boolean
111
     */
112
    private static $use_cdn = true;
113
114
    /**
115
     * @config
116
     * @var boolean
117
     */
118
    private static $use_bundle = false;
119
120
    /**
121
     * @config
122
     * @var boolean
123
     */
124
    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...
125
126
    /**
127
     * @config
128
     * @var int
129
     */
130
    private static $poster_width = 352;
131
132
    /**
133
     * @config
134
     * @var int
135
     */
136
    private static $poster_height = 264;
137
138
    /**
139
     * @var array
140
     */
141
    protected $filePondConfig = [];
142
143
    /**
144
     * @var array
145
     */
146
    protected $customServerConfig = null;
147
148
    /**
149
     * @var int
150
     */
151
    protected $posterHeight = null;
152
153
    /**
154
     * @var int
155
     */
156
    protected $posterWidth = null;
157
158
    /**
159
     * Create a new file field.
160
     *
161
     * @param string $name The internal field name, passed to forms.
162
     * @param string $title The field label.
163
     * @param SS_List $items Items assigned to this field
164
     */
165
    public function __construct($name, $title = null, SS_List $items = null)
166
    {
167
        parent::__construct($name, $title, $items);
168
169
        if (self::config()->chunk_by_default) {
170
            $this->setChunkUploads(true);
171
        }
172
    }
173
174
    /**
175
     * Set a custom config value for this field
176
     *
177
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
178
     * @param string $k
179
     * @param string|bool|array $v
180
     * @return $this
181
     */
182
    public function addFilePondConfig($k, $v)
183
    {
184
        $this->filePondConfig[$k] = $v;
185
        return $this;
186
    }
187
188
    /**
189
     * @param string $k
190
     * @param mixed $default
191
     * @return mixed
192
     */
193
    public function getCustomConfigValue($k, $default = null)
194
    {
195
        if (isset($this->filePondConfig[$k])) {
196
            return $this->filePondConfig[$k];
197
        }
198
        return $default;
199
    }
200
201
    /**
202
     * Custom configuration applied to this field
203
     *
204
     * @return array
205
     */
206
    public function getCustomFilePondConfig()
207
    {
208
        return $this->filePondConfig;
209
    }
210
211
    /**
212
     * Get the value of chunkUploads
213
     * @return bool
214
     */
215
    public function getChunkUploads()
216
    {
217
        if (!isset($this->filePondConfig['chunkUploads'])) {
218
            return false;
219
        }
220
        return $this->filePondConfig['chunkUploads'];
221
    }
222
223
    /**
224
     * Get the value of customServerConfig
225
     * @return array
226
     */
227
    public function getCustomServerConfig()
228
    {
229
        return $this->customServerConfig;
230
    }
231
232
    /**
233
     * Set the value of customServerConfig
234
     *
235
     * @param array $customServerConfig
236
     * @return $this
237
     */
238
    public function setCustomServerConfig(array $customServerConfig)
239
    {
240
        $this->customServerConfig = $customServerConfig;
241
        return $this;
242
    }
243
244
    /**
245
     * Set the value of chunkUploads
246
     *
247
     * Note: please set max file upload first if you want
248
     * to see the size limit in the description
249
     *
250
     * @param bool $chunkUploads
251
     * @return $this
252
     */
253
    public function setChunkUploads($chunkUploads)
254
    {
255
        $this->addFilePondConfig('chunkUploads', true);
256
        $this->addFilePondConfig('chunkForce', true);
257
        $this->addFilePondConfig('chunkSize', $this->computeMaxChunkSize());
258
        if ($this->isDefaultMaxFileSize()) {
259
            $this->showDescriptionSize = false;
260
        }
261
        return $this;
262
    }
263
264
    /**
265
     * @param array $sizes
266
     * @return array
267
     */
268
    public function getImageSizeConfigFromArray($sizes)
269
    {
270
        $mode = null;
271
        if (isset($sizes[2])) {
272
            $mode = $sizes[2];
273
        }
274
        return $this->getImageSizeConfig($sizes[0], $sizes[1], $mode);
275
    }
276
277
    /**
278
     * @param int $width
279
     * @param int $height
280
     * @param string $mode min|max|crop|resize|crop_resize
281
     * @return array
282
     */
283
    public function getImageSizeConfig($width, $height, $mode = null)
284
    {
285
        if ($mode === null) {
286
            $mode = self::IMAGE_MODE_MIN;
287
        }
288
        $config = [];
289
        switch ($mode) {
290
            case self::IMAGE_MODE_MIN:
291
                $config['imageValidateSizeMinWidth'] = $width;
292
                $config['imageValidateSizeMinHeight'] = $height;
293
                break;
294
            case self::IMAGE_MODE_MAX:
295
                $config['imageValidateSizeMaxWidth'] = $width;
296
                $config['imageValidateSizeMaxHeight'] = $height;
297
                break;
298
            case self::IMAGE_MODE_CROP:
299
                // It crops only to given ratio and tries to keep the largest image
300
                $config['allowImageCrop'] = true;
301
                $config['imageCropAspectRatio'] = "{$width}:{$height}";
302
                break;
303
            case self::IMAGE_MODE_RESIZE:
304
                //  Cover will respect the aspect ratio and will scale to fill the target dimensions
305
                $config['allowImageResize'] = true;
306
                $config['imageResizeTargetWidth'] = $width;
307
                $config['imageResizeTargetHeight'] = $height;
308
309
                // Don't use these settings and keep api simple
310
                // $config['imageResizeMode'] = 'cover';
311
                // $config['imageResizeUpscale'] = true;
312
                break;
313
            case self::IMAGE_MODE_CROP_RESIZE:
314
                $config['allowImageResize'] = true;
315
                $config['imageResizeTargetWidth'] = $width;
316
                $config['imageResizeTargetHeight'] = $height;
317
                $config['allowImageCrop'] = true;
318
                $config['imageCropAspectRatio'] = "{$width}:{$height}";
319
                break;
320
            default:
321
                throw new Exception("Unsupported '$mode' mode");
322
        }
323
        return $config;
324
    }
325
326
    /**
327
     * @param int $width
328
     * @param int $height
329
     * @param string $mode min|max|crop|resize|crop_resize
330
     * @return $this
331
     */
332
    public function setImageSize($width, $height, $mode = null)
333
    {
334
        $config = $this->getImageSizeConfig($width, $height, $mode);
335
        foreach ($config as $k => $v) {
336
            $this->addFilePondConfig($k, $v);
337
        }
338
339
        // We need a custom poster size
340
        $this->adjustPosterSize($width, $height);
341
342
        return $config;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $config returns the type array which is incompatible with the documented return type LeKoala\FilePond\FilePondField.
Loading history...
343
    }
344
345
    /**
346
     * @param int $width
347
     * @param int $height
348
     * @return void
349
     */
350
    protected function adjustPosterSize($width, $height)
351
    {
352
        // If the height is smaller than our default, make smaller
353
        if ($height < self::getDefaultPosterHeight()) {
354
            $this->posterHeight = $height;
355
            $this->posterWidth = $width;
356
        } else {
357
            // Adjust width to keep aspect ratio with our default height
358
            $ratio = $height / self::getDefaultPosterHeight();
359
            $this->posterWidth = $width / $ratio;
360
        }
361
    }
362
363
    /**
364
     * @return int
365
     */
366
    public function getPosterWidth()
367
    {
368
        if ($this->posterWidth) {
369
            return $this->posterWidth;
370
        }
371
        return self::getDefaultPosterWidth();
372
    }
373
374
    /**
375
     * @return int
376
     */
377
    public function getPosterHeight()
378
    {
379
        if ($this->posterHeight) {
380
            return $this->posterHeight;
381
        }
382
        return self::getDefaultPosterHeight();
383
    }
384
385
    /**
386
     * Return the config applied for this field
387
     *
388
     * Typically converted to json and set in a data attribute
389
     *
390
     * @return array
391
     */
392
    public function getFilePondConfig()
393
    {
394
        $name = $this->getName();
395
        $multiple = $this->getIsMultiUpload();
396
397
        // Multi uploads need []
398
        if ($multiple && strpos($name, '[]') === false) {
399
            $name .= '[]';
400
            $this->setName($name);
401
        }
402
403
        $i18nConfig = [
404
            'labelIdle' => _t('FilePondField.labelIdle', 'Drag & Drop your files or <span class="filepond--label-action"> Browse </span>'),
405
            'labelFileProcessing' => _t('FilePondField.labelFileProcessing', 'Uploading'),
406
            'labelFileProcessingComplete' => _t('FilePondField.labelFileProcessingComplete', 'Upload complete'),
407
            'labelFileProcessingAborted' => _t('FilePondField.labelFileProcessingAborted', 'Upload cancelled'),
408
            'labelTapToCancel' => _t('FilePondField.labelTapToCancel', 'tap to cancel'),
409
            'labelTapToRetry' => _t('FilePondField.labelTapToCancel', 'tap to retry'),
410
            'labelTapToUndo' => _t('FilePondField.labelTapToCancel', 'tap to undo'),
411
        ];
412
413
        // Base config
414
        $config = [
415
            'name' => $name, // This will also apply to the hidden fields
416
            'allowMultiple' => $multiple,
417
            'maxFiles' => $this->getAllowedMaxFileNumber(),
418
            'maxFileSize' => $this->getMaxFileSize(),
419
            'server' => $this->getServerOptions(),
420
            'files' => $this->getExistingUploadsData(),
421
        ];
422
423
        $acceptedFileTypes = $this->getAcceptedFileTypes();
424
        if (!empty($acceptedFileTypes)) {
425
            $config['acceptedFileTypes'] = array_values($acceptedFileTypes);
426
        }
427
428
        // image poster
429
        // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
430
        if (self::config()->enable_poster) {
431
            $config['filePosterHeight'] = self::config()->poster_height ?? self::DEFAULT_POSTER_HEIGHT;
432
        }
433
434
        // image validation/crop based on record
435
        $record = $this->getForm()->getRecord();
436
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
437
            $sizes = $record->config()->image_sizes;
438
            $name = $this->getSafeName();
439
            if ($sizes && isset($sizes[$name])) {
440
                $newConfig = $this->getImageSizeConfigFromArray($sizes[$name]);
441
                $config = array_merge($config, $newConfig);
442
                $this->adjustPosterSize($sizes[$name][0], $sizes[$name][1]);
443
            }
444
        }
445
446
447
        // Any custom setting will override the base ones
448
        $config = array_merge($config, $i18nConfig, $this->filePondConfig);
449
450
        return $config;
451
    }
452
453
    /**
454
     * Compute best size for chunks based on server settings
455
     *
456
     * @return int
457
     */
458
    protected function computeMaxChunkSize()
459
    {
460
        $maxUpload = Convert::memstring2bytes(ini_get('upload_max_filesize'));
461
        $maxPost = Convert::memstring2bytes(ini_get('post_max_size'));
462
463
        // ~90%, allow some overhead
464
        return round(min($maxUpload, $maxPost) * 0.9);
465
    }
466
467
    /**
468
     * @inheritDoc
469
     */
470
    public function setValue($value, $record = null)
471
    {
472
        // Normalize values to something similar to UploadField usage
473
        if (is_numeric($value)) {
474
            $value = ['Files' => [$value]];
475
        } elseif (is_array($value) && empty($value['Files'])) {
476
            $value = ['Files' => $value];
477
        }
478
        // Track existing record data
479
        if ($record) {
480
            $name = $this->name;
481
            if ($record instanceof DataObject && $record->hasMethod($name)) {
482
                $data = $record->$name();
483
                // Wrap
484
                if ($data instanceof DataObject) {
485
                    $data = new ArrayList([$data]);
486
                }
487
                foreach ($data as $uploadedItem) {
488
                    $this->trackFileID($uploadedItem->ID);
489
                }
490
            }
491
        }
492
        return parent::setValue($value, $record);
493
    }
494
495
    /**
496
     * Configure our endpoint
497
     *
498
     * @link https://pqina.nl/filepond/docs/patterns/api/server/
499
     * @return array
500
     */
501
    public function getServerOptions()
502
    {
503
        if (!empty($this->customServerConfig)) {
504
            return $this->customServerConfig;
505
        }
506
        if (!$this->getForm()) {
507
            throw new LogicException(
508
                'Field must be associated with a form to call getServerOptions(). Please use $field->setForm($form);'
509
            );
510
        }
511
        $endpoint = $this->getChunkUploads() ? 'chunk' : 'upload';
512
        $server = [
513
            'process' => $this->getUploadEnabled() ? $this->getLinkParameters($endpoint) : null,
514
            'fetch' => null,
515
            'revert' => $this->getUploadEnabled() ? $this->getLinkParameters('revert') : null,
516
        ];
517
        if ($this->getUploadEnabled() && $this->getChunkUploads()) {
518
            $server['fetch'] =  $this->getLinkParameters($endpoint . "?fetch=");
519
            $server['patch'] =  $this->getLinkParameters($endpoint . "?patch=");
520
        }
521
        return $server;
522
    }
523
524
    /**
525
     * Configure the following parameters:
526
     *
527
     * url : Path to the end point
528
     * method : Request method to use
529
     * withCredentials : Toggles the XMLHttpRequest withCredentials on or off
530
     * headers : An object containing additional headers to send
531
     * timeout : Timeout for this action
532
     * onload : Called when server response is received, useful for getting the unique file id from the server response
533
     * onerror : Called when server error is received, receis the response body, useful to select the relevant error data
534
     *
535
     * @param string $action
536
     * @return array
537
     */
538
    protected function getLinkParameters($action)
539
    {
540
        $form = $this->getForm();
541
        $token = $form->getSecurityToken()->getValue();
542
        $record = $form->getRecord();
543
544
        $headers = [
545
            'X-SecurityID' => $token
546
        ];
547
        // Allow us to track the record instance
548
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
549
            $headers['X-RecordClassName'] = get_class($record);
550
            $headers['X-RecordID'] = $record->ID;
551
        }
552
        return [
553
            'url' => $this->Link($action),
554
            'headers' => $headers,
555
        ];
556
    }
557
558
    /**
559
     * The maximum size of a file, for instance 5MB or 750KB
560
     * Suitable for JS usage
561
     *
562
     * @return string
563
     */
564
    public function getMaxFileSize()
565
    {
566
        return str_replace(' ', '', File::format_size($this->getValidator()->getAllowedMaxFileSize()));
567
    }
568
569
    /**
570
     * Set initial values to FilePondField
571
     * See: https://pqina.nl/filepond/docs/patterns/api/filepond-object/#setting-initial-files
572
     *
573
     * @return array
574
     */
575
    public function getExistingUploadsData()
576
    {
577
        // Both Value() & dataValue() seem to return an array eg: ['Files' => [258, 259, 257]]
578
        $fileIDarray = $this->Value() ?: ['Files' => []];
579
        if (!isset($fileIDarray['Files']) || !count($fileIDarray['Files'])) {
580
            return [];
581
        }
582
583
        $existingUploads = [];
584
        foreach ($fileIDarray['Files'] as $fileID) {
585
            /* @var $file File */
586
            $file = File::get()->byID($fileID);
587
            if (!$file) {
588
                continue;
589
            }
590
            $existingUpload = [
591
                // the server file reference
592
                'source' => (int) $fileID,
593
                // set type to local to indicate an already uploaded file
594
                'options' => [
595
                    'type' => 'local',
596
                    // file information
597
                    'file' => [
598
                        'name' => $file->Name,
599
                        'size' => (int) $file->getAbsoluteSize(),
600
                        'type' => $file->getMimeType(),
601
                    ],
602
                ],
603
                'metadata' => []
604
            ];
605
606
            // Show poster
607
            // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
608
            if (self::config()->enable_poster && $file instanceof Image && $file->ID) {
609
                // Size matches the one from asset admin or from or set size
610
                $w = self::getDefaultPosterWidth();
611
                if ($this->posterWidth) {
612
                    $w = $this->posterWidth;
613
                }
614
                $h = self::getDefaultPosterHeight();
615
                if ($this->posterHeight) {
616
                    $h = $this->posterHeight;
617
                }
618
                $resizedImage = $file->Fill($w, $h);
619
                if ($resizedImage) {
620
                    $poster = $resizedImage->getAbsoluteURL();
621
                    $existingUpload['options']['metadata']['poster'] = $poster;
622
                }
623
            }
624
            $existingUploads[] = $existingUpload;
625
        }
626
        return $existingUploads;
627
    }
628
629
    /**
630
     * @return int
631
     */
632
    public static function getDefaultPosterWidth()
633
    {
634
        return self::config()->poster_width ?? self::DEFAULT_POSTER_WIDTH;
635
    }
636
637
    /**
638
     * @return int
639
     */
640
    public static function getDefaultPosterHeight()
641
    {
642
        return self::config()->poster_height ?? self::DEFAULT_POSTER_HEIGHT;
643
    }
644
645
    /**
646
     * Requirements are NOT versioned since filepond is regularly updated
647
     *
648
     * @return void
649
     */
650
    public static function Requirements()
651
    {
652
        $baseDir = self::BASE_CDN;
653
        if (!self::config()->use_cdn || self::config()->use_bundle) {
654
            // We need some kind of base url to serve as a starting point
655
            $asset = ModuleResourceLoader::resourceURL('lekoala/silverstripe-filepond:javascript/FilePondField.js');
656
            $baseDir = dirname($asset) . "/cdn";
657
        }
658
        $baseDir = rtrim($baseDir, '/');
659
660
        // It will load everything regardless of enabled plugins
661
        if (self::config()->use_bundle) {
662
            Requirements::css('lekoala/silverstripe-filepond:javascript/bundle.css');
663
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/bundle.js');
664
        } else {
665
            // Polyfill to ensure max compatibility
666
            if (self::config()->enable_polyfill) {
667
                Requirements::javascript("$baseDir/filepond-polyfill/dist/filepond-polyfill.min.js");
668
            }
669
670
            // File/image validation plugins
671
            if (self::config()->enable_validation) {
672
                Requirements::javascript("$baseDir/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.min.js");
673
                Requirements::javascript("$baseDir/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.min.js");
674
                Requirements::javascript("$baseDir/filepond-plugin-image-validate-size/dist/filepond-plugin-image-validate-size.min.js");
675
            }
676
677
            // Poster plugins
678
            if (self::config()->enable_poster) {
679
                Requirements::javascript("$baseDir/filepond-plugin-file-metadata/dist/filepond-plugin-file-metadata.min.js");
680
                Requirements::css("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.css");
681
                Requirements::javascript("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.js");
682
            }
683
684
            // Image plugins
685
            if (self::config()->enable_image) {
686
                Requirements::javascript("$baseDir/filepond-plugin-image-exif-orientation/dist/filepond-plugin-image-exif-orientation.min.js");
687
                Requirements::css("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css");
688
                Requirements::javascript("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js");
689
                Requirements::javascript("$baseDir/filepond-plugin-image-transform/dist/filepond-plugin-image-transform.min.js");
690
                Requirements::javascript("$baseDir/filepond-plugin-image-resize/dist/filepond-plugin-image-resize.min.js");
691
                Requirements::javascript("$baseDir/filepond-plugin-image-crop/dist/filepond-plugin-image-crop.min.js");
692
            }
693
694
            // Base elements
695
            Requirements::css("$baseDir/filepond/dist/filepond.min.css");
696
            Requirements::javascript("$baseDir/filepond/dist/filepond.min.js");
697
        }
698
699
        // Our custom init
700
        Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField.js');
701
702
        // In the cms, init will not be triggered
703
        // Or you could use simpler instead
704
        if (self::config()->enable_ajax_init && Director::is_ajax()) {
705
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField-init.js?t=' . time());
706
        }
707
    }
708
709
    public function FieldHolder($properties = array())
710
    {
711
        $config = $this->getFilePondConfig();
712
713
        $this->setAttribute('data-config', json_encode($config));
714
715
        if (self::config()->enable_requirements) {
716
            self::Requirements();
717
        }
718
719
        return parent::FieldHolder($properties);
720
    }
721
722
    /**
723
     * Check the incoming request
724
     *
725
     * @param HTTPRequest $request
726
     * @return array
727
     */
728
    public function prepareUpload(HTTPRequest $request)
729
    {
730
        $name = $this->getName();
731
        $tmpFile = $request->postVar($name);
732
        if (!$tmpFile) {
733
            throw new RuntimeException("No file");
734
        }
735
        $tmpFile = $this->normalizeTempFile($tmpFile);
736
737
        // Update $tmpFile with a better name
738
        if ($this->renamePattern) {
739
            $tmpFile['name'] = $this->changeFilenameWithPattern(
740
                $tmpFile['name'],
741
                $this->renamePattern
742
            );
743
        }
744
745
        return $tmpFile;
746
    }
747
748
    /**
749
     * @param HTTPRequest $request
750
     * @return void
751
     */
752
    protected function securityChecks(HTTPRequest $request)
753
    {
754
        if ($this->isDisabled() || $this->isReadonly()) {
755
            throw new RuntimeException("Field is disabled or readonly");
756
        }
757
758
        // CSRF check
759
        $token = $this->getForm()->getSecurityToken();
760
        if (!$token->checkRequest($request)) {
761
            throw new RuntimeException("Invalid token");
762
        }
763
    }
764
765
    /**
766
     * @param File $file
767
     * @param HTTPRequest $request
768
     * @return void
769
     */
770
    protected function setFileDetails(File $file, HTTPRequest $request)
771
    {
772
        // Mark as temporary until properly associated with a record
773
        // Files will be unmarked later on by saveInto method
774
        $file->IsTemporary = true;
775
776
        // We can also track the record
777
        $RecordID = $request->getHeader('X-RecordID');
778
        $RecordClassName = $request->getHeader('X-RecordClassName');
779
        if (!$file->ObjectID) {
780
            $file->ObjectID = $RecordID;
781
        }
782
        if (!$file->ObjectClass) {
783
            $file->ObjectClass = $RecordClassName;
784
        }
785
786
        if ($file->isChanged()) {
787
            // If possible, prevent creating a version for no reason
788
            // @link https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#writing-changes-to-a-versioned-dataobject
789
            if ($file->hasExtension(Versioned::class)) {
790
                $file->writeWithoutVersion();
791
            } else {
792
                $file->write();
793
            }
794
        }
795
    }
796
797
    /**
798
     * Creates a single file based on a form-urlencoded upload.
799
     *
800
     * 1 client uploads file my-file.jpg as multipart/form-data using a POST request
801
     * 2 server saves file to unique location tmp/12345/my-file.jpg
802
     * 3 server returns unique location id 12345 in text/plain response
803
     * 4 client stores unique id 12345 in a hidden input field
804
     * 5 client submits the FilePond parent form containing the hidden input field with the unique id
805
     * 6 server uses the unique id to move tmp/12345/my-file.jpg to its final location and remove the tmp/12345 folder
806
     *
807
     * Along with the file object, FilePond also sends the file metadata to the server, both these objects are given the same name.
808
     *
809
     * @param HTTPRequest $request
810
     * @return HTTPResponse
811
     */
812
    public function upload(HTTPRequest $request)
813
    {
814
        try {
815
            $this->securityChecks($request);
816
            $tmpFile = $this->prepareUpload($request);
817
        } catch (Exception $ex) {
818
            return $this->httpError(400, $ex->getMessage());
819
        }
820
821
        $file = $this->saveTemporaryFile($tmpFile, $error);
822
823
        // Handle upload errors
824
        if ($error) {
825
            $this->getUpload()->clearErrors();
826
            return $this->httpError(400, json_encode($error));
827
        }
828
829
        // File can be an AssetContainer and not a DataObject
830
        if ($file instanceof DataObject) {
831
            $this->setFileDetails($file, $request);
832
        }
833
834
        $this->getUpload()->clearErrors();
835
        $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...
836
        $this->trackFileID($fileId);
837
838
        if (self::config()->auto_clear_temp_folder) {
839
            // Set a limit of 100 because otherwise it would be really slow
840
            self::clearTemporaryUploads(true, 100);
841
        }
842
843
        // server returns unique location id 12345 in text/plain response
844
        $response = new HTTPResponse($fileId);
845
        $response->addHeader('Content-Type', 'text/plain');
846
        return $response;
847
    }
848
849
    /**
850
     * @link https://pqina.nl/filepond/docs/api/server/#process-chunks
851
     * @param HTTPRequest $request
852
     * @return HTTPResponse
853
     */
854
    public function chunk(HTTPRequest $request)
855
    {
856
        try {
857
            $this->securityChecks($request);
858
        } catch (Exception $ex) {
859
            return $this->httpError(400, $ex->getMessage());
860
        }
861
862
        $method = $request->httpMethod();
863
864
        // The random token is returned as a query string
865
        $id = $request->getVar('patch');
866
867
        // FilePond will send a POST request (without file) to start a chunked transfer,
868
        // expecting to receive a unique transfer id in the response body, it'll add the Upload-Length header to this request.
869
        if ($method == "POST") {
870
            // Initial post payload doesn't contain name
871
            // It would be better to return some kind of random token instead
872
            // But FilePond stores the id upon the first request :-(
873
            $file = new File();
874
            $this->setFileDetails($file, $request);
875
            $fileId = $file->ID;
876
            $this->trackFileID($fileId);
877
            $response = new HTTPResponse($fileId, 200);
878
            $response->addHeader('Content-Type', 'text/plain');
879
            return $response;
880
        }
881
882
        // location of patch files
883
        $filePath = TEMP_PATH . "/filepond-" . $id;
884
885
        // FilePond will send a HEAD request to determine which chunks have already been uploaded,
886
        // expecting the file offset of the next expected chunk in the Upload-Offset response header.
887
        if ($method == "HEAD") {
888
            $nextOffset = 0;
889
            while (is_file($filePath . '.patch.' . $nextOffset)) {
890
                $nextOffset++;
891
            }
892
893
            $response = new HTTPResponse($nextOffset, 200);
894
            $response->addHeader('Content-Type', 'text/plain');
895
            $response->addHeader('Upload-Offset', $nextOffset);
896
            return $response;
897
        }
898
899
        // FilePond will send a PATCH request to push a chunk to the server.
900
        // Each of these requests is accompanied by a Content-Type, Upload-Offset, Upload-Name, and Upload-Length header.
901
        if ($method != "PATCH") {
902
            return $this->httpError(400, "Invalid method");
903
        }
904
905
        // The name of the file being transferred
906
        $uploadName = $request->getHeader('Upload-Name');
907
        // The offset of the chunk being transferred (starts with 0)
908
        $offset = $request->getHeader('Upload-Offset');
909
        // The total size of the file being transferred (in bytes)
910
        $length = (int) $request->getHeader('Upload-Length');
911
912
        // should be numeric values, else exit
913
        if (!is_numeric($offset) || !is_numeric($length)) {
914
            return $this->httpError(400, "Invalid offset or length");
915
        }
916
917
        // write patch file for this request
918
        file_put_contents($filePath . '.patch.' . $offset, $request->getBody());
919
920
        // calculate total size of patches
921
        $size = 0;
922
        $patch = glob($filePath . '.patch.*');
923
        foreach ($patch as $filename) {
924
            $size += filesize($filename);
925
        }
926
        // if total size equals length of file we have gathered all patch files
927
        if ($size >= $length) {
928
            // create output file
929
            $outputFile = fopen($filePath, 'wb');
930
            // write patches to file
931
            foreach ($patch as $filename) {
932
                // get offset from filename
933
                list($dir, $offset) = explode('.patch.', $filename, 2);
934
                // read patch and close
935
                $patchFile = fopen($filename, 'rb');
936
                $patchContent = fread($patchFile, filesize($filename));
937
                fclose($patchFile);
938
939
                // apply patch
940
                fseek($outputFile, (int) $offset);
941
                fwrite($outputFile, $patchContent);
942
            }
943
            // remove patches
944
            foreach ($patch as $filename) {
945
                unlink($filename);
946
            }
947
            // done with file
948
            fclose($outputFile);
949
950
            // Finalize real filename
951
952
            // We need to class this as it mutates the state and set the record if any
953
            $relationClass = $this->getRelationAutosetClass(null);
0 ignored issues
show
Unused Code introduced by
The assignment to $relationClass is dead and can be removed.
Loading history...
954
            $realFilename = $this->getFolderName() . "/" . $uploadName;
955
            if ($this->renamePattern) {
956
                $realFilename = $this->changeFilenameWithPattern(
957
                    $realFilename,
958
                    $this->renamePattern
959
                );
960
            }
961
962
            // write output file to asset store
963
            $file = $this->getFileByID($id);
964
            if (!$file) {
965
                return $this->httpError(400, "File $id not found");
966
            }
967
            $file->setFromLocalFile($filePath);
968
            $file->setFilename($realFilename);
969
            $file->Title = $uploadName;
970
            // Set proper class
971
            $relationClass = File::get_class_for_file_extension(
972
                File::get_file_extension($realFilename)
973
            );
974
            $file->setClassName($relationClass);
975
            $file->write();
976
            // Reload file instance to get the right class
977
            // it is not cached so we should get a fresh record
978
            $file = $this->getFileByID($id);
979
            // since we don't go through our upload object, call extension manually
980
            $file->extend('onAfterUpload');
981
        }
982
        $response = new HTTPResponse('', 204);
983
        return $response;
984
    }
985
986
    /**
987
     * @link https://pqina.nl/filepond/docs/api/server/#revert
988
     * @param HTTPRequest $request
989
     * @return HTTPResponse
990
     */
991
    public function revert(HTTPRequest $request)
992
    {
993
        try {
994
            $this->securityChecks($request);
995
        } catch (Exception $ex) {
996
            return $this->httpError(400, $ex->getMessage());
997
        }
998
999
        $method = $request->httpMethod();
1000
1001
        if ($method != "DELETE") {
1002
            return $this->httpError(400, "Invalid method");
1003
        }
1004
1005
        $fileID = (int) $request->getBody();
1006
        if (!in_array($fileID, $this->getTrackedIDs())) {
1007
            return $this->httpError(400, "Invalid ID");
1008
        }
1009
        $file = File::get()->byID($fileID);
1010
        if (!$file->IsTemporary) {
1011
            return $this->httpError(400, "Invalid file");
1012
        }
1013
        if (!$file->canDelete()) {
1014
            return $this->httpError(400, "Cannot delete file");
1015
        }
1016
        $file->delete();
1017
        $response = new HTTPResponse('', 200);
1018
        return $response;
1019
    }
1020
1021
    /**
1022
     * Clear temp folder that should not contain any file other than temporary
1023
     *
1024
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
1025
     * @param int $limit
1026
     * @return File[] List of files removed
1027
     */
1028
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
1029
    {
1030
        $tempFiles = File::get()->filter('IsTemporary', true);
1031
        if ($limit) {
1032
            $tempFiles = $tempFiles->limit($limit);
1033
        }
1034
1035
        $threshold = self::config()->auto_clear_threshold;
1036
1037
        // Set a default threshold if none set
1038
        if (!$threshold) {
1039
            if (Director::isDev()) {
1040
                $threshold = '-10 minutes';
1041
            } else {
1042
                $threshold = '-1 day';
1043
            }
1044
        }
1045
        if (is_int($threshold)) {
1046
            $thresholdTime = time() - $threshold;
1047
        } else {
1048
            $thresholdTime = strtotime($threshold);
1049
        }
1050
1051
        // Update query to avoid fetching unecessary records
1052
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
1053
1054
        $filesDeleted = [];
1055
        foreach ($tempFiles as $tempFile) {
1056
            $createdTime = strtotime($tempFile->Created);
1057
            if ($createdTime < $thresholdTime) {
1058
                $filesDeleted[] = $tempFile;
1059
                if ($doDelete) {
1060
                    if ($tempFile->hasExtension(Versioned::class)) {
1061
                        $tempFile->deleteFromStage(Versioned::LIVE);
1062
                        $tempFile->deleteFromStage(Versioned::DRAFT);
1063
                    } else {
1064
                        $tempFile->delete();
1065
                    }
1066
                }
1067
            }
1068
        }
1069
        return $filesDeleted;
1070
    }
1071
1072
    /**
1073
     * Allows tracking uploaded ids to prevent unauthorized attachements
1074
     *
1075
     * @param int $fileId
1076
     * @return void
1077
     */
1078
    public function trackFileID($fileId)
1079
    {
1080
        $session = $this->getRequest()->getSession();
1081
        $uploadedIDs = $this->getTrackedIDs();
1082
        if (!in_array($fileId, $uploadedIDs)) {
1083
            $uploadedIDs[] = $fileId;
1084
        }
1085
        $session->set('FilePond', $uploadedIDs);
1086
    }
1087
1088
    /**
1089
     * Get all authorized tracked ids
1090
     * @return array
1091
     */
1092
    public function getTrackedIDs()
1093
    {
1094
        $session = $this->getRequest()->getSession();
1095
        $uploadedIDs = $session->get('FilePond');
1096
        if ($uploadedIDs) {
1097
            return $uploadedIDs;
1098
        }
1099
        return [];
1100
    }
1101
1102
    public function saveInto(DataObjectInterface $record)
1103
    {
1104
        // Note that the list of IDs is based on the value sent by the user
1105
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
1106
        $IDs = $this->getItemIDs();
1107
1108
        $Member = Security::getCurrentUser();
1109
1110
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
1111
        $trackedIDs = $this->getTrackedIDs();
1112
        foreach ($IDs as $ID) {
1113
            if (!in_array($ID, $trackedIDs)) {
1114
                throw new ValidationException("Invalid file ID : $ID");
1115
            }
1116
        }
1117
1118
        // Move files out of temporary folder
1119
        foreach ($IDs as $ID) {
1120
            $file = $this->getFileByID($ID);
1121
            if ($file && $file->IsTemporary) {
1122
                // The record does not have an ID which is a bad idea to attach the file to it
1123
                if (!$record->ID) {
1124
                    $record->write();
1125
                }
1126
                // Check if the member is owner
1127
                if ($Member && $Member->ID != $file->OwnerID) {
1128
                    throw new ValidationException("Failed to authenticate owner");
1129
                }
1130
                $file->IsTemporary = false;
1131
                $file->ObjectID = $record->ID;
1132
                $file->ObjectClass = get_class($record);
1133
                $file->write();
1134
            } else {
1135
                // File was uploaded earlier, no need to do anything
1136
            }
1137
        }
1138
1139
        // Proceed
1140
        return parent::saveInto($record);
1141
    }
1142
1143
    public function Type()
1144
    {
1145
        return 'filepond';
1146
    }
1147
}
1148