Passed
Push — master ( b9d1ee...7fe716 )
by Thomas
02:25
created

FilePondField::adjustPosterSize()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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